diff --git a/.changeset/validate-get-sse-content-type.md b/.changeset/validate-get-sse-content-type.md new file mode 100644 index 0000000000..ff108f2aa3 --- /dev/null +++ b/.changeset/validate-get-sse-content-type.md @@ -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`. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 9067fd1ec4..21e7aae9ef 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -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()) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a36bbc0ad3..267587e612 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -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('')); + controller.close(); + } + }), + text: async () => '' + }); + + 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();