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/fix-streamable-http-content-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

Allow Streamable HTTP clients to send valid JSON POST requests without requiring `Content-Type: application/json`.
21 changes: 17 additions & 4 deletions packages/middleware/node/test/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,20 @@ describe('Zod v4', () => {
expect(response.headers.get('mcp-session-id')).toBeDefined();
});

it('should initialize from valid JSON without Content-Type header', async () => {
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
Accept: 'application/json, text/event-stream'
},
body: new TextEncoder().encode(JSON.stringify(TEST_MESSAGES.initialize))
});

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
expect(response.headers.get('mcp-session-id')).toBeDefined();
});

it('should reject second initialization request', async () => {
// First initialize
const sessionId = await initializeServer();
Expand Down Expand Up @@ -680,10 +694,9 @@ describe('Zod v4', () => {
expectErrorResponse(errorData, -32_000, /Client must accept both application\/json and text\/event-stream/);
});

it('should reject unsupported Content-Type', async () => {
it('should reject invalid JSON regardless of Content-Type', async () => {
sessionId = await initializeServer();

// Try POST with text/plain Content-Type
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
Expand All @@ -694,9 +707,9 @@ describe('Zod v4', () => {
body: 'This is plain text'
});

expect(response.status).toBe(415);
expect(response.status).toBe(400);
const errorData = await response.json();
expectErrorResponse(errorData, -32_000, /Content-Type must be application\/json/);
expectErrorResponse(errorData, -32_700, /Parse error.*Invalid JSON/);
});

it('should handle JSON-RPC batch notification messages with 202 response', async () => {
Expand Down
6 changes: 0 additions & 6 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,12 +629,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
);
}

const ct = req.headers.get('content-type');
if (!ct || !ct.includes('application/json')) {
this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json'));
return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json');
}

const request = req;

let rawMessage;
Expand Down
40 changes: 27 additions & 13 deletions packages/server/test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,24 @@ describe('Zod v4', () => {
expectErrorResponse(errorData, -32_000, /Not Acceptable/);
});

it('should reject request with wrong Content-Type header', async () => {
it('should accept valid JSON without Content-Type header', async () => {
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: {
Accept: 'application/json, text/event-stream'
},
body: new TextEncoder().encode(JSON.stringify(TEST_MESSAGES.initialize))
});

expect(request.headers.get('content-type')).toBeNull();

const response = await transport.handleRequest(request);

expect(response.status).toBe(200);
expect(response.headers.get('mcp-session-id')).toBeDefined();
});

it('should not reject valid JSON based on Content-Type header', async () => {
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: {
Expand All @@ -312,9 +329,8 @@ describe('Zod v4', () => {
});
const response = await transport.handleRequest(request);

expect(response.status).toBe(415);
const errorData = await response.json();
expectErrorResponse(errorData, -32_000, /Unsupported Media Type/);
expect(response.status).toBe(200);
expect(response.headers.get('mcp-session-id')).toBeDefined();
});

it('should reject invalid JSON', async () => {
Expand Down Expand Up @@ -844,23 +860,21 @@ describe('Zod v4', () => {
expect(error?.message).toContain('Not Acceptable');
});

it('should call onerror for unsupported Content-Type', async () => {
it('should not call onerror for valid JSON without Content-Type', async () => {
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: {
Accept: 'application/json, text/event-stream',
'Content-Type': 'text/plain'
Accept: 'application/json, text/event-stream'
},
body: JSON.stringify(TEST_MESSAGES.initialize)
body: new TextEncoder().encode(JSON.stringify(TEST_MESSAGES.initialize))
});

expect(request.headers.get('content-type')).toBeNull();

const response = await transport.handleRequest(request);

expect(response.status).toBe(415);
expect(errors.length).toBeGreaterThan(0);
const error = errors[0];
expect(error).toBeDefined();
expect(error?.message).toContain('Unsupported Media Type');
expect(response.status).toBe(200);
expect(errors).toHaveLength(0);
});

it('should call onerror for server not initialized', async () => {
Expand Down
Loading