fix: 405 for unsupported MCP route methods + survive invalid WebSocket close frames - #73
Open
aryrabelo wants to merge 2 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
Running a self-hosted Hub against real daemon/agent traffic, two failures showed up:
GET /agent-executions/:id/mcp(andDELETE) 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".process.uncaught_exception, taking down every daemon connection on the instance, not just the one that sent the bad frame.Cause
src/routes/agent-executions/$executionId/mcp.tsonly declared aPOSThandler. 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.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'sReceiverrejects reserved codes such as 1004-1006),wsemitserroron that socket. Node'sEventEmitterrethrows an unlistenederrorevent as an uncaught exception, killing the whole process.Fix
GET/DELETEhandlers on the MCP route that return405withAllow: POST, matching theerrorbody shape already used byhandleExecutionCapabilitiesin the same module.errorlistener inaccept()that reports through the existingreportFailure/reportpipeline.wsalready closes the connection itself once the error fires, so the existingclosehandler still drives normal offline-presence cleanup — only that one daemon's socket is affected now.How to reproduce / verify
src/daemons/registry.test.ts— new test writes a raw close frame with status1006directly to the underlying TCP socket (bypassingws's ownclose()validation, which already rejects invalid codes client-side) via a newDaemonRegistryHarness.sendInvalidClose()helper. Without the fix this crashes the vitest worker with the exactWS_ERR_INVALID_CLOSE_CODEseen in production; with the fix it asserts the failure is reported and the socket still closes cleanly.src/mcp-route-method-guard.test.ts— calls the route'sGET/DELETEhandlers directly and asserts405+Allow: POST. Without the fix,Route.options.server.handlers.GETdoesn't exist and the test throwshandlers.GET is not a function.DATABASE_URL):curlagainst/agent-executions/does-not-exist/mcpreturns405withAllow: POSTfor bothGETandDELETE, and/healthstill returns200.Not changed
Checked whether
@modelcontextprotocol/sdkcould be bumped to support MCP protocol version2026-07-28(seen advertised by an external client). The installed version,1.30.0, is also the latest version published to npm, and itsSUPPORTED_PROTOCOL_VERSIONStops out at2025-11-25. No published SDK version supports2026-07-28yet, so there is nothing to bump here — that ceiling is upstream in@modelcontextprotocol/sdk.Test commands run
Did not run the full
npm test/npm run lint/npm run release:checksuite (out of scope for this change; the e2e suite additionally requires Docker/Postgres via testcontainers).