Skip to content
Open
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/relax-accept-header.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 146 additions & 6 deletions packages/mcp/src/streamable-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
eventStore?: EventStore
onsessioninitialized?: (sessionId: string) => void | Promise<void>
onsessionclosed?: (sessionId: string) => void
strictAcceptHeader?: boolean
}

/**
Expand Down Expand Up @@ -54,6 +55,7 @@
eventStore: config.eventStore,
onsessioninitialized: config.onsessioninitialized,
onsessionclosed: config.onsessionclosed,
strictAcceptHeader: config.strictAcceptHeader,
})

await mcpServer.connect(transport)
Expand Down Expand Up @@ -115,6 +117,7 @@
eventStore: config.eventStore,
onsessioninitialized: config.onsessioninitialized,
onsessionclosed: config.onsessionclosed,
strictAcceptHeader: config.strictAcceptHeader,
})

await mcpServer.connect(transport)
Expand Down Expand Up @@ -253,9 +256,9 @@
): void {
expect(data).toMatchObject({
jsonrpc: '2.0',
error: expect.objectContaining({

Check failure on line 259 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 259 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
code: expectedCode,
message: expect.stringMatching(expectedMessagePattern),

Check failure on line 261 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 261 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
}),
})
}
Expand Down Expand Up @@ -308,7 +311,7 @@
const response = await sendPostRequest(server, secondInitMessage)

expect(response.status).toBe(400)
const errorData = await response.json()

Check failure on line 314 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 314 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
expectErrorResponse(errorData, -32600, /Server already initialized/)
})

Expand All @@ -329,7 +332,7 @@
const response = await sendPostRequest(server, batchInitMessages)

expect(response.status).toBe(400)
const errorData = await response.json()

Check failure on line 335 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 335 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/)
})

Expand All @@ -348,11 +351,11 @@
const dataLine = eventLines.find((line) => line.startsWith('data:'))
expect(dataLine).toBeDefined()

const eventData = JSON.parse(dataLine!.substring(5))

Check failure on line 354 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 354 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
expect(eventData).toMatchObject({
jsonrpc: '2.0',
result: expect.objectContaining({

Check failure on line 357 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 357 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
tools: expect.arrayContaining([

Check failure on line 358 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 358 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
expect.objectContaining({
name: 'greet',
description: 'A simple greeting tool',
Expand Down Expand Up @@ -386,7 +389,7 @@
const dataLine = eventLines.find((line) => line.startsWith('data:'))
expect(dataLine).toBeDefined()

const eventData = JSON.parse(dataLine!.substring(5))

Check failure on line 392 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 392 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
expect(eventData).toMatchObject({
jsonrpc: '2.0',
result: {
Expand Down Expand Up @@ -441,14 +444,14 @@
const dataLine = eventLines.find((line) => line.startsWith('data:'))
expect(dataLine).toBeDefined()

const eventData = JSON.parse(dataLine!.substring(5))

Check failure on line 447 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 447 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value

expect(eventData).toMatchObject({
jsonrpc: '2.0',
result: {
content: [
{ type: 'text', text: 'Hello, Test User!' },
{ type: 'text', text: expect.any(String) },

Check failure on line 454 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / autofix

Unsafe assignment of an `any` value

Check failure on line 454 in packages/mcp/src/streamable-http.test.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe assignment of an `any` value
],
},
id: 'call-1',
Expand Down Expand Up @@ -590,10 +593,10 @@
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: {
Expand All @@ -608,15 +611,94 @@
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',
},
Expand All @@ -628,7 +710,7 @@
expectErrorResponse(
errorData,
-32000,
/Client must accept both application\/json and text\/event-stream/
/Client must accept application\/json or text\/event-stream/
)
})

Expand Down Expand Up @@ -2163,6 +2245,64 @@
})
})

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
*/
Expand Down
67 changes: 47 additions & 20 deletions packages/mcp/src/streamable-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add some JSDocs around this new prop

) {
this.#sessionIdGenerator = options?.sessionIdGenerator
this.#enableJsonResponse = options?.enableJsonResponse ?? false
this.#eventStore = options?.eventStore
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole block can be like -

const isAcceptable = this.#strictAcceptHeader
  ? acceptHeader.includes('application/json') && acceptHeader.includes('text/event-stream')
  : acceptHeader.includes('application/json') ||
    acceptHeader.includes('text/event-stream') ||
    acceptHeader.includes('*/*')

if (!isAcceptable) {}

Just to keep things clean

// 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')
Expand Down
Loading