From 372977c11409dc69a3d87aad17d211a66bdfcb57 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 21 Jul 2026 10:28:34 +1000 Subject: [PATCH 1/3] fix(api): return 405 for GET/DELETE on MCP endpoint The stateless Streamable HTTP transport routed every method through the SDK, which answered GET with a server-initiated SSE stream that the per-request teardown immediately closes, and DELETE with a non-405 status. MCP SDK clients (e.g. Claude Code) abort the connection on anything but 405, so they could not connect at all. GET and DELETE now return 405 Method Not Allowed with Allow: POST, which the spec requires for a server that offers neither a standalone SSE stream nor client-terminable sessions. POST is unchanged. Rate limiting is kept on all methods. --- .changeset/mcp-transport-405.md | 5 +++++ packages/api/src/mcp/__tests__/app.test.ts | 24 ++++++++++++++++++++++ packages/api/src/mcp/app.ts | 15 +++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-transport-405.md create mode 100644 packages/api/src/mcp/__tests__/app.test.ts diff --git a/.changeset/mcp-transport-405.md b/.changeset/mcp-transport-405.md new file mode 100644 index 0000000000..e950e00038 --- /dev/null +++ b/.changeset/mcp-transport-405.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': patch +--- + +fix: MCP endpoint (`/api/mcp`) now returns 405 for GET and DELETE instead of aborting spec-compliant clients. The stateless Streamable HTTP transport doesn't offer a server-initiated SSE stream or client-terminable sessions, so it now responds `405 Method Not Allowed` (with `Allow: POST`) for those methods, which official MCP SDK clients (e.g. Claude Code) treat as "not offered, continue" rather than a failed connection. diff --git a/packages/api/src/mcp/__tests__/app.test.ts b/packages/api/src/mcp/__tests__/app.test.ts new file mode 100644 index 0000000000..a7fa87cb05 --- /dev/null +++ b/packages/api/src/mcp/__tests__/app.test.ts @@ -0,0 +1,24 @@ +import express from 'express'; +import request from 'supertest'; + +import mcpRouter from '@/mcp/app'; + +// The MCP transport is stateless, so GET (standalone SSE stream) and DELETE +// (session termination) are not offered and must return 405 so spec-compliant +// SDK clients continue connecting rather than aborting. See issue #2686. +describe('mcp app transport methods', () => { + const app = express(); + app.use('/mcp', mcpRouter); + + it('returns 405 with Allow: POST for GET', async () => { + const res = await request(app).get('/mcp'); + expect(res.status).toBe(405); + expect(res.headers.allow).toBe('POST'); + }); + + it('returns 405 with Allow: POST for DELETE', async () => { + const res = await request(app).delete('/mcp'); + expect(res.status).toBe(405); + expect(res.headers.allow).toBe('POST'); + }); +}); diff --git a/packages/api/src/mcp/app.ts b/packages/api/src/mcp/app.ts index 3ebde38673..21171b0da3 100644 --- a/packages/api/src/mcp/app.ts +++ b/packages/api/src/mcp/app.ts @@ -1,6 +1,7 @@ import { setTraceAttributes } from '@hyperdx/node-opentelemetry'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; import { validateUserAccessKey } from '@/middleware/auth'; import logger from '@/utils/logger'; @@ -19,7 +20,19 @@ const mcpRateLimiter = rateLimiter({ keyGenerator: rateLimiterKeyGenerator, }); -app.all('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => { +// This transport is stateless: a fresh server/transport is created per POST, so +// we neither offer a server-initiated SSE stream (GET) nor client-terminable +// sessions (DELETE). Per the Streamable HTTP spec a server that doesn't offer +// these MUST respond 405; SDK clients treat 405 as "not offered, continue" +// whereas any other status (e.g. the SDK's default doomed SSE stream on GET, or +// a 400) aborts the connection. See issue #2686. +const methodNotAllowed = (_req: express.Request, res: express.Response) => { + res.set('Allow', 'POST').sendStatus(405); +}; +app.get('/', mcpRateLimiter, methodNotAllowed); +app.delete('/', mcpRateLimiter, methodNotAllowed); + +app.post('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless }); From f4de208a77ed6d5f014b5a79801344940717a363 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 21 Jul 2026 10:33:24 +1000 Subject: [PATCH 2/3] fix(api): return 405 for OPTIONS on MCP endpoint Express's automatic OPTIONS handler builds its Allow header from every registered route, which advertised GET and DELETE as usable even though both return 405. Route OPTIONS through the same handler so the endpoint honestly reports POST as its only supported method. The endpoint sets no CORS headers, so it was never a working preflight target. --- packages/api/src/mcp/__tests__/app.test.ts | 8 ++++++++ packages/api/src/mcp/app.ts | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/packages/api/src/mcp/__tests__/app.test.ts b/packages/api/src/mcp/__tests__/app.test.ts index a7fa87cb05..7bac5133f0 100644 --- a/packages/api/src/mcp/__tests__/app.test.ts +++ b/packages/api/src/mcp/__tests__/app.test.ts @@ -21,4 +21,12 @@ describe('mcp app transport methods', () => { expect(res.status).toBe(405); expect(res.headers.allow).toBe('POST'); }); + + // Explicit OPTIONS handling stops Express's automatic response from + // advertising the rejected GET/DELETE routes in its Allow header. + it('returns 405 with Allow: POST for OPTIONS', async () => { + const res = await request(app).options('/mcp'); + expect(res.status).toBe(405); + expect(res.headers.allow).toBe('POST'); + }); }); diff --git a/packages/api/src/mcp/app.ts b/packages/api/src/mcp/app.ts index 21171b0da3..d8f484a3ca 100644 --- a/packages/api/src/mcp/app.ts +++ b/packages/api/src/mcp/app.ts @@ -26,11 +26,17 @@ const mcpRateLimiter = rateLimiter({ // these MUST respond 405; SDK clients treat 405 as "not offered, continue" // whereas any other status (e.g. the SDK's default doomed SSE stream on GET, or // a 400) aborts the connection. See issue #2686. +// +// OPTIONS is handled explicitly for the same reason: Express's automatic OPTIONS +// response would build its Allow header from every registered route (GET, POST, +// DELETE) and advertise GET/DELETE as usable. Routing it through the same 405 +// keeps the advertised contract honest — POST is the only supported method. const methodNotAllowed = (_req: express.Request, res: express.Response) => { res.set('Allow', 'POST').sendStatus(405); }; app.get('/', mcpRateLimiter, methodNotAllowed); app.delete('/', mcpRateLimiter, methodNotAllowed); +app.options('/', mcpRateLimiter, methodNotAllowed); app.post('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => { const transport = new StreamableHTTPServerTransport({ From b5c5ad775c3ca1b9e4bc0ff2479632e93605c3b7 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 21 Jul 2026 11:18:42 +1000 Subject: [PATCH 3/3] fix(api): leave MCP OPTIONS to the global CORS middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's per-route OPTIONS handler was dead code: the global CORS middleware (api-app.ts) short-circuits every OPTIONS preflight with a 204 before the /mcp router runs, so the handler never executed in production and only 'worked' in the isolated unit test. It was also semantically wrong — returning 405 to a CORS preflight would break browser preflight. Revert it and document why OPTIONS is left to the CORS layer. GET/DELETE are unaffected: CORS only short-circuits OPTIONS, so they still reach the 405 handler. --- packages/api/src/mcp/__tests__/app.test.ts | 8 -------- packages/api/src/mcp/app.ts | 8 +++----- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/api/src/mcp/__tests__/app.test.ts b/packages/api/src/mcp/__tests__/app.test.ts index 7bac5133f0..a7fa87cb05 100644 --- a/packages/api/src/mcp/__tests__/app.test.ts +++ b/packages/api/src/mcp/__tests__/app.test.ts @@ -21,12 +21,4 @@ describe('mcp app transport methods', () => { expect(res.status).toBe(405); expect(res.headers.allow).toBe('POST'); }); - - // Explicit OPTIONS handling stops Express's automatic response from - // advertising the rejected GET/DELETE routes in its Allow header. - it('returns 405 with Allow: POST for OPTIONS', async () => { - const res = await request(app).options('/mcp'); - expect(res.status).toBe(405); - expect(res.headers.allow).toBe('POST'); - }); }); diff --git a/packages/api/src/mcp/app.ts b/packages/api/src/mcp/app.ts index d8f484a3ca..3c52dc8e0c 100644 --- a/packages/api/src/mcp/app.ts +++ b/packages/api/src/mcp/app.ts @@ -27,16 +27,14 @@ const mcpRateLimiter = rateLimiter({ // whereas any other status (e.g. the SDK's default doomed SSE stream on GET, or // a 400) aborts the connection. See issue #2686. // -// OPTIONS is handled explicitly for the same reason: Express's automatic OPTIONS -// response would build its Allow header from every registered route (GET, POST, -// DELETE) and advertise GET/DELETE as usable. Routing it through the same 405 -// keeps the advertised contract honest — POST is the only supported method. +// OPTIONS is intentionally not handled here: the global CORS middleware in +// api-app.ts short-circuits preflight before this router runs, and it must — +// answering OPTIONS with a 405 would break browser CORS preflight. const methodNotAllowed = (_req: express.Request, res: express.Response) => { res.set('Allow', 'POST').sendStatus(405); }; app.get('/', mcpRateLimiter, methodNotAllowed); app.delete('/', mcpRateLimiter, methodNotAllowed); -app.options('/', mcpRateLimiter, methodNotAllowed); app.post('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => { const transport = new StreamableHTTPServerTransport({