// node --test javascript/src/mcp/mcp.test.mjs (after `cargo wasm`): module actions as MCP tools, on the // compose example's lookup (it doubles n, calling a progress capability first when given one). import assert from "node:assert/strict"; import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { createNodeContext } from "../server/index.js"; import { actionsServer, argumentsOf, exposed, hostRun, ingressMcp, inputSchema } from "./index.js"; const wasm = new Uint8Array(await readFile(new URL("../../../target/wasm32-unknown-unknown/release/compose.wasm", import.meta.url))); const lookup = { entry: "lookup", label: "Lookup", description: "Doubles n.", readOnly: true, fields: [{ key: "n", label: "n", type: "integer", required: true, description: "the number to double" }] }; const write = { entry: "lookup", label: "Lookup (as a write)", fields: [{ key: "n", type: "integer" }] }; const mod = (name, actions, extra) => ({ module: wasm, name, id: `test:${name}`, calls: [], actions, ...extra }); const rpc = (server, method, params, id = 1, options) => server.message({ jsonrpc: "2.0", id, method, params }, options); const ctx = createNodeContext({ id: "mcp-test", stateDir: await mkdtemp(join(tmpdir(), "atlibs-mcp-")) }); test.after(() => ctx.close()); test("fields become a JSON Schema; arguments are coerced to it, undeclared ones dropped", () => { const fields = [{ key: "n", type: "integer", required: true }, { key: "x", type: "number" }, { key: "on", type: "boolean" }, { key: "tags", type: "array" }, { key: "o", type: "object" }, { key: "s", label: "S" }]; assert.deepEqual(inputSchema(fields).required, ["n"]); assert.deepEqual(inputSchema(fields).properties.s, { type: "string", title: "S", description: "S" }); assert.equal(inputSchema(fields).additionalProperties, false); assert.throws(() => inputSchema([{ key: "k", type: "date" }]), /type date is not one of/); assert.deepEqual(argumentsOf(fields, { n: "4", x: "1.5", on: "false", tags: '["a"]', o: { a: 1 }, s: 7, did: "did:plc:mallory" }), { args: { n: 4, x: 1.5, on: false, tags: ["a"], o: { a: 1 }, s: "7" } }); assert.deepEqual(argumentsOf(fields, { n: 1.5, on: "yes", tags: "{}", s: {} }), { problems: ["n must be an integer", "on must be a boolean", "tags must be an array", "s must be a string"] }); assert.deepEqual(argumentsOf(fields, { n: "" }), { problems: ["n is required"] }); }); test("tools from actions; a read runs with no confirm, a write asks first; host options per module", async () => { const asked = []; const server = actionsServer({ name: "compose", modules: [mod("compose", [lookup]), mod("compose-w", [write])], ctx, host: { "compose-w": { input: { n: 50 }, confirm: async (q) => (asked.push(q), q.arguments.n === 2) } }, }); const { tools } = (await rpc(server, "tools/list")).result; assert.deepEqual(tools.map((t) => [t.name, t.annotations.readOnlyHint, t.inputSchema.properties.n.type]), [["lookup", true, "integer"], ["w_lookup", false, "integer"]]); assert.equal(server.readOnly("lookup"), true); assert.equal(server.readOnly("w_lookup"), false); const read = await rpc(server, "tools/call", { name: "lookup", arguments: { n: "21", did: "did:plc:mallory" } }); assert.deepEqual(read.result.structuredContent, { doubled: 42, ack: null, caller: null }); const bad = await rpc(server, "tools/call", { name: "lookup", arguments: {} }); assert.equal(bad.result.isError, true); assert.match(bad.result.content[0].text, /n is required/); // the module's own confirm; host input wins over the arguments assert.match((await rpc(server, "tools/call", { name: "w_lookup", arguments: { n: 3 } })).result.content[0].text, /declined/); assert.equal((await rpc(server, "tools/call", { name: "w_lookup", arguments: { n: 2 } })).result.structuredContent.doubled, 100); assert.deepEqual(asked.map((q) => [q.tool, q.module, q.arguments.n]), [["w_lookup", "compose-w", 3], ["w_lookup", "compose-w", 2]]); assert.equal((await rpc(server, "tools/call", { name: "nope" })).error.code, -32602); assert.equal((await rpc(server, "resources/list")).error.code, -32601); assert.equal(await server.message({ jsonrpc: "2.0", method: "notifications/initialized" }), null); }); test("progress: a module's progress capability, as MCP notifications/progress when the transport carries them", async () => { const server = actionsServer({ name: "compose", modules: [mod("compose", [lookup])], ctx, host: { compose: { progress: true } } }); const sent = []; const notify = (n) => sent.push(n); const out = await rpc(server, "tools/call", { name: "lookup", arguments: { n: 1 }, _meta: { progressToken: "p1" } }, 1, { notify }); assert.equal(out.result.structuredContent.doubled, 2); assert.deepEqual(sent, [{ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: "p1", progress: 1, message: '{"step":1}' } }]); // no notify (a composition handler, ingressMcp): the module gets no capability assert.equal((await rpc(server, "tools/call", { name: "lookup", arguments: { n: 1 }, _meta: { progressToken: "p1" } })).result.structuredContent.ack, null); // hostRun alone: the per-call callback const seen = []; const ran = await hostRun(ctx, { compose: { progress: true } })(mod("compose", [lookup]), "lookup", { n: 4 }, { progress: (v) => (seen.push(v), { ok: true }) }); assert.deepEqual([ran.result, seen], [{ doubled: 8, ack: { ok: true }, caller: null }, [{ step: 1 }]]); }); test("transports and attest reach only a module that lists its calls", async () => { // the compose example's caller calls `nsid` and returns its outcome const run = hostRun(ctx, () => ({ call: async (nsid) => ({ via: `session ${nsid}` }), calls: { "com.example.t.mcp": async () => ({ via: "transport" }) }, attest: async () => ({ via: "attest" }) })); const outcome = async (calls, nsid) => (await run(mod("t", [], { calls }), "caller", { nsid })).result.q; assert.deepEqual(await outcome(["com.example.t.mcp"], "com.example.t.mcp"), { ok: true, output: { via: "transport" } }); assert.deepEqual(await outcome(["com.fedfork.exec.attest"], "com.fedfork.exec.attest"), { ok: true, output: { via: "attest" } }); // lists no calls (may call anything): the session's call, never the host's own assert.deepEqual(await outcome(undefined, "com.example.t.mcp"), { ok: true, output: { via: "session com.example.t.mcp" } }); }); test("exposed and ingressMcp: a public view over stateless Streamable HTTP, with CORS", async () => { const server = actionsServer({ name: "compose", modules: [mod("compose", [lookup]), mod("compose-w", [write])], ctx, confirm: async () => true }); const forward = ingressMcp(() => ({ compose: exposed(server.message, server.readOnly) })); const post = (body) => forward({ method: "POST", path: "/mcp/compose", body: JSON.stringify(body), callerDid: "did:plc:remote" }); assert.deepEqual((await post({ jsonrpc: "2.0", id: 1, method: "tools/list" })).body.result.tools.map((t) => t.name), ["lookup"]); assert.match((await post({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "w_lookup", arguments: { n: 1 } } })).body.error.message, /Unknown tool/); const called = await post({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "lookup", arguments: { n: 5 } } }); assert.equal(called.body.result.structuredContent.doubled, 10); assert.deepEqual(called.headers, { "access-control-allow-origin": "*", "access-control-expose-headers": "mcp-session-id" }); assert.equal((await post({ jsonrpc: "2.0", method: "notifications/initialized" })).status, 202); assert.equal((await forward({ method: "GET", path: "/mcp/compose" })).status, 405); assert.equal((await forward({ method: "OPTIONS", path: "/mcp/compose" })).status, 204); assert.deepEqual((await forward({ method: "POST", path: "/" })).body.servers, ["/mcp/compose"]); assert.equal((await forward({ method: "POST", path: "/mcp/compose", body: "[" })).body.error.code, -32700); });