~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
150 lines · 8.3 KB Raw History

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 fieldwhat
readOnlythe entry changes none of the user's data: it runs without asking. Absent: a write, which confirm asks the user about first
typestring (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
requireda call without it fails before the module runs
descriptionthe 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 optionwhat
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
callsthe host's own pseudo-calls by NSID (transports, as hostCall takes them); also what it provides
attestthe com.fedfork.exec.attest handler (attestHost)
ledgerthe managed_records store for the module's runs (default: the context's)
budget, http, held, load, providesrunModule's options
inputfields the host adds to every run's input, such as { did }; they win over the arguments
progresstrue when the module takes a progress capability (below)
confirmthis 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 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 | Next: CLI