Skip to content

fix(transport)!: answer 404 for an unknown or expired Mcp-Session-Id - #309

Open
mcollina wants to merge 1 commit into
mainfrom
fix/304-unknown-session-404
Open

mcollina wants to merge 1 commit into
mainfrom
fix/304-unknown-session-404

Conversation

@mcollina

Copy link
Copy Markdown
Member

Problem

The Streamable HTTP transport (2025-03-26 and later) says that once a server terminates a session it MUST answer requests carrying that Mcp-Session-Id with 404, and the client then re-initializes. This plugin never did:

  • POST /mcp with enableSSE: an unknown or expired id silently got a brand-new session, and the request was served under it.
  • POST /mcp without SSE: the request was served statelessly under the dead id.
  • GET /mcp: an unknown id (header or ?mcp-session-id=) opened a stream on a new session.

Since #242 sessions expire after sessionTtlMs, so idle clients were losing their negotiated state without ever being told.

Fix

The session-owner preHandler (now enforceSession) also rejects ids the store does not know, on POST, GET and DELETE:

  • POST answers 404 with a JSON-RPC error { "jsonrpc": "2.0", "id": null, "error": { "code": -32001, "message": "Session not found" } } (-32001 is the code the official SDKs use). No session is created and the message is not processed. GET and DELETE keep the existing { "error": "Session not found" } body. DELETE already answered 404, so it does not change.
  • initialize carrying a stale id is accepted and gets a fresh session. Clients should send initialize without the header, but accepting it is how a client that kept its old id recovers instead of getting stuck.
  • A session owned by another user is treated exactly like an unknown one, as before. Its POST body changes to the JSON-RPC error too, and initialize with someone else's id now gives the caller a fresh session of their own rather than a 404. Otherwise initialize would answer differently for "unknown" and "someone else's live session", which would tell an attacker that a leaked id is still live.
  • The handlers check the session again after the preHandler, so a session that expires between the two checks still gets a 404 and never a new session. In GET this lookup now happens before reply.hijack().

Without SSE: the plugin does issue and store session ids without SSE (on initialize, since #242/#253), so returning 404 is the spec-compliant answer in both modes. Stateless clients that never send Mcp-Session-Id are unaffected: requests without the header are still served statelessly (no SSE) or get a new session (SSE), as before.

Expiry (sessionTtlMs) works with both stores, because MemorySessionStore.get and the Redis key TTL both make an expired session read as null. Tests cover memory with and without SSE, expiry, DELETE-then-reuse, and Redis.

Breaking change

This is a behaviour change, and I'd treat it as potentially breaking (semver-minor at least, or major if you want to be strict):

  • Clients that keep sending an expired or invented Mcp-Session-Id and relied on being served anyway now get 404 and must re-initialize. Spec-compliant clients (including the official SDKs) already handle this.
  • Multi-instance deployments using the memory backend without sticky sessions used to "work" when a request reached an instance that did not know the session (served statelessly or under a new session). They now get 404. Such setups need sticky sessions or the redis option, which the README now says.
  • The POST 404 body for a session owned by another user changes from { "error": "Session not found" } to the JSON-RPC error above.

Existing tests updated:

  • test/tool-context-access.test.ts called a tool with a made-up mcp-session-id. It now initializes first.
  • test/session-owner-binding.test.ts expects the JSON-RPC 404 body and covers initialize with a foreign id.

Overlap

PR #170 (2026-07-28 revision) moves this POST session logic into its legacy-POST path and still references enforceSessionOwner in its preHandler arrays. Whichever of the two merges second needs a mechanical rebase: rename the hook, and keep the "unknown id → 404 / ignore the id on initialize" check in the legacy POST path.

Fixes #304

🤖 Generated with Claude Code

https://claude.ai/code/session_01UF4S3w7iA83V9DVsasLJzM

The Streamable HTTP transport requires a server to answer requests that
carry a terminated or expired session ID with 404, so the client knows to
re-initialize. POST /mcp instead minted a new session (with enableSSE) or
served the request statelessly under the dead ID (without SSE), and GET
/mcp opened a stream on a brand-new session. With sessionTtlMs expiry this
meant idle clients silently lost their negotiated state.

The session-owner preHandler now also rejects unknown IDs, on POST, GET and
DELETE alike. POST gets a JSON-RPC error body (-32001 "Session not found"),
which is also what a session owned by another user now answers, so the two
cases stay indistinguishable. `initialize` carrying an unknown (or foreign)
ID is accepted and gets a fresh session, since that is how a client
recovers. Requests without the header behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UF4S3w7iA83V9DVsasLJzM
@mcollina mcollina added the semver-major Breaking change; requires a major release label Oct 15, 2026
@mcollina mcollina changed the title fix(transport): answer 404 for an unknown or expired Mcp-Session-Id fix(transport)!: answer 404 for an unknown or expired Mcp-Session-Id Oct 15, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver-major Breaking change; requires a major release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

POST with an unknown or expired Mcp-Session-Id is not answered 404

1 participant