arizona_mcp_handler (arizona v0.4.0)
View SourceThe MCP (Model Context Protocol) endpoint, a controller (arizona_controller)
every arizona_server adapter with the mcp capability serves.
Implements MCP's Streamable HTTP transport for a tools server: a client
POSTs a single JSON-RPC 2.0 message and receives a buffered
application/json reply; with sessions enabled, GET opens the SSE
channel and DELETE tears a session down. Before any dispatch the request
passes the security gate: the Origin allowlist (DNS-rebinding defense)
and the route's optional auth hook.
The MCP method dispatch (dispatch/3) is a pure function over a decoded
request and the app's arizona_mcp module -- it is exported so it can be
unit-tested without a transport, mirroring arizona_stateful's call_*
helpers. handle/2 owns the transport: reading the body, the Origin
gate, and mapping a dispatch crash to a JSON-RPC -32603 error so the
protocol contract holds even when a tool raises. The SSE channel and a
streaming POST are controller loop replies, fed by handle_info/3.
Summary
Functions
Pure MCP method dispatch. Maps a decoded JSON-RPC request and the app's
arizona_mcp module to one of
The controller action. Serves the MCP endpoint: a JSON-RPC POST returns a
buffered application/json reply (or, for a progress-tracked tools/call, an
SSE stream). When sessions are enabled, GET opens the SSE channel and
DELETE tears a session down; otherwise non-POST methods return 405. The
route options, the handler module among them, are the context's state.
The controller loop callback for the two SSE loops
Run one non-initialize MCP method against a session, returning the JSON-RPC
outcome paired with the (possibly updated) session. arizona_mcp_session
calls this with its held session and stores the returned session back, so a
stateful callback's new state (handle_tool / read_resource /
get_prompt) carries into the session's later requests; dispatch/3
(stateless) discards the returned session. Methods that run no stateful
callback (ping, the */list reads, an unknown or capability-gated-off
method) return the session unchanged.
Frame a JSON-RPC message (a progress notification or the final result) as a
single SSE message event. The streaming POST is request-scoped, so -- unlike
the resumable GET channel -- the events carry no id.
Run a streaming tools/call against a snapshot of Session, relaying the
result (and, via Ctx, any notifications/progress) to ConnPid. Threads no
state back -- streaming runs on a snapshot, and only the serialized buffered
queue mutates session state. Shared by the stateless worker and the session
process; exported for the latter's cross-module call.
Types
-type auth_hook() :: fun((arizona_req:request()) -> auth_result()) | {module(), atom()}.
-type auth_result() :: ok | {reject, arizona_req:resp_status()}.
-type route_opts() :: #{origins => [binary()], auth => auth_hook(), sessions => boolean(), max_sessions => pos_integer() | infinity, session_ttl_ms => pos_integer(), session_buffer_max => pos_integer(), allow_remote_access => boolean(), _ => term()}.
-type session() :: #{mod := module(), state := arizona_mcp:state(), caps := arizona_mcp:capabilities(), page_size := pos_integer(), log_min_severity := 0..7, stateless => true}.
Functions
-spec dispatch(Mod, Request, Ctx) -> {reply, map()} | notification | {error, map()} when Mod :: module(), Request :: arizona_jsonrpc:request(), Ctx :: map().
Pure MCP method dispatch. Maps a decoded JSON-RPC request and the app's
arizona_mcp module to one of:
{reply, Object}-- a JSON-RPC response object to encode and return,notification-- an accepted notification with no reply (HTTP 202),{error, Object}-- a JSON-RPC error object to encode and return.
Exported for unit testing; handle/2 calls it inside a crash guard.
-spec handle(Req, Ctx) -> {Reply, Req} when Req :: arizona_req:request(), Ctx :: arizona_controller:context(), Reply :: arizona_controller:reply().
The controller action. Serves the MCP endpoint: a JSON-RPC POST returns a
buffered application/json reply (or, for a progress-tracked tools/call, an
SSE stream). When sessions are enabled, GET opens the SSE channel and
DELETE tears a session down; otherwise non-POST methods return 405. The
route options, the handler module among them, are the context's state.
-spec handle_info(Info, Push, State) -> {ok, State} | {stop, State} when Info :: term(), Push :: arizona_controller:push_fun(), State :: #{session := pid(), _ => term()} | #{mcp_post_stream := true, _ => term()}.
The controller loop callback for the two SSE loops:
- the GET channel (
#{session := Pid}) -- forwards server-initiated events from the session; on client disconnect it detaches from the session (which keeps the session alive) and stops. - the streaming POST (
#{mcp_post_stream := true}) -- pushes the running tool'snotifications/progressas they arrive, then the final result, then stops; a client disconnect just stops the loop.
Both loops monitor their session (session_mon), so a session death of any
cause -- idle reap, DELETE, a crash, even one racing the loop's setup --
releases the connection instead of leaving it open forever.
-spec handle_method(Method, Params, Id, Session) -> {Outcome, Session} when Method :: binary(), Params :: map(), Id :: arizona_jsonrpc:id(), Session :: session(), Outcome :: {reply, map()} | {error, map()}.
Run one non-initialize MCP method against a session, returning the JSON-RPC
outcome paired with the (possibly updated) session. arizona_mcp_session
calls this with its held session and stores the returned session back, so a
stateful callback's new state (handle_tool / read_resource /
get_prompt) carries into the session's later requests; dispatch/3
(stateless) discards the returned session. Methods that run no stateful
callback (ping, the */list reads, an unknown or capability-gated-off
method) return the session unchanged.
Frame a JSON-RPC message (a progress notification or the final result) as a
single SSE message event. The streaming POST is request-scoped, so -- unlike
the resumable GET channel -- the events carry no id.
Called by whoever produces the object, never by the connection process that
pushes the frame. json:encode/1 rejects a binary that is not valid UTF-8, and
a callback module can return one; encoding at the producer keeps that failure
inside the producer's crash guard, which answers -32603. Encoded in the
connection process it would instead kill the connection, and the client would
see the socket drop with no reply.
-spec run_streaming_tool(Session, Name, Args, Id, Ctx, ConnPid) -> ok when Session :: session(), Name :: binary(), Args :: map(), Id :: arizona_jsonrpc:id(), Ctx :: arizona_mcp:progress_ctx(), ConnPid :: pid().
Run a streaming tools/call against a snapshot of Session, relaying the
result (and, via Ctx, any notifications/progress) to ConnPid. Threads no
state back -- streaming runs on a snapshot, and only the serialized buffered
queue mutates session state. Shared by the stateless worker and the session
process; exported for the latter's cross-module call.