Skip to content

MCP /mcp endpoint returns non-MCP response under Bun unless Web-standard transport + Bun-aware startup are used #1

Description

@szymonkm

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 dev and connecting MCP Inspector via Streamable HTTP to http://localhost:3000/mcp, the inspector can fail with Failed 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, /mcp could return a fallback/non-MCP response path in Bun runtime due to Node req/res bridging around streaming responses.

Observed symptoms:

  • MCP Inspector connection fails (Failed to fetch)
  • Inspector reports invalid/unexpected content type in initialize flow

Reproduction

  1. Start server with Bun:
    • bun dev
  2. Open MCP Inspector
  3. Connect to:
    • Transport: streamable-http
    • URL: http://localhost:3000/mcp
  4. Try initialize/connect

Expected behavior

  • POST /mcp initialize responds with MCP-compatible stream response (text/event-stream when appropriate)
  • Inspector can complete initialize flow without fetch/content-type errors

Actual behavior

  • Connection fails in inspector (Failed to fetch) and/or invalid content-type behavior depending on startup/transport path

Root cause

Two parts were involved:

  1. Transport/adapter mismatch on /mcp
    Using 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.

  2. Runtime startup path under Bun
    Forcing @hono/node-server startup in Bun can break stream behavior for this flow. Bun runtime should use Bun.serve, while Node runtime should use @hono/node-server.

Proposed fix

  • Switch MCP route to Web-standard transport:
    • WebStandardStreamableHTTPServerTransport
    • pass c.req.raw directly to transport.handleRequest(...)
    • return Response directly (remove toReqRes / toFetchResponse)
  • Add response-lifecycle cleanup helper (withRequestContextCleanup) to preserve context cleanup after migrating away from Node res.on('close')
  • Type transports map explicitly for the Web-standard transport
  • Make startup runtime-aware:
    • Bun -> Bun.serve
    • Node -> @hono/node-server

Validation

  • bun run typecheck passes
  • /mcp initialize flow returns expected MCP response semantics
  • With auth enabled, unauthenticated /mcp returns proper 401 JSON challenge response

Patch

--- 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', {

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions