Skip to content

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

Description

@itsjamie

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

Environment

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

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