1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# 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)