// 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/. * 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); };