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

johnandersen777.bsky.social/atlibs-code-mode

0
0
main
164 lines · 9.4 KB Raw History
// atlibs-code-mode: an MCP server behind one host NSID (com.fedfork.exec.mcp.message). Its one tool, run_assemblyscript, compiles an
// agent's AssemblyScript with asc (browser and Node alike) and runs it as an atlibs module: runModule on
// the host's ctx (no imports, 64 MiB memory, 5 s per event, the run's budget) and the host's own call,
// held to `allow`. node: only behind a dynamic import (no Worker global), so pages load it as is.
import { runModule } from "@publicdomainrelay/atlibs/run-module/index.js";

// one MCP JSON-RPC message in, its response out (null for a notification)
export const MESSAGE = "com.fedfork.exec.mcp.message";
export const PROVIDES = [MESSAGE];
// 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"];
export const VERSION = "0.2.0";
export const TOOL = "run_assemblyscript";
export const ASC_VERSION = "0.28.20";
// the compiled module's limits are the host's (atlibs WASM_LIMITS); these bound what reaches the compiler
export const SOURCE_MAX = 65536;
export const COMPILE_MS = 20000;
// a page maps these in its import map (node_modules/assemblyscript/dist/{asc,assemblyscript}.js, binaryen/index.js, long/index.js)
export const ASC_IMPORTS = ["assemblyscript/asc", "assemblyscript", "binaryen", "long"];
export const COMPILE_HEAP_MB = 512;

/**
 * AssemblyScript source -> { binary } or { error } (the compiler's log). The compiler runs in a worker
 * (compile-worker.js) that is terminated after `timeoutMs`: a crash or a runaway compile costs that worker.
 * It reads only the prelude, the entry and `code`, and refuses a module that declares imports.
 */
export async function compile(code, { timeoutMs = COMPILE_MS } = {}) {
	if (typeof code !== "string" || !code.trim()) return { error: "code must be a non-empty string" };
	if (code.length > SOURCE_MAX) return { error: `code exceeds ${SOURCE_MAX} characters` };
	const worker = await spawn(new URL("./compile-worker.js", import.meta.url));
	try {
		return await new Promise((resolve) => {
			const timer = setTimeout(() => resolve({ error: `compile ran longer than ${timeoutMs} ms` }), timeoutMs);
			const done = (result) => (clearTimeout(timer), resolve(result));
			worker.on(done, () => done({ error: "the compiler crashed (a stack overflow, or out of memory)" }));
			worker.post({ code });
		});
	} finally {
		worker.terminate();
	}
}

// ponytail: a fresh worker per compile (asc loads in ~0.3 s each time); keep a warm one if that shows.
const spawn = async (url) => {
	if (typeof Worker === "function") {
		// a same-origin blob that imports compile-worker.js: a page may load this file from a CDN, and a
		// Worker's script must be the page's own origin (a module worker may import from anywhere with CORS)
		const worker = new Worker(URL.createObjectURL(new Blob([`import ${JSON.stringify(url.href)};`], { type: "text/javascript" })), { type: "module" });
		// the page's import map, which its workers do not see: compile-worker.js loads asc from these
		const urls = Object.fromEntries(ASC_IMPORTS.map((name) => [name, import.meta.resolve(name)]));
		return {
			post: (m) => worker.postMessage({ ...m, urls }),
			on: (message, fail) => ((worker.onmessage = ({ data }) => message(data)), (worker.onerror = fail)),
			terminate: () => worker.terminate(),
		};
	}
	const { Worker: NodeWorker } = await import("node:worker_threads");
	const worker = new NodeWorker(url, { stdout: true, stderr: true, resourceLimits: { maxOldGenerationSizeMb: COMPILE_HEAP_MB } });
	return {
		post: (m) => worker.postMessage(m),
		on: (message, fail) => (worker.once("message", message), worker.once("error", fail), worker.once("exit", fail)),
		terminate: () => void worker.terminate(),
	};
};

const describe = (allow) =>
	`Compile and run AssemblyScript (asc ${ASC_VERSION}, stub runtime) in a sandbox: no imports (no console, Math.random or Date), ` +
	"64 MiB of memory, 5 s per event. Write `export function main(run: Run): void`; Json, Run and print are in scope. " +
	"print(text) writes a line of stdout. run.input is the tool's `input` as Json (get(key), has(key), at(i), " +
	".length (array items, object keys, string chars), .keys (string[]), .items (Json[]), .str, .num (f64), .bool, " +
	".isNull/.isBool/.isNumber/.isString/.isArray/.isObject; Json.object().set(k, Json) (values are Json: Json.string(s), not s), " +
	"push(Json), Json.array([...]), Json.string/number(f64)/bool/null, Json.parse(text), toString()). " +
	"run.finish(value) ends the run with that result. main runs once per event: run.start on the first; " +
	(allow.length
		? `run.call(id, nsid, input) or run.query(id, nsid, params, service?) makes an XRPC call (allowed: ${allow.join(", ")}); ` +
			"its result is the next event (run.id, run.ok, run.output, run.error). Keep what you need in run.state (Json). "
		: "it may make no calls. ") +
	"throw (or any trap) ends the run with an error. Returns JSON { stdout, result } (and error); a run that never calls run.finish returns result null.";

const text = (value, isError) => ({ content: [{ type: "text", text: JSON.stringify(value) }], ...(isError && { isError: true }) });

/**
 * The code-mode MCP server for a host: message(jsonrpc) -> the JSON-RPC response, or null for a
 * notification (or a response sent to us). Transport-agnostic: a hook, hostCall's calls, a Streamable
 * HTTP endpoint and the MCP SDK's transports all carry the same messages.
 *   ctx: the host's context (createPluginContext in a page, createNodeContext in Node): its wasm engine runs the code
 *   call: the host's call (hostCall, policyCall, ...) for the code's XRPC calls; absent: none
 *   allow: the NSIDs the code may call (default none), as a module's `calls`
 *   budget: runModule's budget for each run (atlibs defaults otherwise)
 * Returns message, `calls` for hostCall's calls (a module that lists MESSAGE in its calls), and `handler`
 * for runCompose's handlers (an edge routing MESSAGE to it).
 */
export function codeMode({ ctx, call, allow = [], budget } = {}) {
	if (!ctx?.wasm?.run) throw new Error("codeMode needs the host's ctx (its wasm engine runs the code)");
	allow = [...allow];
	const own = call ?? (async (nsid) => Promise.reject(new Error(`${nsid}: code mode was given no call`)));
	const tools = [{
		name: TOOL,
		title: "Run AssemblyScript",
		description: describe(allow),
		inputSchema: {
			type: "object",
			properties: { code: { type: "string", description: "AssemblyScript that exports main(run: Run): void" }, input: { description: "any JSON: run.input" } },
			required: ["code"],
		},
	}];
	const run = async (args, signal) => {
		const built = await compile(args?.code);
		if (built.error) return text({ error: `compile: ${built.error}` }, true);
		const mod = { name: "code-mode", module: built.binary, calls: allow };
		const ran = await runModule(ctx, mod, "run", { input: args.input ?? null }, own, { budget, signal });
		return text({ stdout: ran.result?.stdout ?? "", result: ran.result?.value ?? null, ...(ran.error !== undefined && { error: ran.error }) }, ran.error !== undefined);
	};
	const methods = {
		initialize: (params) => ({
			protocolVersion: PROTOCOL_VERSIONS.includes(params?.protocolVersion) ? params.protocolVersion : PROTOCOL_VERSIONS[0],
			capabilities: { tools: {} },
			serverInfo: { name: "atlibs-code-mode", version: VERSION },
		}),
		ping: () => ({}),
		"tools/list": () => ({ tools }),
		"tools/call": (params, signal) => {
			if (typeof params?.name !== "string") throw rpcError(-32602, "tools/call needs params.name");
			if (params.name !== TOOL) throw rpcError(-32602, `Unknown tool: ${params.name}`);
			return run(params.arguments ?? {}, signal);
		},
	};
	const message = async (msg, { signal } = {}) => {
		const id = msg?.id;
		if (id === undefined) return null; // a notification (notifications/initialized, cancelled): 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(methods, msg.method)) throw rpcError(-32601, `Method not found: ${msg.method}`);
			return { jsonrpc: "2.0", id, result: await methods[msg.method](msg.params, signal) };
		} catch (error) {
			return { jsonrpc: "2.0", id, error: { code: Number.isInteger(error?.code) ? error.code : -32603, message: String(error?.message ?? error) } };
		}
	};
	return {
		provides: PROVIDES,
		message,
		calls: { [MESSAGE]: (request = {}) => message(request.input, request) },
		handler: (input, { signal } = {}) => message(input, { signal }),
	};
}

const rpcError = (code, message) => Object.assign(new Error(message), { code });

/**
 * Streamable HTTP (MCP 2025-11-25), stateless and JSON only, over web Request/Response (pages' service
 * workers, Node, Deno, an ingress module's forward): POST one message, get its response as JSON, or 202
 * for a notification. No GET stream (405): this server never sends unprompted.
 */
export const streamableHttp = (message) => async (request) => {
	if (request.method !== "POST") return new Response(null, { status: 405, headers: { allow: "POST" } });
	let msg;
	try {
		msg = await request.json();
	} catch {
		return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, { status: 400 });
	}
	const reply = await message(msg, { signal: request.signal });
	return reply === null ? new Response(null, { status: 202 }) : Response.json(reply);
};