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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
// atlibs module actions -> an MCP server, for any host (a page, a Node server). Generic: it reads
// module.json `actions` (entry, label, description, readOnly, fields with key, label, type, required,
// description) and knows nothing about any one lib; what differs per module is host config (`host`).
// One message in, its response out (null for a notification): the contract of com.fedfork.exec.mcp.message,
// so the same server is an in-process composition handler, a hostCall call, or (ingressMcp below) a
// Streamable HTTP endpoint. Plus two wrappers for any such `message`: exposed and ingressMcp.
import { ATTEST_NSID } from "../attest/index.js";
import { hostCall } from "../run-module/host-call.js";
import { runModule } from "../run-module/index.js";
// newest first: initialize answers the client's version when it is one of these, else the newest
export const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
/** The field types module.json may declare (default string), each a JSON Schema type. */
export const FIELD_TYPES = ["string", "integer", "number", "boolean", "object", "array"];
const safe = (s) => s.replace(/[^A-Za-z0-9_-]/g, "_");
/** The tool for a module's entry: the entry alone when the module is the server's namesake, else module (less the server's prefix) _ entry. */
export const toolName = (server, module, entry) => safe((module === server ? entry : `${module.replace(`${server}-`, "")}_${entry}`).slice(0, 64));
const text = (value, isError) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value) }], ...(isError && { isError: true }) });
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const rpcError = (code, message) => Object.assign(new Error(message), { code });
/**
* One argument as its field's type, or undefined when it is not one. A model often sends a number or a
* boolean as text, or an object as JSON text: those are taken; nothing else is guessed.
*/
export function coerce(type, v) {
if (type === "string") return typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : undefined;
if (type === "integer" || type === "number") {
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
return Number.isFinite(n) && (type === "number" || Number.isInteger(n)) ? n : undefined;
}
if (type === "boolean") return typeof v === "boolean" ? v : v === "true" ? true : v === "false" ? false : undefined;
let parsed = v;
if (typeof v === "string") {
try {
parsed = JSON.parse(v);
} catch {
return undefined;
}
}
return (type === "array" ? Array.isArray(parsed) : isRecord(parsed)) ? parsed : undefined;
}
/** An action's fields as a tool's inputSchema (JSON Schema). Throws on a type that is not one of FIELD_TYPES. */
export function inputSchema(fields = [], where = "action") {
const properties = {};
for (const f of fields) {
const type = f.type ?? "string";
if (!FIELD_TYPES.includes(type)) throw new Error(`${where} field ${f.key}: type ${type} is not one of ${FIELD_TYPES.join(", ")}`);
properties[f.key] = { type, ...(f.label && { title: f.label }), description: f.description ?? f.label ?? f.key };
}
const required = fields.filter((f) => f.required === true).map((f) => f.key);
return { type: "object", properties, ...(required.length && { required }), additionalProperties: false };
}
/** The arguments a call gives, as `fields` take them: { args } (undeclared ones dropped; null and "" are absent), or { problems }. */
export function argumentsOf(fields = [], given = {}) {
const args = {};
const problems = [];
for (const f of fields) {
const v = given[f.key];
if (v === undefined || v === null || v === "") {
if (f.required === true) problems.push(`${f.key} is required`);
continue;
}
const value = coerce(f.type ?? "string", v);
if (value === undefined) problems.push(`${f.key} must be ${/^[aeiou]/.test(f.type ?? "s") ? "an" : "a"} ${f.type ?? "string"}`);
else args[f.key] = value;
}
return problems.length ? { problems } : { args };
}
/**
* A JSON-RPC 2.0 MCP server over a method table: { initialize, ping, tools/list, tools/call } and the
* protocol plumbing (notifications, -32600/-32601, errors as JSON-RPC errors).
*/
function rpcServer(info, methods) {
const all = {
initialize: (params) => ({
protocolVersion: PROTOCOL_VERSIONS.includes(params?.protocolVersion) ? params.protocolVersion : PROTOCOL_VERSIONS[0],
capabilities: { tools: {} },
serverInfo: info,
}),
ping: () => ({}),
...methods,
};
return async (msg, options = {}) => {
const id = msg?.id;
if (id === undefined) return null; // a notification: no response
if (msg.jsonrpc !== "2.0" || typeof msg.method !== "string") return "result" in msg || "error" in msg ? null : { jsonrpc: "2.0", id, error: { code: -32600, message: "Invalid Request" } };
try {
if (!Object.hasOwn(all, msg.method)) throw rpcError(-32601, `Method not found: ${msg.method}`);
return { jsonrpc: "2.0", id, result: await all[msg.method](msg.params, options) };
} catch (error) {
return { jsonrpc: "2.0", id, error: { code: Number.isInteger(error?.code) ? error.code : -32603, message: String(error?.message ?? error) } };
}
};
}
const optionsOf = (host, mod) => (typeof host === "function" ? host(mod) : host?.[mod.name]) ?? {};
const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
/**
* runModule for one action, on its module's host options: `host` is (mod) => options, or { [mod.name]: options }.
* call: the module's runModule call (the user's session, held to its scope); or
* callFor(mod): builds it, for this module and for each hook default (runModule's callFor); neither: hostCall({ ctx }), public reads
* calls: the host's own pseudo-calls by NSID (transports, e.g. { "com.fedfork.exec.webrtc.connect": fn }); also runModule's provides
* attest: the com.fedfork.exec.attest handler (attestHost(ctx, secp256k1, did))
* calls and attest reach only a module that lists its calls (one that lists none may call anything)
* ledger: the managed_records store for its runs (default ctx.storage.managed_records)
* budget, http, fetchers, held, load, provides: runModule's options (fetchers: a transport's fetchers(), e.g. { "webrtc:": fn })
* input: fields the host adds to every run's input (e.g. { did }): they win over the arguments
* progress: true when the module takes a `progress` capability: a call with a progress sink passes one
* Returns run(mod, entry, args, { signal, progress }) -> runModule's result.
*/
export function hostRun(ctx, host = {}) {
return async (mod, entry, args, { signal, progress } = {}) => {
const o = optionsOf(host, mod);
const own = { ...o.calls, ...(o.attest && { [ATTEST_NSID]: (request) => o.attest(request.input) }) };
const layered = (m, base) => (m.calls?.length && Object.keys(own).length ? (nsid, request) => (Object.hasOwn(own, nsid) ? own[nsid](request) : base(nsid, request)) : base);
let call = layered(mod, o.call ?? (o.callFor ? await o.callFor(mod) : hostCall({ ctx })));
const input = { ...args, ...o.input };
if (o.progress === true && typeof progress === "function") {
// a capability token, as runCompose mints one: the module calls it like an NSID it may call
const token = `cap.${hex(crypto.getRandomValues(new Uint8Array(16)))}.1`;
input.progress = { $cap: token };
if (mod.calls) mod = { ...mod, calls: [...mod.calls, token] };
const inner = call;
call = async (nsid, request) => (nsid === token ? ((await progress(request.input)) ?? {}) : inner(nsid, request));
}
const runCtx = o.ledger ? Object.assign(Object.create(ctx), { storage: { ...ctx.storage, managed_records: o.ledger } }) : ctx;
return runModule(runCtx, mod, entry, input, call, {
signal,
budget: o.budget,
http: o.http,
fetchers: o.fetchers,
held: o.held,
load: o.load,
provides: o.provides ?? Object.keys(o.calls ?? {}),
callFor: o.callFor && (async (m) => layered(m, await o.callFor(m))),
});
};
}
/**
* An MCP server whose tools are the actions of `modules` (loaded modules: module.json's fields).
* name: the server's name (tool names: toolName(name, module, entry))
* ctx, host: run each action with hostRun(ctx, host); or
* run(mod, entry, args, { signal, progress }): runs one action, to runModule's { result, error }
* readOnly(module, entry, action): true for an action that changes none of the user's data (default: module.json's readOnly)
* confirm({ server, tool, module, entry, arguments, caller }): asked before any other action runs; false declines it
* (default: decline). A module's host options may carry its own confirm.
* Arguments are validated and coerced to the fields' types; undeclared ones are dropped (a tool cannot set `did`).
* tools/call awaits the whole run. A message's options: { signal, caller, notify }: with notify (a transport
* that carries notifications) and a request's _meta.progressToken, a progress capability's calls become
* notifications/progress; without, they are not reported.
* Returns { name, tools, message, handler, readOnly(tool) }: handler is runCompose's (an edge routing an MCP hook to it).
*/
export function actionsServer({ name, version = "0.1.0", modules, ctx, host, run = ctx && hostRun(ctx, host), readOnly = (module, entry, action) => action.readOnly === true, confirm = async () => false }) {
if (typeof run !== "function") throw new Error(`${name}: actionsServer needs run, or ctx (and host options)`);
const actions = new Map();
const tools = modules.flatMap((mod) =>
(mod.actions ?? []).map((action) => {
const tool = toolName(name, mod.name, action.entry);
if (actions.has(tool)) throw new Error(`${name}: two actions are tool ${tool}`);
const ro = readOnly(mod.name, action.entry, action) === true;
const schema = inputSchema(action.fields, `${mod.name} ${action.entry}`);
actions.set(tool, { mod, action, readOnly: ro, confirm: optionsOf(host, mod).confirm ?? confirm });
return {
name: tool,
title: action.label ?? action.entry,
description: `${action.description ?? action.label ?? action.entry} (${mod.name} module, ${action.entry}${ro ? "" : "; changes your data: the user confirms each call"})`,
inputSchema: schema,
annotations: { title: action.label ?? action.entry, readOnlyHint: ro, destructiveHint: !ro, openWorldHint: true },
};
}),
);
const call = async (params, { signal, caller, notify } = {}) => {
if (typeof params?.name !== "string") throw rpcError(-32602, "tools/call needs params.name");
const found = actions.get(params.name);
if (!found) throw rpcError(-32602, `Unknown tool: ${params.name}`);
const given = params.arguments ?? {};
if (!isRecord(given)) throw rpcError(-32602, "arguments must be an object");
const { mod, action } = found;
// a validation error is a tool error, so the model can correct the call
const { args, problems } = argumentsOf(action.fields, given);
if (problems) return text(`${params.name}: ${problems.join("; ")}`, true);
if (!found.readOnly && !(await found.confirm({ server: name, tool: params.name, module: mod.name, entry: action.entry, arguments: args, caller }))) return text(`${params.name}: the user declined this call`, true);
const token = params._meta?.progressToken;
let step = 0;
const progress =
typeof notify === "function" && (typeof token === "string" || typeof token === "number")
? (value) => notify({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: ++step, message: typeof value === "string" ? value : JSON.stringify(value) } })
: undefined;
try {
const { result, error } = await run(mod, action.entry, args, { signal, progress });
if (error !== undefined) return text(String(error), true);
return { ...text(result ?? null), ...(isRecord(result) && { structuredContent: result }) };
} catch (error) {
return text(String(error?.message ?? error), true);
}
};
const message = rpcServer({ name: `atlibs-${name}`, version }, { "tools/list": () => ({ tools }), "tools/call": call });
return { name, tools, message, handler: (input, { signal } = {}) => message(input, { signal }), readOnly: (tool) => actions.get(tool)?.readOnly === true };
}
/** `message` with only the tools allow(name) admits: listed, and callable. */
export const exposed = (message, allow) => async (msg, options) => {
if (msg?.method === "tools/call" && msg.id !== undefined && !allow(msg.params?.name)) return { jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: `Unknown tool: ${msg.params?.name}` } };
const reply = await message(msg, options);
if (msg?.method === "tools/list" && Array.isArray(reply?.result?.tools)) return { ...reply, result: { ...reply.result, tools: reply.result.tools.filter((t) => allow(t.name)) } };
return reply;
};
// Any origin may call these servers from a browser (no cookies: the relay holds no credentials of the page's).
// ponytail: stateless, so no mcp-session-id is issued; exposed for clients that look for it.
const CORS = { "access-control-allow-origin": "*", "access-control-expose-headers": "mcp-session-id" };
const PREFLIGHT = { ...CORS, "access-control-allow-methods": "POST, GET, OPTIONS", "access-control-allow-headers": "content-type, accept, authorization, mcp-protocol-version, mcp-session-id, last-event-id", "access-control-max-age": "86400" };
const json = (status, body) => ({ status, body, contentType: "application/json", headers: CORS });
/**
* Streamable HTTP (stateless, JSON only) for an ingress forward (fedproxy ingress, com.fedfork.exec.ingress.request):
* { method, path, body, callerDid } -> { status, body, contentType, headers }. servers(): { name: message }, each at /mcp/<name>.
* POST one message: its response as JSON, or 202 for a notification. No GET stream (405): these servers never send
* unprompted, so a tool's progress is not reported here. Every response allows any origin (CORS); OPTIONS answers the preflight.
*/
export const ingressMcp = (servers) => async (request) => {
const name = /^\/mcp\/([A-Za-z0-9_-]+)\/?$/.exec(request?.path ?? "")?.[1];
if (!name || !Object.hasOwn(servers(), name)) return json(404, { error: "NotFound", servers: Object.keys(servers()).map((n) => `/mcp/${n}`) });
if (request.method === "OPTIONS") return { status: 204, body: "", contentType: "text/plain", headers: PREFLIGHT };
if (request.method !== "POST") return json(405, { error: "MethodNotAllowed", message: "POST one JSON-RPC message" });
let msg;
try {
msg = typeof request.body === "string" ? JSON.parse(request.body) : request.body;
} catch {
return json(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
}
if (!isRecord(msg)) return json(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: one message, not a batch" } });
const reply = await servers()[name](msg, { caller: request.callerDid ?? "a remote MCP client" });
return reply === null ? { status: 202, body: "", contentType: "text/plain", headers: CORS } : json(200, reply);
};