Skip to content

Commit d58edb0

Browse files
committed
examples/mcp: Add deployable Computer MCP
1 parent 9422fc8 commit d58edb0

15 files changed

Lines changed: 678 additions & 0 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/computerd": patch
3+
---
4+
5+
Publish a computerd image compatible with the current Computer runtime.

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ jobs:
143143
workspace: "@example/computer-container"
144144
path: examples/container
145145
typegen: "npx wrangler types"
146+
- name: mcp
147+
workspace: "@example/computer-mcp"
148+
path: examples/mcp
146149
- name: think
147150
workspace: "@cloudflare/example-think"
148151
path: examples/think

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ public surface. Each is a Worker workspace with its own README.
6060
- [`examples/worker-javascript`](examples/worker-javascript) — mirrors
6161
`worker-shell`, but `exec` evaluates an ECMAScript module in a Dynamic
6262
Worker instead of running a shell command.
63+
- [`examples/mcp`](examples/mcp) — a Computer MCP example:
64+
one Code Mode `code` tool backed by a durable workspace, a Worker shell,
65+
and a full Linux container.
6366
- [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think)
6467
chat agent that uses the workspace as its working directory, reachable
6568
from a terminal.

examples/mcp/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.dev.vars
2+
.wrangler/
3+
build/
4+
node_modules/
5+
worker-configuration.d.ts

examples/mcp/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd
2+
3+
FROM node:22-bookworm-slim
4+
5+
RUN apt-get update \
6+
&& apt-get install -y --no-install-recommends \
7+
ca-certificates curl fuse3 git libfuse2 \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd
11+
12+
ENV PORT=8080
13+
ENV MOUNT_POINT=/workspace
14+
ENV FUSE_MOUNT=auto
15+
16+
EXPOSE 8080
17+
ENTRYPOINT ["/usr/local/bin/computerd"]

examples/mcp/README.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Deploy a Computer MCP
2+
3+
This example exposes Computer through MCP. It gives an MCP client one durable workspace, a fast Worker shell, and a full Linux container behind a single Code Mode `code` tool.
4+
5+
## Deploy
6+
7+
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/computer/tree/main/examples/mcp)
8+
9+
To deploy from a clone instead, start Docker and run:
10+
11+
```bash
12+
npm install
13+
npm run deploy --workspace @example/computer-mcp
14+
```
15+
16+
The endpoint fails closed until you set `MCP_TOKEN`. After deployment, add it as an encrypted Worker secret in the Cloudflare dashboard, or set it from a clone of this repository:
17+
18+
```bash
19+
npx wrangler secret put MCP_TOKEN --config examples/mcp/wrangler.jsonc
20+
```
21+
22+
## Connect
23+
24+
Configure your MCP client to use the remote HTTP endpoint:
25+
26+
```text
27+
https://<your-worker>.workers.dev/mcp
28+
```
29+
30+
Send the token on every MCP request:
31+
32+
```text
33+
Authorization: Bearer <MCP_TOKEN>
34+
```
35+
36+
For clients that accept MCP server configuration as JSON, the entry typically looks like this:
37+
38+
```json
39+
{
40+
"mcpServers": {
41+
"computer": {
42+
"type": "http",
43+
"url": "https://<your-worker>.workers.dev/mcp",
44+
"headers": {
45+
"Authorization": "Bearer <MCP_TOKEN>"
46+
}
47+
}
48+
}
49+
}
50+
```
51+
52+
The exact configuration filename and format depend on the client. Keep the token in the client's secret storage when it provides one rather than committing it to a configuration file.
53+
54+
The Worker's root URL prints its MCP endpoint and available backends. `GET /health` returns `ok` without authentication.
55+
56+
## Use it
57+
58+
Once connected, ask your MCP client to work in the Computer workspace. For example:
59+
60+
```text
61+
Create /workspace/hello.txt, read it back, and list the workspace files.
62+
```
63+
64+
This uses `worker-shell` by default. Select the container when the task needs a full Linux environment:
65+
66+
```text
67+
Use container-shell to create a small Node.js project in /workspace, install its dependencies, and run its tests.
68+
```
69+
70+
The client sees one public MCP tool named `code`. The model uses that tool to write a small JavaScript function that combines Computer's durable filesystem and command tools:
71+
72+
```js
73+
async () => {
74+
await codemode.write({
75+
path: "/workspace/package.json",
76+
content: JSON.stringify({ scripts: { test: "node --test" } }),
77+
});
78+
79+
const result = await codemode.exec({
80+
command: "npm test",
81+
backend: "container-shell",
82+
});
83+
84+
return { exitCode: result.exitCode, stdout: result.stdout };
85+
}
86+
```
87+
88+
You do not need to call the underlying Computer tools individually. The `code` tool describes these functions and backends to the model:
89+
90+
| Function | Purpose |
91+
| --- | --- |
92+
| `codemode.read({ path, offset?, limit? })` | Read a file, optionally one range at a time. |
93+
| `codemode.ls({ path })` | List a directory. |
94+
| `codemode.write({ path, content })` | Create or replace a file. |
95+
| `codemode.edit({ path, edits })` | Apply exact text replacements to a file. |
96+
| `codemode.exec({ command, cwd?, backend?, env? })` | Run a command, using `worker-shell` unless another backend is selected. |
97+
98+
## How it works
99+
100+
`@cloudflare/codemode` runs Code Mode orchestration code in an isolated Dynamic Worker with outbound networking disabled. Tool calls return to the Durable Object and operate on its Computer workspace.
101+
102+
| Backend | Use it for |
103+
| --- | --- |
104+
| `worker-shell` | The fast default for common commands and workspace operations. |
105+
| `container-shell` | Full Debian Linux with Node.js, npm, git, native binaries, and outbound networking. |
106+
107+
The model can select a backend in `codemode.exec()`. The example does not retry automatically, so backend choice, cost, and failures remain visible.
108+
109+
The container starts only when `container-shell` is selected. Computer synchronizes `/workspace` between the Durable Object and the container's FUSE mount before and after each command.
110+
111+
## Run locally
112+
113+
Local development requires a running Docker daemon for the Linux container. From the repository root:
114+
115+
```bash
116+
npm install
117+
printf 'MCP_TOKEN=development-token\n' > examples/mcp/.dev.vars
118+
npm run dev --workspace @example/computer-mcp
119+
```
120+
121+
The `predev` script builds the workspace packages before Wrangler starts. On the first run, Wrangler also builds the container image. Connect to `http://127.0.0.1:8787/mcp` with the same bearer token.
122+
123+
## Validate
124+
125+
```bash
126+
npm run typecheck --workspace @example/computer-mcp
127+
npm test --workspace @example/computer-mcp
128+
```
129+
130+
The workerd integration test authenticates a real MCP client, verifies that only `code` is public, runs filesystem and Worker-shell operations, and confirms that files persist across calls. It does not start the Linux container; validate `container-shell` against a deployment when changing the container image or Computer/computerd protocol.
131+
132+
## Debug
133+
134+
Check the public routes first:
135+
136+
```bash
137+
curl https://<your-worker>.workers.dev/health
138+
curl https://<your-worker>.workers.dev/
139+
```
140+
141+
Then stream Worker and Durable Object logs:
142+
143+
```bash
144+
npx wrangler tail --config examples/mcp/wrangler.jsonc
145+
```
146+
147+
A `401` means the bearer token is missing or incorrect. A `503` means `MCP_TOKEN` has not been configured. Backend failures are returned in the `codemode.exec()` result with the selected backend name.
148+
149+
## Security model
150+
151+
This example is intentionally single-user. Every authenticated request reaches the same Durable Object and workspace. Keep `MCP_TOKEN` private and deploy a separate copy for each trust boundary.
152+
153+
Code Mode's orchestration Worker cannot access the network directly. The Worker shell also has outbound access disabled. The Linux container has outbound access so package managers and development tools work.
154+
155+
For a multi-user service, replace the bearer-token check with OAuth, derive the Durable Object name from the authenticated subject, and add per-user execution and storage limits.

examples/mcp/package.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@example/computer-mcp",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"description": "Expose a durable Computer workspace through MCP.",
7+
"scripts": {
8+
"build:computer": "npm run build --workspace @cloudflare/computer",
9+
"predev": "npm run build:computer",
10+
"dev": "wrangler dev",
11+
"predeploy": "npm run build:computer",
12+
"deploy": "wrangler deploy",
13+
"test": "vitest run --config vitest.config.ts",
14+
"typecheck": "tsc --noEmit",
15+
"cf-typegen": "wrangler types"
16+
},
17+
"dependencies": {
18+
"@cloudflare/codemode": "^0.5.0",
19+
"@cloudflare/computer": "*",
20+
"@modelcontextprotocol/sdk": "1.30.0",
21+
"ai": "^7.0.0",
22+
"zod": "^4.4.3"
23+
},
24+
"devDependencies": {
25+
"@cloudflare/vitest-pool-workers": "^0.16.10",
26+
"@cloudflare/workers-types": "^4.20260616.1",
27+
"typescript": "^6.0.3",
28+
"vitest": "^4.1.7",
29+
"wrangler": "^4.107.1"
30+
}
31+
}

examples/mcp/src/index.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { SELF } from "cloudflare:test";
2+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
let client: Client | undefined;
7+
8+
const authorizedFetch: typeof fetch = (input, init = {}) => {
9+
const headers = new Headers(init.headers);
10+
headers.set("authorization", "Bearer test-token");
11+
return SELF.fetch(input, { ...init, headers });
12+
};
13+
14+
afterEach(async () => {
15+
await client?.close();
16+
client = undefined;
17+
});
18+
19+
describe("Computer Code Mode MCP", () => {
20+
it("serves setup and health routes without authentication", async () => {
21+
const home = await SELF.fetch("https://example.test/");
22+
expect(home.status).toBe(200);
23+
expect(await home.text()).toContain("https://example.test/mcp");
24+
25+
const health = await SELF.fetch("https://example.test/health");
26+
expect(health.status).toBe(200);
27+
expect(await health.text()).toBe("ok\n");
28+
});
29+
30+
it("requires the configured bearer token", async () => {
31+
const missing = await SELF.fetch("https://example.test/mcp", { method: "POST" });
32+
expect(missing.status).toBe(401);
33+
expect(missing.headers.get("www-authenticate")).toBe("Bearer");
34+
35+
const wrong = await SELF.fetch("https://example.test/mcp", {
36+
method: "POST",
37+
headers: { authorization: "Bearer wrong" },
38+
});
39+
expect(wrong.status).toBe(401);
40+
41+
const get = await authorizedFetch("https://example.test/mcp");
42+
expect(get.status).toBe(405);
43+
expect(get.headers.get("allow")).toBe("POST");
44+
});
45+
46+
it("exposes durable Computer tools through one Code Mode tool", async () => {
47+
client = new Client({ name: "computer-mcp-test", version: "1.0.0" });
48+
const transport = new StreamableHTTPClientTransport(new URL("https://example.test/mcp"), {
49+
fetch: authorizedFetch,
50+
});
51+
await client.connect(transport);
52+
53+
const listed = await client.listTools();
54+
expect(listed.tools.map((tool) => tool.name)).toEqual(["code"]);
55+
const description = listed.tools[0]?.description;
56+
expect(description).toContain("codemode.read");
57+
expect(description).toContain('"worker-shell"');
58+
expect(description).toContain("no outbound network");
59+
expect(description).toContain("Cannot run npm");
60+
expect(description).toContain('"container-shell"');
61+
expect(description).toContain("Full Debian Linux");
62+
expect(description).toContain("Cold starts more slowly");
63+
64+
const result = await client.callTool({
65+
name: "code",
66+
arguments: {
67+
code: `async () => {
68+
await codemode.write({ path: "/workspace/message.txt", content: "hello" });
69+
await codemode.edit({
70+
path: "/workspace/message.txt",
71+
edits: [{ oldText: "hello", newText: "hello from Code Mode" }]
72+
});
73+
const file = await codemode.read({ path: "/workspace/message.txt" });
74+
const listing = await codemode.ls({ path: "/workspace" });
75+
const shell = await codemode.exec({ command: "pwd" });
76+
return {
77+
content: file.content,
78+
listed: listing.entries.some((entry) => entry.name === "message.txt"),
79+
backend: shell.backend,
80+
cwd: shell.stdout.trim()
81+
};
82+
}`,
83+
},
84+
});
85+
86+
expect(result.isError, JSON.stringify(result)).not.toBe(true);
87+
expect(readTextResult(result)).toEqual({
88+
content: "hello from Code Mode",
89+
listed: true,
90+
backend: "worker-shell",
91+
cwd: "/workspace",
92+
});
93+
94+
const persisted = await client.callTool({
95+
name: "code",
96+
arguments: {
97+
code: `async () => {
98+
const file = await codemode.read({ path: "/workspace/message.txt" });
99+
return file.content;
100+
}`,
101+
},
102+
});
103+
expect(readTextResult(persisted)).toBe("hello from Code Mode");
104+
105+
const outbound = await client.callTool({
106+
name: "code",
107+
arguments: {
108+
code: `async () => {
109+
const response = await fetch("https://example.com");
110+
return response.status;
111+
}`,
112+
},
113+
});
114+
expect(outbound.isError).toBe(true);
115+
});
116+
});
117+
118+
function readTextResult(result: Awaited<ReturnType<Client["callTool"]>>) {
119+
const content = result.content as Array<{ type: string; text?: string }>;
120+
const text = content.find((item) => item.type === "text");
121+
if (!text?.text) throw new Error("Expected a text MCP result.");
122+
try {
123+
return JSON.parse(text.text) as unknown;
124+
} catch {
125+
return text.text;
126+
}
127+
}

0 commit comments

Comments
 (0)