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/mcp-transport-405.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/api/src/mcp/__tests__/app.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
19 changes: 18 additions & 1 deletion packages/api/src/mcp/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Comment thread
jordan-simonovski marked this conversation as resolved.
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) => {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
});
Expand Down
Loading