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