XBSTACK
Xiaobai

Xiaobai

Developer · Builder

Building AI engineering systems, developer tools and long-term digital assets at XBSTACK.

About Xiaobai & XBSTACK →
MCP StreamableHTTPClientTransport request-scoped SSE disconnect leaving a JSON-RPC request pending until timeout

MCP StreamableHTTPClientTransport POST Stays Pending After SSE Disconnect: Issue #2739 Reproduced

A v1 MCP StreamableHTTPClientTransport POST can remain pending until request timeout after request-scoped SSE error/EOF. XBSTACK reproduced Issue #2739 on SDK 1.29.0 and 1.30.0.

Published · 2026-09-016 min readXBSTACK
#MCP#Streamable HTTP#TypeScript SDK#Troubleshooting#SSE#Timeout

If you use the v1 @modelcontextprotocol/sdk StreamableHTTPClientTransport and see a strange failure pattern—the POST has already received text/event-stream, the request-scoped SSE has died, client.onerror may already have fired, but client.ping() or another request still sits pending until the normal request timeout—do not automatically classify it as a slow server or a generic network timeout.

On September 1, 2026, I independently reran the lifecycle boundary described in upstream Issue #2739 on Node.js 22.18.0. @modelcontextprotocol/sdk 1.29.0 and the current npm release, 1.30.0, both reproduce it. In the erroring-SSE case, the transport reports the failure within roughly 3–4 ms, yet the JSON-RPC request rejects only when the 300 ms test timeout expires. With clean EOF, there is no client.onerror in the reproduction and the request still waits for timeout. An application/json control resolves in about 1 ms.

This article stays narrow: why a request-scoped SSE leg can become incapable of delivering its JSON-RPC result while the corresponding v1 request remains pending, and what you can safely do until the SDK lifecycle is fixed.

First, verify that your failure matches this boundary

The closer your case is to these conditions, the more relevant #2739 becomes:

  1. You are on the v1 @modelcontextprotocol/sdk StreamableHTTPClientTransport.
  2. The POST response uses Content-Type: text/event-stream.
  3. The server accepted the JSON-RPC request.
  4. The request-scoped SSE body errors or reaches EOF before the matching JSON-RPC result or error arrives.
  5. In the error case, client.onerror may fire quickly while the request Promise remains unsettled.
  6. The final caller-visible failure is just MCP error -32001: Request timed out.

This is not the same diagnostic path as initialization failure, HTTP 401/403, -32700 Parse error, reverse-proxy buffering, a server that never accepted the POST, or a stream lost only after explicit cancellation. Upstream #2739 explicitly distinguishes its no-cancellation reproduction from related lifecycle issue #2691.

Reproduction design: three POST response modes, no real server required

I wanted to remove OAuth, reverse proxies, external networking, model APIs and tool logic from the experiment. Instead of running a real remote MCP server, the test injects a custom fetch into StreamableHTTPClientTransport. Initialization returns normal JSON; only the ping response changes:

A. erroring SSE
POST ping -> text/event-stream -> controller.error()

B. clean EOF
POST ping -> text/event-stream -> controller.close()

C. JSON control
POST ping -> application/json -> matching JSON-RPC result

The request timeout is fixed at 300 ms. That isolates one question: if the transport knows within a few milliseconds that the request-scoped SSE leg can no longer deliver a result, does the request Promise settle immediately?

The key test fragment is small:

if (message.method === 'ping') {
  const body = new ReadableStream({
    start(controller) {
      queueMicrotask(() => controller.error(new Error('response leg lost')));
      // Replace with controller.close() for the clean-EOF case.
    },
  });

  return new Response(body, {
    headers: { 'content-type': 'text/event-stream' },
  });
}

The harness timestamps both client.onerror and the eventual settlement of client.ping({ timeout: 300 }). The full local asset lives in experiments/mcp-streamable-http-sse-pending-repro/, including raw logs and a version matrix.

SDK 1.29.0: transport error at 4 ms, request failure at 304 ms

The first local run used @modelcontextprotocol/sdk 1.29.0 on Node.js 22.18.0:

error:
  onerror = 4ms
  request rejected = 304ms
  final error = MCP error -32001: Request timed out

eof:
  onerror = none
  request rejected = 302ms
  final error = MCP error -32001: Request timed out

json-control:
  request resolved = 1ms

The interesting number is not 304 ms by itself. Production timeouts may be seconds or much longer. The important observation is the separation between two clocks: the transport already knows at about 4 ms that the SSE stream failed, but the JSON-RPC request does not reject at about 4 ms.

Clean EOF is even harder to observe. A stream ending cleanly is not proof that the matching JSON-RPC response arrived. In this minimal case, there is no onerror, no result and no request settlement until the timeout.

The current 1.30.0 release still reproduces the same lifecycle gap

Upstream Issue #2739 was opened on August 30, 2026 and reports Node 24.18.0 with SDK 1.26.0 and 1.29.0. Before treating that as yesterday’s already-fixed bug, I checked npm and found @modelcontextprotocol/sdk 1.30.0 is the current release. I then reran the exact same local harness without changing the test logic.

The result remained consistent:

Scenario1.29.01.30.0What it shows
Erroring SSE fires onerror~4 ms~3 msTransport sees the stream failure quickly
Erroring SSE request rejects~304 ms~306 msRequest still waits for timeout
Clean EOF request rejects~302 ms~302 msStill waits for timeout, with no onerror
JSON control resolves~1 ms~1 msNormal request settles immediately

So the claim I can support as of September 1 is specific: the v1 lifecycle gap reproduces locally on 1.29.0 and 1.30.0. I did not independently rerun 1.26.0; that version is upstream evidence, not XBSTACK test evidence.

The root problem is not “the timeout is too long”

The upstream cause analysis fits the local behavior. In the v1 path, send() starts _handleSseStream() without exposing the request-scoped stream’s complete lifecycle as something Protocol can await. The reader error can reach the global onerror callback without directly settling the JSON-RPC request that owns this POST response. Clean EOF does not necessarily produce the same transport error signal.

The resulting state looks like this:

JSON-RPC request pending
        |
        +-- transport.send() already returned
        |
        +-- request-scoped SSE errored / reached EOF
        |
        +-- no matching JSON-RPC result/error
        |
        +-- Protocol has only request timeout left to settle the Promise

That is why better global network logging is not enough. The missing contract is request ownership: which SSE response leg belongs to which outbound request, and who rejects that pending request when the leg disappears before delivering all required responses?

Practical containment: distinguish ordinary JSON results from genuine streaming

If you control the server and the request only needs to return a normal JSON-RPC result, not a request-scoped stream, returning application/json is a reproducible containment for this particular v1 path.

The local JSON control resolved in about 1 ms on both tested SDK versions. The difference matters because the JSON response path is awaited by send(), allowing parsing success/failure to become a request outcome directly.

A minimal server response looks like this:

return new Response(
  JSON.stringify({
    jsonrpc: '2.0',
    id: message.id,
    result: {},
  }),
  { headers: { 'content-type': 'application/json' } },
);

This does not mean “stop using SSE in MCP.” If the response genuinely needs request-scoped streaming, switching it to JSON changes semantics and merely avoids the failing path. It does not repair it.

The second containment is a finite request timeout appropriate for the tool SLA and upstream network budget. This prevents an unbounded pending Promise. But it is still only a safety net. If the transport knows after 3 ms that the response leg is gone and the application waits another 30 seconds, the bug is still amplifying failure for almost 30 seconds.

What a real SDK fix needs to accomplish

I did not fork the SDK locally and present that as an upstream fix. For the #2739 boundary, a complete lifecycle repair needs at least these properties:

  1. When a POST creates a request-scoped SSE response, the transport knows which JSON-RPC request IDs it owns.
  2. The lifecycle resolves normally after all matching responses arrive.
  3. If a non-resumable SSE body errors or reaches EOF first, the corresponding pending request rejects promptly.
  4. If the caller times out or aborts, reconnect/resume work cannot continue detached from the request that created it.

This is different from closing the entire client whenever any SSE stream fails. A client may have concurrent work. The important property is request-scoped ownership and settlement, not a coarse connection-wide teardown.

Do not generalize this v1 result to MCP 2026-07-28 or TypeScript SDK v2

This boundary matters because the ecosystem is currently mid-migration. Issue #2739 uses v1 @modelcontextprotocol/sdk and negotiates 2025-11-25, which means the older initialize/initialized and session-era flow.

MCP 2026-07-28 changes the protocol core to stateless request/response, and the TypeScript SDK v2 packages reorganize the modern serving/client paths. The official v2 migration documentation also states that on a 2026-07-28 Streamable HTTP connection, aborting an in-flight request closes that request’s SSE response stream as the protocol cancellation signal.

Therefore the supported conclusion is not “all MCP Streamable HTTP has this bug.” It is:

As of September 1, 2026, the v1 @modelcontextprotocol/sdk 2025-era StreamableHTTPClientTransport still reproduces #2739’s pending-until-timeout behavior on current 1.30.0 when a request-scoped SSE response ends/errors before delivering the matching JSON-RPC result. The v2 / 2026-07-28 path needs separate testing.

If you are already migrating, use the MCP Streamable HTTP deployment and 2026-07-28 migration guide rather than designing a new architecture around this v1 workaround. If the failure is a malformed JSON-RPC message rather than a pending request, use the MCP -32700 Parse Error guide. If the request completes but the tool result is cut off, that is a different MCP Tool Call Result Truncated path. The MCP engineering hub connects these troubleshooting paths.

Production troubleshooting checklist

When a remote MCP tool call looks stuck and eventually reports only a timeout, I would inspect it in this order:

  1. Record HTTP status and Content-Type. Is the POST using JSON or text/event-stream?
  2. Timestamp transport errors. An early onerror combined with a much later request timeout is a strong lifecycle clue.
  3. Verify that the SSE stream actually delivered the matching JSON-RPC id. HTTP 200 alone does not complete the request.
  4. Separate error from clean EOF. Clean stream termination may be silent at the transport error hook.
  5. Check for explicit abort, DELETE or timeout first. Cancellation paths overlap in symptoms but are not identical to #2739.
  6. A/B a normal JSON response when the result does not need streaming. Immediate JSON settlement versus SSE timeout narrows the fault domain sharply.
  7. Keep a finite request timeout. Treat it as a containment boundary, not the root-cause fix.

Final decision

This is more than a cosmetic “increase the timeout” problem. In a production MCP client, the dangerous part is state disagreement: observability already knows the SSE leg is gone while the application still treats the JSON-RPC request as running. At concurrency, those unnecessary pending requests consume task slots, retry budgets and upstream state.

My current routing decision is:

  • Maintaining a v1 client: keep explicit request timeouts and use JSON responses for non-streaming request results when you control the server.
  • Depending on request-scoped SSE: do not treat the JSON containment as a real fix; track the #2739 lifecycle repair.
  • Building or migrating to 2026-07-28: verify the v2/modern path separately instead of inheriting this v1 conclusion.
  • Operating production clients: log both the transport-error timestamp and the JSON-RPC request-settlement timestamp; the gap between them is itself valuable diagnostic evidence.

The exact reproduction, version matrix and raw logs are being published as a GitHub asset. If upstream ships a fix, I will use the same three scenarios as a regression test and update this page with the first verified fixed version.

Topic path / MCP

Continue from protocol details to production MCP governance

The MCP hub connects protocol fundamentals, transports, authentication, security, JSON-RPC debugging and production deployment without splitting the search intent across isolated guides.

More to Explore

Topic hub →
How to Test an MCP Server Before Production: Read-Only Inspector PreflightTest an MCP Server before production with read-only server/discover and list checks for protocol version, catalogs, authorization, cache hints, and modern/legacy compatibility.MCP Streamable HTTP in Practice: From Local stdio to a Remote MCP ServerDeploy MCP Streamable HTTP with the 2026-07-28 protocol and Python SDK, covering stateless requests, proxies, auth, Origin checks, timeouts, and legacy compatibility.MCP -32700 Parse Error: stdout Pollution, Tool List Failed, and Version ChecksFix MCP -32700 Parse Error by separating stdout/stderr, malformed JSON, startup failures, SDK v2 migration, and legacy 2025 versus stateless 2026-07-28 lifecycle issues.MCP Tool Call Result Truncated: Causes, Pagination, Cursors, and Size LimitsMCP Tool Call Result Truncated is not a universal 64KB limit. Diagnose client, SDK, context and timeout limits, then return bounded results with totals, cursors and pagination.

AI Engineering Weekly

Production changes, real failures, experiments and new XBSTACK assets.

Comments & evidence

DISCUSSION

Questions, verification and corrections

Sign in to comment. Every new comment is reviewed before publication; while pending, it is visible only to you and the administrator.

Sign-in required Reviewed before public
Loading the discussion…