arizona_mcp behaviour (arizona v0.4.0)
View SourceBehaviour for MCP (Model Context Protocol) servers.
An Arizona app implements this behaviour to expose tools to AI agents over MCP's Streamable HTTP transport, then mounts it on a route:
{mcp, ~"/mcp", my_mcp, #{origins => [~"http://localhost:3000"]}}arizona_mcp_handler drives the transport (JSON-RPC over HTTP POST) and
calls back into this module to negotiate the connection and run tools.
Callbacks
init/1-- runs per request to build the server identity, the advertised capability map, and a handler state value threaded into the tool callbacks. Called once oninitialize, and again to source the state fortools/list/tools/call.tools/1-- returns the tool metadata advertised bytools/list.handle_tool/4-- runs onetools/call, dispatched by name (the MCP analogue of howarizona_statefuldispatches an event by name).resources/1+read_resource/2-- optional; list and read resources.prompts/1+get_prompt/3-- optional; list and render prompts.channels/1-- optional; pubsub channels a session subscribes to for server-initiated notifications (see below).terminate/2-- optional cleanup hook.
Resources and prompts are gated on the advertised capability map: the
transport routes resources/* / prompts/* only when init/1 advertised
the matching resources / prompts capability, returning method not found otherwise. A server advertising a capability must implement its
callbacks. Tools are always available (their callbacks are required).
Pagination
tools/1 / resources/1 / prompts/1 always return their full list;
the transport paginates the */list results with opaque cursors. A list at or
under the route's page_size opt (default 50) returns in one page with no
cursor; a larger one returns a page plus a nextCursor the client echoes to
fetch the next. The callbacks never see a cursor -- pagination is entirely the
transport's concern.
The cursor is an offset into the list the callback returns, so it assumes the
list is stable across pages: if the callback returns a different list
mid-pagination (an item added or removed between page requests), the client may
skip or repeat an item. This is fine for the near-static lists tools/resources/
prompts usually are -- and a client that receives a list_changed notification
re-lists from the start anyway.
Tool results vs protocol errors
handle_tool/4 distinguishes two failure kinds, matching the MCP spec:
{reply, Result, State}-- the tool succeeded;Resultbecomes atools/callresult withisError: false.{error, ToolError, State}-- the tool ran but failed (bad domain input, upstream timeout); it becomes atools/callresult withisError: true, not a JSON-RPC error. The agent sees the failure in-band and can react.
A protocol-level fault (unknown method, unknown tool name, malformed
params) is the transport's concern and surfaces as a JSON-RPC error,
never reaching handle_tool/4.
Progress
A tools/call may carry a _meta.progressToken; when it does, the tool can
stream notifications/progress to the client while it runs, before the final
result. handle_tool/4's third argument is a progress context: call
progress/2,3 with it to emit a progress update. When the client sent no
token (or in any non-streaming call) the context is inert and progress/2,3
is a no-op, so a tool can always call it unconditionally.
Streaming is automatic and transport-owned: a token-bearing tools/call from a
client that accepts text/event-stream answers its POST as an SSE stream (the
progress notifications, then the result, then close) instead of a buffered JSON
body. A client that does not accept SSE gets the buffered reply and the progress
is dropped. The tool just emits; it does not choose the transport.
State
init/1 is the state constructor; handle_tool/4, read_resource/2, and
get_prompt/3 each return a new state alongside their result.
In session mode (a route with sessions => true) init/1 runs once on
initialize and the state lives in the session process. Each callback's
returned state threads back into the session, so a tools/call that
increments a counter is visible to the session's next request -- the session
is genuinely stateful. The new state carries back whether the callback
succeeded ({reply, ...}) or failed in-band ({error, ...}), since a
ran-but-failed callback may have mutated state legitimately; only a crash
(a -32603) discards it, leaving the prior state intact.
One exception: a streaming tools/call (one carrying a progress
token) runs on a state snapshot and does not thread state back, so
concurrent streams never race on the session's state. Persist anything it
computes via a follow-up buffered call.
In stateless mode the transport calls init/1 per request and discards
the returned state, so the threaded-back state does not persist across
requests -- every request starts from a fresh init/1.
Server-initiated notifications
A session (a route with sessions => true) can stream notifications/*
messages to a client that has opened the server-to-client SSE channel
(GET on the route). Two ways to push, both no-ops when the addressed
session has no channel attached:
- Broadcast --
broadcast/3publishes to a pubsub channel; every session subscribed to it (via the optionalchannels/1callback) forwards the notification. Natural fornotifications/*/list_changedand resource-updated fan-out, and decoupled (the caller holds no session reference). - Targeted --
notify/3pushes to one session by itsMcp-Session-Id. The id is handed to the handler atinitializeunder themcp_session_idkey of the init params, so a handler can keep it and address its own session later.
Resource subscriptions and templates
A client subscribes to a resource with resources/subscribe (advertise
resources => #{subscribe => true} from init/1); the server announces a
change with resource_updated/1, which fans notifications/resources/updated
out to every subscribed session. It is built on the same pubsub path as
broadcast/3: subscribe joins a per-uri channel, resource_updated/1
broadcasts to it, and resources/unsubscribe (or session exit) leaves. Like
the other pushes it needs a session with an attached SSE channel; a stateless
route accepts the subscription but has nowhere to deliver, so it joins nothing.
The optional resource_templates/1 callback advertises URI templates
(mem://user/{id}) through resources/templates/list. Omit it for a server
with no parameterized resources.
Logging and completion
With logging => #{} advertised, the client sets a minimum severity via
logging/setLevel and the server pushes logs with log/3 (a
notifications/message); a message below the session's level is dropped (the
default is info). With completions => #{} advertised, the optional
complete/3 callback autocompletes a prompt or resource argument from
completion/complete -- it returns the matching strings and the transport caps
them at 100.
Operational notes
Session mode starts one process per initialize. The per-route session count
is bounded by max_sessions (an initialize past the cap gets a 503);
unset, it is unbounded, so a public deployment should also gate the endpoint
with the auth hook and a reverse-proxy rate limit. An abandoned session is
reaped after its idle TTL (session_ttl_ms, default 5 minutes). Every request
runs in a worker, so app code never blocks the session process: it stays
responsive to cancels, idle-reap, and server pushes even while a tool runs.
Buffered requests are served one at a time (so their state threading stays
serialized) and bounded by request_timeout_ms (default 60s): a slower tool
frees the client with a -32603 while the session keeps running it; queued
buffered requests are capped at session_max_pending (default 100), past which
a dispatch is rejected. Any request is cancellable by a
notifications/cancelled {requestId} or a client disconnect, which frees the
caller and kills the worker. An in-flight streaming worker holds its session
alive (it is not idle-reaped mid-run); every worker is killed if the session is
torn down. An attached SSE channel gets a periodic keep-alive comment
(session_keepalive_ms, default 30s; infinity disables) so idle proxies
don't drop it. The optional terminate/2 callback runs when a session ends
(DELETE, idle TTL, or shutdown); it does not run in stateless mode, which
has no session to end.
Clients that use fetch (browsers and the official MCP SDK) refuse to
connect to ports on the WHATWG Fetch "bad ports" blocklist, so mount the
listener on a normal HTTP port (not, e.g., 4045 / 6000 / 6666). The SSE
channel is a controller {loop, ...} reply, so it works on every protocol the
server adapter sends loops over (roadrunner: HTTP/1.1, h2, and h3, the last two
over TLS). The endpoint is verified against the official MCP SDK client.
Summary
Callbacks
Return the pubsub channels this session subscribes to for server-initiated
notifications. Anything published to one of these channels via broadcast/3
is forwarded to the session's SSE channel. Optional; defaults to none.
Autocomplete a prompt or resource argument, dispatched from
completion/complete. Ref is {prompt, Name} or {resource, Uri}; Arg is
{ArgName, PartialValue}. Return {reply, Values, State} with the matching
completion strings (the transport caps them at 100 and sets hasMore).
Optional; implement it when advertising the completions capability.
Render one prompt by name, dispatched from prompts/get. Args is the
decoded arguments object. Return {reply, Result, State} on success or
{error, Message, State} for a prompt-level failure.
Run one tools/call, dispatched by tool name. Args is the decoded
arguments object. Ctx is the progress context: pass it to progress/2,3
to stream notifications/progress while the tool runs (a no-op when the
client sent no _meta.progressToken). Return {reply, Result, State} on
success or {error, ToolError, State} for an in-band tool failure.
Build the server identity, advertised capabilities, and handler state.
Return the prompt metadata advertised by prompts/list. Optional;
implement it (with get_prompt/3) when advertising the prompts
capability.
Read one resource by uri, dispatched from resources/read. The uri is
guaranteed to be one returned by resources/1. Return
{reply, Contents, State} on success or {error, Message, State} for a
resource-level failure (surfaced as a -32002 error).
Return the URI-template metadata advertised by resources/templates/list.
Optional; a server with no parameterized resources omits it (the transport
then returns an empty template list).
Return the resource metadata advertised by resources/list. Optional;
implement it (with read_resource/2) when advertising the resources
capability.
Optional cleanup hook.
Return the tool metadata advertised by tools/list.
Functions
Broadcast a server-initiated notification to every session subscribed to
Channel (via its channels/1 callback). Each subscribed session with an
attached SSE channel forwards notifications/<Method> with Params to its
client; sessions without an attached channel ignore it.
Send a log message (a notifications/message) to one session by its
Mcp-Session-Id. Dropped if Level is below the level the client set via
logging/setLevel (default info), or if the session is unknown/expired or has
no SSE channel. Data is any JSON-encodable term. Like notify/3, the id comes
from the handler's initialize params.
Push a server-initiated notification to one session by its Mcp-Session-Id.
A no-op if the session is unknown/expired or has no SSE channel attached.
The id is supplied to the handler at initialize (the mcp_session_id key
of the init params).
Emit a notifications/progress update from a running tools/call, using the
Ctx handed to handle_tool/4. Progress is the amount done so far. A no-op
when the client sent no _meta.progressToken (the context is inert), so a tool
can call it unconditionally.
Emit a notifications/progress update with extras. Opts may carry total
(the expected final amount) and message (a human-readable status). A no-op
when the context is inert.
Announce that a resource changed to every session subscribed to its Uri (via
resources/subscribe). Each subscribed session forwards
notifications/resources/updated to its client; sessions that did not subscribe
(or have no SSE channel) ignore it. Decoupled -- the caller holds no session
reference.
Types
-nominal capabilities() :: map().
-nominal channel() :: arizona_pubsub:channel().
-nominal init_params() :: map().
-nominal log_level() :: debug | info | notice | warning | error | critical | alert | emergency.
-nominal prompt() :: #{name := binary(), description => binary(), arguments => [prompt_argument()]}.
-nominal prompt_error() :: binary().
-nominal prompt_result() :: #{messages := [prompt_message()], description => binary()}.
-nominal resource_error() :: binary().
-nominal state() :: term().
-nominal tool_error() :: binary() | #{content := [content_block()]}.
-nominal tool_result() :: binary() | #{content := [content_block()], structured_content => map()}.
Callbacks
Return the pubsub channels this session subscribes to for server-initiated
notifications. Anything published to one of these channels via broadcast/3
is forwarded to the session's SSE channel. Optional; defaults to none.
-callback complete(Ref :: complete_ref(), Arg :: complete_argument(), State :: state()) -> {reply, [binary()], state()}.
Autocomplete a prompt or resource argument, dispatched from
completion/complete. Ref is {prompt, Name} or {resource, Uri}; Arg is
{ArgName, PartialValue}. Return {reply, Values, State} with the matching
completion strings (the transport caps them at 100 and sets hasMore).
Optional; implement it when advertising the completions capability.
-callback get_prompt(Name :: binary(), Args :: map(), State :: state()) -> {reply, prompt_result(), state()} | {error, prompt_error(), state()}.
Render one prompt by name, dispatched from prompts/get. Args is the
decoded arguments object. Return {reply, Result, State} on success or
{error, Message, State} for a prompt-level failure.
-callback handle_tool(Name :: binary(), Args :: map(), Ctx :: progress_ctx(), State :: state()) -> {reply, tool_result(), state()} | {error, tool_error(), state()}.
Run one tools/call, dispatched by tool name. Args is the decoded
arguments object. Ctx is the progress context: pass it to progress/2,3
to stream notifications/progress while the tool runs (a no-op when the
client sent no _meta.progressToken). Return {reply, Result, State} on
success or {error, ToolError, State} for an in-band tool failure.
-callback init(InitParams :: init_params()) -> {ok, ServerInfo :: server_info(), Capabilities :: capabilities(), State :: state()}.
Build the server identity, advertised capabilities, and handler state.
InitParams carries the client's initialize params (its protocol
version, capabilities, and clientInfo) when invoked from initialize,
or the empty map when the transport reconstructs state for a later
request. In session mode it also carries the atom key mcp_session_id.
Capabilities must be an atom-keyed map (e.g. #{tools => #{}}): the
transport gates resources/* and prompts/* by matching the atom
capability name, so a binary-keyed capability would never be served.
Return the prompt metadata advertised by prompts/list. Optional;
implement it (with get_prompt/3) when advertising the prompts
capability.
-callback read_resource(Uri :: binary(), State :: state()) -> {reply, resource_contents(), state()} | {error, resource_error(), state()}.
Read one resource by uri, dispatched from resources/read. The uri is
guaranteed to be one returned by resources/1. Return
{reply, Contents, State} on success or {error, Message, State} for a
resource-level failure (surfaced as a -32002 error).
-callback resource_templates(State :: state()) -> [resource_template()].
Return the URI-template metadata advertised by resources/templates/list.
Optional; a server with no parameterized resources omits it (the transport
then returns an empty template list).
Return the resource metadata advertised by resources/list. Optional;
implement it (with read_resource/2) when advertising the resources
capability.
Optional cleanup hook.
Return the tool metadata advertised by tools/list.
Functions
-spec broadcast(Channel, Method, Params) -> ok when Channel :: channel(), Method :: binary(), Params :: map().
Broadcast a server-initiated notification to every session subscribed to
Channel (via its channels/1 callback). Each subscribed session with an
attached SSE channel forwards notifications/<Method> with Params to its
client; sessions without an attached channel ignore it.
-spec log(SessionId, Level, Data) -> ok when SessionId :: binary(), Level :: log_level(), Data :: term().
Send a log message (a notifications/message) to one session by its
Mcp-Session-Id. Dropped if Level is below the level the client set via
logging/setLevel (default info), or if the session is unknown/expired or has
no SSE channel. Data is any JSON-encodable term. Like notify/3, the id comes
from the handler's initialize params.
-spec notify(SessionId, Method, Params) -> ok when SessionId :: binary(), Method :: binary(), Params :: map().
Push a server-initiated notification to one session by its Mcp-Session-Id.
A no-op if the session is unknown/expired or has no SSE channel attached.
The id is supplied to the handler at initialize (the mcp_session_id key
of the init params).
-spec progress(Ctx, Progress) -> ok when Ctx :: progress_ctx(), Progress :: number().
Emit a notifications/progress update from a running tools/call, using the
Ctx handed to handle_tool/4. Progress is the amount done so far. A no-op
when the client sent no _meta.progressToken (the context is inert), so a tool
can call it unconditionally.
-spec progress(Ctx, Progress, Opts) -> ok when Ctx :: progress_ctx(), Progress :: number(), Opts :: progress_opts().
Emit a notifications/progress update with extras. Opts may carry total
(the expected final amount) and message (a human-readable status). A no-op
when the context is inert.
-spec resource_updated(Uri) -> ok when Uri :: binary().
Announce that a resource changed to every session subscribed to its Uri (via
resources/subscribe). Each subscribed session forwards
notifications/resources/updated to its client; sessions that did not subscribe
(or have no SSE channel) ignore it. Decoupled -- the caller holds no session
reference.