Wymcp. Server behaviour
(Wymcp v0.6.2)
View Source
Legacy-only
The session lifecycle this behaviour hooks exists only on the legacy
lane. Whether a modern-lane replacement for init/2 and
terminate/2 is owed is the legacy decommission's to decide, and
until then both are fully supported.
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