Hey! I ran into the issues with setting up MCP and using MCP inspector. Below is the summary of the issue and the code that fixed problems to me. I'm new in creating MCPs so let me know if this makes sense! Below sections are LLM generated.
--- a/src/http/routes/mcp.ts
+++ b/src/http/routes/mcp.ts
@@ -4,8 +4,7 @@
import { randomUUID } from 'node:crypto';
import type { HttpBindings } from '@hono/node-server';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
-import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
-import { toFetchResponse, toReqRes } from 'fetch-to-node';
+import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { Hono } from 'hono';
import { config } from '../../config/env.js';
import { authContextStorage, contextRegistry } from '../../core/context.js';
@@ -58,14 +57,14 @@
export function buildMcpRoutes(params: {
server: McpServer;
- transports: Map<string, StreamableHTTPServerTransport>;
+ transports: Map<string, WebStandardStreamableHTTPServerTransport>;
}) {
const { server, transports } = params;
const app = new Hono<{ Bindings: HttpBindings }>();
const sessionStore = getSessionStore();
// Track which transports have been connected to avoid duplicate connect() calls
- const connectedTransports = new WeakSet<StreamableHTTPServerTransport>();
+ const connectedTransports = new WeakSet<WebStandardStreamableHTTPServerTransport>();
const MCP_SESSION_HEADER = 'Mcp-Session-Id';
@@ -74,16 +73,47 @@
* McpServer.connect() should be called once per transport lifecycle.
*/
async function ensureConnected(
- transport: StreamableHTTPServerTransport,
+ transport: WebStandardStreamableHTTPServerTransport,
): Promise<void> {
if (!connectedTransports.has(transport)) {
await server.connect(transport);
connectedTransports.add(transport);
}
+ }
+
+ function withRequestContextCleanup(
+ response: Response,
+ requestId: string | number | undefined,
+ ): Response {
+ if (requestId === undefined) return response;
+
+ const cleanup = () => {
+ contextRegistry.delete(requestId);
+ void logger.debug('mcp', {
+ message: 'Request context cleaned up',
+ requestId,
+ });
+ };
+
+ if (!response.body) {
+ cleanup();
+ return response;
+ }
+
+ const [clientStream, monitorStream] = response.body.tee();
+ void new Response(monitorStream)
+ .arrayBuffer()
+ .catch(() => {})
+ .finally(cleanup);
+
+ return new Response(clientStream, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ });
}
app.post('/', async (c) => {
- const { req, res } = toReqRes(c.req.raw);
let requestId: string | number | undefined;
try {
@@ -171,8 +201,8 @@
sessionId,
isInitialize,
hasSessionIdHeader: !!sessionIdHeader,
- hasAuthorizationHeader: !!req.headers.authorization,
- requestMethod: req.method,
+ hasAuthorizationHeader: Boolean(c.req.header('Authorization')),
+ requestMethod: c.req.method,
bodyMethod: messages[0]?.method,
});
@@ -184,7 +214,7 @@
}
return c.text('Invalid session', 404);
}
- const created = new StreamableHTTPServerTransport({
+ const created = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId as string,
onsessioninitialized: async (sid: string) => {
transports.set(sid, created);
@@ -257,22 +287,11 @@
// Run transport handling within AsyncLocalStorage context
// This makes auth context available to tool handlers via getCurrentAuthContext()
- await authContextStorage.run(requestContext, async () => {
- await transport.handleRequest(req, res, body);
- });
+ const response = await authContextStorage.run(requestContext, () =>
+ transport.handleRequest(c.req.raw, { parsedBody: body }),
+ );
- // Event-driven cleanup: delete context when response closes
- res.on('close', () => {
- if (requestId !== undefined) {
- contextRegistry.delete(requestId);
- void logger.debug('mcp', {
- message: 'Request context cleaned up',
- requestId,
- });
- }
- });
-
- return toFetchResponse(res);
+ return withRequestContextCleanup(response, requestId);
} catch (error) {
// Cleanup on error as well
if (requestId !== undefined) {
@@ -294,7 +313,6 @@
});
app.get('/', async (c) => {
- const { req, res } = toReqRes(c.req.raw);
const sessionIdHeader = c.req.header(MCP_SESSION_HEADER);
if (!sessionIdHeader) {
return c.json(
@@ -330,8 +348,7 @@
return c.text('Invalid session', 404);
}
await ensureConnected(transport);
- await transport.handleRequest(req, res);
- return toFetchResponse(res);
+ return await transport.handleRequest(c.req.raw);
} catch (error) {
void logger.error('mcp', {
message: 'Error handling GET request',
@@ -349,7 +366,6 @@
});
app.delete('/', async (c) => {
- const { req, res } = toReqRes(c.req.raw);
const sessionIdHeader = c.req.header(MCP_SESSION_HEADER);
if (!sessionIdHeader) {
return c.json(
@@ -385,7 +401,7 @@
return c.text('Invalid session', 404);
}
await ensureConnected(transport);
- await transport.handleRequest(req, res);
+ const response = await transport.handleRequest(c.req.raw);
// Clean up all contexts for this session before closing
const cleanedCount = contextRegistry.deleteBySession(sessionIdHeader);
@@ -396,9 +412,9 @@
});
transports.delete(sessionIdHeader);
- transport.close();
+ await transport.close();
await sessionStore.delete(sessionIdHeader).catch(() => {});
- return toFetchResponse(res);
+ return response;
} catch (error) {
void logger.error('mcp', {
message: 'Error handling DELETE request',
--- a/src/http/app.ts
+++ b/src/http/app.ts
@@ -2,6 +2,7 @@
// From Spotify MCP
import type { HttpBindings } from '@hono/node-server';
+import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { Hono } from 'hono';
import { createMcpSecurityMiddleware } from '../adapters/http-hono/middleware.security.js';
import { buildDiscoveryRoutes } from '../adapters/http-hono/routes.discovery.js';
@@ -53,7 +54,7 @@
contextResolver: createContextResolver(),
});
- const transports = new Map();
+ const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
// Global middleware
app.use('*', corsMiddleware());
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,3 @@
-import { serve } from '@hono/node-server';
import { config } from './config/env.js';
import { stopContextCleanup } from './core/context.js';
import { buildHttpApp } from './http/app.js';
@@ -12,6 +11,18 @@
let tokenStore: FileTokenStore | null = null;
let sessionStore: MemorySessionStore | null = null;
+type FetchHandler = (request: Request) => Response | Promise<Response>;
+
+async function startHttpServer(fetch: FetchHandler, port: number, hostname: string) {
+ if (typeof Bun !== 'undefined' && typeof Bun.serve === 'function') {
+ Bun.serve({ fetch, port, hostname });
+ return;
+ }
+
+ const { serve } = await import('@hono/node-server');
+ serve({ fetch, port, hostname });
+}
+
async function main(): Promise<void> {
try {
// Initialize storage singleton with encryption
@@ -20,16 +31,12 @@
initializeStorage(tokenStore, sessionStore);
const app = buildHttpApp();
- serve({ fetch: app.fetch, port: config.PORT, hostname: config.HOST });
+ await startHttpServer(app.fetch, config.PORT, config.HOST);
// OAuth Authorization Server (runs on PORT+1 if auth enabled)
if (config.AUTH_ENABLED) {
const authApp = buildAuthApp();
- serve({
- fetch: authApp.fetch,
- port: Number(config.PORT) + 1,
- hostname: config.HOST,
- });
+ await startHttpServer(authApp.fetch, Number(config.PORT) + 1, config.HOST);
}
await logger.info('server', {
Intro
Hey! I ran into the issues with setting up MCP and using MCP inspector. Below is the summary of the issue and the code that fixed problems to me. I'm new in creating MCPs so let me know if this makes sense! Below sections are LLM generated.
Summary
When running the server with
bun devand connecting MCP Inspector via Streamable HTTP tohttp://localhost:3000/mcp, the inspector can fail withFailed to fetch/ invalid content type behavior unless MCP HTTP handling is fully Web-standard and server startup is runtime-aware.Problem
In the original implementation,
/mcpcould return a fallback/non-MCP response path in Bun runtime due to Node req/res bridging around streaming responses.Observed symptoms:
Failed to fetch)Reproduction
bun devstreamable-httphttp://localhost:3000/mcpExpected behavior
POST /mcpinitialize responds with MCP-compatible stream response (text/event-streamwhen appropriate)Actual behavior
Failed to fetch) and/or invalid content-type behavior depending on startup/transport pathRoot cause
Two parts were involved:
Transport/adapter mismatch on
/mcpUsing Node req/res bridge (
fetch-to-node) around MCP streamable transport caused Bun runtime edge cases where the inspector did not receive a valid MCP response stream.Runtime startup path under Bun
Forcing
@hono/node-serverstartup in Bun can break stream behavior for this flow. Bun runtime should useBun.serve, while Node runtime should use@hono/node-server.Proposed fix
WebStandardStreamableHTTPServerTransportc.req.rawdirectly totransport.handleRequest(...)Responsedirectly (removetoReqRes/toFetchResponse)withRequestContextCleanup) to preserve context cleanup after migrating away from Noderes.on('close')transportsmap explicitly for the Web-standard transportBun.serve@hono/node-serverValidation
bun run typecheckpasses/mcpinitialize flow returns expected MCP response semantics/mcpreturns proper401JSON challenge responsePatch