arizona_mcp_handler (arizona v0.4.0)

View Source

The 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

auth_hook()

-type auth_hook() :: fun((arizona_req:request()) -> auth_result()) | {module(), atom()}.

auth_result()

-type auth_result() :: ok | {reject, arizona_req:resp_status()}.

route_opts()

-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()}.

session()

-type session() ::
          #{mod := module(),
            state := arizona_mcp:state(),
            caps := arizona_mcp:capabilities(),
            page_size := pos_integer(),
            log_min_severity := 0..7,
            stateless => true}.

Functions

dispatch(Mod, Request, Ctx)

-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.

handle(Req, Ctx)

-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.

handle_info(Info, Push, 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's notifications/progress as 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.

handle_method(Method, Params, Id, Session)

-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.

message_frame(Object)

-spec message_frame(Object) -> iodata() when Object :: map().

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.

run_streaming_tool(Session, Name, Args, Id, Ctx, ConnPid)

-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.