Return 404 for unknown or expired MCP session IDs - #172
Merged
Merged
Conversation
…ELETE /mcp so clients re-initialize Classify each /mcp request by its session header, look sessions up by own key so prototype-named ids are unknown, let an initialize with a stale header start a new session, and echo the request id in the POST 404.
…r tick and at shutdown One line per flush carries window_s and the GET, POST and DELETE counts, and nothing client-supplied.
…ver helper stop() closes the HTTP server, flushes the 404 counts, clears the reaper and telemetry intervals and removes the signal listeners. Production callers ignore the result.
jpr5
added a commit
that referenced
this pull request
Oct 2, 2026
## Summary
Phase P2 of the stateless-MCP migration plan. Every query_log row now
records how the query arrived: transport, protocol era, the protocol
version the client requested, client name, and OAuth client id. The
analytics summary adds unique clients and a protocol/transport mix,
including unclassified counts. The dashboard gets a "Unique Clients"
tile. The weekly report gets "Unique clients" and "Protocol mix" lines
with coverage ("N of M calls classified", plus partial/inconsistent
markers). This is the baseline we need before the new stateless protocol
ships (P4), so we can see adoption.
## Changes
- Add nullable transport, protocol era/version, client name and auth
client id columns to query_log
- Add the requestContext adapter, a shared client-string cleaner and
rate-limited warning, and normalise the request-context fields in
logQuery
- Capture the MCP initialize handshake on /mcp and /sse after the
transport accepts it, and write the session analytics context from the
search and knowledge tools
- Count unique clients, the legacy/modern and streamable_http/sse mix,
and unclassified rows in the analytics summary
- Add a Unique Clients tile to the analytics dashboard, hidden when the
summary omits the count
- Show unique clients and the protocol mix, with unclassified coverage,
in the weekly search report
- Seed analytics rows with fixed per-session transport, protocol, client
and IP context, and read unique clients from the real summary
Worth calling out:
- The migration is additive and nullable. Proof: a drop-and-remigrate
run kept existing rows.
- One shared cleaner for client-supplied strings. It strips NUL and
other control characters and lone surrogates, trims, caps by code point,
and drops over-long auth ids instead of truncating them. A NUL in a
client name used to make Postgres reject the row.
- The handshake is recorded only after the transport accepts initialize.
On SSE the first accepted initialize wins.
- `protocol_era` is "legacy" for every current writer; "modern" is
reserved for the stateless protocol.
- Unique clients are keyed by OAuth client id, falling back to IP and
user agent. An IP of 'unknown' or '' counts as no IP.
- Analytics fallbacks log a rate-limited warning, with the error class
and no client bytes.
## Local red-green proof
RED ran on 9d6f5e4. The final GREEN ran on d2a32be, whose tree is
identical to this head after the commit regroup.
## RED vs GREEN on final commit d2a32be (source: p2-red.txt vs
p2-green-final.txt)
| Item | RED (9d6f5e4) | GREEN (d2a32be) |
|---|---|---|
| `\d query_log` | 5 columns absent | transport, protocol_era,
protocol_version, client_name, auth_client_id present, nullable |
| Column SELECT | error: columns absent | 4 rows: 3x streamable_http +
1x sse, all legacy / 2025-11-25 / p2-proof-client; auth NULL, oauth
client_id, NULL (not ''), NULL |
| Summary curl | HTTP 200, 5 contract fields absent | HTTP 200,
unique_client=2, legacy=4, modern=0, streamable_http=3, sse=1 |
| Hand SQL unique-client count | error: column absent | 2, equals
summary |
| Weekly report header | no "Unique clients" / "Protocol mix" lines |
"Unique clients: 2", "Protocol mix: legacy 100% / modern 0%; transport:
streamable_http 75% / sse 25%". exit=1 is the Notion publish with unset
NOTION_TOKEN, same as RED |
| Gap analysis | exit 0 | exit 0, diff vs RED is only the node PID in a
deprecation warning |
| Server log initialize lines | no era/client capture | initialize
protocol=... client=... logged for all 4 sessions plus SSE |
| Extra: NUL+emoji client name | n/a | row written; client_name stored
`nulbad😀client` (NUL stripped, emoji kept, 13 chars, has_nul=f) |
| Extra: unknown Mcp-Session-Id | n/a | 404, code -32001 "Session not
found" (P1 intact) |
| Extra: summary vs hand SQL (after 5th row) | n/a | summary 3/5/0/4/1 =
SQL 3/5/0/4/1 (unique/legacy/modern/http/sse) |
## Review
Three rounds of 25 reviewers each, then a narrowed final pass under
surgical scope. The one bug fixed in the final pass: the
accessor-failure path (errorClassName / analyticsContextFields) could
throw and fail the tool call.
## Tests
4202 passing. tsc (both projects), build, prettier with CI's globs,
test-shape and version-sync are all clean.
## Follow-ups (not in this PR)
Test-strength gaps and comment-accuracy items deferred under surgical
scope:
- Test gap: the 4xx-initialize "not recorded" half is vacuous.
- Test gap: the Infinity test does not assert the warning; the "logQuery
throws" test is unasserted.
- Test gap: no SSE call-then-init-then-call test, no /mcp or SSE
capture-at-init tests, no errorClassName fallback tests.
- Test hygiene: warn spies restored outside finally, rate-limiter reset
only in afterEach, duplicated fixtures and JWT secret.
- Docs: unique_client and unique_ip treat '' / 'unknown' IP differently
while the doc says they use the same predicate (doc fix, do not change
unique_ip).
- Comments: stale or inaccurate claims ("cannot throw", "raw value
logged", "captured once at session init" on SSE, "negotiated"
protocol_version, safeLogToken/cleaner claims, seed "never drift").
- Design nits: CHECK constraint on the new columns; shared SQL constants
for era/transport lists; dashboard lacks protocol counts; SSE
auth_client_id fixed at GET; seed realism (no client_ip/ua); pair
rounding can sum to 100.1%.
Pre-existing bugs found during review:
- `getMachineRelayRules` bare catch silently disables relay exclusion
(src/db/analytics.ts:187).
- A rejected /mcp initialize (406/415/400) keeps its session slot and
per-IP limiter until the reaper runs (src/server.ts).
- `/messages` session lookup uses a plain-object index, so a
prototype-key sid resolves to an inherited property
(src/sse-handlers.ts:410).
- `/messages` 404 log lines log ip and sid unsanitized.
- The weekly report exits 1 when the Notion publish fails after the
report file is written; its archive catch is empty, relay rows are
unvalidated, and a build error skips Slack.
- weekly-search-report.yml: the Sunday 09:07 UTC run with a 7-day window
drops most of Sunday; no concurrency group.
- docs/analytics.html: blocked/relay panels ignore the date range; local
date getters are off by one at UTC+12..+14; pill counts skip safeNumber.
- `src/mcp/tools/knowledge.ts:358` returns raw error text to the client.
- `src/server.ts:4131` swallows a getConfig throw with no log.
- seed-analytics never sets score_kind and is not in CI
typecheck/format.
- static-quality.yml runs prettier via npx without a pinned
devDependency and tests only Node 24 while engines allows >=20;
package.json engines >=20 but deps need >=20.19.0.
## Context
Plan: https://www.notion.so/3ed3aa38185281d89892e2c4a20ed154
P1: #172
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01EDxYQLKhDxwoYe8GV2noDY
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.
Summary
An unknown or expired
Mcp-Session-Idon POST, GET and DELETE/mcpnow returns 404 with JSON-RPC code-32001"Session not found", as the MCP spec requires. Before this change it returned 400 or 405. Aninitializerequest that carries a stale header now starts a new session.Effect: after a Railway redeploy, Claude Code now re-initializes by itself. Before, it failed and told the user to run
/mcp.Changes
dfff5c7Answer 404 to an unknown or expired Mcp-Session-Id on POST, GET and DELETE /mcp so clients re-initialize22b77e7Count unknown-session 404s per method and log the totals on the reaper tick and at shutdown996eb0eReturn {server, stop} from startServer and add an in-process test server helper3e9b7f5Add route-level tests for the /mcp unknown-session 404Also worth calling out:
constructorreturn 404 instead of 500.[mcp] 404 unknown-session-id window_s=… total=… GET=… POST=… DELETE=…. No client bytes are logged.startServernow returns{server, stop}. The in-process route tests use this seam.Local red-green proof
RED = base
9d6f5e4. GREEN = final HEAD264faf571fa598a2a87583f29470276a9edaf51f. Same harness for both (Pathfinder on :3001,deploy/pathfinder-docs.yaml). Fake session id:deadbeef-0000-4000-8000-000000000000. Raw GREEN run:p1-green-final2.txt.Rows that differ from the earlier expectation: 5c (DELETE, no sid) keeps status 400 but the body message is now "Missing session ID" (RED: "Invalid or missing session ID"). The route test
mcp-unknown-session-404-routes.test.tspins this text. All other rows match.tools/list(id 1), fake sid400 Bad Request,-32000"Bad Request: No valid session. Send an initialize request first.",id:null404 Not Found,{"code":-32001,"message":"Session not found"},id:1initialize, fake sid400 Bad Request,-32000"Bad Request: No valid session. Send an initialize request first."200 OK, new headermcp-session-id: 4ab724d4-4a0a-4ac8-b54f-43a9ca38abe7mcp-session-idresponse header405 Method Not Allowed,-32000"Method Not Allowed"404 Not Found,-32001"Session not found",id:null400 Bad Request,-32000"Invalid or missing session ID"404 Not Found,-32001"Session not found",id:nulltools/list, no sid400 Bad Request,-32000"Bad Request: No valid session. Send an initialize request first."405 Method Not Allowed,-32000"Method Not Allowed"400 Bad Request,-32000"Invalid or missing session ID"400 Bad Request,-32000"Missing session ID"server/discover(modern probe)400 Bad Request,-32000"Bad Request: No valid session. Send an initialize request first."tools/list, prototype keyMcp-Session-Id: constructor404,-32001"Session not found",id:1Mcp-Session-Id: constructor404,-32001"Session not found"Mcp-Session-Id: constructor404,-32001"Session not found"tools/listwith"id":42, junk sidid:nullfor case 1)404,-32001"Session not found","id":42@modelcontextprotocol/sdk@1.31.0), secondlistToolsafter server restartStreamableHTTPErrorcode400:Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: No valid session. Send an initialize request first."},"id":null}StreamableHTTPErrorcode404:Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found"},"id":2}. The SSE reconnect GET also got404.search-docscall after server restartError POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: No valid session. Send an initialize request first."},"id":null}. One POST in the server log, noNew session, no second initialize. The model told the user to reconnect with/mcp.86d4de81,75c446b7,6454960b) after the restart.search-docs("hello")reached the server with no user action. Result wasError: Search failed. Please try again later.(app-level, dummy OpenAI key). Server flush:404 unknown-session-id window_s=11 total=3 GET=2 POST=1 DELETE=0.[mcp] 404 unknown-session-id window_s=11 total=3 GET=2 POST=1 DELETE=0, logged right after[shutdown] Received SIGTERM(the curl-phase server:window_s=25 total=7 GET=2 POST=3 DELETE=2, matching the 7 curl 404s; the v1-phase server:window_s=15 total=2 GET=1 POST=1 DELETE=0)SDK v1 client behaviour (a fact, not a FAIL): after the 404 the v1 client did not re-initialize by itself.
sessionId AFTERequals the session id before the call (7ccc905c-f7fa-41a8-ab13-518b47b883ca),listTools#2ended in ERROR, and the transport closed. In RED it behaved the same way. The spec requires only the 404 from the server.Notes
claude -p --input-format stream-json, one process, two user turns over a FIFO, so the MCP connection persists across the restart. There are no pane captures. The driver is the same for both runs. The GREEN run usedp1-cc-final2.sh, a copy ofp1-cc-green.shwith only the output paths changed and the server log opened in append mode.sk-dummy-p1.search-docstherefore returnedError: Search failed. Please try again later.in both RED and GREEN. This is an app-level error, not a session error.The proof ran on
264faf5, whose tree is identical to this head after the commit regroup.Client behaviour note
The SDK v1
StreamableHTTPClientTransportthrows on 404 and leaves re-initialize to the caller. That is client behaviour; the server now does what the spec requires.Tests
4032 passing. tsc, build, prettier (CI globs), test-shape and version-sync are all clean.
Follow-ups (not in this PR)
/messageswith a prototype-key session id returns 500 atsrc/sse-handlers.ts:410(live-probed withconstructor,__proto__,toString,hasOwnProperty). Same class as the own-key fix here. Next fix candidate.errorhandler exits 0 on bind failure; it should exit non-zero.static-quality.ymluses an unpinned prettier, CI runs Node 24 only, and comment lines 22-24 are stale.package.jsonenginessays>=20but deps need>=20.19.0; there is nofilesfield.ensureSessioncomments outside this diff.sse-transport.test.tsis flaky under full-suite load.Context
This is phase P1 of the stateless-MCP migration plan: https://www.notion.so/3ed3aa38185281d89892e2c4a20ed154
🤖 Generated with Claude Code
https://claude.ai/code/session_01EDxYQLKhDxwoYe8GV2noDY