From a94ccfc4a19ba9eabee9d9d678fe748945487d64 Mon Sep 17 00:00:00 2001 From: DDT <141296842+Hugo-DDT@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:31:32 +0800 Subject: [PATCH 1/3] fix(server): set Allow: POST on stateless legacy 405 responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createMcpHandler's legacy stateless fallback answered non-POST requests with 405 without an Allow header. RFC 9110 §15.5.6 requires Allow on 405, and WebStandardStreamableHTTPServerTransport.handleUnsupportedRequest already sets it. Stateless serving only accepts POST, so advertise that. Closes #2970 Assisted-by: AI (Grok Bot) --- packages/server/src/server/createMcpHandler.ts | 18 +++++++++++++++++- .../test/server/createMcpHandler.test.ts | 1 + .../server/legacyStatelessFallback.test.ts | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 91bca56517..ac3a25a140 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -335,7 +335,23 @@ function createLegacyStatelessFallback( ): LegacyHttpHandler { return async (request, options) => { if (request.method.toUpperCase() !== 'POST') { - return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.'); + // RFC 9110 §15.5.6 requires Allow on 405. Stateless serving only accepts POST. + return Response.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Method not allowed.' + }, + id: null + }, + { + status: 405, + headers: { + Allow: 'POST' + } + } + ); } try { const product = await factory({ diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index a1b9ce7986..1069faba2e 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -546,6 +546,7 @@ describe('createMcpHandler — stateless legacy fallback (the default)', () => { for (const method of ['GET', 'DELETE']) { const response = await handler.fetch(new Request('http://localhost/mcp', { method })); expect(response.status).toBe(405); + expect(response.headers.get('Allow')).toBe('POST'); const body = (await response.json()) as JSONRPCErrorBody; expect(body.error.code).toBe(-32_000); expect(body.error.message).toBe('Method not allowed.'); diff --git a/packages/server/test/server/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts index 50247ae6ab..efd1177cff 100644 --- a/packages/server/test/server/legacyStatelessFallback.test.ts +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -87,6 +87,7 @@ describe('legacyStatelessFallback', () => { for (const method of ['GET', 'DELETE']) { const response = await handler(new Request('http://localhost/mcp', { method })); expect(response.status).toBe(405); + expect(response.headers.get('Allow')).toBe('POST'); const body = (await response.json()) as JSONRPCErrorBody; expect(body.error.code).toBe(-32_000); expect(body.error.message).toBe('Method not allowed.'); From cf633b3f08cbefeeb60fc1042cf85d38a29c1791 Mon Sep 17 00:00:00 2001 From: DDT <141296842+Hugo-DDT@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:28:31 +0800 Subject: [PATCH 2/3] chore: add patch changeset for Allow header on stateless 405 --- .changeset/stateless-405-allow-header.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/stateless-405-allow-header.md diff --git a/.changeset/stateless-405-allow-header.md b/.changeset/stateless-405-allow-header.md new file mode 100644 index 0000000000..7aec9d00d4 --- /dev/null +++ b/.changeset/stateless-405-allow-header.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +fix(server): set `Allow: POST` on legacy stateless 405 responses for non-POST requests (RFC 9110). From 0d76cbbc7a7346c9328a3cef1c96406b6f8b16f8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:13:24 +0000 Subject: [PATCH 3/3] fix(server): set Allow: POST in the shared 405 response Moves the header into jsonRpcErrorResponse, so the 405 of legacy: 'reject' carries it too, and asserts it in the e2e requirement that runs on both postures. --- .changeset/stateless-405-allow-header.md | 2 +- .../server/src/server/createMcpHandler.ts | 21 +++---------------- test/e2e/requirements.ts | 4 ++-- test/e2e/scenarios/hosting-entry-http.test.ts | 1 + 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/.changeset/stateless-405-allow-header.md b/.changeset/stateless-405-allow-header.md index 7aec9d00d4..63c4851017 100644 --- a/.changeset/stateless-405-allow-header.md +++ b/.changeset/stateless-405-allow-header.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/server': patch --- -fix(server): set `Allow: POST` on legacy stateless 405 responses for non-POST requests (RFC 9110). +`createMcpHandler` now sends `Allow: POST` on every `405` it answers to a non-POST request, with `legacy: 'stateless'` (the default) and with `legacy: 'reject'`. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index ac3a25a140..4ef836c636 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -281,7 +281,8 @@ function jsonRpcErrorResponse(httpStatus: number, code: number, message: string, error: { code, message, ...(data !== undefined && { data }) }, id }, - { status: httpStatus } + // This entry serves POST only, so every 405 it answers names POST (RFC 9110 §15.5.6). + { status: httpStatus, ...(httpStatus === 405 && { headers: { Allow: 'POST' } }) } ); } @@ -335,23 +336,7 @@ function createLegacyStatelessFallback( ): LegacyHttpHandler { return async (request, options) => { if (request.method.toUpperCase() !== 'POST') { - // RFC 9110 §15.5.6 requires Allow on 405. Stateless serving only accepts POST. - return Response.json( - { - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }, - { - status: 405, - headers: { - Allow: 'POST' - } - } - ); + return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.'); } try { const product = await factory({ diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 3d9811809f..a40d36fe9f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2733,9 +2733,9 @@ export const REQUIREMENTS: Record = { 'typescript:hosting:entry:method-405': { source: 'sdk', behavior: - 'A non-POST HTTP method (GET, DELETE, PUT, PATCH) on a createMcpHandler endpoint is answered 405 with a JSON-RPC Method-not-allowed body on both legs: the stateless legacy fallback rejects every non-POST method, and the modern-only strict path rejects body-less non-POST traffic via the modern-only-method-not-allowed cell.', + 'A non-POST HTTP method (GET, DELETE, PUT, PATCH) on a createMcpHandler endpoint is answered 405 with `Allow: POST` and a JSON-RPC Method-not-allowed body on both legs: the stateless legacy fallback rejects every non-POST method, and the modern-only strict path rejects body-less non-POST traffic via the modern-only-method-not-allowed cell.', transports: ['entryStateless', 'entryModern'], - note: 'Runs on the createMcpHandler entry arms; each non-POST method is sent through wired.fetch so the HTTP status and body are observed directly. The entry does not emit an Allow header (the per-session server transport does), so only the status and JSON-RPC error shape are pinned.' + note: 'Runs on the createMcpHandler entry arms; each non-POST method is sent through wired.fetch so the HTTP status and body are observed directly. The status, the `Allow` header and the JSON-RPC error shape are pinned.' }, 'typescript:hosting:entry:parse-error-400': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', diff --git a/test/e2e/scenarios/hosting-entry-http.test.ts b/test/e2e/scenarios/hosting-entry-http.test.ts index 386d843756..bfe8d319d6 100644 --- a/test/e2e/scenarios/hosting-entry-http.test.ts +++ b/test/e2e/scenarios/hosting-entry-http.test.ts @@ -39,6 +39,7 @@ verifies('typescript:hosting:entry:method-405', async ({ transport }: TestArgs) for (const method of ['GET', 'DELETE', 'PUT', 'PATCH']) { const response = await wired.fetch!(wired.url!, { method }); expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); const body = (await response.json()) as { jsonrpc: string; error: { code: number; message: string } }; expect(body.jsonrpc).toBe('2.0'); expect(body.error.code).toBe(-32_000);