Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions examples/codemode/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.wrangler/
build/
node_modules/
worker-configuration.d.ts
56 changes: 56 additions & 0 deletions examples/codemode/README.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions examples/codemode/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
80 changes: 80 additions & 0 deletions examples/codemode/src/codemode.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<Client["callTool"]>>) {
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;
}
}
135 changes: 135 additions & 0 deletions examples/codemode/src/computer-mcp.ts
Original file line number Diff line number Diff line change
@@ -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<ToolResult>,
) => unknown;

type ExecuteTool = (
args: unknown,
options: {
toolCallId: string;
messages: never[];
abortSignal: AbortSignal;
context: undefined;
},
) => unknown | PromiseLike<unknown>;

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<T>(start: () => T | PromiseLike<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(new Error("Computer tool call cancelled."));

return new Promise<T>((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)),
);
}
});
}
Loading
Loading