From 30d730269666f59e061856eb221e2fdee62dfbb0 Mon Sep 17 00:00:00 2001 From: asemabdallah Date: Sun, 11 Oct 2026 02:28:19 +0200 Subject: [PATCH] fix(mcp): create an MCP server per HTTP request --- apps/mcp/README.md | 3 +- apps/mcp/src/app.ts | 41 +++++++++++++++++++++ apps/mcp/src/index.ts | 53 ++------------------------- apps/mcp/tests/app.test.ts | 73 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 52 deletions(-) create mode 100644 apps/mcp/src/app.ts create mode 100644 apps/mcp/tests/app.test.ts diff --git a/apps/mcp/README.md b/apps/mcp/README.md index c4a6c43..6dcc26d 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -259,7 +259,8 @@ apps/mcp/ ├── Dockerfile # Container definition ├── skills/ # Skill instruction files (copied from source) └── src/ - ├── index.ts # HTTP server entry point (Hono) + ├── index.ts # HTTP server entry point + ├── app.ts # Hono app and /mcp route ├── stdio.ts # stdio transport entry point (Claude Desktop) ├── server.ts # MCP server construction (shared) ├── skills.ts # Skill file loader diff --git a/apps/mcp/src/app.ts b/apps/mcp/src/app.ts new file mode 100644 index 0000000..89621fb --- /dev/null +++ b/apps/mcp/src/app.ts @@ -0,0 +1,41 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { createMcpServer } from "./server.js"; + +export function createApp(allowedOrigins: string[] = ["*"]): Hono { + const app = new Hono(); + + app.use( + "*", + cors({ + origin: + allowedOrigins.length === 1 && allowedOrigins[0] === "*" + ? "*" + : allowedOrigins, + allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowHeaders: [ + "Content-Type", + "mcp-session-id", + "Last-Event-ID", + "mcp-protocol-version", + ], + exposeHeaders: ["mcp-session-id", "mcp-protocol-version"], + }) + ); + + app.get("/health", (c) => c.json({ status: "ok" })); + + app.all("/mcp", async (c) => { + // Stateless mode: each request gets its own server and transport. + // Connecting one shared McpServer to a new transport per request + // replaces its active transport, so a response for a request whose + // body was still arriving could be written to another request's stream. + const server = createMcpServer(); + const transport = new WebStandardStreamableHTTPServerTransport(); + await server.connect(transport); + return transport.handleRequest(c.req.raw); + }); + + return app; +} diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 8e74307..a2c77f3 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -1,59 +1,10 @@ import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { createMcpServer } from "./server.js"; +import { createApp } from "./app.js"; const PORT = Number(process.env.MCP_PORT) || 3100; const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") || ["*"]; -const server = createMcpServer(); -const sessions = new Map(); - -const app = new Hono(); - -app.use( - "*", - cors({ - origin: - ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === "*" - ? "*" - : ALLOWED_ORIGINS, - allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], - allowHeaders: [ - "Content-Type", - "mcp-session-id", - "Last-Event-ID", - "mcp-protocol-version", - ], - exposeHeaders: ["mcp-session-id", "mcp-protocol-version"], - }) -); - -app.get("/health", (c) => c.json({ status: "ok" })); - -app.all("/mcp", async (c) => { - const sessionId = c.req.header("mcp-session-id"); - - if (sessionId && sessions.has(sessionId)) { - return sessions.get(sessionId)!.handleRequest(c.req.raw); - } - - const transport = new WebStandardStreamableHTTPServerTransport(); - await server.connect(transport); - - const response = await transport.handleRequest(c.req.raw); - - const newSessionId = response.headers.get("mcp-session-id"); - if (newSessionId) { - sessions.set(newSessionId, transport); - transport.onclose = () => { - sessions.delete(newSessionId); - }; - } - - return response; -}); +const app = createApp(ALLOWED_ORIGINS); serve({ fetch: app.fetch, port: PORT }); console.log(`MCP server running on http://localhost:${PORT}/mcp`); diff --git a/apps/mcp/tests/app.test.ts b/apps/mcp/tests/app.test.ts new file mode 100644 index 0000000..10884ba --- /dev/null +++ b/apps/mcp/tests/app.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { createApp } from "../src/app"; + +function toolCall(id: string, html: string) { + return JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name: "assemble_document", arguments: { title: id, description: id, html } }, + }); +} + +function withTimeout(promise: Promise, label: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} never answered`)), 2000) + ), + ]); +} + +function mcpRequest(body: BodyInit) { + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body, + // Required by Node's fetch for streaming request bodies. + duplex: "half", + } as RequestInit); +} + +describe("POST /mcp", () => { + it("answers each request on its own stream when requests overlap", async () => { + const app = createApp(); + + // Request 1 sends half its body, then stalls until request 2 is done. + const body1 = toolCall("req-1", "

first

"); + let release!: () => void; + const stalled = new ReadableStream({ + start(controller) { + const bytes = new TextEncoder().encode(body1); + controller.enqueue(bytes.slice(0, 10)); + release = () => { + controller.enqueue(bytes.slice(10)); + controller.close(); + }; + }, + }); + const first = app.fetch(mcpRequest(stalled)); + + const second = await withTimeout( + Promise.resolve( + app.fetch(mcpRequest(toolCall("req-2", "

second

"))) + ), + "request 2" + ); + expect(second.status).toBe(200); + const secondText = await second.text(); + expect(secondText).toContain('"id":"req-2"'); + expect(secondText).toContain("second"); + + release(); + const firstText = await withTimeout( + Promise.resolve(first).then((r) => r.text()), + "request 1" + ); + expect(firstText).toContain('"id":"req-1"'); + expect(firstText).toContain("first"); + }); +});