From 3dcc21b50030a26df7d3827a953cb039b4a6635a Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 15 Jul 2026 19:52:08 -0700 Subject: [PATCH 1/3] feat(codemode): add secure-exec MCP executor --- .changeset/secure-exec-mcp.md | 5 + packages/secure-exec-mcp/README.md | 82 +++ packages/secure-exec-mcp/package.json | 44 ++ packages/secure-exec-mcp/scripts/build.ts | 24 + packages/secure-exec-mcp/src/index.ts | 13 + .../src/secure-exec-executor.test.ts | 162 +++++ .../src/secure-exec-executor.ts | 608 ++++++++++++++++++ packages/secure-exec-mcp/tsconfig.json | 4 + packages/secure-exec-mcp/vitest.config.ts | 8 + pnpm-lock.yaml | 93 +++ 10 files changed, 1043 insertions(+) create mode 100644 .changeset/secure-exec-mcp.md create mode 100644 packages/secure-exec-mcp/README.md create mode 100644 packages/secure-exec-mcp/package.json create mode 100644 packages/secure-exec-mcp/scripts/build.ts create mode 100644 packages/secure-exec-mcp/src/index.ts create mode 100644 packages/secure-exec-mcp/src/secure-exec-executor.test.ts create mode 100644 packages/secure-exec-mcp/src/secure-exec-executor.ts create mode 100644 packages/secure-exec-mcp/tsconfig.json create mode 100644 packages/secure-exec-mcp/vitest.config.ts diff --git a/.changeset/secure-exec-mcp.md b/.changeset/secure-exec-mcp.md new file mode 100644 index 0000000000..c5fc0f79f1 --- /dev/null +++ b/.changeset/secure-exec-mcp.md @@ -0,0 +1,5 @@ +--- +"@secure-exec/mcp": minor +--- + +Add a secure-exec executor for Cloudflare Code Mode and re-export the Code Mode MCP server builders. diff --git a/packages/secure-exec-mcp/README.md b/packages/secure-exec-mcp/README.md new file mode 100644 index 0000000000..f73b36747b --- /dev/null +++ b/packages/secure-exec-mcp/README.md @@ -0,0 +1,82 @@ +# `@secure-exec/mcp` + +Run Cloudflare Code Mode MCP servers in secure-exec instead of Cloudflare +Dynamic Workers. Each execution gets a fresh secure-exec VM, denied guest +network access by default, and controlled host capabilities for Code Mode +providers and connectors. + +## Install + +```sh +npm install @secure-exec/mcp +``` + +## OpenAPI Code Mode MCP server + +```ts +import { SecureExecExecutor, openApiMcpServer } from "@secure-exec/mcp"; + +const server = openApiMcpServer({ + name: "acme-api", + version: "1.0.0", + spec: await fetch("https://api.acme.test/openapi.json").then((response) => + response.json() + ), + executor: new SecureExecExecutor(), + request: async (options, context) => { + const url = new URL(options.path, "https://api.acme.test"); + for (const [name, value] of Object.entries(options.query ?? {})) { + if (value !== undefined) url.searchParams.set(name, String(value)); + } + + const response = await fetch(url, { + method: options.method, + headers: { + authorization: `Bearer ${process.env.ACME_API_TOKEN}`, + "content-type": options.contentType ?? "application/json" + }, + body: + options.body === undefined + ? undefined + : options.rawBody + ? (options.body as BodyInit) + : JSON.stringify(options.body), + signal: context.signal + }); + + return response.json(); + } +}); +``` + +The MCP server exposes `search({ code })` and `execute({ code })`. API +credentials stay in the trusted `request` callback and are never projected into +the VM. + +## Use the executor directly + +`SecureExecExecutor` implements the portable `Executor` contract from +`@cloudflare/codemode`: + +```ts +const executor = new SecureExecExecutor({ timeout: 30_000 }); + +const result = await executor.execute( + `async () => { + const users = await api.listUsers({ active: true }); + return users.map(({ id, email }) => ({ id, email })); + }`, + [ + { + name: "api", + fns: { + listUsers: async (input) => trustedApiClient.listUsers(input) + } + } + ] +); +``` + +Provider callbacks run on the trusted host. Generated code runs in the VM and +can only reach those callbacks through secure-exec's schema-validated binding +path. diff --git a/packages/secure-exec-mcp/package.json b/packages/secure-exec-mcp/package.json new file mode 100644 index 0000000000..1ef847e1c2 --- /dev/null +++ b/packages/secure-exec-mcp/package.json @@ -0,0 +1,44 @@ +{ + "name": "@secure-exec/mcp", + "version": "0.0.1", + "description": "Run Cloudflare Code Mode MCP servers with secure-exec", + "type": "module", + "license": "MIT", + "repository": { + "directory": "packages/secure-exec-mcp", + "type": "git", + "url": "git+https://github.com/rivet-dev/agents.git" + }, + "bugs": { + "url": "https://github.com/rivet-dev/agents/issues" + }, + "author": "Rivet", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsx ./scripts/build.ts", + "test": "vitest run" + }, + "dependencies": { + "@cloudflare/codemode": "workspace:*", + "@modelcontextprotocol/sdk": "1.29.0", + "acorn": "^8.17.0", + "secure-exec": "^0.3.3" + }, + "devDependencies": { + "tsdown": "^0.22.3", + "vitest": "4.1.9" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/secure-exec-mcp/scripts/build.ts b/packages/secure-exec-mcp/scripts/build.ts new file mode 100644 index 0000000000..1187a0be4e --- /dev/null +++ b/packages/secure-exec-mcp/scripts/build.ts @@ -0,0 +1,24 @@ +import { build } from "tsdown"; +import { formatDeclarationFiles } from "../../../scripts/format-declarations"; + +async function main() { + await build({ + clean: true, + dts: true, + entry: ["src/index.ts"], + deps: { + skipNodeModulesBundle: true + }, + format: "esm", + sourcemap: true, + fixedExtension: false, + platform: "node" + }); + + formatDeclarationFiles(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/secure-exec-mcp/src/index.ts b/packages/secure-exec-mcp/src/index.ts new file mode 100644 index 0000000000..00be8fa36f --- /dev/null +++ b/packages/secure-exec-mcp/src/index.ts @@ -0,0 +1,13 @@ +export { + codeMcpServer, + openApiMcpServer, + type CodeMcpServerOptions, + type OpenApiMcpRequestContext, + type OpenApiMcpServerOptions, + type RequestOptions +} from "@cloudflare/codemode/mcp"; + +export { + SecureExecExecutor, + type SecureExecExecutorOptions +} from "./secure-exec-executor"; diff --git a/packages/secure-exec-mcp/src/secure-exec-executor.test.ts b/packages/secure-exec-mcp/src/secure-exec-executor.test.ts new file mode 100644 index 0000000000..6cf58b15eb --- /dev/null +++ b/packages/secure-exec-mcp/src/secure-exec-executor.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ResolvedProvider } from "@cloudflare/codemode"; +import { openApiMcpServer } from "./index"; +import { SecureExecExecutor } from "./secure-exec-executor"; + +function provider( + fns: ResolvedProvider["fns"], + name = "codemode" +): ResolvedProvider { + return { name, fns }; +} + +async function connectClient(server: McpServer): Promise { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + return client; +} + +function callText(result: Awaited>): string { + return (result.content as Array<{ type: string; text: string }>)[0].text; +} + +describe("SecureExecExecutor", () => { + it("executes code inside secure-exec", async () => { + const executor = new SecureExecExecutor(); + const result = await executor.execute("async () => 42", [provider({})]); + + expect(result).toEqual({ result: 42, logs: undefined }); + }); + + it("dispatches provider calls and captures logs", async () => { + const executor = new SecureExecExecutor(); + const result = await executor.execute( + `async () => { + console.log("adding"); + return codemode.add({ a: 20, b: 22 }); + }`, + [ + provider({ + add: async (...args: unknown[]) => { + const input = args[0] as { a: number; b: number }; + return input.a + input.b; + } + }) + ] + ); + + expect(result.result).toBe(42); + expect(result.error).toBeUndefined(); + expect(result.logs).toContain("adding"); + }); + + it("round-trips binary provider values", async () => { + const executor = new SecureExecExecutor(); + const result = await executor.execute( + `async () => { + const bytes = await codemode.echo(new Uint8Array([1, 2, 3])); + return { + isBytes: bytes instanceof Uint8Array, + values: Array.from(bytes) + }; + }`, + [ + provider({ + echo: async (...args: unknown[]) => args[0] + }) + ] + ); + + expect(result).toEqual({ + result: { isBytes: true, values: [1, 2, 3] }, + logs: undefined + }); + }); + + it("returns guest exceptions through the Executor result", async () => { + const executor = new SecureExecExecutor(); + const result = await executor.execute( + `async () => { throw new Error("boom"); }`, + [provider({})] + ); + + expect(result.error).toBe("boom"); + expect(result.result).toBeUndefined(); + }); + + it("rejects provider name and tool sanitization collisions", async () => { + const executor = new SecureExecExecutor(); + + await expect( + executor.execute("async () => 42", [ + provider({ "a-b": async () => 1, a_b: async () => 2 }) + ]) + ).resolves.toMatchObject({ + error: expect.stringContaining("both sanitize") + }); + + await expect( + executor.execute("async () => 42", [provider({}, "Promise")]) + ).resolves.toEqual({ + result: undefined, + error: 'Provider name "Promise" is reserved' + }); + }); + + it("powers OpenAPI search and execute MCP tools", async () => { + const server = openApiMcpServer({ + spec: { + openapi: "3.0.0", + paths: { + "/users": { + get: { summary: "List users" } + } + } + }, + executor: new SecureExecExecutor(), + request: async (options) => ({ + method: options.method, + path: options.path, + source: "trusted-host" + }) + }); + const client = await connectClient(server); + + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name).sort()).toEqual([ + "execute", + "search" + ]); + + const search = await client.callTool({ + name: "search", + arguments: { + code: "async () => Object.keys((await codemode.spec()).paths)" + } + }); + expect(JSON.parse(callText(search))).toEqual(["/users"]); + + const execute = await client.callTool({ + name: "execute", + arguments: { + code: `async () => codemode.request({ + method: "GET", + path: "/users" + })` + } + }); + expect(JSON.parse(callText(execute))).toEqual({ + method: "GET", + path: "/users", + source: "trusted-host" + }); + + await client.close(); + }); +}); diff --git a/packages/secure-exec-mcp/src/secure-exec-executor.ts b/packages/secure-exec-mcp/src/secure-exec-executor.ts new file mode 100644 index 0000000000..ec50c0b2f0 --- /dev/null +++ b/packages/secure-exec-mcp/src/secure-exec-executor.ts @@ -0,0 +1,608 @@ +import * as acorn from "acorn"; +import { Buffer } from "node:buffer"; +import { NodeRuntime } from "secure-exec"; +import type { + ConnectorBinding, + ExecuteOptions, + ExecuteResult, + Executor, + ResolvedProvider +} from "@cloudflare/codemode"; +import type { + BindingDefinition, + NodeRuntimeCreateOptions, + NodeRuntimeRunResult +} from "secure-exec"; + +const DEFAULT_EXECUTION_TIMEOUT_MS = 60_000; +const BINARY_TAG = "__codemode_binary_v1__"; +const CONNECTOR_CONTROL_KEY = "__codemode_control__"; +const PAUSE_SENTINEL = "__CODEMODE_PAUSE__"; + +type ProviderFunctions = Record< + string, + (...args: unknown[]) => Promise +>; + +type GuestExecutionEnvelope = { + result?: unknown; + error?: string; + logs?: string[]; +}; + +export interface SecureExecExecutorOptions { + /** Per-execution timeout in milliseconds. Defaults to 60 seconds. */ + timeout?: number; + /** + * Options forwarded to `NodeRuntime.create()`. Each execution receives a + * fresh VM. Provider bindings are owned by the executor and cannot be set + * here. + */ + runtimeOptions?: Omit; +} + +const JS_RESERVED = new Set([ + "abstract", + "arguments", + "await", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "double", + "else", + "enum", + "eval", + "export", + "extends", + "false", + "final", + "finally", + "float", + "for", + "function", + "goto", + "if", + "implements", + "import", + "in", + "instanceof", + "int", + "interface", + "let", + "long", + "native", + "new", + "null", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "true", + "try", + "typeof", + "undefined", + "var", + "void", + "volatile", + "while", + "with", + "yield" +]); + +const RESERVED_NAMES = new Set([ + "__connectors", + "__logs", + "__secureExecReturn", + "__CODEMODE_BINARY_TAG", + "__bytesToBase64", + "__base64ToBytes", + "__encodeCodemodeValue", + "__decodeCodemodeValue", + "__stringifyForCodemode", + "__parseForCodemode", + "__encodeForTransport", + "callBinding", + "Promise", + "setTimeout", + "Error", + "console" +]); + +const VALID_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +const SANDBOX_CODEC = String.raw` +const __CODEMODE_BINARY_TAG = "__codemode_binary_v1__"; +function __bytesToBase64(bytes) { + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.byteLength; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.byteLength))); + } + return btoa(binary); +} +function __base64ToBytes(b64) { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} +function __encodeCodemodeValue(value) { + if (value instanceof Uint8Array) { + return { [__CODEMODE_BINARY_TAG]: "Uint8Array", data: __bytesToBase64(value) }; + } + if (value instanceof ArrayBuffer) { + return { [__CODEMODE_BINARY_TAG]: "ArrayBuffer", data: __bytesToBase64(new Uint8Array(value)) }; + } + if (ArrayBuffer.isView(value)) { + return { [__CODEMODE_BINARY_TAG]: "ArrayBufferView", data: __bytesToBase64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)) }; + } + return value; +} +function __decodeCodemodeValue(value) { + if (!value || typeof value !== "object" || !(__CODEMODE_BINARY_TAG in value) || typeof value.data !== "string") return value; + const bytes = __base64ToBytes(value.data); + if (value[__CODEMODE_BINARY_TAG] === "ArrayBuffer") { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + return bytes; +} +function __stringifyForCodemode(value) { + return JSON.stringify(value, (_key, nested) => __encodeCodemodeValue(nested)); +} +function __parseForCodemode(json) { + return JSON.parse(json, (_key, nested) => __decodeCodemodeValue(nested)); +} +function __encodeForTransport(value) { + const json = __stringifyForCodemode(value); + return json === undefined ? undefined : JSON.parse(json); +} +`; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function sanitizeToolName(name: string): string { + if (!name) return "_"; + let sanitized = name.replace(/[-.\s]/g, "_"); + sanitized = sanitized.replace(/[^a-zA-Z0-9_$]/g, ""); + if (!sanitized) return "_"; + if (/^[0-9]/.test(sanitized)) sanitized = `_${sanitized}`; + if (JS_RESERVED.has(sanitized)) sanitized = `${sanitized}_`; + return sanitized; +} + +function stripCodeFences(code: string): string { + const fenced = + /^```(?:js|javascript|typescript|ts|tsx|jsx)?\s*\n([\s\S]*?)```\s*$/; + const match = code.match(fenced); + return match ? match[1] : code; +} + +function normalizeCode(code: string): string { + const source = stripCodeFences(code.trim()).trim(); + if (!source) return "async () => {}"; + + try { + const ast = acorn.parse(source, { + ecmaVersion: "latest", + sourceType: "module" + }); + + if (ast.body.length === 1 && ast.body[0].type === "ExpressionStatement") { + const expression = ast.body[0].expression; + if (expression.type === "ArrowFunctionExpression") return source; + } + + if ( + ast.body.length === 1 && + ast.body[0].type === "ExportDefaultDeclaration" + ) { + const declaration = ast.body[0].declaration; + const inner = source.slice(declaration.start, declaration.end); + if ( + declaration.type === "FunctionDeclaration" && + declaration.id === null + ) { + return `async () => {\nreturn (${inner})();\n}`; + } + if (declaration.type === "ClassDeclaration" && declaration.id === null) { + return `async () => {\nreturn (${inner});\n}`; + } + return normalizeCode(inner); + } + + if (ast.body.length === 1 && ast.body[0].type === "FunctionDeclaration") { + const name = ast.body[0].id?.name ?? "fn"; + return `async () => {\n${source}\nreturn ${name}();\n}`; + } + + const last = ast.body[ast.body.length - 1]; + if (last?.type === "ExpressionStatement") { + const before = source.slice(0, last.start); + const expression = source.slice( + last.expression.start, + last.expression.end + ); + return `async () => {\n${before}return (${expression})\n}`; + } + + return `async () => {\n${source}\n}`; + } catch { + return `async () => {\n${source}\n}`; + } +} + +function encodeCodemodeValue(value: unknown): unknown { + if (value instanceof Uint8Array) { + return { + [BINARY_TAG]: "Uint8Array", + data: Buffer.from(value).toString("base64") + }; + } + if (value instanceof ArrayBuffer) { + return { + [BINARY_TAG]: "ArrayBuffer", + data: Buffer.from(new Uint8Array(value)).toString("base64") + }; + } + if (ArrayBuffer.isView(value)) { + return { + [BINARY_TAG]: "ArrayBufferView", + data: Buffer.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + ).toString("base64") + }; + } + return value; +} + +function decodeCodemodeValue(value: unknown): unknown { + if (!value || typeof value !== "object" || !(BINARY_TAG in value)) { + return value; + } + const encoded = value as Record; + if (typeof encoded.data !== "string") return value; + const buffer = Buffer.from(encoded.data, "base64"); + const bytes = new Uint8Array( + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) + ); + return encoded[BINARY_TAG] === "ArrayBuffer" ? bytes.buffer : bytes; +} + +function encodeForTransport(value: unknown): unknown { + const json = JSON.stringify(value, (_key, nested) => + encodeCodemodeValue(nested) + ); + return json === undefined ? undefined : JSON.parse(json); +} + +function decodeFromTransport(value: unknown): unknown { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value), (_key, nested) => + decodeCodemodeValue(nested) + ); +} + +function validateNamespace( + name: string, + seenNames: Set +): string | null { + if (RESERVED_NAMES.has(name)) { + return `Provider name "${name}" is reserved`; + } + if (!VALID_IDENTIFIER.test(name)) { + return `Provider name "${name}" is not a valid JavaScript identifier`; + } + if (seenNames.has(name)) { + return `Duplicate name "${name}"`; + } + seenNames.add(name); + return null; +} + +function sanitizeProviderFunctions(provider: ResolvedProvider): { + fns?: ProviderFunctions; + error?: string; +} { + const fns: ProviderFunctions = {}; + const sanitizedNames = new Map(); + + for (const [name, fn] of Object.entries(provider.fns)) { + const sanitized = sanitizeToolName(name); + const existing = sanitizedNames.get(sanitized); + if (existing && existing !== name) { + return { + error: + `Tool names "${existing}" and "${name}" both sanitize to ` + + `"${sanitized}" in provider "${provider.name}"` + }; + } + sanitizedNames.set(sanitized, name); + fns[sanitized] = fn; + } + + return { fns }; +} + +function parseBridgeInput(input: unknown): { tool: string; args: unknown[] } { + if (!input || typeof input !== "object") { + throw new Error("Code Mode bridge input must be an object"); + } + const record = input as Record; + if (typeof record.tool !== "string" || !Array.isArray(record.args)) { + throw new Error("Code Mode bridge input requires tool and args fields"); + } + return { + tool: record.tool, + args: decodeFromTransport(record.args) as unknown[] + }; +} + +function bridgeBinding( + fns: ProviderFunctions, + timeoutMs: number +): BindingDefinition { + return { + description: "Dispatch calls from an isolated Code Mode execution.", + inputSchema: { + type: "object", + properties: { + tool: { type: "string" }, + args: { type: "array" } + }, + required: ["tool", "args"], + additionalProperties: false + }, + timeoutMs, + handler: async (input) => { + const { tool, args } = parseBridgeInput(input); + const fn = fns[tool]; + if (!fn) throw new Error(`Tool "${tool}" not found`); + return { result: encodeForTransport(await fn(...args)) }; + } + }; +} + +function connectorBinding( + connector: ConnectorBinding, + timeoutMs: number +): BindingDefinition { + return { + description: + "Dispatch connector calls from an isolated Code Mode execution.", + inputSchema: { + type: "object", + properties: { + tool: { type: "string" }, + args: { type: "array" } + }, + required: ["tool", "args"], + additionalProperties: false + }, + timeoutMs, + handler: async (input) => { + const { tool, args } = parseBridgeInput(input); + const result = await connector.binding.callTool(tool, args[0]); + return { result: encodeForTransport(result) }; + } + }; +} + +function providerProxySource(name: string, bindingName: string): string { + return `const ${name} = new Proxy({}, { + get: (target, toolName) => { + if (Object.prototype.hasOwnProperty.call(target, toolName)) return target[toolName]; + if (typeof toolName !== "string") return undefined; + return async (...args) => { + const response = await callBinding(${JSON.stringify(bindingName)}, { + tool: String(toolName), + args: __encodeForTransport(args) + }); + const result = response?.result; + return result === undefined ? undefined : __parseForCodemode(JSON.stringify(result)); + }; + } +});`; +} + +function connectorProxySource(name: string, bindingName: string): string { + return `const ${name} = new Proxy({}, { + get: (_, toolName) => { + if (typeof toolName !== "string") return undefined; + return async (...args) => { + const response = await callBinding(${JSON.stringify(bindingName)}, { + tool: String(toolName), + args: __encodeForTransport(args) + }); + const result = response?.result; + if (result && typeof result === "object") { + if (result.${CONNECTOR_CONTROL_KEY} === "pause") throw new Error(${JSON.stringify(PAUSE_SENTINEL)}); + if (result.${CONNECTOR_CONTROL_KEY} === "error") throw new Error(String(result.message)); + } + return result === undefined ? undefined : __parseForCodemode(JSON.stringify(result)); + }; + } +});`; +} + +function lines(text: string, prefix = ""): string[] { + return text + .split(/\r?\n/) + .filter(Boolean) + .map((line) => `${prefix}${line}`); +} + +function mergeRuntimeLogs( + run: NodeRuntimeRunResult +): string[] | undefined { + const logs = [ + ...(run.value?.logs ?? []), + ...lines(run.stdout), + ...lines(run.stderr, "[stderr] ") + ]; + return logs.length > 0 ? logs : undefined; +} + +function appendError(primary: string, secondary: string): string { + return `${primary}; ${secondary}`; +} + +/** + * Cloudflare Code Mode `Executor` backed by a fresh secure-exec VM for every + * call. Guest networking remains denied unless explicitly enabled through + * `runtimeOptions.permissions`. + */ +export class SecureExecExecutor implements Executor { + readonly #timeout: number; + readonly #runtimeOptions: Omit; + + constructor(options: SecureExecExecutorOptions = {}) { + this.#timeout = options.timeout ?? DEFAULT_EXECUTION_TIMEOUT_MS; + this.#runtimeOptions = options.runtimeOptions ?? {}; + } + + async execute( + code: string, + providersOrFns: ResolvedProvider[] | ProviderFunctions, + options?: ExecuteOptions + ): Promise { + const providers = Array.isArray(providersOrFns) + ? providersOrFns + : [{ name: "codemode", fns: providersOrFns }]; + const connectors = options?.connectors ?? []; + const seenNames = new Set(); + const bindings: Record = {}; + const proxySources: string[] = []; + const preludes: string[] = []; + + for (const [index, provider] of providers.entries()) { + const namespaceError = validateNamespace(provider.name, seenNames); + if (namespaceError) return { result: undefined, error: namespaceError }; + + const sanitized = sanitizeProviderFunctions(provider); + if (sanitized.error || !sanitized.fns) { + return { result: undefined, error: sanitized.error }; + } + + const bindingName = `codemode-provider-${index}`; + bindings[bindingName] = bridgeBinding(sanitized.fns, this.#timeout); + proxySources.push(providerProxySource(provider.name, bindingName)); + if (provider.prelude) preludes.push(provider.prelude); + } + + for (const [index, connector] of connectors.entries()) { + const namespaceError = validateNamespace(connector.name, seenNames); + if (namespaceError) return { result: undefined, error: namespaceError }; + + const bindingName = `codemode-connector-${index}`; + bindings[bindingName] = connectorBinding(connector, this.#timeout); + proxySources.push(connectorProxySource(connector.name, bindingName)); + } + + const normalized = normalizeCode(code); + const program = ` +const __secureExecReturn = globalThis.__return; +delete globalThis.__return; +const __logs = []; +console.log = (...args) => { __logs.push(args.map(String).join(" ")); }; +console.warn = (...args) => { __logs.push("[warn] " + args.map(String).join(" ")); }; +console.error = (...args) => { __logs.push("[error] " + args.map(String).join(" ")); }; +${SANDBOX_CODEC} +${proxySources.join("\n")} +${preludes.join("\n")} +try { + const result = await (${normalized})(); + __secureExecReturn({ result: __encodeForTransport(result), logs: __logs }); +} catch (error) { + __secureExecReturn({ + error: error instanceof Error ? error.message : String(error), + logs: __logs + }); +} +`; + + let runtime: NodeRuntime | undefined; + let outcome: ExecuteResult; + + try { + runtime = await NodeRuntime.create({ + ...this.#runtimeOptions, + permissions: { + network: "deny", + ...this.#runtimeOptions.permissions + }, + bindings + }); + const run = await runtime.run(program, { + timeout: this.#timeout + }); + const logs = mergeRuntimeLogs(run); + + if (run.value?.error) { + outcome = { result: undefined, error: run.value.error, logs }; + } else if (run.exitCode !== 0) { + outcome = { + result: undefined, + error: + run.stderr.trim() || + `secure-exec guest exited with code ${run.exitCode}`, + logs + }; + } else if (!run.value) { + outcome = { + result: undefined, + error: "secure-exec guest did not return an execution result", + logs + }; + } else { + outcome = { + result: decodeFromTransport(run.value.result), + logs + }; + } + } catch (error) { + outcome = { result: undefined, error: errorMessage(error) }; + } + + if (runtime) { + try { + await runtime.dispose(); + } catch (error) { + const disposeError = `secure-exec VM disposal failed: ${errorMessage(error)}`; + outcome = { + result: undefined, + error: outcome.error + ? appendError(outcome.error, disposeError) + : disposeError, + logs: outcome.logs + }; + } + } + + return outcome; + } +} diff --git a/packages/secure-exec-mcp/tsconfig.json b/packages/secure-exec-mcp/tsconfig.json new file mode 100644 index 0000000000..7f0d26ac93 --- /dev/null +++ b/packages/secure-exec-mcp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../agents/agents.tsconfig.json", + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/secure-exec-mcp/vitest.config.ts b/packages/secure-exec-mcp/vitest.config.ts new file mode 100644 index 0000000000..f3bb215aaf --- /dev/null +++ b/packages/secure-exec-mcp/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + testTimeout: 120_000 + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 787b316fba..3582f64069 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3951,6 +3951,28 @@ importers: specifier: ^4.12.27 version: 4.12.27 + packages/secure-exec-mcp: + dependencies: + '@cloudflare/codemode': + specifier: workspace:* + version: link:../codemode + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) + acorn: + specifier: ^8.17.0 + version: 8.17.0 + secure-exec: + specifier: ^0.3.3 + version: 0.3.3 + devDependencies: + tsdown: + specifier: ^0.22.3 + version: 0.22.3(@typescript/native-preview@7.0.0-dev.20260629.1)(tsx@4.22.4)(typescript@6.0.3) + vitest: + specifier: 4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.0.1)(typescript@6.0.3))(vite@8.1.0(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/shell: dependencies: '@cloudflare/codemode': @@ -6865,6 +6887,10 @@ packages: '@remix-run/node-fetch-server@0.13.3': resolution: {integrity: sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==} + '@rivetkit/bare-ts@0.6.2': + resolution: {integrity: sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg==} + engines: {node: ^14.18.0 || >=16.0.0} + '@rolldown/binding-android-arm64@1.1.2': resolution: {integrity: sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7142,6 +7168,39 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@secure-exec/core@0.3.3': + resolution: {integrity: sha512-atqGtAW+uFzbk6wx2n4GcVN6SLWJ3Pf6sYQ61vVyOF+od2wI5MiWJXEk/wvsKi9MRYe1A+NdiwuaqUDVIvZyXQ==} + + '@secure-exec/sidecar-darwin-arm64@0.3.3': + resolution: {integrity: sha512-9z8hx9N+Al96FV+d5XOWWIrdkCslaWrteaYboVeMsa18cqOxOhyBr/nrvhqNSaFcSa8xL2pZbPTnY9lKDWV+2Q==} + engines: {node: '>=20'} + cpu: [arm64] + os: [darwin] + + '@secure-exec/sidecar-darwin-x64@0.3.3': + resolution: {integrity: sha512-KX2GJ0wLSxSDfE+8DSRP4y/tpSMnFtqMty8SQsTKQLi3ml91GqESYA8TREmV2JfbRyxwWSx+Ep96gnv9vjlHZw==} + engines: {node: '>=20'} + cpu: [x64] + os: [darwin] + + '@secure-exec/sidecar-linux-arm64-gnu@0.3.3': + resolution: {integrity: sha512-ZjeGXxX/c6YfFpgt1sfUubV8umdsooS+jFg+A+ODfJnuF6F6dq/t+GJN4vl/BFZm18gmvdE4IE52iJoOqYar8A==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@secure-exec/sidecar-linux-x64-gnu@0.3.3': + resolution: {integrity: sha512-HGrMqJRO36ZltG4EeaozGpY4MNSxA9S4DZXCN91Vfy4kmh1eCzZl95YPFDOAvEgdGy0gesgt+BEWvPKyh7U/ag==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@secure-exec/sidecar@0.3.3': + resolution: {integrity: sha512-0HgjSks96FgzRD7HoApCX8wIh2W2cUHIWeKO7Qw+LwSlzVcqHvhLbeHmJdvujEpTrST0yyLwg40km5PqYzJoOw==} + engines: {node: '>=20'} + '@shikijs/core@3.23.0': resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} @@ -10988,6 +11047,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-exec@0.3.3: + resolution: {integrity: sha512-s+d+E7CUbXBHHPnBq7rSSuYD4hJP6g+CLyu11NA247sto074n0MeigyMR077H2jUkTRfeohNwYc8WP3doTtpGA==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -14344,6 +14406,8 @@ snapshots: '@remix-run/node-fetch-server@0.13.3': {} + '@rivetkit/bare-ts@0.6.2': {} + '@rolldown/binding-android-arm64@1.1.2': optional: true @@ -14504,6 +14568,31 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@secure-exec/core@0.3.3': + dependencies: + '@rivetkit/bare-ts': 0.6.2 + '@secure-exec/sidecar': 0.3.3 + zod: 4.4.3 + + '@secure-exec/sidecar-darwin-arm64@0.3.3': + optional: true + + '@secure-exec/sidecar-darwin-x64@0.3.3': + optional: true + + '@secure-exec/sidecar-linux-arm64-gnu@0.3.3': + optional: true + + '@secure-exec/sidecar-linux-x64-gnu@0.3.3': + optional: true + + '@secure-exec/sidecar@0.3.3': + optionalDependencies: + '@secure-exec/sidecar-darwin-arm64': 0.3.3 + '@secure-exec/sidecar-darwin-x64': 0.3.3 + '@secure-exec/sidecar-linux-arm64-gnu': 0.3.3 + '@secure-exec/sidecar-linux-x64-gnu': 0.3.3 + '@shikijs/core@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -19075,6 +19164,10 @@ snapshots: scheduler@0.27.0: {} + secure-exec@0.3.3: + dependencies: + '@secure-exec/core': 0.3.3 + secure-json-parse@4.1.0: {} seek-bzip@2.0.0: From 2ef273c44a7edcd4d0af4ff78fff35b0f372cc06 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sat, 18 Jul 2026 19:13:43 -0700 Subject: [PATCH 2/3] docs(codemode): document and validate secure-exec MCP --- packages/secure-exec-mcp/README.md | 49 ++++++++++++++++++- packages/secure-exec-mcp/package.json | 1 + .../src/secure-exec-executor.test.ts | 40 ++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/secure-exec-mcp/README.md b/packages/secure-exec-mcp/README.md index f73b36747b..e7985b1d64 100644 --- a/packages/secure-exec-mcp/README.md +++ b/packages/secure-exec-mcp/README.md @@ -5,12 +5,22 @@ Dynamic Workers. Each execution gets a fresh secure-exec VM, denied guest network access by default, and controlled host capabilities for Code Mode providers and connectors. +This package re-exports Cloudflare's `codeMcpServer` and `openApiMcpServer` +wrappers with a `SecureExecExecutor` implementation. It runs on Node.js and +does not require Cloudflare Workers or the Dynamic Worker Loader API. + ## Install +The package is currently available from this fork and has not been published to +npm yet. From another pnpm workspace, point the dependency at a local checkout: + ```sh -npm install @secure-exec/mcp +pnpm add @secure-exec/mcp@file:../agents/packages/secure-exec-mcp ``` +After the first release, the normal install command will be +`pnpm add @secure-exec/mcp`. + ## OpenAPI Code Mode MCP server ```ts @@ -53,6 +63,30 @@ The MCP server exposes `search({ code })` and `execute({ code })`. API credentials stay in the trusted `request` callback and are never projected into the VM. +## Wrap an existing MCP server + +```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { codeMcpServer, SecureExecExecutor } from "@secure-exec/mcp"; + +const upstream = new McpServer({ + name: "acme-tools", + version: "1.0.0" +}); + +// Register the normal upstream tools on `upstream`, then replace their large +// tool catalog with one Code Mode tool backed by a secure-exec VM. +const server = await codeMcpServer({ + server: upstream, + executor: new SecureExecExecutor() +}); +``` + +`codeMcpServer` discovers the upstream server through an in-memory MCP +transport. Generated code sees the discovered tools as typed `codemode.*` +methods, while the actual handlers and their credentials stay in trusted host +code. + ## Use the executor directly `SecureExecExecutor` implements the portable `Executor` contract from @@ -80,3 +114,16 @@ const result = await executor.execute( Provider callbacks run on the trusted host. Generated code runs in the VM and can only reach those callbacks through secure-exec's schema-validated binding path. + +## Validate the integration + +```sh +pnpm --dir packages/secure-exec-mcp test +pnpm --dir packages/secure-exec-mcp build +``` + +The integration suite connects an actual MCP SDK client to both Cloudflare Code +Mode wrappers. It verifies the OpenAPI `search` and `execute` tools, wraps and +calls a tool on an ordinary MCP server through the `code` tool, executes the +generated JavaScript in secure-exec, and confirms that privileged handlers run +through trusted host callbacks. diff --git a/packages/secure-exec-mcp/package.json b/packages/secure-exec-mcp/package.json index 1ef847e1c2..8be0af6ffa 100644 --- a/packages/secure-exec-mcp/package.json +++ b/packages/secure-exec-mcp/package.json @@ -26,6 +26,7 @@ ], "scripts": { "build": "tsx ./scripts/build.ts", + "pretest": "pnpm --filter @cloudflare/codemode build", "test": "vitest run" }, "dependencies": { diff --git a/packages/secure-exec-mcp/src/secure-exec-executor.test.ts b/packages/secure-exec-mcp/src/secure-exec-executor.test.ts index 6cf58b15eb..7b3b0b0501 100644 --- a/packages/secure-exec-mcp/src/secure-exec-executor.test.ts +++ b/packages/secure-exec-mcp/src/secure-exec-executor.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ResolvedProvider } from "@cloudflare/codemode"; -import { openApiMcpServer } from "./index"; +import { codeMcpServer, openApiMcpServer } from "./index"; import { SecureExecExecutor } from "./secure-exec-executor"; function provider( @@ -159,4 +159,40 @@ describe("SecureExecExecutor", () => { await client.close(); }); + + it("wraps an existing MCP server with the code tool", async () => { + const upstream = new McpServer({ + name: "upstream-test-server", + version: "1.0.0" + }); + upstream.registerTool( + "answer", + { + description: "Return an answer from trusted host code", + inputSchema: {} + }, + async () => ({ + content: [{ type: "text", text: JSON.stringify({ answer: 42 }) }] + }) + ); + + const server = await codeMcpServer({ + server: upstream, + executor: new SecureExecExecutor() + }); + const client = await connectClient(server); + + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toEqual(["code"]); + + const result = await client.callTool({ + name: "code", + arguments: { + code: "async () => codemode.answer({})" + } + }); + expect(JSON.parse(callText(result))).toEqual({ answer: 42 }); + + await client.close(); + }); }); From 01be72baaa5f34c00803bea13b8367a7ff262866 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 00:31:23 -0700 Subject: [PATCH 3/3] chore: update repository metadata --- packages/secure-exec-mcp/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/secure-exec-mcp/package.json b/packages/secure-exec-mcp/package.json index 8be0af6ffa..edc463746b 100644 --- a/packages/secure-exec-mcp/package.json +++ b/packages/secure-exec-mcp/package.json @@ -7,10 +7,10 @@ "repository": { "directory": "packages/secure-exec-mcp", "type": "git", - "url": "git+https://github.com/rivet-dev/agents.git" + "url": "git+https://github.com/rivet-dev/cloudflare-agents.git" }, "bugs": { - "url": "https://github.com/rivet-dev/agents/issues" + "url": "https://github.com/rivet-dev/cloudflare-agents/issues" }, "author": "Rivet", "types": "dist/index.d.ts",