diff --git a/.changeset/stateless-405-allow-header.md b/.changeset/stateless-405-allow-header.md new file mode 100644 index 0000000000..63c4851017 --- /dev/null +++ b/.changeset/stateless-405-allow-header.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`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 91bca56517..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' } }) } ); } 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.'); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index a36127a1a9..519d92acfa 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2755,9 +2755,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);