├── .vscode └── settings.json ├── .prettierrc ├── worker-configuration.d.ts ├── .editorconfig ├── src ├── client.ts ├── mcp │ ├── proxy-server.ts │ ├── websocket-transport.ts │ └── server.ts └── index.ts ├── package.json ├── README.md ├── tsconfig.json ├── wrangler.jsonc └── .gitignore /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "wrangler.json": "jsonc" 4 | } 5 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types` 2 | 3 | interface Env { 4 | MCP_DURABLE_OBJECT: DurableObjectNamespace; 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import { WebsocketProxyServer } from './mcp/proxy-server'; 3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'; 4 | 5 | const run = async () => { 6 | const url = process.argv[2]; 7 | const websocket = new WebSocket(url); 8 | const proxy = new WebsocketProxyServer(websocket, new StdioServerTransport()); 9 | await proxy.start(); 10 | } 11 | 12 | run() 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-mcp-durable-object", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "cf-typegen": "wrangler types" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/workers-types": "^4.20250214.0", 13 | "@types/node": "^22.13.5", 14 | "@types/ws": "^8.5.14", 15 | "typescript": "^5.5.2", 16 | "wrangler": "^3.109.2" 17 | }, 18 | "dependencies": { 19 | "@modelcontextprotocol/sdk": "^1.5.0", 20 | "ws": "^8.18.1", 21 | "zod": "^3.24.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP over Websockets on Cloudflare Durable Objects 2 | 3 | This is a proof of concept Model Context Protocol (MCP) server running on a Cloudflare Durable Object. 4 | 5 | The agent connects to the DO Server via a proxy using the JSON stdio transport, which is then forwarded to the DO Server via Websockets. 6 | 7 | Agent <-stdio-> Proxy <-ws-> DO Server 8 | 9 | ## Usage 10 | 11 | - Deploy to CloudFlare with `npm run deploy`. 12 | - Run the proxy server with `npx tsx src/client.ts wss://my-durable-object.foo.workers.dev`. 13 | - Use mcp inspector to test the server `npx -y @modelcontextprotocol/inspector npx -y tsx src/client.ts wss://...` 14 | -------------------------------------------------------------------------------- /src/mcp/proxy-server.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport"; 3 | 4 | export class WebsocketProxyServer { 5 | constructor(private server: WebSocket, private transport: Transport) { 6 | } 7 | 8 | async start() { 9 | this.transport.onmessage = (message) => { 10 | this.server.send(JSON.stringify(message)); 11 | } 12 | 13 | this.server.addEventListener("message", (event) => { 14 | this.transport.send(JSON.parse(event.data.toString())); 15 | }) 16 | this.server.addEventListener("close", () => { 17 | this.transport.close(); 18 | }); 19 | this.server.addEventListener("open", () => { 20 | this.transport.start(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/mcp/websocket-transport.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport"; 2 | import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types"; 3 | 4 | export class WebSocketTransport implements Transport { 5 | constructor(private server: WebSocket) { 6 | 7 | } 8 | onclose?: () => void; 9 | 10 | onerror?: (error: Error) => void; 11 | 12 | onmessage?: (message: JSONRPCMessage) => void; 13 | 14 | async start() { 15 | this.server.addEventListener("message", (event: MessageEvent) => { 16 | const message = JSON.parse(event.data.toString()); 17 | this.onmessage?.(message); 18 | }) 19 | this.server.addEventListener("close", (cls: CloseEvent) => { 20 | this.close(); 21 | }); 22 | } 23 | 24 | async send(message: JSONRPCMessage): Promise { 25 | this.server.send(JSON.stringify(message)); 26 | } 27 | 28 | async close(): Promise { 29 | this.server.close(); 30 | this.onclose?.(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/mcp/server.ts: -------------------------------------------------------------------------------- 1 | import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | 4 | 5 | export function buildServer() { 6 | const server = new McpServer({ 7 | name: "Echo", 8 | version: "1.0.0" 9 | }); 10 | 11 | server.resource( 12 | "echo", 13 | new ResourceTemplate("echo://{message}", { list: undefined }), 14 | async (uri, { message }) => ({ 15 | contents: [{ 16 | uri: uri.href, 17 | text: `Resource echo: ${message}` 18 | }] 19 | }) 20 | ); 21 | 22 | server.tool( 23 | "echo", 24 | { message: z.string() }, 25 | async ({ message }) => ({ 26 | content: [{ type: "text", text: `Tool echo: ${message}` }] 27 | }) 28 | ); 29 | 30 | server.prompt( 31 | "echo", 32 | { message: z.string() }, 33 | ({ message }) => ({ 34 | messages: [{ 35 | role: "user", 36 | content: { 37 | type: "text", 38 | text: `Please process this message: ${message}` 39 | } 40 | }] 41 | }) 42 | ); 43 | 44 | return server; 45 | } 46 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | import { buildServer } from "./mcp/server"; 3 | import { WebSocketTransport } from "./mcp/websocket-transport"; 4 | 5 | export class MCPDurableObject extends DurableObject { 6 | constructor(ctx: DurableObjectState, env: Env) { 7 | super(ctx, env); 8 | } 9 | 10 | async fetch(request: Request): Promise { 11 | const webSocketPair = new WebSocketPair(); 12 | const client = webSocketPair[0]; 13 | const server = webSocketPair[1]; 14 | 15 | server.accept(); 16 | 17 | const mcpServer = buildServer(); 18 | const transport = new WebSocketTransport(server); 19 | mcpServer.connect(transport); 20 | 21 | return new Response(null, { 22 | status: 101, 23 | webSocket: client 24 | }); 25 | } 26 | } 27 | 28 | export default { 29 | async fetch(request, env, ctx): Promise { 30 | const upgradeHeader = request.headers.get("Upgrade"); 31 | if (!upgradeHeader || upgradeHeader !== "websocket") { 32 | return new Response("Durable Object expected Upgrade: websocket", { 33 | status: 426 34 | }); 35 | } 36 | 37 | // Every client will connect to the same Durable Object 38 | // Could create a DO per client, or on some request parameters 39 | //let id: DurableObjectId = env.MCP_DURABLE_OBJECT.idFromName(new URL(request.url).pathname); 40 | let id: DurableObjectId = env.MCP_DURABLE_OBJECT.idFromName("mcp-server"); 41 | 42 | let stub = env.MCP_DURABLE_OBJECT.get(id); 43 | 44 | return stub.fetch(request); 45 | } 46 | } satisfies ExportedHandler; 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 6 | "target": "es2021", 7 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 8 | "lib": ["es2021"], 9 | /* Specify what JSX code is generated. */ 10 | "jsx": "react-jsx", 11 | 12 | /* Specify what module code is generated. */ 13 | "module": "es2022", 14 | /* Specify how TypeScript looks up a file from a given module specifier. */ 15 | "moduleResolution": "node", 16 | /* Specify type package names to be included without being referenced in a source file. */ 17 | "types": [ 18 | "@cloudflare/workers-types/2023-07-01" 19 | ], 20 | /* Enable importing .json files */ 21 | "resolveJsonModule": true, 22 | 23 | /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 24 | "allowJs": true, 25 | /* Enable error reporting in type-checked JavaScript files. */ 26 | "checkJs": false, 27 | 28 | /* Disable emitting files from a compilation. */ 29 | "noEmit": true, 30 | 31 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 32 | "isolatedModules": true, 33 | /* Allow 'import x from y' when a module doesn't have a default export. */ 34 | "allowSyntheticDefaultImports": true, 35 | /* Ensure that casing is correct in imports. */ 36 | "forceConsistentCasingInFileNames": true, 37 | 38 | /* Enable all strict type-checking options. */ 39 | "strict": true, 40 | 41 | /* Skip type checking all .d.ts files. */ 42 | "skipLibCheck": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "cf-mcp-durable-object", 8 | "main": "src/index.ts", 9 | "compatibility_date": "2025-02-14", 10 | "migrations": [ 11 | { 12 | "new_classes": [ 13 | "MCPDurableObject" 14 | ], 15 | "tag": "v1" 16 | } 17 | ], 18 | "durable_objects": { 19 | "bindings": [ 20 | { 21 | "class_name": "MCPDurableObject", 22 | "name": "MCP_DURABLE_OBJECT", 23 | } 24 | ] 25 | }, 26 | "observability": { 27 | "enabled": true 28 | } 29 | /** 30 | * Smart Placement 31 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 32 | */ 33 | // "placement": { "mode": "smart" }, 34 | 35 | /** 36 | * Bindings 37 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 38 | * databases, object storage, AI inference, real-time communication and more. 39 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 40 | */ 41 | 42 | /** 43 | * Environment Variables 44 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 45 | */ 46 | // "vars": { "MY_VARIABLE": "production_value" }, 47 | /** 48 | * Note: Use secrets to store sensitive data. 49 | * https://developers.cloudflare.com/workers/configuration/secrets/ 50 | */ 51 | 52 | /** 53 | * Static Assets 54 | * https://developers.cloudflare.com/workers/static-assets/binding/ 55 | */ 56 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 57 | 58 | /** 59 | * Service Bindings (communicate between multiple Workers) 60 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 61 | */ 62 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 63 | } 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | --------------------------------------------------------------------------------