Skip to content

Commit 3e863ee

Browse files
lntutorclaude
authored andcommitted
fix(client): validate Content-Type on GET/resume SSE responses
The standalone-GET and resume paths (_startOrAuthSse) piped the response body into the SSE parser without checking Content-Type, while the POST path already validates it via mediaTypeEssence and throws ClientHttpUnexpectedContent. A proxy/captive-portal/misconfigured server answering the GET with 200 and a non-SSE body (e.g. text/html) was therefore silently swallowed: the parser yielded no events, onerror never fired, and the caller believed it had a live stream. Apply the same content-type check on the GET path and throw ClientHttpUnexpectedContent when the media type is not text/event-stream, matching the Streamable HTTP spec (GET returns text/event-stream or 405). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6RtoHuxrDqTUo9Mw9h4Cv
1 parent f130e1a commit 3e863ee

3 files changed

Lines changed: 43 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
`StreamableHTTPClientTransport` now validates the `Content-Type` of a GET/resume SSE response. The standalone-GET and resume paths piped the response body straight into the SSE parser without checking `Content-Type`, so a proxy, captive portal, or misconfigured server answering the GET with `200` and a non-SSE body (e.g. `text/html`) was silently swallowed — the parser produced no events, `onerror` never fired, and the caller believed it was attached to a live stream. The GET path now applies the same `mediaTypeEssence` check the POST path already uses and throws `SdkError(ClientHttpUnexpectedContent)` when the media type is not `text/event-stream`, matching the Streamable HTTP spec requirement that a GET returns `text/event-stream` or `405`.

packages/client/src/client/streamableHttp.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,19 @@ export class StreamableHTTPClientTransport implements Transport {
600600
});
601601
}
602602

603+
// A GET SSE response MUST be text/event-stream (spec: the server
604+
// either returns text/event-stream or 405). Mirror the POST path's
605+
// check so a proxy / captive portal answering 200 with a non-SSE
606+
// body is surfaced as an error instead of being piped through the
607+
// SSE parser, which silently yields no events.
608+
const contentType = response.headers.get('content-type');
609+
if (mediaTypeEssence(contentType) !== 'text/event-stream') {
610+
await response.text?.().catch(() => {});
611+
throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
612+
contentType
613+
});
614+
}
615+
603616
this._handleSseStream(response.body, options, true);
604617
} catch (error) {
605618
if (!isIntentionalAbort()) {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,31 @@ describe('StreamableHTTPClientTransport', () => {
430430
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
431431
});
432432

433+
it('should reject a GET SSE response whose content-type is not text/event-stream', async () => {
434+
// A proxy / captive portal / misconfigured server can answer the GET
435+
// with 200 and a non-SSE body (e.g. text/html). The POST path already
436+
// rejects this with ClientHttpUnexpectedContent; the GET/resume path
437+
// must do the same instead of piping HTML through the SSE parser and
438+
// silently yielding no events.
439+
(globalThis.fetch as Mock).mockResolvedValueOnce({
440+
ok: true,
441+
status: 200,
442+
headers: new Headers({ 'content-type': 'text/html' }),
443+
body: new ReadableStream({
444+
start(controller) {
445+
controller.enqueue(new TextEncoder().encode('<html></html>'));
446+
controller.close();
447+
}
448+
}),
449+
text: async () => '<html></html>'
450+
});
451+
452+
await transport.start();
453+
await expect(transport['_startOrAuthSse']({})).rejects.toMatchObject({
454+
code: SdkErrorCode.ClientHttpUnexpectedContent
455+
});
456+
});
457+
433458
it('should handle successful initial GET connection for SSE', async () => {
434459
// Set up readable stream for SSE events
435460
const encoder = new TextEncoder();

0 commit comments

Comments
 (0)