Skip to content

Bug: finally { transport.close() } breaks SSE streaming in MCP endpoints #2326

@viktormarinho

Description

@viktormarinho

Summary

The finally { transport.close() } block added in v1.2.1 of @decocms/runtime breaks Server-Sent Events (SSE) streaming for MCP endpoints. The transport is closed immediately after handleRequest() returns, but before the async MCP message handler can write the response to the stream.

Affected Versions

  • Broken: 1.2.1, 1.2.2, 1.2.3, 1.2.4
  • Working: 1.2.0 and earlier

Location

packages/runtime/src/tools.ts - the fetch function in createMCPServer()

Current Code (Broken)

const fetch = async (req: Request, env: TEnv) => {
  const { server } = await createServer(env);
  const transport = new HttpServerTransport();

  await server.connect(transport);

  try {
    return await transport.handleRequest(req);
  } finally {
    // CRITICAL: Close transport to prevent memory leaks
    // Without this, ReadableStream/WritableStream controllers accumulate
    // causing thousands of stream objects to be retained in memory
    try {
      await transport.close?.();
    } catch {
      // Ignore close errors - transport may already be closed
    }
  }
};

Problem

The MCP SDK's WebStandardStreamableHTTPServerTransport.handleRequest() returns a Response with an SSE stream immediately, then writes to the stream asynchronously when the MCP server processes messages via onmessagesend().

The timeline:

  1. handleRequest() creates a ReadableStream, calls onmessage(message) (not awaited), returns Response immediately
  2. finally block executes → transport.close() is called
  3. close() calls cleanup() on all streams → closes the ReadableStream controller
  4. MCP server finishes processing → tries to call send()stream already closed!

Result: Client receives HTTP 200 with Content-Type: text/event-stream but empty body (0 bytes).

Reproduction

curl -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'

# Returns: HTTP 200, Content-Type: text/event-stream, Content-Length: 0 (empty body)

Suggested Fix

The transport should not be closed in a finally block for SSE responses. Options:

  1. Don't close immediately - let the transport manage its own lifecycle:
const fetch = async (req: Request, env: TEnv) => {
  const { server } = await createServer(env);
  const transport = new HttpServerTransport();
  await server.connect(transport);
  return await transport.handleRequest(req);
};
  1. Close only after stream completes - the transport already handles cleanup when all responses are sent via the stream's cleanup callback in _streamMapping

Workaround

Pin to @decocms/runtime@1.2.0:

"@decocms/runtime": "1.2.0"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions