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

johnandersen777.bsky.social/atlibs

0
0
main
243 lines · 14.9 KB Raw History
// 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);
};