GenMCP.SessionController behaviour (gen_mcp v2.0.0)

Copy Markdown View Source

Behaviour for the session store of the 2025 compatibility transport.

The 2026-07-28 core is stateless and has no sessions. The 2025 protocol requires one: the client handshakes with initialize, the server answers with an Mcp-Session-Id, and every later request carries that id back. A session controller is what bridges the two — it is asked to create/3 a session id at the end of the handshake, and to fetch/3 the session data back on each subsequent request.

The session is deliberately tiny. It holds only what the server actually reads later: the negotiated protocol version and the client's name and version, which the transport writes into the _meta of the V2607 request it builds, so GenMCP.Mux.Channel carries them to handlers exactly as it would for a 2026 request. It is written once at initialize and only read afterwards.

The default controller is GenMCP.SessionController.Token, which stores nothing server-side. Configure another one on the compat transport with the :session_controller option, as a module or a {module, arg} tuple:

forward "/mcp-2025", GenMCP.Transport.StreamableHTTP.V2511,
  server_name: "My App",
  server_version: "1.0.0",
  tools: [MyApp.AddTool],
  session_controller: {MyApp.RedisSessions, pool: :sessions}

Implement one when the deployment needs the full clientInfo and capabilities kept server-side, or needs real revocation, which a self-contained session id cannot offer.

Summary

Types

The value configured alongside the module as {module, arg}.

The client data a session carries across requests.

Callbacks

Creates a session and returns the id the client will send back.

Drops the session, on a client DELETE. Optional.

Reads back the session named by session_id.

Functions

Normalizes the :session_controller option into a %{mod: _, arg: _} descriptor.

Types

arg()

@type arg() :: term()

The value configured alongside the module as {module, arg}.

controller()

@type controller() :: module() | {module(), arg()} | %{mod: module(), arg: arg()}

session()

@type session() :: %{
  protocol_version: binary(),
  client_name: binary() | nil,
  client_version: binary() | nil
}

The client data a session carries across requests.

session_id()

@type session_id() :: binary()

Callbacks

create(session, t, arg)

@callback create(session(), Plug.Conn.t(), arg()) ::
  {:ok, session_id()} | {:error, term()}

Creates a session and returns the id the client will send back.

Called once, while answering initialize, with the session built from the handshake. The returned id becomes the response's Mcp-Session-Id header. It must be usable as an HTTP header value, and small enough to travel on every later request.

Returning {:error, reason} fails the handshake with that reason.

delete(session_id, t, arg)

(optional)
@callback delete(session_id(), Plug.Conn.t(), arg()) :: term()

Drops the session, on a client DELETE. Optional.

A controller that keeps no server-side state has nothing to do here, so the callback is optional and the DELETE is accepted either way.

fetch(session_id, t, arg)

@callback fetch(session_id(), Plug.Conn.t(), arg()) :: {:ok, session()} | {:error, term()}

Reads back the session named by session_id.

Called on every request after the handshake. Return {:error, :invalid} for an id this server did not issue or can no longer honor, and {:error, :expired} for one that has aged out; both are answered to the client as a 404 Not Found, which the 2025 spec defines as the signal for the client to start a new session.

Functions

expand(controller)

@spec expand(controller()) :: %{mod: module(), arg: arg()}

Normalizes the :session_controller option into a %{mod: _, arg: _} descriptor.