From 467851c91b5bafa9d7ab19a0966062d47629bf60 Mon Sep 17 00:00:00 2001 From: bookernath Date: Sun, 1 Mar 2026 18:05:12 -0800 Subject: [PATCH] feat(mcp): relax Accept header validation with opt-in strict mode The strict requirement for both application/json and text/event-stream in the Accept header breaks Gemini CLI, Java MCP SDK, Open WebUI, curl, and other standard HTTP clients. The server already knows its response format, so pre-rejecting based on Accept is unnecessarily strict. Default to permissive validation that accepts application/json, text/event-stream, or */* individually. Add strictAcceptHeader option for users who need strict MCP spec compliance. Also accept */* in GET handler for consistency. Closes #1773 Co-Authored-By: Claude Opus 4.6 --- .changeset/relax-accept-header.md | 5 + packages/mcp/README.md | 14 +++ packages/mcp/src/streamable-http.test.ts | 152 ++++++++++++++++++++++- packages/mcp/src/streamable-http.ts | 67 +++++++--- 4 files changed, 212 insertions(+), 26 deletions(-) create mode 100644 .changeset/relax-accept-header.md diff --git a/.changeset/relax-accept-header.md b/.changeset/relax-accept-header.md new file mode 100644 index 000000000..75293f513 --- /dev/null +++ b/.changeset/relax-accept-header.md @@ -0,0 +1,5 @@ +--- +'@hono/mcp': patch +--- + +relax Accept header validation to accept `application/json`, `text/event-stream`, or `*/*` individually, improving compatibility with Gemini CLI, Java MCP SDK, Open WebUI, and curl. Add `strictAcceptHeader` option for strict MCP spec compliance. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 752e6e173..6dbcbcd78 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -32,6 +32,20 @@ app.all('/mcp', async (c) => { export default app ``` +### Options + +The `StreamableHTTPTransport` constructor accepts all options from the MCP SDK's `StreamableHTTPServerTransportOptions`, plus: + +- **`strictAcceptHeader`** (default: `false`) — When `false` (the default), the transport accepts POST requests where the `Accept` header contains `application/json`, `text/event-stream`, or `*/*`. This improves compatibility with clients like Gemini CLI, Java MCP SDK, Open WebUI, and curl. When `true`, the transport enforces strict MCP spec compliance, requiring both `application/json` and `text/event-stream` in the `Accept` header. + +```ts +// Permissive mode (default) — works with most HTTP clients +const transport = new StreamableHTTPTransport() + +// Strict mode — requires fully spec-compliant Accept header +const transport = new StreamableHTTPTransport({ strictAcceptHeader: true }) +``` + ## Auth The simplest way to setup MCP Auth when using 3rd party auth providers. diff --git a/packages/mcp/src/streamable-http.test.ts b/packages/mcp/src/streamable-http.test.ts index 30891f319..48e1b1bf3 100644 --- a/packages/mcp/src/streamable-http.test.ts +++ b/packages/mcp/src/streamable-http.test.ts @@ -22,6 +22,7 @@ interface TestServerConfig { eventStore?: EventStore onsessioninitialized?: (sessionId: string) => void | Promise onsessionclosed?: (sessionId: string) => void + strictAcceptHeader?: boolean } /** @@ -54,6 +55,7 @@ async function createTestServer( eventStore: config.eventStore, onsessioninitialized: config.onsessioninitialized, onsessionclosed: config.onsessionclosed, + strictAcceptHeader: config.strictAcceptHeader, }) await mcpServer.connect(transport) @@ -115,6 +117,7 @@ async function createTestAuthServer( eventStore: config.eventStore, onsessioninitialized: config.onsessioninitialized, onsessionclosed: config.onsessionclosed, + strictAcceptHeader: config.strictAcceptHeader, }) await mcpServer.connect(transport) @@ -590,10 +593,10 @@ describe('MCP helper', () => { expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/) }) - it('should reject GET requests without Accept: text/event-stream header', async () => { + it('should reject GET requests without acceptable Accept header', async () => { sessionId = await initializeServer() - // Try GET without proper Accept header + // Try GET with Accept that doesn't include text/event-stream or */* const response = await server.request('/', { method: 'GET', headers: { @@ -608,15 +611,94 @@ describe('MCP helper', () => { expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/) }) - it('should reject POST requests without proper Accept header', async () => { + it('should accept GET requests with Accept: */*', async () => { + sessionId = await initializeServer() + + const response = await server.request('/', { + method: 'GET', + headers: { + Accept: '*/*', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/event-stream') + }) + + it('should accept POST requests with Accept: application/json only (permissive default)', async () => { + sessionId = await initializeServer() + + const response = await server.request('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }) + + expect(response.status).toBe(200) + }) + + it('should accept POST requests with Accept: text/event-stream only (permissive default)', async () => { + sessionId = await initializeServer() + + const response = await server.request('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }) + + expect(response.status).toBe(200) + }) + + it('should accept POST requests with Accept: */* (permissive default)', async () => { + sessionId = await initializeServer() + + const response = await server.request('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }) + + expect(response.status).toBe(200) + }) + + it('should accept POST requests with no Accept header (permissive default)', async () => { + // No Accept header defaults to */* internally + const response = await server.request('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }) + + expect(response.status).toBe(200) + }) + + it('should reject POST requests with unacceptable Accept header (permissive default)', async () => { sessionId = await initializeServer() - // Try POST without Accept: text/event-stream const response = await server.request('/', { method: 'POST', headers: { 'Content-Type': 'application/json', - Accept: 'application/json', // Missing text/event-stream + Accept: 'text/html', 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-03-26', }, @@ -628,7 +710,7 @@ describe('MCP helper', () => { expectErrorResponse( errorData, -32000, - /Client must accept both application\/json and text\/event-stream/ + /Client must accept application\/json or text\/event-stream/ ) }) @@ -2163,6 +2245,64 @@ describe('StreamableHTTPServerTransport DNS rebinding protection', () => { }) }) +describe('strictAcceptHeader option', () => { + let server: Hono + let transport: StreamableHTTPTransport + let mcpServer: McpServer + + afterEach(async () => { + await stopTestServer({ transport }) + }) + + it('should reject POST with Accept: application/json only when strict mode is enabled', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => crypto.randomUUID(), + strictAcceptHeader: true, + }) + server = result.server + transport = result.transport + mcpServer = result.mcpServer + + // Initialize first (with both Accept types) + const initResponse = await sendPostRequest(server, TEST_MESSAGES.initialize) + expect(initResponse.status).toBe(200) + const sessionId = initResponse.headers.get('mcp-session-id') as string + + // Try POST with only application/json + const response = await server.request('/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }) + + expect(response.status).toBe(406) + const errorData = await response.json() + expectErrorResponse( + errorData, + -32000, + /Client must accept both application\/json and text\/event-stream/ + ) + }) + + it('should accept POST with both Accept types when strict mode is enabled', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => crypto.randomUUID(), + strictAcceptHeader: true, + }) + server = result.server + transport = result.transport + mcpServer = result.mcpServer + + const response = await sendPostRequest(server, TEST_MESSAGES.initialize) + expect(response.status).toBe(200) + }) +}) + /** * Helper to create test server with DNS rebinding protection options */ diff --git a/packages/mcp/src/streamable-http.ts b/packages/mcp/src/streamable-http.ts index b9992f73d..3006261ee 100644 --- a/packages/mcp/src/streamable-http.ts +++ b/packages/mcp/src/streamable-http.ts @@ -54,13 +54,16 @@ export class StreamableHTTPTransport implements Transport { #allowedHosts?: string[] #allowedOrigins?: string[] #enableDnsRebindingProtection: boolean + #strictAcceptHeader: boolean sessionId?: string onclose?: () => void onerror?: (error: Error) => void onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void - constructor(options?: StreamableHTTPServerTransportOptions) { + constructor( + options?: StreamableHTTPServerTransportOptions & { strictAcceptHeader?: boolean } + ) { this.#sessionIdGenerator = options?.sessionIdGenerator this.#enableJsonResponse = options?.enableJsonResponse ?? false this.#eventStore = options?.eventStore @@ -69,6 +72,7 @@ export class StreamableHTTPTransport implements Transport { this.#allowedHosts = options?.allowedHosts this.#allowedOrigins = options?.allowedOrigins this.#enableDnsRebindingProtection = options?.enableDnsRebindingProtection ?? false + this.#strictAcceptHeader = options?.strictAcceptHeader ?? false } /** @@ -148,8 +152,8 @@ export class StreamableHTTPTransport implements Transport { private async handleGetRequest(ctx: Context) { try { // The client MUST include an Accept header, listing text/event-stream as a supported content type. - const acceptHeader = ctx.req.header('Accept') - if (!acceptHeader?.includes('text/event-stream')) { + const getAcceptHeader = ctx.req.header('Accept') || '*/*' + if (!getAcceptHeader.includes('text/event-stream') && !getAcceptHeader.includes('*/*')) { throw new HTTPException(406, { res: Response.json({ jsonrpc: '2.0', @@ -275,23 +279,46 @@ export class StreamableHTTPTransport implements Transport { private async handlePostRequest(ctx: Context, parsedBody?: unknown) { try { // Validate the Accept header - const acceptHeader = ctx.req.header('Accept') - // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. - if ( - !acceptHeader?.includes('application/json') || - !acceptHeader.includes('text/event-stream') - ) { - throw new HTTPException(406, { - res: Response.json({ - jsonrpc: '2.0', - error: { - code: ErrorCode.ConnectionClosed, - message: - 'Not Acceptable: Client must accept both application/json and text/event-stream', - }, - id: null, - }), - }) + const acceptHeader = ctx.req.header('Accept') || '*/*' + + if (this.#strictAcceptHeader) { + // Strict spec compliance: require both as per MCP spec + if ( + !acceptHeader.includes('application/json') || + !acceptHeader.includes('text/event-stream') + ) { + throw new HTTPException(406, { + res: Response.json({ + jsonrpc: '2.0', + error: { + code: ErrorCode.ConnectionClosed, + message: + 'Not Acceptable: Client must accept both application/json and text/event-stream', + }, + id: null, + }), + }) + } + } else { + // Permissive mode (default): accept if client can handle JSON, SSE, or wildcard + const acceptable = + acceptHeader.includes('application/json') || + acceptHeader.includes('text/event-stream') || + acceptHeader.includes('*/*') + + if (!acceptable) { + throw new HTTPException(406, { + res: Response.json({ + jsonrpc: '2.0', + error: { + code: ErrorCode.ConnectionClosed, + message: + 'Not Acceptable: Client must accept application/json or text/event-stream', + }, + id: null, + }), + }) + } } const ct = ctx.req.header('Content-Type')