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)
    end
stateDiagram-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 note

Pushes

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 answered

Tool 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 listState'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/2 or unregister_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 over merge_tools/1's output, not over runtime_tools: order is not part of the tools/list contract, 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 on runtime_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/list therefore produce two notifications; the notification is payload-free and idempotent, and a client honouring listChanged lists after the first one.
  • Only get_tools_for_list/1 — the read tools/list is served from — marks the flag clean. A push ack cannot: the stream loop answers :ok when 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 served

The 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

await_client_response(pid, request_id, message, timeout)

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.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

complete_request(pid, request_id)

deliver_response(pid, request_id, result_or_error)

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).

get_state(pid)

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.

get_tools(pid)

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.

get_tools_for_list(pid)

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.

lookup(session_id)

mark_ready(pid)

protocol_version(pid)

push(pid, message, timeout \\ 5000)

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.

put_assigns(pid, assigns)

ready?(pid)

register_stream(pid, stream_pid)

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.

register_tool(pid, tool_module)

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
end

Tools 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.

set_log_level(pid, level)

start_link(arg)

start_session(opts)

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.

terminate_session(session_id)

touch(pid)

track_request(pid, request_id, method)

unregister_stream(pid, stream_pid)

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.

unregister_tool(pid, tool_name)

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")