-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(client): preserve Streamable HTTP request provenance #2667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d1584c4
d363e64
d0f8ca2
5d42a7f
d237eda
fb4110c
e7c7e36
913689e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@modelcontextprotocol/client": patch | ||
| --- | ||
|
|
||
| Expose the originating client request ID for server-initiated requests received on a Streamable HTTP response stream. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import type { ReadableWritablePair } from 'node:stream/web'; | ||
|
|
||
| import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal'; | ||
| import type { FetchLike, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core-internal'; | ||
| import { | ||
| createFetchWithInit, | ||
| encodeMcpParamValue, | ||
|
|
@@ -54,6 +54,12 @@ const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOp | |
| * Options for starting or authenticating an SSE connection | ||
| */ | ||
| export interface StartSSEOptions { | ||
| /** | ||
| * The client request whose POST response stream carries this SSE stream. | ||
| * Standalone GET streams have no related request. | ||
| */ | ||
| relatedRequestId?: RequestId; | ||
|
|
||
| /** | ||
| * The resumption token used to continue long-running requests that were interrupted. | ||
| * | ||
|
|
@@ -330,7 +336,7 @@ export class StreamableHTTPClientTransport implements Transport { | |
|
|
||
| onclose?: () => void; | ||
| onerror?: (error: Error) => void; | ||
| onmessage?: (message: JSONRPCMessage) => void; | ||
| onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; | ||
|
|
||
| /** | ||
| * Streamable HTTP opens one POST (and SSE response stream) per outbound | ||
|
|
@@ -713,15 +719,15 @@ export class StreamableHTTPClientTransport implements Transport { | |
| options.onRequestStreamEnd?.(); | ||
| return; | ||
| } | ||
| const { onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options; | ||
| const { onresumptiontoken, replayMessageId, relatedRequestId, requestSignal, onRequestStreamEnd } = options; | ||
| // An intentional abort — transport-wide close OR a per-request abort | ||
| // (McpSubscription.close() aborting its `requestSignal`) — must read as | ||
| // a clean shutdown: no misleading "SSE stream disconnected" onerror, | ||
| // and no GET+Last-Event-ID reconnect that would resurrect a stream the | ||
| // caller just tore down. | ||
| const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; | ||
|
|
||
| let lastEventId: string | undefined; | ||
| let lastEventId: string | undefined = options.resumptionToken; | ||
| // Track whether we've received a priming event (event with ID) | ||
| // Per spec, server SHOULD send a priming event with ID before closing | ||
| let hasPrimingEvent = false; | ||
|
|
@@ -775,7 +781,11 @@ export class StreamableHTTPClientTransport implements Transport { | |
| message.id = replayMessageId; | ||
| } | ||
| } | ||
| this.onmessage?.(message); | ||
| if (relatedRequestId === undefined || !isJSONRPCRequest(message)) { | ||
| this.onmessage?.(message); | ||
| } else { | ||
| this.onmessage?.(message, { relatedRequestId }); | ||
| } | ||
| } catch (error) { | ||
| this.onerror?.(error as Error); | ||
| } | ||
|
|
@@ -794,6 +804,7 @@ export class StreamableHTTPClientTransport implements Transport { | |
| resumptionToken: lastEventId, | ||
| onresumptiontoken, | ||
| replayMessageId, | ||
| relatedRequestId, | ||
| requestSignal, | ||
| onRequestStreamEnd | ||
| }, | ||
|
|
@@ -827,6 +838,7 @@ export class StreamableHTTPClientTransport implements Transport { | |
| resumptionToken: lastEventId, | ||
| onresumptiontoken, | ||
| replayMessageId, | ||
| relatedRequestId, | ||
| requestSignal, | ||
| onRequestStreamEnd | ||
| }, | ||
|
|
@@ -954,10 +966,18 @@ export class StreamableHTTPClientTransport implements Transport { | |
| // same per-request abort as the original POST — modern-era | ||
| // cancel-via-stream-close routes through `requestSignal`, and | ||
| // without it a resumed long-running request would not cancel. | ||
| // `relatedRequestId` rides along for the same reason: the | ||
| // resumed GET continues *this* request's stream, so a server | ||
| // request replayed on it keeps the provenance the original | ||
| // POST stream would have carried. | ||
| const resumedRequestId = isJSONRPCRequest(message) ? message.id : undefined; | ||
| this._startOrAuthSse({ | ||
| resumptionToken, | ||
| replayMessageId: isJSONRPCRequest(message) ? message.id : undefined, | ||
| requestSignal: options?.requestSignal | ||
| onresumptiontoken, | ||
| replayMessageId: resumedRequestId, | ||
| relatedRequestId: resumedRequestId, | ||
| requestSignal: options?.requestSignal, | ||
| onRequestStreamEnd: options?.onRequestStreamEnd | ||
| }).catch(error => this.onerror?.(error)); | ||
|
Comment on lines
974
to
981
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing (in code this diff edits): the resumptionToken branch of _send threads replayMessageId/relatedRequestId/requestSignal into _startOrAuthSse but still omits onresumptiontoken and onRequestStreamEnd, so a resumed request permanently stops sur [additional confirmed claim at this location: Pre-existing, in the resumption statement this diff edits: the unguarded Extended reasoning...A client runs a long-running tool call with Verification: pre-existing — but in the exact lines this diff edits. In /home/claude/typescript-sdk/packages/client/src/client/streamableHttp.ts, the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed the underlying issue on the pre-fix shape and added a direct regression. The current resumption-token branch forwards both Post-fix verification on |
||
| return; | ||
| } | ||
|
|
@@ -1120,7 +1140,9 @@ export class StreamableHTTPClientTransport implements Transport { | |
| // Get original message(s) for detecting request IDs | ||
| const messages = Array.isArray(message) ? message : [message]; | ||
|
|
||
| const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg && msg.id !== undefined); | ||
| const requests = messages.filter(msg => isJSONRPCRequest(msg)); | ||
| const hasRequests = requests.length > 0; | ||
| const relatedRequestId = messages.length === 1 && requests.length === 1 ? requests[0]!.id : undefined; | ||
|
|
||
| // Check the response type (parsed media type — see mediaTypeEssence) | ||
| const contentType = response.headers.get('content-type'); | ||
|
|
@@ -1135,6 +1157,7 @@ export class StreamableHTTPClientTransport implements Transport { | |
| response.body, | ||
| { | ||
| onresumptiontoken, | ||
| relatedRequestId, | ||
| requestSignal: options?.requestSignal, | ||
| onRequestStreamEnd: options?.onRequestStreamEnd | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import type { JSONRPCMessage, MessageExtraInfo, Transport } from '@modelcontextprotocol/core-internal'; | ||
| import { isJSONRPCRequest } from '@modelcontextprotocol/core-internal'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { Client } from '../../src/client/client'; | ||
|
|
||
| class ScriptedTransport implements Transport { | ||
| onclose?: () => void; | ||
| onerror?: (error: Error) => void; | ||
| onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; | ||
| sent: JSONRPCMessage[] = []; | ||
|
|
||
| async start(): Promise<void> {} | ||
|
|
||
| async close(): Promise<void> { | ||
| this.onclose?.(); | ||
| } | ||
|
|
||
| async send(message: JSONRPCMessage): Promise<void> { | ||
| this.sent.push(message); | ||
| if (isJSONRPCRequest(message) && message.method === 'initialize') { | ||
| queueMicrotask(() => | ||
| this.onmessage?.({ | ||
| jsonrpc: '2.0', | ||
| id: message.id, | ||
| result: { | ||
| protocolVersion: '2025-11-25', | ||
| capabilities: {}, | ||
| serverInfo: { name: 'scripted-server', version: '1.0.0' } | ||
| } | ||
| }) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| emit(message: JSONRPCMessage, extra?: MessageExtraInfo): void { | ||
| this.onmessage?.(message, extra); | ||
| } | ||
| } | ||
|
|
||
| describe('Streamable HTTP provenance in client request context', () => { | ||
| it('exposes relatedRequestId to the client request handler', async () => { | ||
| const transport = new ScriptedTransport(); | ||
| const client = new Client({ name: 'provenance-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: false } } }); | ||
| let relatedRequestId: string | number | undefined; | ||
|
|
||
| client.setRequestHandler('roots/list', async (_request, context) => { | ||
| relatedRequestId = context.mcpReq.relatedRequestId; | ||
| return { roots: [] }; | ||
| }); | ||
|
|
||
| await client.connect(transport); | ||
| transport.emit({ jsonrpc: '2.0', id: 'server-request-1', method: 'roots/list', params: {} }, { relatedRequestId: 'tool-call-1' }); | ||
| await new Promise(resolve => setTimeout(resolve, 0)); | ||
|
|
||
| expect(relatedRequestId).toBe('tool-call-1'); | ||
| await client.close(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 relatedRequestId survives a reconnect that has no resumption token, so a fallback standalone GET stream's server-initiated requests are falsely attributed to the old request
Extended reasoning...
A per-request stream is resumed via GET (either send() with resumptionToken, or a POST-SSE stream that reconnected after its priming event). The resumed GET (isReconnectable=true) then drops before delivering any event carrying an
id— a realistic flaky-network case, and precisely the situation reconnection exists for. In _handleSseStream both reconnect sites (graceful-close path lines 802-812 and error path lines 836-846) build the next StartSSEOptions withresumptionToken: lastEventId, where lastEventId is LOCAL to this stream invocation and still undefined (the in-force options.resumptionToken from the current leg is never carried forward), whilerelatedRequestIdandreplayMessageIdARE carried forward. _startOrAuthSse then sends a GET with no Last-Event-ID header (lines 544-546), which per the Streamable HTTP spec the server treats as a NEW standalone listening stream, not a resumption. Every server-initiated request the server pushes on that standalone stream — elicitation/sampling aimed at no particular client request — is now delivered as `onmessage(message, { relatedRVerification: normal — the misattribution path is real and reachable in code this diff added. Chain, all in packages/client/src/client/streamableHttp.ts: (1) every GET stream is reconnectable — line 636
this._handleSseStream(response.body, options, true); (2) in_handleSseStream, the locallastEventIdstartsundefined(line 730) and is never seeded fromoptions.resumptionToken, so a resumed GET (openeThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reconnect case is covered and fixed on the current head.
_handleSseStream()now seedslastEventIdfromoptions.resumptionTokenbefore reading the resumed GET. The existing regressionstreamableHttp.test.ts(“keeps the original resumption token when a resumed GET closes before receiving a new event ID”) starts withevent-42, closes the resumed stream without an event ID, then asserts the next GET still sendsLast-Event-ID: event-42.That focused test passes on
913689ee; the current source does not treat the fallback GET as standalone while the per-request resume chain is active. The standalone GET path has norelatedRequestIdby construction.