Skip to content
5 changes: 5 additions & 0 deletions .changeset/quiet-stream-provenance.md
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.
16 changes: 16 additions & 0 deletions docs/clients/server-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ Sampling request: { type: 'text', text: 'Summarize this order: 1 Travel mug to L
[ { type: 'text', text: 'host-model: One travel mug to Lisbon.' } ]
```

## Associate a Streamable HTTP request with its parent

When a server-initiated request arrives on a Streamable HTTP response stream, the handler context
includes `ctx.mcpReq.relatedRequestId`: the JSON-RPC id of the client request whose stream carried
it. Use it to associate an elicitation, sampling request, or roots request with the operation that
started it. The field is absent for standalone GET messages and transports that do not provide a
stream association.

```ts
client.setRequestHandler('elicitation/create', async (_request, ctx) => {
const parentRequestId = ctx.mcpReq.relatedRequestId;
console.log('Elicitation belongs to:', parentRequestId ?? 'no associated request');
return { action: 'accept' };
});
```

## Register each handler once

Register each handler once, on the `Client` you construct. The same handler answers a request the server pushes to your client and a request the SDK fulfils for you inside a `callTool()` round — your code never sees the difference.
Expand Down
39 changes: 31 additions & 8 deletions packages/client/src/client/streamableHttp.ts
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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -794,6 +804,7 @@ export class StreamableHTTPClientTransport implements Transport {
resumptionToken: lastEventId,
onresumptiontoken,
replayMessageId,
relatedRequestId,
requestSignal,
onRequestStreamEnd
},
Expand Down Expand Up @@ -827,6 +838,7 @@ export class StreamableHTTPClientTransport implements Transport {
resumptionToken: lastEventId,
onresumptiontoken,
replayMessageId,
relatedRequestId,
requestSignal,
onRequestStreamEnd
},
Comment on lines 838 to 844

Copy link
Copy Markdown

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 with resumptionToken: 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), while relatedRequestId and replayMessageId ARE 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, { relatedR

Verification: 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 local lastEventId starts undefined (line 730) and is never seeded from options.resumptionToken, so a resumed GET (opene

Copy link
Copy Markdown
Author

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 seeds lastEventId from options.resumptionToken before reading the resumed GET. The existing regression streamableHttp.test.ts (“keeps the original resumption token when a resumed GET closes before receiving a new event ID”) starts with event-42, closes the resumed stream without an event ID, then asserts the next GET still sends Last-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 no relatedRequestId by construction.

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 .catch(error => this.onerror?.(error)) on the _startOrAuthSse call in _send's resumptionToken branch both double-fires on]

Extended reasoning...

A client runs a long-running tool call with client.request(req, schema, { onresumptiontoken: t => persist(t) }) and persists tokens to survive restarts (the documented resumption pattern). After a crash it resumes with { resumptionToken: saved, onresumptiontoken: t => persist(t) }. _send takes the if (resumptionToken) branch (lines 963-981) and calls _startOrAuthSse({ resumptionToken, replayMessageId, relatedRequestId, requestSignal }) WITHOUT onresumptiontoken/onRequestStreamEnd, even though line 961 destructures onresumptiontoken from options. In _handleSseStream, line 765 onresumptiontoken?.(event.id) is now a no-op for the resumed stream, and the reconnect paths (lines 802-812, 836-846) propagate the undefined callback forever. The app's persisted token never advances past the pre-crash value; on the next crash/resume the server replays every event since the stale token (duplicate elicitation requests, duplicated progress) or the token has been expired by the server's event store and the resume fails outright. The diff touched exactly this options literal (add

Verification: pre-existing — but in the exact lines this diff edits. In /home/claude/typescript-sdk/packages/client/src/client/streamableHttp.ts, the _send resumption branch calls this._startOrAuthSse({ resumptionToken, replayMessageId: resumedRequestId, relatedRequestId: resumedRequestId, requestSignal: options?.requestSignal }) (lines 974-979) — onresumptiontoken is destructured at line 961 (`const { re

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 onresumptiontoken and onRequestStreamEnd into _startOrAuthSse. The new test starts a resumption-token GET that receives 405 and asserts the per-request stream-end callback fires. Removing those two forwarding lines makes the test fail with 0 callback calls; restoring them passes.

Post-fix verification on 913689ee: the focused client suite passes 76/76, client typecheck passes, ESLint/Prettier pass, and git diff --check passes.

return;
}
Expand Down Expand Up @@ -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');
Expand All @@ -1135,6 +1157,7 @@ export class StreamableHTTPClientTransport implements Transport {
response.body,
{
onresumptiontoken,
relatedRequestId,
requestSignal: options?.requestSignal,
onRequestStreamEnd: options?.onRequestStreamEnd
},
Expand Down
59 changes: 59 additions & 0 deletions packages/client/test/client/streamProvenanceContext.test.ts
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();
});
});
182 changes: 182 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,188 @@ describe('StreamableHTTPClientTransport', () => {
).toBe(true);
});

it('attributes server requests received on a POST SSE stream to the originating request', async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
'event: message\ndata: {"jsonrpc":"2.0","id":"elicitation-1","method":"elicitation/create","params":{}}\n\n'
)
);
}
});

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});

const messageSpy = vi.fn();
transport.onmessage = messageSpy;

await transport.send({ jsonrpc: '2.0', id: 'tool-call-1', method: 'tools/call', params: { name: 'route' } });
await new Promise(resolve => setTimeout(resolve, 50));

expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'elicitation-1', method: 'elicitation/create' }), {
relatedRequestId: 'tool-call-1'
});
});

it('keeps that attribution when the request is resumed with a resumption token', async () => {
// Resuming an interrupted request replaces the POST response stream
// with a `Last-Event-ID` GET, but the stream still belongs to the same
// client request — so provenance has to survive the swap. This is the
// long-running-call case: the stream drops, the client resumes, and the
// elicitation arrives on the resumed stream.
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
'id: event-43\nevent: message\ndata: {"jsonrpc":"2.0","id":"elicitation-1","method":"elicitation/create","params":{}}\n\n'
)
);
}
});

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});

const messageSpy = vi.fn();
transport.onmessage = messageSpy;

const resumptionTokenSpy = vi.fn();
await transport.send(
{ jsonrpc: '2.0', id: 'tool-call-1', method: 'tools/call', params: { name: 'route' } },
{ resumptionToken: 'event-42', onresumptiontoken: resumptionTokenSpy }
);
await new Promise(resolve => setTimeout(resolve, 50));

expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'elicitation-1', method: 'elicitation/create' }), {
relatedRequestId: 'tool-call-1'
});
expect(resumptionTokenSpy).toHaveBeenCalledWith('event-43');
});

it('keeps per-request stream callbacks when a resumption-token GET is started directly', async () => {
const fetchMock = globalThis.fetch as Mock;
const onresumptiontoken = vi.fn();
const onRequestStreamEnd = vi.fn();
fetchMock.mockResolvedValueOnce({ ok: false, status: 405, headers: new Headers() });

await transport.start();
await transport.send(
{ jsonrpc: '2.0', id: 'tool-call-1', method: 'tools/call', params: { name: 'route' } },
{ resumptionToken: 'event-42', onresumptiontoken, onRequestStreamEnd }
);
await vi.waitFor(() => expect(onRequestStreamEnd).toHaveBeenCalledTimes(1));

expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
expect((fetchMock.mock.calls[0]![1]?.headers as Headers).get('last-event-id')).toBe('event-42');
expect(onresumptiontoken).not.toHaveBeenCalled();
});

it('keeps the original resumption token when a resumed GET closes before receiving a new event ID', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 5,
maxReconnectionDelay: 100,
reconnectionDelayGrowFactor: 1,
maxRetries: 1
}
});
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: new ReadableStream({
start(controller) {
controller.close();
}
})
});
fetchMock.mockResolvedValueOnce({ ok: false, status: 405, headers: new Headers() });

await transport.start();
await transport['_startOrAuthSse']({ resumptionToken: 'event-42', relatedRequestId: 'tool-call-1' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2), { timeout: 250 });

const reconnectHeaders = fetchMock.mock.calls[1]![1]?.headers as Headers;
expect(reconnectHeaders.get('last-event-id')).toBe('event-42');
});

it('does not attribute server requests received on the standalone GET stream', async () => {
// Transports spec (2025-03-26 … 2025-11-25) §Listening for Messages:
// messages on the standalone GET stream SHOULD be unrelated to any
// concurrently-running client request. Attributing one to whatever
// request happened to be in flight would be a fabricated relation.
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
'event: message\ndata: {"jsonrpc":"2.0","id":"elicitation-1","method":"elicitation/create","params":{}}\n\n'
)
);
}
});

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});

const messageSpy = vi.fn();
transport.onmessage = messageSpy;

const transportWithPrivateMethods = transport as unknown as {
_startOrAuthSse: (options: StartSSEOptions) => Promise<void>;
};
await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: undefined });
await new Promise(resolve => setTimeout(resolve, 50));

expect(messageSpy).toHaveBeenCalledTimes(1);
expect(messageSpy.mock.calls[0]![1]).toBeUndefined();
});

it('does not attach provenance to the response that terminates a POST SSE stream', async () => {
// A response already carries its own correlation — its `id` IS the
// originating request. Only server-initiated requests, whose ids come
// from the server's own numbering, need the stream to supply it.
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('event: message\ndata: {"jsonrpc":"2.0","id":"tool-call-1","result":{}}\n\n'));
}
});

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});

const messageSpy = vi.fn();
transport.onmessage = messageSpy;

await transport.send({ jsonrpc: '2.0', id: 'tool-call-1', method: 'tools/call', params: { name: 'route' } });
await new Promise(resolve => setTimeout(resolve, 50));

expect(messageSpy).toHaveBeenCalledTimes(1);
expect(messageSpy.mock.calls[0]![1]).toBeUndefined();
});

it('declares hasPerRequestStream so the protocol layer routes 2026-era cancellation to stream-close', () => {
// Spec basic/patterns/cancellation §Transport-Specific (2026-07-28):
// closing the per-request SSE stream IS the cancel signal on
Expand Down
Loading
Loading