Wymcp.Server behaviour (Wymcp v0.5.0)

View Source

Behaviour for consuming applications to hook into the MCP session lifecycle.

Implement this behaviour to run custom logic when a session becomes ready (after the notifications/initialized handshake) and when it shuts down.

Both callbacks are required, and use Wymcp.Server provides working no-op defaults for both: init/2 returns {:ok, assigns} with the assigns unchanged, and terminate/2 returns :ok. A module that implements this behaviour without the macro must define both itself — neither call site probes for them. That is the callback-surface invariant, whose home is Wymcp.Tool's moduledoc; this behaviour satisfies it by declaring nothing optional.

Design decisions

Only two lifecycle hooks are provided: init/2 and terminate/2. There is deliberately no handle_request/2 callback, and the two concerns such a callback would have carried have different homes.

Observability is telemetry's. wymcp emits an event at every point a per-request hook would have been asked to observe — Wymcp.Telemetry is the catalogue — and ships Wymcp.Telemetry.Logger to render those events as structured lines. A plug ahead of the mount can count statuses and time requests; what it cannot know is which rejecter refused a request and why, so wymcp publishes that fact rather than leaving it to be reconstructed: [:wymcp, :wire, :reject] carries it to a handler, and Wymcp.Response.rejection/1 reads it off the connection, where a host application's own access line already runs.

Everything else composes ahead of the mount. Rate limiting, per-route auth, and any other cross-cutting concern that is not observability stays a Plug the consuming app adds before the mount module it forwards to, where it composes with the host app and needs nothing from this behaviour. A handle_request/2 callback can still be added non-breakingly later, if a concrete use case appears that neither home serves.

Session-aware data (like the authenticated user) is available in conn.assigns from earlier plugs, or in ctx.assigns inside tools.

init/2 is the primary extension point for per-client configuration. The assigns map arrives with session_pid pre-seeded — following the Phoenix pattern where socket.assigns carries process references (like transport_pid in Channel). The typical pattern: inspect client_info, look up the authenticated user's permissions (the auth plug already stored the token in assigns), and call Wymcp.Session.register_tool/2 for each authorized tool. register_tool/2 raises ArgumentError on a tool that claims the reserved name help or carries a malformed action schema. Wymcp catches the raise here, terminates the session, and answers the request with a JSON-RPC internal_error, so one bad module refuses one session instead of crashing the request. If you build the tool list from something your own tests do not cover, validate or rescue around the loop yourself.

terminate/2 runs from the session GenServer's terminate/2 callback, so it fires on normal shutdown (idle timeout, client DELETE) and abnormal shutdown (crash, supervisor restart).

Usage

defmodule MyApp.McpServer do
  use Wymcp.Server

  @impl Wymcp.Server
  def init(client_info, assigns) do
    user = MyApp.Auth.lookup_user(assigns[:auth_token])

    for tool <- MyApp.Permissions.tools_for(user) do
      Wymcp.Session.register_tool(assigns.session_pid, tool)
    end

    {:ok, Map.put(assigns, :user, user)}
  end

  @impl Wymcp.Server
  def terminate(_reason, assigns) do
    MyApp.Audit.log_session_end(assigns[:user])
    :ok
  end
end

Summary

Callbacks

init(client_info, assigns)

@callback init(client_info :: map(), assigns :: map()) ::
  {:ok, assigns :: map()} | {:error, reason :: term()}

terminate(reason, assigns)

@callback terminate(reason :: term(), assigns :: map()) :: :ok