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:
- Serialize tool calls so only one can be in flight. Not acceptable for an agent loop.
- Guess by content. Wrong whenever two calls ask similar questions.
- 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
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.
StreamableHTTPClientTransportknows which stream it is reading, but drops that association before the message reachesonmessage, so a client cannot tell which of its in-flight requests aelicitation/create(orsampling/createMessage, orroots/list) belongs to.This matters most on the legacy era, where an elicitation arrives as a mid-call push rather than as an
input_requiredresult. 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 passesrelatedRequestId: ctx.mcpReq.idfor 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
relatedRequestIdThis is worth stating separately, because it decides how much the fix depends on server behaviour: nothing.
relatedRequestIdis 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-streamresponse 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:and then hands the response body to
_handleSseStreamwithout the originating JSON-RPC id:Inside
_handleSseStreamevery inbound message flattens tothis.onmessage?.(message), andTransport.onmessageis typed(message: JSONRPCMessage) => void. By the timeProtocoldispatches to a request handler, only the incoming request's own id survives —ctx.mcpReq.idis the elicitation's id, not the tool call's.Two details suggest this is an oversight rather than a design decision:
_handleSseStreamalready destructures areplayMessageIdfrom its options for the resumption path, so the options bag is already the place an associated message id travels.onmessage(message, extra)is already used elsewhere in the codebase;StreamableHTTPClientTransportis 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:
We do (3): wrap
fetch, decode frames before the SDK parses them, and recordserverRequestId -> 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
_handleSseStreamon 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.
_sendalready holds the id it just wrote to the POST body, and_handleSseStreamis 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:
relatedRequestIdis 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
relatedRequestIdwhen raising elicitation. This issue is the client being unable to read what that produces.Environment
@modelcontextprotocol/client2.0.0,@modelcontextprotocol/server2.0.0