Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/validate-get-sse-content-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

`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`.
13 changes: 13 additions & 0 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,19 @@ export class StreamableHTTPClientTransport implements Transport {
});
}

// A GET SSE response MUST be text/event-stream (spec: the server
// either returns text/event-stream or 405). Mirror the POST path's
// check so a proxy / captive portal answering 200 with a non-SSE
// body is surfaced as an error instead of being piped through the
// SSE parser, which silently yields no events.
const contentType = response.headers.get('content-type');
if (mediaTypeEssence(contentType) !== 'text/event-stream') {
await response.text?.().catch(() => {});
throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, {
contentType
});
}

this._handleSseStream(response.body, options, true);
} catch (error) {
if (!isIntentionalAbort()) {
Expand Down
25 changes: 25 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,31 @@ describe('StreamableHTTPClientTransport', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it('should reject a GET SSE response whose content-type is not text/event-stream', async () => {
// A proxy / captive portal / misconfigured server can answer the GET
// with 200 and a non-SSE body (e.g. text/html). The POST path already
// rejects this with ClientHttpUnexpectedContent; the GET/resume path
// must do the same instead of piping HTML through the SSE parser and
// silently yielding no events.
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/html' }),
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('<html></html>'));
controller.close();
}
}),
text: async () => '<html></html>'
});

await transport.start();
await expect(transport['_startOrAuthSse']({})).rejects.toMatchObject({
code: SdkErrorCode.ClientHttpUnexpectedContent
});
});

it('should handle successful initial GET connection for SSE', async () => {
// Set up readable stream for SSE events
const encoder = new TextEncoder();
Expand Down
Loading