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: 2 additions & 1 deletion apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions apps/mcp/src/app.ts
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 2 additions & 51 deletions apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, WebStandardStreamableHTTPServerTransport>();

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`);
73 changes: 73 additions & 0 deletions apps/mcp/tests/app.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(promise: Promise<T>, label: string): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, 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", "<p>first</p>");
let release!: () => void;
const stalled = new ReadableStream<Uint8Array>({
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", "<p>second</p>")))
),
"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");
});
});