# MCP servers `javascript/src/mcp` turns modules' actions into an MCP server, in a page or a Node server. It reads module.json `actions` and knows nothing about any one module: what differs per module (its session, its transports, its budget) is host config. An agent can then call the actions as tools, in process or over Streamable HTTP. ## Declare the tools Each action is a tool. Its `fields` are the tool's input schema: ```json norun "actions": [ { "entry": "lookup", "label": "Lookup", "description": "Doubles n.", "readOnly": true, "fields": [{ "key": "n", "label": "n", "type": "integer", "required": true, "description": "the number to double" }] } ] ``` | action or field | what | |---|---| | `readOnly` | the entry changes none of the user's data: it runs without asking. Absent: a write, which `confirm` asks the user about first | | `type` | `string` (default), `integer`, `number`, `boolean`, `object` or `array`: the JSON Schema type. Arguments are coerced to it (`"4"` to `4`, `"true"` to `true`, JSON text to an object or array) or the call fails | | `required` | a call without it fails before the module runs | | `description` | the property's description (default: the label) | A call's other arguments are dropped, so a tool cannot set `did` or anything else the host adds. A bad argument comes back as a tool error (`isError`), so the model can correct the call. `readOnly` is not part of the module's pin ([Publish a module](../modules/publish.md)): it changes no grant, and the host's own `readOnly(module, entry, action)` may override it. ## Serve them `actionsServer({ name, modules, ctx, host, readOnly?, confirm? })` answers one JSON-RPC message at a time (`initialize`, `ping`, `tools/list`, `tools/call`). `host` holds each module's options, by module name, or is a function of the module: | host option | what | |---|---| | `call`, or `callFor(mod)` | the module's call: the user's session, held to the module's own scope. `callFor` also builds each hook default's. Neither: `hostCall({ ctx })`, public reads | | `calls` | the host's own pseudo-calls by NSID (transports, as `hostCall` takes them); also what it `provides` | | `attest` | the `com.fedfork.exec.attest` handler (`attestHost`) | | `ledger` | the `managed_records` store for the module's runs (default: the context's) | | `budget`, `http`, `held`, `load`, `provides` | `runModule`'s options | | `input` | fields the host adds to every run's input, such as `{ did }`; they win over the arguments | | `progress` | `true` when the module takes a `progress` capability (below) | | `confirm` | this module's own confirm | `calls` and `attest` reach only a module that lists its calls: one that lists none may call anything. Tool names: an action's entry when the module is the server's namesake, else the module's name (less the server's) and the entry (`toolName`). Here, in Node, the compose example's lookup as a tool: ```js run import { localModule } from "./javascript/src/run-module/index.js"; import { actionsServer } from "./javascript/src/mcp/index.js"; import { createNodeContext, fileFetch } from "./javascript/src/server/index.js"; const ctx = createNodeContext({ id: "my-mcp", stateDir: ".state/my-mcp" }); const root = `file://${process.cwd()}/`; const compose = await localModule(new URL("target/wasm32-unknown-unknown/release/compose.wasm", root), new URL("examples/rust/compose/module.json", root), fileFetch); // its lookup, as a module.json declares a tool const lookup = { entry: "lookup", label: "Lookup", description: "Doubles n.", readOnly: true, fields: [{ key: "n", label: "n", type: "integer", required: true }] }; const server = actionsServer({ name: "compose", modules: [{ ...compose, name: "compose", actions: [lookup] }], ctx, host: { compose: { budget: { wallMs: 10_000 } } } }); const rpc = (method, params) => server.message({ jsonrpc: "2.0", id: 1, method, params }); console.log(JSON.stringify((await rpc("tools/list")).result.tools[0].inputSchema)); console.log(JSON.stringify((await rpc("tools/call", { name: "lookup", arguments: { n: "21" } })).result.structuredContent)); console.log((await rpc("tools/call", { name: "lookup", arguments: {} })).result.content[0].text); ctx.close(); ``` ```output {"type":"object","properties":{"n":{"type":"integer","title":"n","description":"n"}},"required":["n"],"additionalProperties":false} {"ack":null,"caller":null,"doubled":42} lookup: n is required ``` `server.handler` is the same server as a composition handler: an edge that routes an MCP hook (such as atlibs-asyncai's `mcp` servers by `hook`) to it ([Compositions](../compositions.md)). ## Over HTTP, and from a page `ingressMcp(() => ({ name: message }))` speaks stateless, JSON-only Streamable HTTP at `/mcp/`: a POST gets the response (202 for a notification), a GET 405, an OPTIONS the CORS preflight. Every answer carries `access-control-allow-origin: *` and exposes `mcp-session-id`. It takes the request as an ingress forward hands it over (`com.fedfork.exec.ingress.request`: `{ method, path, body, callerDid }`), so a page can publish its servers through fedproxy's ingress; a Node server mounts it the same way. `exposed(message, allow)` lists and calls only the tools `allow(name)` admits: a public view. In a page, Statusphere's actions, with a stub for the user's PDS and a confirm that says yes: ```js run browser import { actionsServer, exposed, ingressMcp } from 'atlibs/mcp/index.js' import { createPluginContext } from 'atlibs/plugin-context/index.js' import { localModule } from 'atlibs/run-module/index.js' const ctx = createPluginContext({ id: 'docs' }) const modules = new URL('./examples/web/hello/modules/', location.href) const statusphere = await localModule(new URL('statusphere.wasm', modules), new URL('statusphere.json', modules)) const call = async (nsid, { input }) => (nsid === 'com.atproto.repo.listRecords' ? { records: [] } : { uri: `at://${input.repo}/${input.collection}/3k`, cid: 'bafy' }) const server = actionsServer({ name: statusphere.name, modules: [statusphere], ctx, host: { Statusphere: { call, input: { did: 'did:plc:alice' } } }, confirm: async ({ tool, arguments: args, caller }) => (console.log(`confirm ${tool} ${JSON.stringify(args)} for ${caller}`), true), }) const forward = ingressMcp(() => ({ statusphere: exposed(server.message, (tool) => tool === 'set_status') })) const post = async (message) => (await forward({ method: 'POST', path: '/mcp/statusphere', body: JSON.stringify(message), callerDid: 'did:plc:bob' })).body console.log(JSON.stringify((await post({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).result.tools.map((t) => t.name))) const set = await post({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'set_status', arguments: { status: '🦋', did: 'did:plc:mallory' } } }) console.log(JSON.stringify(set.result.structuredContent)) ``` ```output ["set_status"] confirm set_status {"status":"🦋"} for did:plc:bob {"status":"🦋","unchanged":false,"uri":"at://did:plc:alice/xyz.statusphere.status/3k"} ``` ## Tools for a model: a module over a module The actionsServer offers each action as a tool. A protocol module such as the requester has many actions and returns receipts, refs and records, so do not give those actions to a model as tools. Write a small module that composes it through hooks and has a few actions with short ids, for example atlibs-socialweb-computer-requester's `vm-mcp` (`vm_create`, `vm_list`, `vm_exec`, `vm_release`). Serve that module instead. Its hooks' `default`s let it run alone, and a composition can pin what serves each hook. ## Long calls and progress `tools/call` awaits the whole run, so a long action holds the request open until the run ends: give the module a `budget` (its `wallMs`) that fits, and the MCP client a timeout that outlasts it. A module that takes a `progress` capability (atlibs-socialweb-computer-requester's `contract` reports its VM's refs through one) gets one when its host options say `progress: true` and the call has somewhere to report to: - `hostRun(ctx, host)(mod, entry, args, { progress })`: the run `actionsServer` uses, with a per-call callback. - `server.message(msg, { notify })`: a transport that carries notifications passes `notify`. Then a `tools/call` with `_meta.progressToken` reports each progress call as `notifications/progress`. `ingressMcp` and a composition handler carry none, so there the module gets no capability. Previous: [App identity](app-identity.md) | Next: [CLI](../cli.md)