Files
rea/experiments/codemode/bridge.mjs
T

423 lines
16 KiB
JavaScript

import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { compile } from "json-schema-to-typescript";
import { newQuickJSWASMModule, Scope } from "quickjs-emscripten";
import { z } from "zod";
const digest = (value) =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");
export const json = (reply) => {
if (reply.structuredContent !== undefined) return reply.structuredContent;
const texts = reply.content.filter((item) => item.type === "text");
if (texts.length !== 1) throw new Error("Expected one REA JSON text block");
return JSON.parse(texts[0].text);
};
/** One upstream session, original tool names, complete live catalog. */
export async function connectUpstream({
command,
args,
env,
stderr = "inherit",
}) {
const transport = new StdioClientTransport({ command, args, env, stderr });
const client = new Client({
name: "rea-codemode-experiment",
version: "0.0.0",
});
try {
await client.connect(transport, { timeout: 60_000 });
const catalog = [];
let cursor;
do {
const page = await client.listTools(cursor ? { cursor } : undefined);
catalog.push(...page.tools);
cursor = page.nextCursor;
} while (cursor !== undefined);
if (new Set(catalog.map((tool) => tool.name)).size !== catalog.length)
throw new Error("Duplicate upstream tool names");
return {
client,
catalog,
call: (name, args, options) =>
client.callTool({ name, arguments: args }, options),
close: () => client.close(),
};
} catch (error) {
await client.close();
throw error;
}
}
export class CodeModeBridge {
constructor(upstream) {
this.upstream = upstream;
this.byName = new Map(upstream.catalog.map((tool) => [tool.name, tool]));
this.catalogDigest = digest(upstream.catalog);
this.declarations = new Map();
this.queue = Promise.resolve();
}
/** The live catalog is immutable for this connection; derive each type once. */
declarationsFor(tool) {
if (!this.declarations.has(tool.name)) {
const options = { bannerComment: "", format: false, unknownAny: true };
const pending = Promise.all([
compile(tool.inputSchema, `${tool.name}_Input`, options),
tool.outputSchema
? compile(
{ ...tool.outputSchema, title: `${tool.name}_Output` },
`${tool.name}_Output`,
options,
)
: undefined,
]);
this.declarations.set(tool.name, pending);
}
return this.declarations.get(tool.name);
}
/** Search exposes original schemas and generated input types on demand. */
async search({ query = "", names = [], schemas, output_schemas = false }) {
const includeSchemas = schemas ?? (names.length > 0 || output_schemas);
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
const selected = this.upstream.catalog.filter((tool) =>
names.length
? names.includes(tool.name)
: terms.every((term) =>
`${tool.name} ${tool.description ?? ""}`
.toLowerCase()
.includes(term),
),
);
// Failed all-word searches keep their original semantics. Offer only the
// best positive lexical matches as names, without expanding their schemas.
const ranked =
selected.length || !terms.length || names.length
? []
: this.upstream.catalog.map((tool) => ({
tool,
score: terms.filter((term) =>
`${tool.name} ${tool.description ?? ""}`
.toLowerCase()
.includes(term),
).length,
}));
const bestScore = Math.max(0, ...ranked.map((row) => row.score));
const suggestions = ranked
.filter((row) => row.score === bestScore && bestScore > 0)
.map(({ tool }) => ({ name: tool.name, description: tool.description }));
const tools = [];
for (const tool of selected) {
const [inputType, outputType] = includeSchemas
? await this.declarationsFor(tool)
: [];
tools.push(
includeSchemas
? {
...tool,
...(!output_schemas ? { outputSchema: undefined } : {}),
typescript: inputType,
output_typescript: outputType,
}
: {
name: tool.name,
description: tool.description,
annotations: tool.annotations,
},
);
}
return {
catalog_sha256: this.catalogDigest,
total_tools: this.byName.size,
matched_tools: tools.length,
tools,
...(suggestions.length ? { suggestions } : {}),
};
}
/** Serialize scripts sharing the active target; no implicit batch races. */
execute(input, options = {}) {
const work = this.queue.then(() => this.run(input, options));
this.queue = work.catch(() => undefined);
return work;
}
async run(
{ code, timeout_ms = 120_000, memory_bytes = 128 * 1024 * 1024 },
options,
) {
const controller = new AbortController();
const signal = options.signal
? AbortSignal.any([controller.signal, options.signal])
: controller.signal;
signal.throwIfAborted();
const deadline = Date.now() + timeout_ms;
const timer = setTimeout(
() => controller.abort(new Error("Code Mode deadline exceeded")),
timeout_ms,
);
const QuickJS = await newQuickJSWASMModule();
const runtime = QuickJS.newRuntime();
// Pending-job execution can create another context in the runtime.
// Own contexts at creation, including those allocated by the job pump.
const contexts = new Scope();
const newContext = runtime.newContext.bind(runtime);
runtime.newContext = (options) => contexts.manage(newContext(options));
runtime.setMemoryLimit(memory_bytes);
runtime.setMaxStackSize(1024 * 1024);
runtime.setInterruptHandler(() => signal.aborted || Date.now() > deadline);
const vm = runtime.newContext();
const pending = new Set();
const calls = [];
let toolQueue = Promise.resolve();
let finished = false;
let promise;
let value;
let failure;
const checkedEval = (source) => {
const result = vm.evalCode(source);
if (result.error) {
const error = vm.dump(result.error);
result.error.dispose();
throw new Error(JSON.stringify(error));
}
return result.value;
};
const pump = () => {
const result = runtime.executePendingJobs();
if (result.error) {
failure = vm.dump(result.error);
result.error.dispose();
finished = true;
}
};
const fromJson = (data) =>
checkedEval(`JSON.parse(${JSON.stringify(JSON.stringify(data))})`);
const callHandle = vm.newFunction("call", (nameHandle, argumentsHandle) => {
const name = vm.getString(nameHandle);
const args = JSON.parse(vm.getString(argumentsHandle));
if (!this.byName.has(name))
throw new Error(`Unknown upstream tool: ${name}`);
signal.throwIfAborted();
const deferred = vm.newPromise();
const audit = { name, arguments_sha256: digest(args) };
calls.push(audit);
const work = toolQueue.then(async () => {
const started = performance.now();
try {
signal.throwIfAborted();
const reply = await this.upstream.call(name, args, {
signal,
timeout: Math.max(1, deadline - Date.now()),
onprogress: options.onprogress,
});
audit.elapsed_ms = performance.now() - started;
audit.result_sha256 = digest(reply);
audit.is_error = reply.isError === true;
try {
const record = json(reply);
if (record.evidence_id) audit.evidence_id = record.evidence_id;
if (Array.isArray(record.evidence_links))
audit.evidence_links = record.evidence_links;
const partial = record.error?.details?.partial_observation;
if (partial?.kind === "retained-evidence")
audit.partial_evidence_id = partial.evidence_id;
} catch {
/* Non-JSON MCP content is still preserved in reply. */
}
const handle = fromJson(reply);
deferred.resolve(handle);
handle.dispose();
} catch (error) {
audit.transport_error = error.message;
const handle = fromJson({ name: error.name, message: error.message });
deferred.reject(handle);
handle.dispose();
} finally {
deferred.dispose();
pump();
}
});
const settled = work.catch((error) => {
failure = { message: error.message };
finished = true;
});
toolQueue = settled;
pending.add(settled);
void settled.then(() => pending.delete(settled));
// The callback transfers its returned handle to QuickJS. Keep the
// deferred's original host handle until settlement, with a separate
// guest handle; otherwise bulk promise cleanup shares one lifetime.
return deferred.handle.dup();
});
const doneHandle = vm.newFunction("done", (data) => {
value = JSON.parse(vm.getString(data));
finished = true;
});
const failHandle = vm.newFunction("fail", (error) => {
failure = vm.dump(error);
finished = true;
});
try {
vm.setProp(vm.global, "__call", callHandle);
vm.setProp(vm.global, "__done", doneHandle);
vm.setProp(vm.global, "__fail", failHandle);
checkedEval(`
const tools = Object.freeze(Object.fromEntries(${JSON.stringify([...this.byName.keys()])}.map(
name => [name, args => __call(name, JSON.stringify(args))]
)));
const json = reply => {
if (reply.structuredContent !== undefined) return reply.structuredContent;
const texts = reply.content.filter(item => item.type === 'text');
if (texts.length !== 1) throw new Error('Expected one REA JSON text block');
return JSON.parse(texts[0].text);
};
`).dispose();
promise = checkedEval(`(async () => {\n${code}\n})().then(
value => __done(JSON.stringify(value === undefined ? null : value)),
error => __fail({name: error?.name, message: error?.message ?? String(error)})
)`);
pump();
while (!finished && !signal.aborted) {
if (pending.size) await Promise.race(pending);
else
throw new Error(
"Guest promise cannot progress: no pending tool calls",
);
pump();
}
signal.throwIfAborted();
if (calls.some((call) => !call.result_sha256 && !call.transport_error)) {
controller.abort(
new Error("Code returned before awaiting all tool calls"),
);
await Promise.allSettled(pending);
failure = {
message:
"Code returned before awaiting all tool calls; calls were cancelled",
};
} else await Promise.allSettled(pending);
} catch (error) {
failure = { name: error.name, message: error.message };
controller.abort(error);
await Promise.allSettled(pending);
} finally {
clearTimeout(timer);
promise?.dispose();
callHandle.dispose();
doneHandle.dispose();
failHandle.dispose();
contexts.dispose();
runtime.dispose();
}
return {
catalog_sha256: this.catalogDigest,
calls,
...(failure ? { bridge_error: failure } : { value }),
};
}
}
export function createBridgeServer(bridge) {
const server = new McpServer({
name: "rea-codemode-experiment",
version: "0.0.0",
});
const reply = (result) => ({
content: [{ type: "text", text: JSON.stringify(result) }],
...(result.bridge_error ? { isError: true } : {}),
});
server.registerTool(
"rea_search",
{
description:
"Discover original upstream REA tools. Start with one domain word, e.g. query=android; query words must all occur. Query/empty discovery defaults to names and descriptions. Then supply all selected exact names together to obtain exact input schemas plus derived input/output TypeScript for code authoring. schemas=true/false overrides this choice. Request output_schemas=true only for original JSON validation contracts. rea_search and rea_execute are already declared bridge tools, not upstream catalog entries. catalog_sha256 hashes the SDK catalog, not REA server_identity.catalog.digests. Code calls tools.<name>(original_arguments); json(reply) parses the original MCP value.",
inputSchema: z.strictObject({
query: z.string().default(""),
names: z.array(z.string()).default([]),
schemas: z.boolean().optional(),
output_schemas: z.boolean().default(false),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async (input) => reply(await bridge.search(input)),
);
server.registerTool(
"rea_execute",
{
description:
"Run a JavaScript async function body with tools.<original_REA_name>(original_arguments), json(reply) and return. Await every call. json(reply) returns the original REA value: Evidence has normalized_result, evidence_id and limitations; session values have result. Select the needed normalized_result fields inside code before returning them with Evidence IDs and limitations. One upstream session persists. Calls run in order, including Promise.all. All original REA effects, validation and retention apply; projections are caller selections, not new Evidence. No host IO or imports. Call audit and Evidence IDs survive script errors. Default deadline 120s and guest memory 128 MiB; use longer deadline for native analysis.",
inputSchema: z.strictObject({
code: z.string(),
timeout_ms: z.number().int().positive().optional(),
memory_bytes: z
.number()
.int()
.positive()
.max(128 * 1024 * 1024)
.optional(),
}),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true,
},
},
async (input, context) =>
reply(
await bridge.execute(input, {
signal: context.mcpReq.signal,
onprogress:
context.mcpReq._meta?.progressToken === undefined
? undefined
: (progress) =>
context.mcpReq.notify({
method: "notifications/progress",
params: {
...progress,
progressToken: context.mcpReq._meta.progressToken,
},
}),
}),
),
);
return server;
}
async function main() {
const [command, ...args] = process.argv.slice(2);
if (!command) throw new Error("Usage: node bridge.mjs COMMAND [ARG...]");
const upstream = await connectUpstream({ command, args, env: process.env });
const server = createBridgeServer(new CodeModeBridge(upstream));
let closing = false;
const close = async () => {
if (closing) return;
closing = true;
await server.close();
await upstream.close();
};
process.once("SIGINT", () => void close());
process.once("SIGTERM", () => void close());
const transport = new StdioServerTransport();
transport.onclose = () => void close();
await server.connect(transport);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});