Wymcp. Session
(Wymcp v0.1.1)
View Source
GenServer that holds state for a single MCP session.
A session is one client connection's state — the negotiated protocol
version, client and server capabilities, tools, per-session assigns, log
level, and pending requests — created during the initialize handshake.
It ends at DELETE, idle timeout, or crash, and an ended session is never
restarted (restart: :temporary): lookup/1 answers
{:error, :not_found}, the wire answers 404 with the -32001 "Session
terminated" error, and the client re-initializes. Restarting would
resurrect the id with empty state — live-but-wrong; gone-means-gone keeps
the 404 contract honest.
Assigns
Tools can store per-session state via assigns — a map of arbitrary
key-value pairs that persists across requests within the session.
Tools update assigns by returning {:ok, content, assigns_updates}
from their run/2 callback, and read them via ctx.assigns.
Idle timeout
Sessions automatically terminate after a configurable idle period (default: 30 minutes). Every incoming request resets the timer. This prevents orphaned sessions from accumulating when clients disconnect without sending DELETE.
Design decisions
Each session is a standalone GenServer rather than an ETS table or Agent because sampling and elicitation require the session to coordinate message routing between the SSE stream process and the pushing request processes. A GenServer gives us a single serialization point for that coordination.
Session IDs are 32-byte URL-safe base64 strings generated with
:crypto.strong_rand_bytes/1. The MCP spec requires session IDs to
contain only visible ASCII characters (0x21–0x7E).
flowchart TD
subgraph Session
S[Wymcp.Session] --> ST[State struct]
S --> IT["idle timeout"]
S --> MT["merge_tools/1"]
end
subgraph External
S --> R[Registry]
S --> DS[DynamicSupervisor]
S --> TS[Transport.Stream]
S --> TEL[Telemetry]
S -->|"server.init/2, server.terminate/2"| SV(Consumer Server)
endstateDiagram-v2
[*] --> Initializing : start_session/1
Initializing --> Ready : mark_ready/1
Initializing --> [*] : terminate (idle timeout / DELETE / crash)
Ready --> [*] : terminate (idle timeout / DELETE / crash)
note right of Initializing
Session created during initialize.
Awaiting notifications/initialized
handshake to transition to Ready.
end note
note right of Ready
Idle timer resets on every
request via touch/1. Expires
after 30 min (configurable).
end notePushes
The session never blocks on a socket write. push/3 and
await_client_response/4 JSON-encode the payload in the caller's own
process (an unencodable payload answers {:error, :unencodable} right
there), and the handlers hand the pre-encoded payload plus a reply
address to the stream loop (Wymcp.Transport.Stream.push/3) — a
stream-answered push. The loop answers a plain push's caller directly;
the server-request round trip's push leg acks the session instead, which
runs one small state machine per pending request — two clocks, so a
wedged stream fails fast (the ack window) while a slow client gets its
full response timeout:
stateDiagram-v2
[*] --> AckPending : await_client_response (push handed to loop)
AckPending --> AwaitingResponse : push ack :ok — client timer armed
AckPending --> [*] : error ack / ack window expiry / stream :DOWN or unregister — caller answered
AckPending --> [*] : early deliver_response — caller answered, late ack dropped
AwaitingResponse --> [*] : deliver_response / server_request_timeout — caller answeredTool list notifications
listChanged is declared unconditionally at initialize, so the session
owes the client a notifications/tools/list_changed whenever the list
tools/list would serve changes. The obligation is carried by session
state rather than by the wire: the dirty tool list — State's
tool_list_dirty field — is whether the client's tool list is behind
the session's, that is, the list tools/list would serve changed and
the client has not fetched it since.
State carries it because the change usually has nowhere to go.
Wymcp.Server.init/2 runs during notifications/initialized, before
the client opens its GET stream, so a notification sent at registration
time would be dropped on most sessions that register tools at all.
Four rules, and the reason for each:
- A real change to the served list marks the flag — not merely a call
to
register_tool/2orunregister_tool/2. The definition above should be true as written rather than true-with-an-asterisk, and a spurious mark is not free: it survives until the client lists, and buys one further notification per stream attach until then. So the comparison is made overmerge_tools/1's output, not overruntime_tools: order is not part of thetools/listcontract, so a re-registration that only reorders is not a change — and neither is registering a module that is already a compile-time tool, which shifts the module between the two lists without changing what the client is served. What is conditional is the mark, not the registration: those calls still take effect onruntime_tools, they just leave the client nothing to fetch. - The notification fires on the clean → dirty rising edge only. While
the flag is already set the client still owes itself a
tools/list, so a second notification tells it nothing new — a batch of registrations coalesces into one. Nothing is lost, because the flag, not the notification, carries the obligation. - A stream attaching sends the notification if the flag is still set,
and does not clear it. This is the deferred case above, discharged.
Two attaches without an intervening
tools/listtherefore produce two notifications; the notification is payload-free and idempotent, and a client honouringlistChangedlists after the first one. - Only
get_tools_for_list/1— the readtools/listis served from — marks the flag clean. A push ack cannot: the stream loop answers:okwhen the chunk reached the socket buffer, and a just-died peer's socket accepts one more write, so clearing on an ack would clear exactly when the notification was lost. Serving the list is the only proof the protocol offers, JSON-RPC notifications being unacknowledged by definition. The notification is therefore a pure optimization — a nudge to list sooner — and losing one costs nothing.
stateDiagram-v2
[*] --> Clean : session start
Clean --> Dirty : served tool list changed — notification sent if a stream is attached
Dirty --> Dirty : served tool list changed again — marked, nothing sent
Dirty --> Dirty : stream attach — notification sent, flag kept
Clean --> Clean : stream attach — nothing owed, nothing sent
Dirty --> Clean : get_tools_for_list/1 — tools/list servedThe read and the clear share one handler, and both are handle_calls on
this GenServer, so they serialize. That gives the invariant the design
rests on: flag clean implies the client's last-served tool list is
current. A change either precedes the read — and is in the list that was
served — or follows the clear, and marks the flag on its own message.
One thing the invariant does not cover: a notification and a tools/list
response are not ordered against each other, because they travel on
different channels — the SSE stream and the POST response. A change
landing between the clear and the response write sends its notification
ahead of the response it invalidates. The session's state is right either
way (the flag is dirty again), and the client's is repaired at its next
stream attach.
Summary
Functions
Pushes a server-initiated request to the client via SSE and blocks the
caller until the client POSTs back a response — the server-request round
trip behind Wymcp.Context.sample/3 and Wymcp.Context.elicit/4.
Returns a specification to start this module under a supervisor.
Delivers a client response to a pending server-initiated request.
Returns the session's full state struct. Accepts a pid or a session-id
binary. Unlike the get_X/1 convention this raises on an unknown
session id instead of returning an error tuple — every caller runs
after session resolution, where a missing session is a bug.
Returns the merged list of compile-time and runtime tools. Runtime tools take precedence when a name collision occurs — compile-time tools with the same name are excluded from the result.
Returns the merged tool list and marks the dirty tool list clean in the
same handler — the read tools/list is served from.
Sends a JSON-RPC message to the client over the stream — a stream-answered push: the message is JSON-encoded here, in the caller's own process, handed to the stream loop together with this call's reply reference, and the loop answers the caller directly after the chunk write. The session itself never blocks on the write.
Registers the SSE stream process for this session.
Registers a tool module on the session at runtime.
Starts a session under the session supervisor and returns
{:ok, pid, session_id} — a three-element tuple, not the usual
{:ok, pid}: the generated session id is the value the transport
layer must echo in the Mcp-Session-Id response header.
Clears the stream registration when the stream reports its own close.
Removes a runtime-registered tool by name.
Functions
Pushes a server-initiated request to the client via SSE and blocks the
caller until the client POSTs back a response — the server-request round
trip behind Wymcp.Context.sample/3 and Wymcp.Context.elicit/4.
The payload is JSON-encoded here, in the caller's own process
({:error, :unencodable} on failure), then handed to the stream loop
with an ack address. The push leg runs on its own clock — the session's
private ack window (~5 s): a push ack of :ok arms the client-response
timer with timeout, so an {:error, :timeout} arriving only after the
full timeout means what it says — the request reached the client and no
response came back in time — while a wedged stream answers the same tuple
within the ack window instead. The push leg's other failures are
immediate: {:error, :no_stream} (no stream registered),
{:error, :disconnected} (the write failed, or the stream closed with
the push still queued), {:error, :stream_down} (the stream died while
the ack was pending). A dead session answers {:error, :no_session}
instead of exiting. When deliver_response/3 arrives with the matching
request_id, the caller receives the client's result or error verbatim.
request_id must be unique among the session's in-flight server requests
(Wymcp.Context mints a random one per call). Reusing one that is still
pending supersedes the earlier entry: the earlier caller stops being
answered through the round trip and falls back to its own outer safety
net, answering {:error, :timeout} once that expires — and because
responses route by request_id, the client's answer to the earlier,
already-delivered request is handed to the later caller.
Returns a specification to start this module under a supervisor.
See Supervisor.
Delivers a client response to a pending server-initiated request.
Called by Methods.DeliverResponse when the router receives a JSON-RPC
response (has "id" + "result"/"error", no "method"). Matches the
response's request_id against pending_server_requests and unblocks
the waiting caller.
Silently ignores responses for unknown request_ids (the request may have already timed out).
Returns the session's full state struct. Accepts a pid or a session-id
binary. Unlike the get_X/1 convention this raises on an unknown
session id instead of returning an error tuple — every caller runs
after session resolution, where a missing session is a bug.
Returns the merged list of compile-time and runtime tools. Runtime tools take precedence when a name collision occurs — compile-time tools with the same name are excluded from the result.
Returns the merged tool list and marks the dirty tool list clean in the
same handler — the read tools/list is served from.
Separate from get_tools/1 because the clear is the point, and every
site that clears must be findable by this name alone: serving the client
the list is the only proof it is current, so clearing from a caller that
never shows the client a list is a silent staleness bug. Callers that
read the tool set without showing it — tools/call, the help tool — use
get_tools/1, which does not clear. See the "Tool list notifications"
section of this module.
Sends a JSON-RPC message to the client over the stream — a stream-answered push: the message is JSON-encoded here, in the caller's own process, handed to the stream loop together with this call's reply reference, and the loop answers the caller directly after the chunk write. The session itself never blocks on the write.
The reply vocabulary, in full: :ok — written; {:error, :unencodable}
— the payload cannot be JSON-encoded (answered synchronously, with a
Logger.warning naming the session); {:error, :no_stream} — no SSE
stream is registered; {:error, :disconnected} — the chunk write failed
and the stream is closing (pushes still queued at close get the same
answer from the drain); {:error, :timeout} — no push ack within
timeout, i.e. a wedged stream or one that crashed without draining;
{:error, :no_session} — the session is dead. Under
restart: :temporary a crashed session is a gone session, so every call
exit except the caller's own {:timeout, _} reads as gone.
timeout defaults to GenServer.call/3's own 5 000 ms; only tests pass
it.
Registers the SSE stream process for this session.
The stream calls this from its own GET request process
(Wymcp.Transport.Stream.serve/3) before the 200 commits. The session
monitors the stream pid — if the stream's process dies, the session
clears the registration via the :DOWN handler; a stream that ends
without its process dying (a disconnect discovered by a failed write)
says so through unregister_stream/2. Registering a new pid while
another stream is registered asks the old one to stop first, closing its
connection: only one active SSE stream per session, so a reconnecting
client does not leave a zombie stream behind.
Registers a tool module on the session at runtime.
Runtime tools are merged with compile-time tools (those passed via
:tools in router opts) and take precedence on name collision.
Registering the same tool twice replaces the previous registration.
The typical place to call this is inside your server's Wymcp.Server.init/2 callback, where
assigns.session_pid is pre-seeded:
defmodule MyApp.McpServer do
use Wymcp.Server
@impl Wymcp.Server
def init(_client_info, assigns) do
user = assigns[:user]
if :admin in user.roles do
Wymcp.Session.register_tool(assigns.session_pid, MyApp.Tools.AdministerUsers)
end
{:ok, assigns}
end
endTools can also be registered later in response to runtime events — for example, a tool that grants elevated access after a confirmation step.
Validates at registration, exactly as Wymcp.Router.init/1 validates
compile-time tools at boot: raises ArgumentError if the module claims
the reserved tool name help or if any action schema is malformed
(Wymcp.Tool.validate_actions!/1) — a bad runtime tool fails here, in
the registering code path, not at its first request.
The raise propagates to the caller. On the Wymcp.Server.init/2 path
above, that means the session is refused: wymcp catches it at its own
call site, logs it with the stacktrace, terminates the session, and
answers notifications/initialized with a JSON-RPC internal_error —
the same treatment init/2 returning {:error, reason} gets. Registering
from anywhere else, the ArgumentError is yours to handle.
A registration that actually changes the tool list this session would serve marks the dirty tool list — see the "Tool list notifications" section of this module. Registering a module that is already serving under that name — whether it was registered here or passed in as a compile-time tool — takes effect as usual but leaves the served list identical, so it marks nothing and sends nothing.
Starts a session under the session supervisor and returns
{:ok, pid, session_id} — a three-element tuple, not the usual
{:ok, pid}: the generated session id is the value the transport
layer must echo in the Mcp-Session-Id response header.
Clears the stream registration when the stream reports its own close.
Wymcp.Transport.Stream calls this when a chunk write fails: the client
is gone, but the loop runs in the adapter's connection process, which
outlives the stream (Bandit may reuse it for the connection's next
request), so the session's stream monitor never fires. Without this the
registration goes stale and every later plain push waits out its
caller's full call timeout against a mailbox nobody drains.
A cast, never a call: the loop must never wait on the session — in the paths that reach this function the session's mailbox is by definition running behind. A pid that is no longer the registered one is ignored — a replacement that registered in the meantime keeps the registration it just took.
Removes a runtime-registered tool by name.
Has no effect on compile-time tools — those are always present. Only
tools added via register_tool/2 can be removed. Returns :ok even
if no tool with the given name was registered.
Removing a tool that was registered marks the dirty tool list — see the "Tool list notifications" section of this module. An unknown name changes nothing and marks nothing.
# Revoke admin access mid-session
Wymcp.Session.unregister_tool(session_pid, "administer_users")