examples/MCP-tools Public
Serve the server over Streamable HTTP from a Worker
Uses the SDK's Web-standard transport in stateless mode with JSON responses: a new server and transport per POST, so any isolate can answer any request. GET and DELETE get a 405 because there are no sessions, and CORS is open so browser clients such as the MCP Inspector can connect directly.
Changed files
6 files changed112 additions2 deletionsagainst a82d7bc
ChangedREADME.md+2−1
| @@ -1,8 +1,9 @@ | ||
| # mcp-tools | ||
| -A small MCP server with exact, read-only helpers for models. It starts with `convert_units`. | ||
| +A small MCP server with exact, read-only helpers for models, running as a Cloudflare Worker. It serves MCP over Streamable HTTP at `/mcp`. | ||
| ```sh | ||
| npm install | ||
| npm test | ||
| +npm run dev # http://localhost:8787/mcp | ||
| ``` | ||
Addedsrc/index.ts+5−0
| @@ -0,0 +1,5 @@ | ||
| +// The Worker's entry point. The handler lives in worker.ts so tests can import it without | ||
| +// anything that only loads in workerd. | ||
| +import { handle, type Env } from "./worker.ts"; | ||
| + | ||
| +export default { fetch: handle } satisfies ExportedHandler<Env>; | ||
Changedsrc/server.ts+1−1
| @@ -15,7 +15,7 @@ | ||
| }; | ||
| // Builds an MCP server with every tool registered. The server holds no state between requests, | ||
| -// so a caller can make one per request, and each test makes its own. | ||
| +// so src/worker.ts makes one per request and each test makes its own. | ||
| export function createServer(options: ServerOptions = {}): McpServer { | ||
| const server = new McpServer(SERVER_INFO, { | ||
| instructions: "Small, exact helpers: unit conversion. Each tool is read-only and answers from its input alone.", | ||
Addedsrc/worker.ts+46−0
| @@ -0,0 +1,46 @@ | ||
| +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | ||
| +import { createServer } from "./server.ts"; | ||
| + | ||
| +export type Env = Record<string, unknown>; | ||
| + | ||
| +// Browser clients (the MCP Inspector in direct mode, web agents) call /mcp cross-origin. The | ||
| +// server has no cookies or credentials to protect, so any origin may call it. | ||
| +const CORS = { | ||
| + "access-control-allow-origin": "*", | ||
| + "access-control-allow-methods": "POST, OPTIONS", | ||
| + "access-control-allow-headers": "content-type, accept, authorization, mcp-protocol-version, mcp-session-id, last-event-id", | ||
| + "access-control-expose-headers": "mcp-session-id, mcp-protocol-version", | ||
| + "access-control-max-age": "86400", | ||
| +}; | ||
| + | ||
| +export async function handle(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | ||
| + const { pathname } = new URL(request.url); | ||
| + if (pathname === "/mcp") return withCors(await serveMcp(request, env, ctx)); | ||
| + if (pathname === "/" && request.method === "GET") { | ||
| + return new Response("mcp-tools: an MCP server. Point a client at /mcp (Streamable HTTP).\n", { headers: { "content-type": "text/plain; charset=utf-8" } }); | ||
| + } | ||
| + return new Response("Not found\n", { status: 404 }); | ||
| +} | ||
| + | ||
| +async function serveMcp(request: Request, _env: Env, _ctx: ExecutionContext): Promise<Response> { | ||
| + if (request.method === "OPTIONS") return new Response(null, { status: 204 }); | ||
| + // Stateless: no sessions, so there is no standalone SSE stream to GET and nothing to DELETE. | ||
| + if (request.method !== "POST") { | ||
| + return Response.json( | ||
| + { jsonrpc: "2.0", error: { code: -32000, message: "This server is stateless: send JSON-RPC requests with POST." }, id: null }, | ||
| + { status: 405, headers: { allow: "POST, OPTIONS" } }, | ||
| + ); | ||
| + } | ||
| + // A fresh server and transport for every request. Nothing carries over, so any isolate can | ||
| + // answer any request and there is no session to lose. | ||
| + const server = createServer(); | ||
| + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); | ||
| + await server.connect(transport); | ||
| + return transport.handleRequest(request); | ||
| +} | ||
| + | ||
| +function withCors(response: Response): Response { | ||
| + const out = new Response(response.body, response); | ||
| + for (const [k, v] of Object.entries(CORS)) out.headers.set(k, v); | ||
| + return out; | ||
| +} | ||
Addedtest/worker.test.ts+51−0
| @@ -0,0 +1,51 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { handle, type Env } from "../src/worker.ts"; | ||
| + | ||
| +const env: Env = {}; | ||
| +const ctx = { waitUntil() {}, passThroughOnException() {}, props: {} } as unknown as ExecutionContext; | ||
| + | ||
| +function rpc(method: string, params?: unknown, id = 1): Request { | ||
| + return new Request("http://localhost/mcp", { | ||
| + method: "POST", | ||
| + headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" }, | ||
| + body: JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }), | ||
| + }); | ||
| +} | ||
| + | ||
| +test("POST /mcp answers initialize as JSON", async () => { | ||
| + const res = await handle(rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "0" } }), env, ctx); | ||
| + assert.equal(res.status, 200); | ||
| + assert.match(res.headers.get("content-type") ?? "", /application\/json/); | ||
| + assert.equal(res.headers.get("mcp-session-id"), null); | ||
| + const body = (await res.json()) as { result: { serverInfo: { name: string } } }; | ||
| + assert.equal(body.result.serverInfo.name, "mcp-tools"); | ||
| +}); | ||
| + | ||
| +test("each request gets its own server, so tools/call needs no session", async () => { | ||
| + const res = await handle(rpc("tools/call", { name: "convert_units", arguments: { value: 1, from: "ft", to: "in" } }), env, ctx); | ||
| + const body = (await res.json()) as { result: { structuredContent: { result: number } } }; | ||
| + assert.equal(body.result.structuredContent.result, 12); | ||
| +}); | ||
| + | ||
| +test("GET and DELETE on /mcp are refused with 405", async () => { | ||
| + for (const method of ["GET", "DELETE"]) { | ||
| + const res = await handle(new Request("http://localhost/mcp", { method }), env, ctx); | ||
| + assert.equal(res.status, 405); | ||
| + assert.equal(res.headers.get("allow"), "POST, OPTIONS"); | ||
| + } | ||
| +}); | ||
| + | ||
| +test("CORS preflights succeed and responses carry the CORS headers", async () => { | ||
| + const pre = await handle(new Request("http://localhost/mcp", { method: "OPTIONS" }), env, ctx); | ||
| + assert.equal(pre.status, 204); | ||
| + assert.equal(pre.headers.get("access-control-allow-origin"), "*"); | ||
| + assert.match(pre.headers.get("access-control-allow-headers") ?? "", /mcp-protocol-version/); | ||
| + const res = await handle(rpc("tools/list"), env, ctx); | ||
| + assert.equal(res.headers.get("access-control-allow-origin"), "*"); | ||
| +}); | ||
| + | ||
| +test("other paths are 404", async () => { | ||
| + const res = await handle(new Request("http://localhost/nope"), env, ctx); | ||
| + assert.equal(res.status, 404); | ||
| +}); | ||
Addedwrangler.jsonc+7−0
| @@ -0,0 +1,7 @@ | ||
| +// mcp-tools: the MCP server, at /mcp. Run it locally with `npm run dev`. | ||
| +{ | ||
| + "name": "mcp-tools", | ||
| + "main": "src/index.ts", | ||
| + "compatibility_date": "2026-09-01", | ||
| + "observability": { "enabled": true } | ||
| +} | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.