Wymcp. Plugs. Session
(Wymcp v0.1.1)
View Source
Resolves the MCP session for an incoming request and enforces the spec-mandated lifecycle.
Four outcomes per request:
Modern-classified request or notification (
Wymcp.Plugs.Eraupstream) — passes through untouched: the modern lane has no sessions, so nothing here applies. A JSON-RPC response is never bypassed — it must name a live session on either lane, becauseWymcp.Plugs.Dispatchdelivers it to that session's process.Session header present and registered — assigns
:wymcp_session_pidand:wymcp_session_id, callsSession.touch/1, and validates theMCP-Protocol-Versionheader against the version pinned atinitializetime — on response messages as well as requests. An absent header is tolerated — major clients (Claude Desktop) omit it — so only a present-but-wrong value rejects. Downstream methods read tools from the session pid, not from compile-time options.Session header missing on a non-exempt method — rejects with HTTP 400 + JSON-RPC -32600 (
invalid_request). Per the MCP 2025-11-25 spec: "Servers that require a session ID SHOULD respond to requests without anMCP-Session-Idheader with HTTP 400 Bad Request." The rejection message names both eras' next actions — initialize a session, or send the modern protocol fields.Session header present but not registered — rejects with HTTP 404. Per the MCP 2025-11-25 spec, Streamable HTTP / Session Management clauses 3 and 4: a server MAY terminate a session at any time and MUST then respond to requests carrying that ID with 404; the client MUST issue a fresh
InitializeRequest. A server-restart-wiped in-memory registry is an instance of clause 3 — the spec does not distinguish "I never saw this ID" from "I terminated this ID".
Header cardinality is not this plug's job: Wymcp.Plugs.SingletonHeaders
runs upstream and rejects a duplicated Mcp-Session-Id or
MCP-Protocol-Version before the request reaches here. That is why no read
below carries a duplicate arm — on every path that gets here, the header
carried at most one value. A header's value is still this plug's
business, so enforce_protocol_version_header/2 keeps a third clause for
the value that is present but wrong. A read that crashes with
CaseClauseError therefore means a route was wired without the check, not
that a client sent something exotic.
Every rejection below carries the id Wymcp.Response.rejection_id/1 gives:
the body's on a request, nil on every other message kind. That holds for
the three 400s, which route through Wymcp.Response.send_rejection/4, and
for the 404, which assembles its own envelope but branches on the same rule
— see "Wire shape for session-not-found" below. There are no exceptions.
Flow
flowchart TD
A[Incoming POST] --> M{era :modern?<br/>and not a response}
M -->|yes| Pass
M -->|no| B{Mcp-Session-Id<br/>required?}
B -->|"no — initialize / ping"| Pass([pass through<br/>to next plug])
B -->|yes| C{Header present?}
C -->|no| R400Missing([HTTP 400<br/>JSON-RPC -32600<br/>missing header])
C -->|yes| D{Session.lookup}
D -->|":not_found"| F{rejection_id?}
F -->|"an id<br/>(a request)"| R404Body([HTTP 404<br/>JSON-RPC -32001<br/>'Session terminated'<br/>no data field])
F -->|"nil<br/>(every other kind)"| R404Empty([HTTP 404<br/>empty body])
D -->|"{:ok, pid}"| E[assign pid<br/>+ touch] --> G{Version header<br/>matches?}
G -->|"no"| R400Version([HTTP 400<br/>JSON-RPC -32600<br/>version mismatch])
G -->|"yes, absent,<br/>or not enforced"| K{Response<br/>message?}
K -->|"yes"| Pass
K -->|"no — every<br/>other kind"| H{Lifecycle gate}
H -->|"exempt method<br/>or session ready"| Pass
H -->|"otherwise"| R400Lifecycle([HTTP 400<br/>JSON-RPC -32600<br/>session not ready])Exemptions
initializeandpingskip session lookup entirely (@session_exempt_methods).tools/list,tools/call,notifications/initialized, and the two exempt methods above also skip the lifecycle gate (@lifecycle_exempt_methods) — they are allowed to run while a session is still in:initializing. This is necessary because clients (notablymcp-remote) sendtools/listandtools/callconcurrently withnotifications/initialized.- A response message skips the lifecycle gate entirely, whatever its
method:
call/2routes it toresolve_session_for_response/1, which checks the protocol version and stops. Unlike the two lists above this is not a method exemption —session_not_ready/1is simply unreachable on that path.
Wire shape for session-not-found
The 404 body branches on the rejection-id rule
(Wymcp.Response.rejection_id/1): it carries an envelope exactly when the
rule yields an id, which is exactly when the inbound message is a request.
Request — body is
{"jsonrpc":"2.0","id":<request-id>,"error": {"code":-32001,"message":"Session terminated"}}, matching the TypeScript SDK exactly: seemodelcontextprotocol/typescript-sdk,packages/server/src/server/streamableHttp.ts, where the SDK throwsnew McpError(-32001, "Session terminated")with nodatafield. Matching that wire shape exactly maximises the chance compliant clients (which MUST re-initialise on this response) recognise it.Every other message kind — a notification, a response message, or a body
Wymcp.Plugs.Classifycould not tag — HTTP 404 with empty body. The rule gives no id, and an id-bearing reply is what JSON-RPC forbids here: a response message'sidbelongs to a request the server itself sent, so echoing it would be a second answer to a call the client is still waiting on.
An id-less envelope would not be forbidden — the MCP spec names that shape for rejected input — so the empty body is a choice, not a constraint. The reason it stays empty is that the status is the whole signal: a client receiving 404 must start a new session, so there is exactly one next action and no diagnostic string would add to it. That is also why the argument which decided the 400s does not transfer — a 400 names something the client author must fix, and naming it is the point.