MCP servers

View Source

Arizona can serve the Model Context Protocol (MCP), so an AI agent (Claude Code, Cursor, the MCP Inspector, ...) can call into your app. You write a handler module that exposes tools, resources, and prompts, and mount it on a route. The transport is MCP's Streamable HTTP (JSON-RPC over HTTP POST, with an optional SSE channel for server-initiated messages); it rides the same listener as the rest of your app, on any server adapter with the mcp capability (roadrunner and Cowboy both have it).

Two shapes fall out of the same engine:

  • A dev tool: a coding agent introspects your running dev app (its routes, its docs) and runs code against it. Localhost, single developer, trusted.
  • A user-facing server: your app exposes its features as tools to end-users' agents at runtime. Untrusted and concurrent, so the session, auth, and resource-limit features earn their keep.

Arizona ships a batteries-included dev tool, arizona_dev_mcp. Add it to your dev server's routes and point a coding agent at it:

arizona_dev_mcp:route(~"/mcp")

route/1 defaults to sessions => true (so eval is a persistent REPL); route/2 merges extra opts. You can mount it as-is (jump to Introspection tools) or write your own -- this guide covers both.

The behaviour

An MCP server is a module with -behaviour(arizona_mcp). Only three callbacks are required -- init/1, tools/1, and handle_tool/4 -- the rest (resources, prompts, completion, logging) are optional and gated by the capabilities init/1 advertises.

-module(my_mcp).
-behaviour(arizona_mcp).
-export([init/1, tools/1, handle_tool/4]).

init(_InitParams) ->
    %% {ok, ServerInfo, Capabilities, State}
    {ok, #{name => ~"my_app_dev", version => ~"0.1.0"}, #{tools => #{}}, #{}}.

tools(_State) ->
    [
        #{
            name => ~"list_routes",
            description => ~"List the routes this app serves",
            input_schema => #{type => ~"object", properties => #{}}
        }
    ].

handle_tool(~"list_routes", _Args, _Ctx, State) ->
    Lines = [format_route(R) || R <- my_app:routes()],
    {reply, iolist_to_binary(lists:join($\n, Lines)), State}.

%% Route tuples are not all one shape: `{live|post|..., Path, Handler, Opts}` is a
%% 4-tuple, `{ws|asset|reload, Path, Opts}` a 3-tuple, `{match, Spec, Path, Handler,
%% Opts}` a 5-tuple. Destructuring a single shape in the generator head would
%% silently drop every other route, so match per clause and keep a catch-all.
format_route({Type, Path, Handler, _Opts}) when is_atom(Handler) ->
    io_lib:format("~p ~ts -> ~p", [Type, Path, Handler]);
format_route({Type, Path, _}) ->
    io_lib:format("~p ~ts", [Type, Path]);
format_route(Other) ->
    io_lib:format("~p", [Other]).

handle_tool/4 returns {reply, Result, State} (a binary, or a map with content / structured_content) or {error, Message, State} for an in-band tool error. The returned State threads back into the session, so state mutated by one call is visible to the next.

Mounting it

Add an mcp route. For a dev tool, run it in session mode so per-session state (like a REPL's bindings) persists across calls:

{mcp, ~"/mcp", my_mcp, #{sessions => true}}

The Origin check is a DNS-rebinding defense: a request with no Origin (a CLI agent, or the Inspector's proxy) is allowed; a browser Origin must be in the origins allowlist, which defaults to empty. So a CLI agent needs no origins at all -- omitting it blocks every browser origin, the safest posture. Add origins => [~"http://localhost:PORT"] only to allow a direct browser-based client.

Connecting an agent

claude mcp add --transport http my-app-dev http://localhost:4040/mcp
# or, to explore by hand:
npx @modelcontextprotocol/inspector

Introspection tools

The built-in arizona_dev_mcp ships these, each a thin wrapper over data the running node already has. Mount it with arizona_dev_mcp:route(~"/mcp") and point your agent at /mcp:

  • list_routes -- the app's routes, read from the arizona server app env.
  • describe_component -- a component's kind (stateful/stateless), its exports, and its moduledoc.
  • get_docs -- a module's or function's documentation via code:get_doc/1 (EEP-48).
  • get_source_location -- where a module (or function) is defined.
  • reloader_status -- the dev reloader's current compile error (arizona_reloader:get_error/0) plus loaded-vs-disk drift: the modules whose loaded code differs from their beam on disk. A node serving stale code otherwise reads "ok", so the drift list is the useful half.
  • get_logs -- recent log output from the running node, filtered by tail, level, grep and since. Each reply ends with a cursor; passing it back as since returns only what was logged after that read, so a loop that checks the logs after every action stops re-reading its own previous window. An agent driving the node has no terminal, so without this a crash report -- the one artifact naming the module and line -- is unreadable, and a live process dying on every interaction reads back as a healthy app. Output produced by the MCP's own tool calls is excluded, so an eval that logs does not come back as the app's behaviour. Backed by a bounded ring (arizona_dev_log) installed on the first MCP session, so a node that never mounts this route captures nothing. It shows whatever the app's own logger level already allows: logger applies the primary level before any handler, so capturing below it would mean raising the app's level -- changing the system in order to observe it. Crash reports are error, so the case this exists for is unaffected.
  • view_state -- a live view's current bindings, by view_id; omit it to list the live views and their child views. Where get_logs answers "what happened", this answers "what does this view believe now", which is what separates a slot rendering a stale value from a slot that has no dependency at all (see "The invariant" in architecture.md). Live views are found by scanning processes, not through a registry: a live process is spawned per WebSocket and linked to it, never registered by id, and a dev-only scan avoids adding bookkeeping to every connection for a tool used a handful of times. A view exists only while a page holds its socket.
  • app_info -- application:get_key/2, erlang:system_info/1.
  • render_component -- render a view/component module to HTML with given bindings.
  • reload -- force a compile+reload sync now: recompile the project (via rebar_agent under rebar3 shell) and reload every module whose beam on disk differs from the loaded code, except modules of an app listed in rebar3's app_reload_blacklist -- reloading purges the old code, which kills any process still lingering in it. This is the action to take when reloader_status reports drift. It swaps running code in the live node, so it sits behind the same localhost-only gate as eval (see below), not the read-only introspection tools.
  • eval -- run Erlang in the live node (see below).

eval: powerful, and a footgun

arizona_dev_mcp's eval tool runs Erlang in the live node and keeps its bindings across calls (a REPL the agent drives), by scanning/parsing/evaluating against the session's erl_eval bindings:

handle_tool(~"eval", #{~"code" := Code}, _Ctx, #{bindings := Bindings} = State) ->
    case eval_code(Code, Bindings) of
        {ok, Value, NewBindings} -> {reply, format_value(Value), State#{bindings := NewBindings}};
        {error, Message} -> {error, Message, State}
    end.

This is what makes a dev tool genuinely useful -- the agent can inspect any state and call any function. It is also arbitrary remote code execution, so eval is always available but the dev route is localhost-only by default: arizona_dev_mcp:route/1,2 set allow_remote_access => false, and the MCP handler refuses any request whose peer is not a loopback address -- regardless of which interface the listener bound. That peer check -- the route protecting itself rather than being gated per tool -- is the primary guard, alongside the Origin check and keeping this a dev-only dependency; set allow_remote_access => true only on a network you trust.

The peer check is void behind a proxy or tunnel. It trusts the immediate TCP peer, so a same-host reverse proxy or a dev tunnel (ngrok/cloudflared/port-forward) makes every remote client look like loopback -- and binding the listener to loopback does not help (the tunnel connects to loopback anyway). Whenever the port is reachable through anything but a direct local socket, gate eval with an auth hook (a shared secret) instead; proto_opts => #{ip => {127,0,0,1}} only hardens a direct remote connection, and proxy_protocol => true lets a client spoof the peer. Running it in session mode means a slow eval runs in the session's worker, so it is cancellable and bounded by request_timeout_ms rather than wedging the session.

Route options

All optional, with safe defaults; a route that sets none behaves as a plain stateless MCP server.

OptionDefaultMeaning
sessionsfalseOpt into stateful sessions (initialize mints an Mcp-Session-Id).
origins[]Origin allowlist (no-Origin requests are always allowed).
authnoneA per-route auth hook run after the Origin check: fun((arizona_req:request()) -> ok | {reject, Status}) or {Module, Function}.
allow_remote_accessfalseLocalhost-only unless true: refuse a non-loopback peer (void behind a same-host proxy/tunnel -- use auth).
max_sessionsunboundedCap on live sessions for this route; initialize past it gets a 503.
session_ttl_ms300000Idle timeout before an abandoned session is reaped.
request_timeout_ms60000How long a buffered request waits before the client gets a timeout.
session_max_pending100Cap on queued buffered requests per session.
session_keepalive_ms30000SSE keep-alive comment interval (infinity disables).
page_size50Page size for */list pagination.

See architecture.md for how the transport, sessions, and dispatch work under the hood.