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 376b618e2e..94b05777eb 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'; @@ -20,7 +21,23 @@ 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. +// +// 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.post('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless });