~ 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:

```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/<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:

```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)