modelcontextprotocol / modelcontextprotocol/typescript-sdk

[v2] StreamableHTTPClientTransport drops SSE stream provenance, so a server-initiated request cannot be attributed to the client request that provoked it

Open
#2,659 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement needs design v1 v2
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

Summary

On Streamable HTTP, a server→client request framed on a client request's SSE response stream is related to the request that opened that stream. StreamableHTTPClientTransport knows which stream it is reading, but drops that association before the message reaches onmessage, so a client cannot tell which of its in-flight requests a elicitation/create (or sampling/createMessage, or roots/list) belongs to.

This matters most on the legacy era, where an elicitation arrives as a mid-call push rather than as an input_required result. With more than one tool call in flight there is no supported way to attribute it.

The relation exists on the wire

The 2025-06-18 transport spec says server→client requests sent on a request's SSE stream SHOULD relate to the originating client request, and that requests on the standalone GET stream SHOULD be unrelated to concurrently running client requests.

The server SDK implements exactly that. send(message, { relatedRequestId }) resolves _requestToStreamMapping.get(requestId) and writes the event on that request's stream, and the high-level server already passes relatedRequestId: ctx.mcpReq.id for elicitation raised inside a tool callback. The relation is therefore transmitted — as stream membership, not as a wire field.

The client does not need the server to have set relatedRequestId

This is worth stating separately, because it decides how much the fix depends on server behaviour: nothing.

relatedRequestId is a server-side routing knob. It selects which stream to write on; it never appears on the wire. What reaches the client is the consequence — the event is framed on one particular response stream.

Streamable HTTP gives every client JSON-RPC message its own POST, and that POST's text/event-stream response belongs to that one message. So a server→client request read from that response is inside the lifetime of that specific client request, structurally, as a property of the transport rather than of anything the server chose to declare. A client that simply remembers which stream it read a message from can attribute it — with no wire change, no new field, and no cooperation from the server beyond ordinary Streamable HTTP behaviour.

The same rule gives the negative case for free: a request arriving on the standalone GET stream has no originating request, which is exactly what the spec says to assume about it.

So the missing piece is only that the client discards which stream it read from.

Where the client drops it

In StreamableHTTPClientTransport._send, the POST path identifies that the outbound message carried requests:

const hasRequests = (Array.isArray(message) ? message : [message]).some(
  msg => 'method' in msg && 'id' in msg && msg.id !== undefined,
);

and then hands the response body to _handleSseStream without the originating JSON-RPC id:

if (hasRequests) {
  if (responseMediaType === 'text/event-stream') {
    this._handleSseStream(response.body, {
      onresumptiontoken,
      requestSignal: options?.requestSignal,
      onRequestStreamEnd: options?.onRequestStreamEnd,
    }, false);
  }
  // ...
}

Inside _handleSseStream every inbound message flattens to this.onmessage?.(message), and Transport.onmessage is typed (message: JSONRPCMessage) => void. By the time Protocol dispatches to a request handler, only the incoming request's own id survives — ctx.mcpReq.id is the elicitation's id, not the tool call's.

Two details suggest this is an oversight rather than a design decision:

  • _handleSseStream already destructures a replayMessageId from its options for the resumption path, so the options bag is already the place an associated message id travels.
  • The two-argument form onmessage(message, extra) is already used elsewhere in the codebase; StreamableHTTPClientTransport is the caller that passes one argument.

Impact

A client presenting both eras through one API has to attribute a legacy push to the call it interrupted. Without provenance the options are:

  1. Serialize tool calls so only one can be in flight. Not acceptable for an agent loop.
  2. Guess by content. Wrong whenever two calls ask similar questions.
  3. Re-read the SSE bytes underneath the transport.

We do (3): wrap fetch, decode frames before the SDK parses them, and record serverRequestId -> originating request id. It works, but it duplicates the SDK's own parsing, depends on the transport not buffering differently, and every client with this requirement has to reinvent it.

Suggested fix

Thread the originating request id into _handleSseStream on the POST path, and surface it to consumers. Keeping it generic (RequestId -> RequestId) rather than elicitation-specific means sampling, roots/list, and future nested server requests get it for free.

This is a client-only change. _send already holds the id it just wrote to the POST body, and _handleSseStream is already the per-stream boundary — the value only has to survive the call between them. No spec change, no wire field, and no behaviour required of the server.

Two possible surfaces, not mutually exclusive:

// 1. transport level, via the existing extra-info channel
this.onmessage?.(message, { relatedRequestId: originatingRequestId });

// 2. handler level, where consumers actually need it
client.setRequestHandler('elicitation/create', async (request, ctx) => {
  ctx.mcpReq.relatedRequestId; // the tools/call this was raised inside
});

relatedRequestId is already the name the outbound side uses (TransportSendOptions.relatedRequestId), so reusing it for the inbound direction restores a symmetry that is currently one-way: a server can say which request a message belongs to, and a client cannot ask.

Related

  • #963 is the same relation at the other end — ergonomics for a server attaching relatedRequestId when raising elicitation. This issue is the client being unable to read what that produces.

Environment

  • @modelcontextprotocol/client 2.0.0, @modelcontextprotocol/server 2.0.0
  • Streamable HTTP transport, legacy (2025-era) protocol negotiation

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in StreamableHTTPClientTransport._send, tracing the POST response into _handleSseStream and then Transport.onmessage. Follow how the originating JSON-RPC id is available on the POST path but omitted from inbound messages, and inspect Protocol request-handler dispatch. Done means a server request read from a POST SSE stream exposes its originating client request id, while standalone GET messages remain unrelated.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.