Skip to content

Commit d2a7d0d

Browse files
committed
examples/mcp: Add deployable Computer MCP
1 parent 4035727 commit d2a7d0d

14 files changed

Lines changed: 717 additions & 0 deletions

File tree

.github/workflows/ci.yml

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

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ public surface. Each is a Worker workspace with its own README.
6363
- [`examples/egress`](examples/egress) — sends one URL through the container,
6464
Worker shell, and Worker JavaScript backends with matching `none`, `all`, or
6565
custom egress policies.
66+
- [`examples/mcp`](examples/mcp) — a Computer MCP example:
67+
one Code Mode `code` tool backed by a durable workspace, a Worker shell,
68+
and a full Linux container.
6669
- [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think)
6770
chat agent that uses the workspace as its working directory, reachable
6871
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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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?, byteOffset?, limit? })` | Read bounded text or model-supported media. |
99+
| `codemode.ls({ path, limit?, offset? })` | List one page of a directory. |
100+
| `codemode.find({ path, pattern, limit?, offset? })` | Find paths matching a glob. |
101+
| `codemode.grep({ path, query, ... })` | Search workspace text. |
102+
| `codemode.write({ path, content })` | Create or replace a file. |
103+
| `codemode.edit({ path, edits })` | Apply exact text replacements to a file. |
104+
| `codemode.delete_({ path, recursive? })` | Delete a file or directory. |
105+
| `codemode.exec({ command, cwd?, backend?, env? })` | Run a command, using `worker-shell` unless another backend is selected. |
106+
107+
## How it works
108+
109+
`@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.
110+
111+
| Backend | Use it for |
112+
| --- | --- |
113+
| `worker-shell` | The fast default for common commands. It has no ambient network access; its built-in Git command supports HTTPS remotes. |
114+
| `container-shell` | Full Debian Linux with Node.js, npm, git, native binaries, and outbound networking. |
115+
116+
The model can select a backend in `codemode.exec()`. The example does not retry automatically, so backend choice, cost, and failures remain visible.
117+
118+
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.
119+
120+
## Run locally
121+
122+
Local development requires a running Docker daemon for the Linux container. From the repository root:
123+
124+
```bash
125+
npm install
126+
printf 'MCP_TOKEN=development-token\n' > examples/mcp/.dev.vars
127+
npm run dev --workspace @example/computer-mcp
128+
```
129+
130+
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.
131+
132+
## Validate
133+
134+
```bash
135+
npm run typecheck --workspace @example/computer-mcp
136+
npm test --workspace @example/computer-mcp
137+
```
138+
139+
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.
140+
141+
## Debug
142+
143+
Check the public routes first:
144+
145+
```bash
146+
curl https://<your-worker>.workers.dev/health
147+
curl https://<your-worker>.workers.dev/
148+
```
149+
150+
Then stream Worker and Durable Object logs:
151+
152+
```bash
153+
npx wrangler tail --config examples/mcp/wrangler.jsonc
154+
```
155+
156+
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.
157+
158+
## Security model
159+
160+
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.
161+
162+
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.
163+
164+
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: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 public setup routes and keeps the container callback private", 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+
const internal = await SELF.fetch("https://example.test/ws");
30+
expect(internal.status).toBe(404);
31+
});
32+
33+
it("requires the configured bearer token", async () => {
34+
const missing = await SELF.fetch("https://example.test/mcp", { method: "POST" });
35+
expect(missing.status).toBe(401);
36+
expect(missing.headers.get("www-authenticate")).toBe("Bearer");
37+
38+
const wrong = await SELF.fetch("https://example.test/mcp", {
39+
method: "POST",
40+
headers: { authorization: "Bearer test-tokem" },
41+
});
42+
expect(wrong.status).toBe(401);
43+
44+
const get = await authorizedFetch("https://example.test/mcp");
45+
expect(get.status).toBe(405);
46+
expect(get.headers.get("allow")).toBe("POST");
47+
48+
const { COMPUTER_MCP } = env as unknown as {
49+
COMPUTER_MCP: DurableObjectNamespace;
50+
};
51+
const id = COMPUTER_MCP.idFromName("direct-auth-test");
52+
const direct = await COMPUTER_MCP.get(id).fetch("https://example.test/mcp", {
53+
method: "POST",
54+
});
55+
expect(direct.status).toBe(401);
56+
});
57+
58+
it("exposes durable Computer tools through one Code Mode tool", async () => {
59+
client = new Client({ name: "computer-mcp-test", version: "1.0.0" });
60+
const transport = new StreamableHTTPClientTransport(new URL("https://example.test/mcp"), {
61+
fetch: authorizedFetch,
62+
});
63+
await client.connect(transport);
64+
65+
const listed = await client.listTools();
66+
expect(listed.tools.map((tool) => tool.name)).toEqual(["code"]);
67+
const description = listed.tools[0]?.description;
68+
expect(description).toContain("codemode.read");
69+
expect(description).toContain('"worker-shell"');
70+
expect(description).toContain("no ambient outbound network");
71+
expect(description).toContain("HTTPS URLs");
72+
expect(description).toContain("Cannot run npm");
73+
expect(description).toContain('"container-shell"');
74+
expect(description).toContain("Full Debian Linux");
75+
expect(description).toContain("Cold starts more slowly");
76+
77+
const result = await client.callTool({
78+
name: "code",
79+
arguments: {
80+
code: `async () => {
81+
await codemode.write({ path: "/workspace/message.txt", content: "hello" });
82+
await codemode.edit({
83+
path: "/workspace/message.txt",
84+
edits: [{ oldText: "hello", newText: "hello from Code Mode" }]
85+
});
86+
const file = await codemode.read({ path: "/workspace/message.txt" });
87+
const listing = await codemode.ls({ path: "/workspace" });
88+
const shell = await codemode.exec({ command: "pwd" });
89+
const git = await codemode.exec({ command: "git init && git status --short" });
90+
return {
91+
content: file.content,
92+
listed: listing.entries.some((entry) => entry.name === "message.txt"),
93+
backend: shell.backend,
94+
cwd: shell.stdout.trim(),
95+
gitWorked: git.exitCode === 0 && git.stdout.includes("message.txt")
96+
};
97+
}`,
98+
},
99+
});
100+
101+
expect(result.isError, JSON.stringify(result)).not.toBe(true);
102+
expect(readTextResult(result)).toEqual({
103+
content: "hello from Code Mode",
104+
listed: true,
105+
backend: "worker-shell",
106+
cwd: "/workspace",
107+
gitWorked: true,
108+
});
109+
110+
const persisted = await client.callTool({
111+
name: "code",
112+
arguments: {
113+
code: `async () => {
114+
const file = await codemode.read({ path: "/workspace/message.txt" });
115+
return file.content;
116+
}`,
117+
},
118+
});
119+
expect(readTextResult(persisted)).toBe("hello from Code Mode");
120+
121+
const outbound = await client.callTool({
122+
name: "code",
123+
arguments: {
124+
code: `async () => {
125+
const response = await fetch("https://example.com");
126+
return response.status;
127+
}`,
128+
},
129+
});
130+
expect(outbound.isError).toBe(true);
131+
});
132+
});
133+
134+
function readTextResult(result: Awaited<ReturnType<Client["callTool"]>>) {
135+
const content = result.content as Array<{ type: string; text?: string }>;
136+
const text = content.find((item) => item.type === "text");
137+
if (!text?.text) throw new Error("Expected a text MCP result.");
138+
try {
139+
return JSON.parse(text.text) as unknown;
140+
} catch {
141+
return text.text;
142+
}
143+
}

0 commit comments

Comments
 (0)