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:
"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): 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:
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();
{"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).
Over HTTP, and from a page
ingressMcp(() => ({ name: message })) speaks stateless, JSON-only Streamable HTTP at /mcp/<name>: 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:
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))
["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' defaults 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 runactionsServeruses, with a per-call callback.server.message(msg, { notify }): a transport that carries notifications passesnotify. Then atools/callwith_meta.progressTokenreports each progress call asnotifications/progress.ingressMcpand a composition handler carry none, so there the module gets no capability.
Previous: App identity | Next: CLI