diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 522416bf..72991e3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,9 @@ jobs: - name: container workspace: "@example/computer-container" path: examples/container + - name: codemode + workspace: "@example/computer-codemode" + path: examples/codemode steps: - uses: actions/checkout@v6 with: diff --git a/README.md b/README.md index d34b0984..8369f559 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ public surface. Each is a Worker workspace with its own README. - [`examples/worker-javascript`](examples/worker-javascript) — mirrors `worker-shell`, but `exec` evaluates an ECMAScript module in a Dynamic Worker instead of running a shell command. +- [`examples/codemode`](examples/codemode) — wraps Computer's native Workspace + tools as one Codemode MCP server. - [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think) chat agent that uses the workspace as its working directory, reachable from a terminal. diff --git a/examples/codemode/.gitignore b/examples/codemode/.gitignore new file mode 100644 index 00000000..d85d546b --- /dev/null +++ b/examples/codemode/.gitignore @@ -0,0 +1,4 @@ +.wrangler/ +build/ +node_modules/ +worker-configuration.d.ts diff --git a/examples/codemode/README.md b/examples/codemode/README.md new file mode 100644 index 00000000..0e00e5eb --- /dev/null +++ b/examples/codemode/README.md @@ -0,0 +1,56 @@ +# Computer tools as one Codemode MCP server + +This example turns Computer's native Workspace tools into a single Codemode MCP tool: + +```text +MCP client + → code + → Codemode DynamicWorkerExecutor + → read / ls / write / edit / exec + → durable Computer Workspace +``` + +It does not add a Codemode backend to Computer. Codemode uses its built-in Dynamic Worker executor for the model-written orchestration code. Tool calls return to the host and invoke the same bounded Computer tools used by other integrations. Filesystem tools operate directly on durable Workspace storage; `exec` uses Computer's `worker-shell` backend with a 30-second limit per command. + +`src/computer-mcp.ts` creates an internal MCP server from `createAITools()`. Codemode's `codeMcpServer()` discovers those tools, generates their JavaScript types, and exposes only one public MCP tool named `code`. Code running in Codemode sees the native tools as methods such as: + +```js +async () => { + await codemode.write({ + path: "/workspace/message.txt", + content: "hello from codemode" + }); + const file = await codemode.read({ path: "/workspace/message.txt" }); + const shell = await codemode.exec({ command: "pwd" }); + return { content: file.content, cwd: shell.stdout.trim() }; +} +``` + +## Run locally + +From the repository root: + +```bash +npm install +npm run dev --workspace @example/computer-codemode +``` + +Connect a Streamable HTTP MCP client to: + +```text +http://127.0.0.1:8787/mcp +``` + +The server advertises one `code` tool rather than exposing every filesystem and execution operation to the model separately. The example returns stateless JSON responses over POST-only Streamable HTTP; it deliberately does not open a session-bound GET event stream. + +> [!WARNING] +> This is a local harness, not an authenticated multi-tenant service. A production host must derive the Durable Object and Workspace identity from an authenticated tenant and enforce authorization, request limits, execution limits, and storage quotas. + +## Validate + +```bash +npm run typecheck --workspace @example/computer-codemode +npm test --workspace @example/computer-codemode +``` + +The integration test connects with a real MCP client, verifies that only `code` is public, executes generated JavaScript through Codemode's Dynamic Worker, calls Computer's native filesystem and shell tools, and confirms that files persist in the durable Workspace across calls. diff --git a/examples/codemode/package.json b/examples/codemode/package.json new file mode 100644 index 00000000..49f6ba8f --- /dev/null +++ b/examples/codemode/package.json @@ -0,0 +1,27 @@ +{ + "name": "@example/computer-codemode", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Computer Workspace tools exposed through one Codemode MCP server.", + "scripts": { + "dev": "wrangler dev", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types" + }, + "dependencies": { + "@cloudflare/codemode": "^0.5.0", + "@cloudflare/computer": "*", + "@modelcontextprotocol/sdk": "1.30.0", + "ai": "^7.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.16.10", + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7", + "wrangler": "^4.107.1" + } +} diff --git a/examples/codemode/src/codemode.integration.test.ts b/examples/codemode/src/codemode.integration.test.ts new file mode 100644 index 00000000..b66cc317 --- /dev/null +++ b/examples/codemode/src/codemode.integration.test.ts @@ -0,0 +1,80 @@ +import { SELF } from "cloudflare:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { afterEach, describe, expect, it } from "vitest"; + +let client: Client | undefined; + +afterEach(async () => { + await client?.close(); + client = undefined; +}); + +describe("Computer Codemode MCP", () => { + it("exposes Computer tools through one Codemode code tool", async () => { + const stream = await SELF.fetch("https://example.test/mcp"); + expect(stream.status).toBe(405); + expect(stream.headers.get("allow")).toBe("POST"); + + client = new Client({ name: "codemode-example-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL("https://example.test/mcp"), { + fetch: (input, init) => SELF.fetch(input, init), + }); + await client.connect(transport); + + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual(["code"]); + expect(listed.tools[0]?.description).toContain("codemode.read"); + expect(listed.tools[0]?.description).toContain("exec: (input: ExecInput)"); + + const result = await client.callTool({ + name: "code", + arguments: { + code: `async () => { + await codemode.write({ path: "/workspace/message.txt", content: "hello" }); + await codemode.edit({ + path: "/workspace/message.txt", + edits: [{ oldText: "hello", newText: "hello from codemode" }] + }); + const file = await codemode.read({ path: "/workspace/message.txt" }); + const listing = await codemode.ls({ path: "/workspace" }); + const shell = await codemode.exec({ command: "pwd", backend: "worker-shell" }); + return { + content: file.content, + listed: listing.entries.some((entry) => entry.name === "message.txt"), + cwd: shell.stdout.trim() + }; + }`, + }, + }); + + expect(result.isError).not.toBe(true); + expect(readTextResult(result)).toEqual({ + content: "hello from codemode", + listed: true, + cwd: "/workspace", + }); + + const persisted = await client.callTool({ + name: "code", + arguments: { + code: `async () => { + const file = await codemode.read({ path: "/workspace/message.txt" }); + return file.content; + }`, + }, + }); + expect(readTextResult(persisted)).toBe("hello from codemode"); + }); +}); + +function readTextResult(result: Awaited>) { + const content = result.content as Array<{ type: string; text?: string }>; + const text = content.find((item) => item.type === "text"); + if (!text?.text) throw new Error("Expected a text MCP result."); + try { + return JSON.parse(text.text) as unknown; + } catch { + return text.text; + } +} diff --git a/examples/codemode/src/computer-mcp.ts b/examples/codemode/src/computer-mcp.ts new file mode 100644 index 00000000..1705a2e1 --- /dev/null +++ b/examples/codemode/src/computer-mcp.ts @@ -0,0 +1,135 @@ +import type { WorkspaceClient } from "@cloudflare/computer"; +import { createAITools } from "@cloudflare/computer/tools"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolSet } from "ai"; + +const EXEC_TIMEOUT_MS = 30_000; +const TOOL_SETTLEMENT_TIMEOUT_MS = EXEC_TIMEOUT_MS + 1_000; + +/** Build the native Computer tool server that Codemode wraps. */ +export function createComputerMcpServer(workspace: WorkspaceClient) { + const server = new McpServer({ name: "computer", version: "1.0.0" }); + const tools = createAITools({ + workspace: { + fs: workspace.fs, + runtime: { + exec(command, options) { + return workspace.runtime.exec(command, { + ...options, + timeoutMs: EXEC_TIMEOUT_MS, + }); + }, + }, + }, + assets: false, + shell: { + backends: { + "worker-shell": { + description: "A fast, isolated shell over the durable Computer workspace.", + }, + }, + defaultBackend: "worker-shell", + }, + }); + + for (const [name, tool] of Object.entries(tools)) { + registerComputerTool(server, name, tool); + } + return server; +} + +interface ToolCallContext { + signal: AbortSignal; +} + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; + +type RegisterTool = ( + name: string, + config: { description?: string; inputSchema: unknown }, + callback: (args: unknown, context: ToolCallContext) => Promise, +) => unknown; + +type ExecuteTool = ( + args: unknown, + options: { + toolCallId: string; + messages: never[]; + abortSignal: AbortSignal; + context: undefined; + }, +) => unknown | PromiseLike; + +function registerComputerTool(server: McpServer, name: string, tool: ToolSet[string]) { + if (!tool.execute) throw new Error(`Computer tool ${name} is not executable.`); + const execute = tool.execute as ExecuteTool; + const registerTool = server.registerTool.bind(server) as RegisterTool; + registerTool( + name, + { + description: typeof tool.description === "string" ? tool.description : undefined, + inputSchema: tool.inputSchema, + }, + async (args, context) => { + try { + const value = await settle( + () => + execute(args, { + toolCallId: `mcp:${name}`, + messages: [], + abortSignal: context.signal, + context: undefined, + }), + context.signal, + ); + return { + content: [{ type: "text", text: JSON.stringify(value) ?? "undefined" }], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + ); +} + +function settle(start: () => T | PromiseLike, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new Error("Computer tool call cancelled.")); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (complete: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + signal.removeEventListener("abort", cancel); + complete(); + }; + const timeout = setTimeout( + () => finish(() => reject(new Error("Computer tool call timed out."))), + TOOL_SETTLEMENT_TIMEOUT_MS, + ); + const cancel = () => finish(() => reject(new Error("Computer tool call cancelled."))); + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + + if (!settled) { + Promise.resolve() + .then(start) + .then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)), + ); + } + }); +} diff --git a/examples/codemode/src/index.ts b/examples/codemode/src/index.ts new file mode 100644 index 00000000..39373c5d --- /dev/null +++ b/examples/codemode/src/index.ts @@ -0,0 +1,83 @@ +import { DurableObject } from "cloudflare:workers"; +import { DynamicWorkerExecutor } from "@cloudflare/codemode"; +import { codeMcpServer } from "@cloudflare/codemode/mcp"; +import { + type DurableObjectStorageLike, + getWorkspace, + WorkspaceServiceProxy, + withWorkspace, +} from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; + +import { createComputerMcpServer } from "./computer-mcp.js"; + +interface Env { + LOADER: WorkerLoader; + CodemodeExample: DurableObjectNamespace; +} + +export { WorkspaceServiceProxy }; + +const CodemodeExampleBase = withWorkspace(class extends DurableObject {}, (self) => { + const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env }; + return { + storage: ctx.storage as unknown as DurableObjectStorageLike, + sessionId: ctx.id.toString(), + backends: [ + new WorkerShellBackend({ + loader: env.LOADER, + workspace: { binding: "CodemodeExample", id: ctx.id.toString() }, + ctx, + }), + ], + }; +}); + +/** A durable Computer workspace exposed as one Codemode MCP server. */ +export class CodemodeExample extends CodemodeExampleBase { + async fetch(request: Request): Promise { + if (new URL(request.url).pathname !== "/mcp") { + return new Response("Connect an MCP client at /mcp.\n", { status: 404 }); + } + if (request.method !== "POST") return methodNotAllowed(); + + const workspace = await getWorkspace(this); + const computer = createComputerMcpServer(workspace); + let server: Awaited> | undefined; + try { + server = await codeMcpServer({ + server: computer, + executor: new DynamicWorkerExecutor({ + loader: this.env.LOADER, + globalOutbound: null, + }), + }); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + await server.connect(transport); + return await transport.handleRequest(request); + } finally { + await Promise.allSettled([...(server ? [server.close()] : []), computer.close()]); + } + } +} + +export default { + fetch(request: Request, env: Env) { + if (new URL(request.url).pathname !== "/mcp") { + return new Response("Connect an MCP client at /mcp.\n", { status: 404 }); + } + if (request.method !== "POST") return methodNotAllowed(); + return env.CodemodeExample.get(env.CodemodeExample.idFromName("example")).fetch(request); + }, +} satisfies ExportedHandler; + +function methodNotAllowed() { + return new Response("The stateless MCP endpoint accepts POST requests only.\n", { + status: 405, + headers: { allow: "POST" }, + }); +} diff --git a/examples/codemode/tsconfig.json b/examples/codemode/tsconfig.json new file mode 100644 index 00000000..6dbb76cf --- /dev/null +++ b/examples/codemode/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "ESNext.Disposable"], + "types": [ + "@cloudflare/workers-types", + "@cloudflare/vitest-pool-workers/types", + "vitest/globals" + ], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/codemode/vitest.config.ts b/examples/codemode/vitest.config.ts new file mode 100644 index 00000000..8ed09668 --- /dev/null +++ b/examples/codemode/vitest.config.ts @@ -0,0 +1,13 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.test.jsonc" }, + }), + ], + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/examples/codemode/wrangler.jsonc b/examples/codemode/wrangler.jsonc new file mode 100644 index 00000000..86acde81 --- /dev/null +++ b/examples/codemode/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-codemode-example", + "main": "src/index.ts", + "compatibility_date": "2026-06-17", + "compatibility_flags": ["nodejs_compat", "experimental"], + "worker_loaders": [{ "binding": "LOADER" }], + "durable_objects": { + "bindings": [{ "name": "CodemodeExample", "class_name": "CodemodeExample" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CodemodeExample"] }] +} diff --git a/examples/codemode/wrangler.test.jsonc b/examples/codemode/wrangler.test.jsonc new file mode 100644 index 00000000..ffb20465 --- /dev/null +++ b/examples/codemode/wrangler.test.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "workspace-codemode-test", + "main": "src/index.ts", + "compatibility_date": "2026-06-17", + "compatibility_flags": ["nodejs_compat", "experimental"], + "worker_loaders": [{ "binding": "LOADER" }], + "durable_objects": { + "bindings": [{ "name": "CodemodeExample", "class_name": "CodemodeExample" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CodemodeExample"] }] +} diff --git a/package-lock.json b/package-lock.json index 18ea2cf7..6a3d067f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,31 @@ "dev": true, "license": "MIT OR Apache-2.0" }, + "examples/codemode": { + "name": "@example/computer-codemode", + "version": "0.0.0", + "dependencies": { + "@cloudflare/codemode": "^0.5.0", + "@cloudflare/computer": "*", + "@modelcontextprotocol/sdk": "1.30.0", + "ai": "^7.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.16.10", + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7", + "wrangler": "^4.107.1" + } + }, + "examples/codemode/node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "examples/container": { "name": "@example/computer-container", "version": "0.0.0", @@ -2833,6 +2858,10 @@ "resolved": "examples/assets", "link": true }, + "node_modules/@example/computer-codemode": { + "resolved": "examples/codemode", + "link": true + }, "node_modules/@example/computer-container": { "resolved": "examples/container", "link": true