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/json-accept-json-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/sdk': patch
---

Allow Streamable HTTP JSON response mode to accept requests with `Accept: application/json` without requiring `text/event-stream`.
44 changes: 36 additions & 8 deletions src/server/webStandardStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,33 @@ interface StreamMapping {
cleanup: () => void;
}

function acceptsMediaType(acceptHeader: string | null, mediaType: string): boolean {
if (!acceptHeader) {
return false;
}

const [expectedType, expectedSubtype] = mediaType.toLowerCase().split('/');

return acceptHeader.split(',').some(entry => {
const [rawMediaRange, ...params] = entry
.trim()
.toLowerCase()
.split(';')
.map(part => part.trim());
if (!rawMediaRange) {
return false;
}

const qParam = params.find(param => param.startsWith('q='));
if (qParam !== undefined && Number(qParam.slice(2)) === 0) {
return false;
}

const [type, subtype] = rawMediaRange.split('/');
return (type === expectedType || type === '*') && (subtype === expectedSubtype || subtype === '*');
});
}

/**
* Configuration options for WebStandardStreamableHTTPServerTransport
*/
Expand Down Expand Up @@ -598,14 +625,15 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
try {
// Validate the Accept header
const acceptHeader = req.headers.get('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')) {
this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream'));
return this.createJsonErrorResponse(
406,
-32000,
'Not Acceptable: Client must accept both application/json and text/event-stream'
);
const acceptsJson = acceptsMediaType(acceptHeader, 'application/json');
const acceptsEventStream = acceptsMediaType(acceptHeader, 'text/event-stream');

if (!acceptsJson || (!this._enableJsonResponse && !acceptsEventStream)) {
const error = this._enableJsonResponse
? 'Not Acceptable: Client must accept application/json'
: 'Not Acceptable: Client must accept both application/json and text/event-stream';
this.onerror?.(new Error(error));
return this.createJsonErrorResponse(406, -32000, error);
}

const ct = req.headers.get('content-type');
Expand Down
48 changes: 48 additions & 0 deletions test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,29 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
});
});

it('should accept application/json-only Accept headers in JSON response mode', async () => {
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId, { Accept: 'application/json' });

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/json');

const result = await response.json();
expect(result).toMatchObject({
jsonrpc: '2.0',
result: expect.objectContaining({
tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })])
}),
id: 'tools-1'
});
});

it('should accept wildcard Accept headers in JSON response mode', async () => {
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId, { Accept: '*/*' });

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/json');
});

it('should return JSON response for batch requests', async () => {
const batchMessages: JSONRPCMessage[] = [
{ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' },
Expand Down Expand Up @@ -3180,6 +3203,31 @@ describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => {
expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/);
});

it('should allow application/json-only Accept headers in JSON response mode', async () => {
const jsonServer = new McpServer({ name: 'json-test-server', version: '1.0.0' });
const jsonTransport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true
});
const jsonOnError = vi.fn<(error: Error) => void>();
jsonTransport.onerror = jsonOnError;
await jsonServer.connect(jsonTransport);

try {
const response = await jsonTransport.handleRequest(
req('POST', { body: TEST_MESSAGES.initialize, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } })
);

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/json');
expect(await response.json()).toMatchObject({ jsonrpc: '2.0', id: 'init-1' });
expect(jsonOnError).not.toHaveBeenCalled();
} finally {
await jsonTransport.close();
await jsonServer.close();
}
});

it('should call onerror for unsupported Content-Type', async () => {
await transport.handleRequest(
req('POST', {
Expand Down
Loading