Skip to content

fix: 405 for unsupported MCP route methods + survive invalid WebSocket close frames - #73

Open
aryrabelo wants to merge 2 commits into
getpaseo:mainfrom
aryrabelo:fix/mcp-route-method-guard-and-ws-crash
Open

fix: 405 for unsupported MCP route methods + survive invalid WebSocket close frames#73
aryrabelo wants to merge 2 commits into
getpaseo:mainfrom
aryrabelo:fix/mcp-route-method-guard-and-ws-crash

Conversation

@aryrabelo

@aryrabelo aryrabelo commented Aug 24, 2026

Copy link
Copy Markdown

Symptom

Running a self-hosted Hub against real daemon/agent traffic, two failures showed up:

  1. GET /agent-executions/:id/mcp (and DELETE) is not handled by the route, so it falls through to the SPA route render: a 200 with the app-shell HTML for an unknown execution id, or a 500 for a real one. An MCP client that probes the transport with a method other than POST (or a proxy/monitor that does a GET health check) reads the 500 as "server is dead" instead of a clean "method not supported, retry with POST".
  2. The Hub process crashed outright with:
    RangeError [WS_ERR_INVALID_CLOSE_CODE]: invalid status code
        at Receiver.controlMessage (ws/lib/receiver.js)
    
    as an unhandled process.uncaught_exception, taking down every daemon connection on the instance, not just the one that sent the bad frame.

Cause

  1. src/routes/agent-executions/$executionId/mcp.ts only declared a POST handler. TanStack Start has no fallback 405 for undeclared methods on a route that also matches page rendering, so GET/DELETE hit the SPA path instead.
  2. ActiveDaemonRegistry.accept() (src/daemons/registry.ts) registers "message" and "close" listeners on a daemon's WebSocket but never "error". When a peer sends a close control frame carrying a status code the protocol forbids on the wire (ws's Receiver rejects reserved codes such as 1004-1006), ws emits error on that socket. Node's EventEmitter rethrows an unlistened error event as an uncaught exception, killing the whole process.

Fix

  • Add explicit GET/DELETE handlers on the MCP route that return 405 with Allow: POST, matching the error body shape already used by handleExecutionCapabilities in the same module.
  • Add an error listener in accept() that reports through the existing reportFailure/report pipeline. ws already closes the connection itself once the error fires, so the existing close handler still drives normal offline-presence cleanup — only that one daemon's socket is affected now.

How to reproduce / verify

  • WebSocket crash: src/daemons/registry.test.ts — new test writes a raw close frame with status 1006 directly to the underlying TCP socket (bypassing ws's own close() validation, which already rejects invalid codes client-side) via a new DaemonRegistryHarness.sendInvalidClose() helper. Without the fix this crashes the vitest worker with the exact WS_ERR_INVALID_CLOSE_CODE seen in production; with the fix it asserts the failure is reported and the socket still closes cleanly.
  • 405 route guard: src/mcp-route-method-guard.test.ts — calls the route's GET/DELETE handlers directly and asserts 405 + Allow: POST. Without the fix, Route.options.server.handlers.GET doesn't exist and the test throws handlers.GET is not a function.
  • Manually verified against a built instance of this branch (embedded/pglite database, no DATABASE_URL): curl against /agent-executions/does-not-exist/mcp returns 405 with Allow: POST for both GET and DELETE, and /health still returns 200.

Not changed

Checked whether @modelcontextprotocol/sdk could be bumped to support MCP protocol version 2026-07-28 (seen advertised by an external client). The installed version, 1.30.0, is also the latest version published to npm, and its SUPPORTED_PROTOCOL_VERSIONS tops out at 2025-11-25. No published SDK version supports 2026-07-28 yet, so there is nothing to bump here — that ceiling is upstream in @modelcontextprotocol/sdk.

Test commands run

npm run typecheck:node
npm run typecheck:start
npx oxlint --config oxlint.json --tsconfig tsconfig.json src/daemons/registry.ts src/daemons/test-utils/daemon-registry-harness.ts src/mcp-route-method-guard.test.ts
npx oxlint --config oxlint.json --tsconfig tsconfig.json src/routes/agent-executions/\$executionId/mcp.ts
npx vitest run src/daemons/registry.test.ts src/mcp-route-method-guard.test.ts
npm run build

Did not run the full npm test/npm run lint/npm run release:check suite (out of scope for this change; the e2e suite additionally requires Docker/Postgres via testcontainers).

The execution capability MCP server at
/agent-executions/:id/mcp only declared a POST handler. A GET or
DELETE request never reached handleExecutionCapabilities and fell
through to the SPA route render instead: a 200 with the app shell
HTML for an unknown execution id, or a 500 once TanStack's fallback
tried to resolve a real one.

The route is a stateless MCP Streamable HTTP server that only
supports POST; the spec expects 405 for a method it does not
support. Returning 500 for a real execution id is worse than a
clean 405: MCP clients that probe the transport with a GET before
falling back to POST-only mode read a 500 as "server is broken" and
give up instead of retrying with POST.

Add explicit GET/DELETE handlers that return 405 with an
Allow: POST header, matching the api/$ catch-all's not-found
handler pattern already used elsewhere in routes/.
ActiveDaemonRegistry.accept() listened for "message" and "close" on
a daemon's WebSocket but never for "error". A daemon peer that sends
a close frame with a status code the protocol forbids on the wire
(ws's Receiver rejects codes like 1004-1006, which are reserved and
must never appear on the wire) makes ws's Receiver emit
WS_ERR_INVALID_CLOSE_CODE as an "error" event on that socket. With
no listener, Node's EventEmitter rethrows it as an uncaught
exception, which crashes the entire Hub process over a single bad
connection instead of just that daemon's socket.

Add an "error" listener that reports the failure through the same
reportFailure/report pipeline other socket faults already use. ws
still closes the underlying connection itself once the error fires,
so the existing "close" handler continues to drive the normal
offline-presence cleanup.

Reproduced with a raw close frame containing status 1006 written
directly to the client's underlying TCP socket (bypassing ws's own
close() validation, which already rejects invalid codes
client-side). Removing the fix crashes the vitest worker with the
exact WS_ERR_INVALID_CLOSE_CODE RangeError seen in production.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant