Skip to content

Return 404 for unknown or expired MCP session IDs - #172

Merged
jpr5 merged 4 commits into
mainfrom
fix/mcp-unknown-session-404
Oct 2, 2026
Merged

jpr5 merged 4 commits into
mainfrom
fix/mcp-unknown-session-404

Conversation

@jpr5

@jpr5 jpr5 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Summary

An unknown or expired Mcp-Session-Id on POST, GET and DELETE /mcp now returns 404 with JSON-RPC code -32001 "Session not found", as the MCP spec requires. Before this change it returned 400 or 405. An initialize request 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

  • dfff5c7 Answer 404 to an unknown or expired Mcp-Session-Id on POST, GET and DELETE /mcp so clients re-initialize
  • 22b77e7 Count unknown-session 404s per method and log the totals on the reaper tick and at shutdown
  • 996eb0e Return {server, stop} from startServer and add an in-process test server helper
  • 3e9b7f5 Add route-level tests for the /mcp unknown-session 404

Also worth calling out:

  • Session lookup is own-key, so ids like constructor return 404 instead of 500.
  • The POST 404 echoes the request id.
  • Unknown-session 404s are counted per method and logged as one line on the 5-minute reaper tick and at shutdown, for example [mcp] 404 unknown-session-id window_s=… total=… GET=… POST=… DELETE=…. No client bytes are logged.
  • DELETE with no header now says "Missing session ID".
  • startServer now returns {server, stop}. The in-process route tests use this seam.

Local red-green proof

RED = base 9d6f5e4. GREEN = final HEAD 264faf571fa598a2a87583f29470276a9edaf51f. 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.ts pins this text. All other rows match.

Case RED observed GREEN observed Expected Pass/fail
1. POST tools/list (id 1), fake sid 400 Bad Request, -32000 "Bad Request: No valid session. Send an initialize request first.", id:null 404 Not Found, {"code":-32001,"message":"Session not found"}, id:1 404 with the target body PASS
2. POST initialize, fake sid 400 Bad Request, -32000 "Bad Request: No valid session. Send an initialize request first." 200 OK, new header mcp-session-id: 4ab724d4-4a0a-4ac8-b54f-43a9ca38abe7 200 with a new mcp-session-id response header PASS
3. GET, fake sid 405 Method Not Allowed, -32000 "Method Not Allowed" 404 Not Found, -32001 "Session not found", id:null 404 with the target body PASS
4. DELETE, fake sid 400 Bad Request, -32000 "Invalid or missing session ID" 404 Not Found, -32001 "Session not found", id:null 404 with the target body PASS
5a. POST tools/list, no sid 400 Bad Request, -32000 "Bad Request: No valid session. Send an initialize request first." same as RED Same as RED PASS
5b. GET, no sid 405 Method Not Allowed, -32000 "Method Not Allowed" same as RED Same as RED PASS
5c. DELETE, no sid 400 Bad Request, -32000 "Invalid or missing session ID" 400 Bad Request, -32000 "Missing session ID" Status 400 PASS on status; body text differs (pinned by route test)
6. POST server/discover (modern probe) 400 Bad Request, -32000 "Bad Request: No valid session. Send an initialize request first." same as RED Same as RED PASS
7a. POST tools/list, prototype key Mcp-Session-Id: constructor not run on RED 404, -32001 "Session not found", id:1 404 (own-key lookup, not a prototype hit) PASS
7b. GET, Mcp-Session-Id: constructor not run on RED 404, -32001 "Session not found" 404 PASS
7c. DELETE, Mcp-Session-Id: constructor not run on RED 404, -32001 "Session not found" 404 PASS
8. POST tools/list with "id":42, junk sid not run on RED (RED returned id:null for case 1) 404, -32001 "Session not found", "id":42 id echoed back PASS
SDK v1 client (@modelcontextprotocol/sdk@1.31.0), second listTools after server restart StreamableHTTPError code 400: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: No valid session. Send an initialize request first."},"id":null} StreamableHTTPError code 404: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found"},"id":2}. The SSE reconnect GET also got 404. Server returns 404 for the unknown session PASS
Claude Code 2.1.287, second search-docs call after server restart Tool result Error 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, no New session, no second initialize. The model told the user to reconnect with /mcp. Server logged 3 new sessions (86d4de81, 75c446b7, 6454960b) after the restart. search-docs("hello") reached the server with no user action. Result was Error: 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. Re-initializes after the 404 and the call reaches the server without user action PASS
Flush line on SIGTERM RED has no counter and no flush line [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) One line per method counts, emitted on shutdown PASS

SDK v1 client behaviour (a fact, not a FAIL): after the 404 the v1 client did not re-initialize by itself. sessionId AFTER equals the session id before the call (7ccc905c-f7fa-41a8-ab13-518b47b883ca), listTools#2 ended in ERROR, and the transport closed. In RED it behaved the same way. The spec requires only the 404 from the server.

Notes

  • tmux was not installed on this machine, so a non-interactive Claude Code driver was used for both RED and GREEN: 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 used p1-cc-final2.sh, a copy of p1-cc-green.sh with only the output paths changed and the server log opened in append mode.
  • OPENAI_API_KEY was the dummy sk-dummy-p1. search-docs therefore returned Error: Search failed. Please try again later. in both RED and GREEN. This is an app-level error, not a session error.
  • The SDK v1 client throws on 404 and leaves re-init to the caller. That is client behaviour, not a server defect.
  • Claude Code: the 404 status was seen server-side only. The client-side status was not captured in the stream.

The proof ran on 264faf5, whose tree is identical to this head after the commit regroup.

Client behaviour note

The SDK v1 StreamableHTTPClientTransport throws 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)

  • /messages with a prototype-key session id returns 500 at src/sse-handlers.ts:410 (live-probed with constructor, __proto__, toString, hasOwnProperty). Same class as the own-key fix here. Next fix candidate.
  • The server error handler exits 0 on bind failure; it should exit non-zero.
  • static-quality.yml uses an unpinned prettier, CI runs Node 24 only, and comment lines 22-24 are stale.
  • package.json engines says >=20 but deps need >=20.19.0; there is no files field.
  • Three stale ensureSession comments outside this diff.
  • sse-transport.test.ts is 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

jpr5 added 4 commits October 1, 2026 21:26
…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
jpr5 merged commit fee485a into main Oct 2, 2026
7 checks passed
@jpr5
jpr5 deleted the fix/mcp-unknown-session-404 branch October 2, 2026 04:34
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
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