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 @@ -143,6 +143,9 @@ jobs:
workspace: "@example/computer-container"
path: examples/container
typegen: "npx wrangler types"
- name: mcp
workspace: "@example/computer-mcp"
path: examples/mcp
- name: think
workspace: "@cloudflare/example-think"
path: examples/think
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ 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/mcp`](examples/mcp) — a Computer MCP example:
one Code Mode `code` tool backed by a durable workspace, a Worker shell,
and a full Linux container.
- [`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
5 changes: 5 additions & 0 deletions examples/mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.dev.vars
.wrangler/
build/
node_modules/
worker-configuration.d.ts
17 changes: 17 additions & 0 deletions examples/mcp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd

FROM node:22-bookworm-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl fuse3 git libfuse2 \
&& rm -rf /var/lib/apt/lists/*

COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd

ENV PORT=8080
ENV MOUNT_POINT=/workspace
ENV FUSE_MOUNT=auto

EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/computerd"]
155 changes: 155 additions & 0 deletions examples/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Deploy a Computer MCP

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.

## Deploy

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/computer/tree/main/examples/mcp)

To deploy from a clone instead, start Docker and run:

```bash
npm install
npm run deploy --workspace @example/computer-mcp
```

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:

```bash
npx wrangler secret put MCP_TOKEN --config examples/mcp/wrangler.jsonc
```

## Connect

Configure your MCP client to use the remote HTTP endpoint:

```text
https://<your-worker>.workers.dev/mcp
```

Send the token on every MCP request:

```text
Authorization: Bearer <MCP_TOKEN>
```

For clients that accept MCP server configuration as JSON, the entry typically looks like this:

```json
{
"mcpServers": {
"computer": {
"type": "http",
"url": "https://<your-worker>.workers.dev/mcp",
"headers": {
"Authorization": "Bearer <MCP_TOKEN>"
}
}
}
}
```

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.

The Worker's root URL prints its MCP endpoint and available backends. `GET /health` returns `ok` without authentication.

## Use it

Once connected, ask your MCP client to work in the Computer workspace. For example:

```text
Create /workspace/hello.txt, read it back, and list the workspace files.
```

This uses `worker-shell` by default. Select the container when the task needs a full Linux environment:

```text
Use container-shell to create a small Node.js project in /workspace, install its dependencies, and run its tests.
```

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:

```js
async () => {
await codemode.write({
path: "/workspace/package.json",
content: JSON.stringify({ scripts: { test: "node --test" } }),
});

const result = await codemode.exec({
command: "npm test",
backend: "container-shell",
});

return { exitCode: result.exitCode, stdout: result.stdout };
}
```

You do not need to call the underlying Computer tools individually. The `code` tool describes these functions and backends to the model:

| Function | Purpose |
| --- | --- |
| `codemode.read({ path, offset?, limit? })` | Read a file, optionally one range at a time. |
| `codemode.ls({ path })` | List a directory. |
| `codemode.write({ path, content })` | Create or replace a file. |
| `codemode.edit({ path, edits })` | Apply exact text replacements to a file. |
| `codemode.exec({ command, cwd?, backend?, env? })` | Run a command, using `worker-shell` unless another backend is selected. |

## How it works

`@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.

| Backend | Use it for |
| --- | --- |
| `worker-shell` | The fast default for common commands and workspace operations. |
| `container-shell` | Full Debian Linux with Node.js, npm, git, native binaries, and outbound networking. |

The model can select a backend in `codemode.exec()`. The example does not retry automatically, so backend choice, cost, and failures remain visible.

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.

## Run locally

Local development requires a running Docker daemon for the Linux container. From the repository root:

```bash
npm install
printf 'MCP_TOKEN=development-token\n' > examples/mcp/.dev.vars
npm run dev --workspace @example/computer-mcp
```

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.

## Validate

```bash
npm run typecheck --workspace @example/computer-mcp
npm test --workspace @example/computer-mcp
```

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.

## Debug

Check the public routes first:

```bash
curl https://<your-worker>.workers.dev/health
curl https://<your-worker>.workers.dev/
```

Then stream Worker and Durable Object logs:

```bash
npx wrangler tail --config examples/mcp/wrangler.jsonc
```

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.

## Security model

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.

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.

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.
31 changes: 31 additions & 0 deletions examples/mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@example/computer-mcp",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Expose a durable Computer workspace through MCP.",
"scripts": {
"build:computer": "npm run build --workspace @cloudflare/computer",
"predev": "npm run build:computer",
"dev": "wrangler dev",
"predeploy": "npm run build:computer",
"deploy": "wrangler deploy",
"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"
}
}
127 changes: 127 additions & 0 deletions examples/mcp/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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;

const authorizedFetch: typeof fetch = (input, init = {}) => {
const headers = new Headers(init.headers);
headers.set("authorization", "Bearer test-token");
return SELF.fetch(input, { ...init, headers });
};

afterEach(async () => {
await client?.close();
client = undefined;
});

describe("Computer Code Mode MCP", () => {
it("serves setup and health routes without authentication", async () => {
const home = await SELF.fetch("https://example.test/");
expect(home.status).toBe(200);
expect(await home.text()).toContain("https://example.test/mcp");

const health = await SELF.fetch("https://example.test/health");
expect(health.status).toBe(200);
expect(await health.text()).toBe("ok\n");
});

it("requires the configured bearer token", async () => {
const missing = await SELF.fetch("https://example.test/mcp", { method: "POST" });
expect(missing.status).toBe(401);
expect(missing.headers.get("www-authenticate")).toBe("Bearer");

const wrong = await SELF.fetch("https://example.test/mcp", {
method: "POST",
headers: { authorization: "Bearer wrong" },
});
expect(wrong.status).toBe(401);

const get = await authorizedFetch("https://example.test/mcp");
expect(get.status).toBe(405);
expect(get.headers.get("allow")).toBe("POST");
});

it("exposes durable Computer tools through one Code Mode tool", async () => {
client = new Client({ name: "computer-mcp-test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL("https://example.test/mcp"), {
fetch: authorizedFetch,
});
await client.connect(transport);

const listed = await client.listTools();
expect(listed.tools.map((tool) => tool.name)).toEqual(["code"]);
const description = listed.tools[0]?.description;
expect(description).toContain("codemode.read");
expect(description).toContain('"worker-shell"');
expect(description).toContain("no outbound network");
expect(description).toContain("Cannot run npm");
expect(description).toContain('"container-shell"');
expect(description).toContain("Full Debian Linux");
expect(description).toContain("Cold starts more slowly");

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 Code Mode" }]
});
const file = await codemode.read({ path: "/workspace/message.txt" });
const listing = await codemode.ls({ path: "/workspace" });
const shell = await codemode.exec({ command: "pwd" });
return {
content: file.content,
listed: listing.entries.some((entry) => entry.name === "message.txt"),
backend: shell.backend,
cwd: shell.stdout.trim()
};
}`,
},
});

expect(result.isError, JSON.stringify(result)).not.toBe(true);
expect(readTextResult(result)).toEqual({
content: "hello from Code Mode",
listed: true,
backend: "worker-shell",
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 Code Mode");

const outbound = await client.callTool({
name: "code",
arguments: {
code: `async () => {
const response = await fetch("https://example.com");
return response.status;
}`,
},
});
expect(outbound.isError).toBe(true);
});
});

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;
}
}
Loading
Loading