Skip to content

Commit d97a33e

Browse files
committed
examples/mcp: Add deployable Computer MCP
1 parent 5bac080 commit d97a33e

14 files changed

Lines changed: 711 additions & 0 deletions

File tree

.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.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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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`. Generate a random token rather than reusing a password:
17+
18+
```bash
19+
openssl rand -hex 32
20+
```
21+
22+
After deployment, add the result as an encrypted Worker secret in the Cloudflare dashboard, or set it from a clone of this repository:
23+
24+
```bash
25+
npx wrangler secret put MCP_TOKEN --config examples/mcp/wrangler.jsonc
26+
```
27+
28+
## Connect
29+
30+
Configure your MCP client to use the remote HTTP endpoint:
31+
32+
```text
33+
https://<your-worker>.workers.dev/mcp
34+
```
35+
36+
Send the token on every MCP request:
37+
38+
```text
39+
Authorization: Bearer <MCP_TOKEN>
40+
```
41+
42+
For clients that accept MCP server configuration as JSON, the entry typically looks like this:
43+
44+
```json
45+
{
46+
"mcpServers": {
47+
"computer": {
48+
"type": "http",
49+
"url": "https://<your-worker>.workers.dev/mcp",
50+
"headers": {
51+
"Authorization": "Bearer <MCP_TOKEN>"
52+
}
53+
}
54+
}
55+
}
56+
```
57+
58+
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.
59+
60+
The Worker's root URL prints its MCP endpoint and available backends. `GET /health` returns `ok` without authentication.
61+
62+
## Use it
63+
64+
Once connected, ask your MCP client to work in the Computer workspace. For example:
65+
66+
```text
67+
Create /workspace/hello.txt, read it back, and list the workspace files.
68+
```
69+
70+
Commands use `worker-shell` by default. Select the container when the task needs a full Linux environment:
71+
72+
```text
73+
Use container-shell to create a small Node.js project in /workspace, install its dependencies, and run its tests.
74+
```
75+
76+
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:
77+
78+
```js
79+
async () => {
80+
await codemode.write({
81+
path: "/workspace/package.json",
82+
content: JSON.stringify({ scripts: { test: "node --test" } }),
83+
});
84+
85+
const result = await codemode.exec({
86+
command: "npm test",
87+
backend: "container-shell",
88+
});
89+
90+
return { exitCode: result.exitCode, stdout: result.stdout };
91+
}
92+
```
93+
94+
You do not need to call the underlying Computer tools individually. The `code` tool describes these functions and backends to the model:
95+
96+
| Function | Purpose |
97+
| --- | --- |
98+
| `codemode.read({ path, offset?, limit? })` | Read a file, optionally one range at a time. |
99+
| `codemode.ls({ path })` | List a directory. |
100+
| `codemode.write({ path, content })` | Create or replace a file. |
101+
| `codemode.edit({ path, edits })` | Apply exact text replacements to a file. |
102+
| `codemode.exec({ command, cwd?, backend?, env? })` | Run a command, using `worker-shell` unless another backend is selected. |
103+
104+
## How it works
105+
106+
`@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.
107+
108+
| Backend | Use it for |
109+
| --- | --- |
110+
| `worker-shell` | The fast default for common commands. It has no ambient network access; its built-in Git command supports HTTPS remotes. |
111+
| `container-shell` | Full Debian Linux with Node.js, npm, git, native binaries, and outbound networking. |
112+
113+
The model can select a backend in `codemode.exec()`. The example does not retry automatically, so backend choice, cost, and failures remain visible.
114+
115+
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.
116+
117+
## Run locally
118+
119+
Local development requires a running Docker daemon for the Linux container. From the repository root:
120+
121+
```bash
122+
npm install
123+
printf 'MCP_TOKEN=development-token\n' > examples/mcp/.dev.vars
124+
npm run dev --workspace @example/computer-mcp
125+
```
126+
127+
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.
128+
129+
## Validate
130+
131+
```bash
132+
npm run typecheck --workspace @example/computer-mcp
133+
npm test --workspace @example/computer-mcp
134+
```
135+
136+
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.
137+
138+
## Debug
139+
140+
Check the public routes first:
141+
142+
```bash
143+
curl https://<your-worker>.workers.dev/health
144+
curl https://<your-worker>.workers.dev/
145+
```
146+
147+
Then stream Worker and Durable Object logs:
148+
149+
```bash
150+
npx wrangler tail --config examples/mcp/wrangler.jsonc
151+
```
152+
153+
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.
154+
155+
## Security model
156+
157+
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.
158+
159+
Code Mode's orchestration Worker and the Worker shell cannot make arbitrary outbound requests. The Worker shell's built-in Git command can use HTTPS remotes. The Linux container has outbound access so package managers and development tools work.
160+
161+
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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { env, 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 test-tokem" },
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+
const { COMPUTER_MCP } = env as unknown as {
46+
COMPUTER_MCP: DurableObjectNamespace;
47+
};
48+
const id = COMPUTER_MCP.idFromName("direct-auth-test");
49+
const direct = await COMPUTER_MCP.get(id).fetch("https://example.test/mcp", {
50+
method: "POST",
51+
});
52+
expect(direct.status).toBe(401);
53+
});
54+
55+
it("exposes durable Computer tools through one Code Mode tool", async () => {
56+
client = new Client({ name: "computer-mcp-test", version: "1.0.0" });
57+
const transport = new StreamableHTTPClientTransport(new URL("https://example.test/mcp"), {
58+
fetch: authorizedFetch,
59+
});
60+
await client.connect(transport);
61+
62+
const listed = await client.listTools();
63+
expect(listed.tools.map((tool) => tool.name)).toEqual(["code"]);
64+
const description = listed.tools[0]?.description;
65+
expect(description).toContain("codemode.read");
66+
expect(description).toContain('"worker-shell"');
67+
expect(description).toContain("no ambient outbound network");
68+
expect(description).toContain("HTTPS URLs");
69+
expect(description).toContain("Cannot run npm");
70+
expect(description).toContain('"container-shell"');
71+
expect(description).toContain("Full Debian Linux");
72+
expect(description).toContain("Cold starts more slowly");
73+
74+
const result = await client.callTool({
75+
name: "code",
76+
arguments: {
77+
code: `async () => {
78+
await codemode.write({ path: "/workspace/message.txt", content: "hello" });
79+
await codemode.edit({
80+
path: "/workspace/message.txt",
81+
edits: [{ oldText: "hello", newText: "hello from Code Mode" }]
82+
});
83+
const file = await codemode.read({ path: "/workspace/message.txt" });
84+
const listing = await codemode.ls({ path: "/workspace" });
85+
const shell = await codemode.exec({ command: "pwd" });
86+
const git = await codemode.exec({ command: "git init && git status --short" });
87+
return {
88+
content: file.content,
89+
listed: listing.entries.some((entry) => entry.name === "message.txt"),
90+
backend: shell.backend,
91+
cwd: shell.stdout.trim(),
92+
gitWorked: git.exitCode === 0 && git.stdout.includes("message.txt")
93+
};
94+
}`,
95+
},
96+
});
97+
98+
expect(result.isError, JSON.stringify(result)).not.toBe(true);
99+
expect(readTextResult(result)).toEqual({
100+
content: "hello from Code Mode",
101+
listed: true,
102+
backend: "worker-shell",
103+
cwd: "/workspace",
104+
gitWorked: true,
105+
});
106+
107+
const persisted = await client.callTool({
108+
name: "code",
109+
arguments: {
110+
code: `async () => {
111+
const file = await codemode.read({ path: "/workspace/message.txt" });
112+
return file.content;
113+
}`,
114+
},
115+
});
116+
expect(readTextResult(persisted)).toBe("hello from Code Mode");
117+
118+
const outbound = await client.callTool({
119+
name: "code",
120+
arguments: {
121+
code: `async () => {
122+
const response = await fetch("https://example.com");
123+
return response.status;
124+
}`,
125+
},
126+
});
127+
expect(outbound.isError).toBe(true);
128+
});
129+
});
130+
131+
function readTextResult(result: Awaited<ReturnType<Client["callTool"]>>) {
132+
const content = result.content as Array<{ type: string; text?: string }>;
133+
const text = content.find((item) => item.type === "text");
134+
if (!text?.text) throw new Error("Expected a text MCP result.");
135+
try {
136+
return JSON.parse(text.text) as unknown;
137+
} catch {
138+
return text.text;
139+
}
140+
}

0 commit comments

Comments
 (0)