Transport-agnostic MCP server.
Receives decoded JSON-RPC messages, dispatches to the Registry for tool/resource
operations, and returns response maps. Transports (stdio, SSE) call
handle_message/2 and write the response back over their I/O channel.
Supported Methods
initialize-- MCP handshake, returns server capabilities andserverInfo(name"raxol";versionthe:server_versionoption, default this package's version)notifications/initialized-- client acknowledgement (no reply)ping-- health checktools/list-- list the registered tools this caller may calltools/call-- invoke a toolresources/list-- list registered resourcesresources/read-- read a resourceprompts/list-- list registered promptsprompts/get-- render a prompt with argumentslogging/setLevel-- set server log levelcompletion/complete-- auto-complete tool arguments
Notifications
The server can push notifications to connected transports. Transports
subscribe via subscribe/2 and receive {:mcp_notification, map()} messages.
Elicitation
When Raxol.MCP.Authorizer returns {:ask, prompt}, what happens depends on
whether the client can be asked. A client that advertised the elicitation
capability at initialize (and has a subscribed transport) is sent a real
elicitation/create request; anything else gets the machine-readable
authorization_required deny.
A parked call never holds up its caller. A handle_message/3 caller blocks
until it gets a reply, so tools/call returns nil immediately and the
call's eventual response is pushed on the subscriber channel:
client server transport
tools/call -------------> ASK -> park, return nil -> (unblocked)
<------------- elicitation/create (push)
response -------------> resume, return nil
<------------- tools/call response (push)Returning nil is what keeps such a transport (Transport.SSE) from
deadlocking: it stays free to READ the client's answer. A request/3 caller
(Transport.Stdio) never blocks, so its call simply stays outstanding: the
prompt is still pushed, but the call's response, or deny, is the REPLY to
that request. Until then cancel/2 with the call's request_ref reaches it,
whether it is still waiting for the answer or, once approved, running.
A parked call is always closed, exactly once, by one of five paths: an
approval (runs the tool), a decline/cancel/unapproved accept, an error
response, a timeout (:elicitation_timeout_ms, default 60s), or, on a full
server, making room for another connection's call (see below). Every path
but approval resolves to the same deny -- silence is not consent. Dropping
it is the exception: cancel/2, its request/3 caller exiting, or its
connection going away ends it with no answer, and kills the tool if an
approval already started it.
Parked calls are capped twice. A connection may have at most
:max_parked_elicitations (default 32) calls parked at once, and the server
at most :max_parked_elicitations_total (default 256) across every
connection. A parked call holds no callback worker, so without the first a
client could park calls as fast as it sent them; without the second, every
connection that can be asked (up to :max_client_capabilities) could park
its full share. The total defaults to eight connections' full share: each
parked call is a timer and a small entry in the server's state, so 256
bounds that state while eight clients can each use their whole share at
once. The per-connection cap may not exceed the total; the server refuses to
start if it does.
Past the per-connection cap an ASK gets the authorization_required deny
with the detail too many elicitations pending. The total is shared by
every client, so it is not first come, first served: when the server is
full, an ASK from a connection holding fewer parked calls than the
connection holding the most closes that connection's oldest parked call
(with the deny and the detail too many elicitations pending on this server) and parks in its place. Any other ASK on a full server gets that
deny itself. So a client that spreads its calls over several connections
(a network transport mints one per stream) cannot hold every other
client's approvals; it only crowds out its own. What remains: a client
with more elicitation-capable connections than the total, each holding one
call, can still keep a newcomer's call from lasting. Bounding that needs a
per-peer identity no transport gives the server (Transport.SSE trusts
the client's session id, and a remote address is shared behind a proxy).
Sensitive tools
A tool annotated destructiveHint: true or sensitive: true (see
Raxol.MCP.ToolDef.sensitive?/1) must not be served without an authorizer.
The server REFUSES TO BOOT when a static one is already registered, and
denies at tools/call for one registered afterwards -- the boot check
alone would be bypassable by registering late. Derived widget tools (the
tool defs Raxol.MCP.TreeWalker derives, which Raxol.MCP.ToolSynchronizer
registers with derived: true; see Raxol.MCP.Registry.register_tools/3
and Raxol.MCP.Registry.static_tools/1) are left out of the boot check:
they come and go with a session's view, so a restart of this server with
live sessions would otherwise refuse to boot. A static tool with a dotted
name (agent.reset), or one whose tool map carries a :derived key, is
still checked. The tools/call refusal covers both.
Unknown tools
A tools/call naming no registered tool answers JSON-RPC -32601, unless
the :unknown_tool_reply option says otherwise. It is a 1-arity function
given the tool name (a binary); a binary it returns is sent back as an
isError tool result with that text, and nil keeps the -32601. An app
whose tools come and go uses it to tell a client that cached a name what
happened to it: Raxol.Application passes
Raxol.Headless.McpTools.unknown_tool_reply/1, which names a stopped
session. The server itself reads nothing into a tool name.
Client capabilities
What each connection advertised at initialize is kept until that
connection's subscriber exits. A connection that never subscribes has no
exit to wait for, so the map is also capped by :max_client_capabilities
(default 1024). Past the cap the oldest entry without a live subscriber is
evicted first, and a live one only when every entry has a subscriber. Each
eviction emits [:raxol, :mcp, :server, :client_capabilities_evicted].
An evicted connection keeps working; it just cannot be asked, so an ASK
becomes the deny, until it sends initialize again.
Callback isolation
Tool, resource, prompt, completion, and authorization callbacks run in linked, monitored workers rather than in the shared server process. The server remains responsive to other clients, notifications, subscriber exits, and elicitation timers while callback work is pending.
:max_in_flight bounds concurrent callback workers across every connection
(default 32); it does not count parked calls, which hold no worker. One
connection may hold at most :max_in_flight_per_connection of them (default
half of :max_in_flight, at least 1), so one client running many slow calls
at once (a stdio peer included) leaves the rest to other connections. It is
a share per connection id, not per peer: handle_message/2 callers use the
stdio transport's :default id and so share its slots, and connections
together can still fill :max_in_flight (two at the default share do). The
share may not exceed :max_in_flight; the server refuses to start if it
does. Both counts are kept in this process and checked when a worker
starts, so concurrent requests cannot both pass a check meant for one.
:callback_timeout_ms bounds each worker's lifetime (default 300,000 ms).
Work above either concurrency bound receives a structured busy error. A finite
timeout passed to handle_message/4 also cancels that call's worker if the
caller stops waiting. request/3 callers cancel explicitly with cancel/2,
and are monitored: when one exits, its outstanding requests are cancelled.
params must be an object. "params": null is treated as absent; any other
non-object params is answered -32602 before the method is looked at.
method must be a string: a request naming anything else is answered
-32600, and such a notification is dropped. logging/setLevel answers an
unknown level with -32602, and is otherwise gated by :read_authorizer
like the other read surfaces.
Summary
Types
Where this server's :authorizer came from.
Identifies one client connection. :default is the implicit single
connection used by stdio and by direct callers; a network transport mints an
opaque per-connection value.
A tools/call parked awaiting the client's elicitation answer. request_id
is the ORIGINAL call's id -- the one the client is still waiting on, and
owner is the connection that made it. Only owner may answer, and the
answer goes only to owner: pushed to its subscriber, or, when the call came
through request/3, sent as the reply to caller (the call's from and
request_ref, plus the monitor on the calling process). parked_at orders
parked calls, so a full server can tell its oldest.
Which transport carries this server's clients, as DECLARED at start.
Functions
Whether authorization on this server was CHOSEN by a caller. Network transport
boot guards use this to fail closed (see Raxol.MCP.Deployment). Returns
false if the server is unreachable.
Cancel a request sent with request/3.
Returns a specification to start this module under a supervisor.
Handle a decoded JSON-RPC message.
Handle a decoded JSON-RPC message attributed to a specific connection.
Send a notification to all subscribed transports.
Whether the READ seam has an authorizer. The network boot guards use this to
refuse a server that would serve resources/read -- live model state -- to
any client that connects. Returns false if the server is unreachable.
Send a decoded JSON-RPC message without waiting for the reply.
Start the server, linked to the calling process.
Subscribe a transport process to server notifications.
Subscribe a transport process as a named connection.
Types
@type authorizer_source() :: :configured | :default
Where this server's :authorizer came from.
:configured -- a caller chose it. :default -- a framework fallback stood in
because nobody chose one. The distinction exists for
authorization_configured?/1; see that function for why the two cannot be told
apart by the authorizer's value.
@type conn_id() :: term()
Identifies one client connection. :default is the implicit single
connection used by stdio and by direct callers; a network transport mints an
opaque per-connection value.
@type pending_elicitation() :: %{ request_id: term(), tool: String.t(), arguments: map(), timer: reference(), owner: conn_id(), caller: caller() | nil, parked_at: integer() }
A tools/call parked awaiting the client's elicitation answer. request_id
is the ORIGINAL call's id -- the one the client is still waiting on, and
owner is the connection that made it. Only owner may answer, and the
answer goes only to owner: pushed to its subscriber, or, when the call came
through request/3, sent as the reply to caller (the call's from and
request_ref, plus the monitor on the calling process). parked_at orders
parked calls, so a full server can tell its oldest.
@type t() :: %Raxol.MCP.Server{ authorizer: Raxol.MCP.Authorizer.t() | nil, authorizer_source: authorizer_source(), callback_timeout_ms: pos_integer(), caller_refs: %{required(reference()) => reference()}, client_capabilities: %{required(conn_id()) => {integer(), map()}}, elicitation_timeout_ms: pos_integer(), in_flight: %{required(reference()) => map()}, in_flight_counts: %{required(conn_id()) => pos_integer()}, initialized: boolean(), log_level: :debug | :info | :notice | :warning | :error | :critical | :alert | :emergency, max_client_capabilities: pos_integer(), max_in_flight: pos_integer(), max_in_flight_per_connection: pos_integer(), max_parked_elicitations: pos_integer(), max_parked_elicitations_total: pos_integer(), parked_counts: %{required(conn_id()) => pos_integer()}, pending_elicitations: %{required(String.t()) => pending_elicitation()}, read_authorizer: Raxol.MCP.Authorizer.t() | nil, registry: GenServer.server(), resource_subscriptions: %{required(String.t()) => boolean()}, server_version: String.t(), subscribers: %{required(conn_id()) => pid()}, transport: transport(), unknown_tool_reply: (String.t() -> String.t() | nil) | nil, worker_refs: %{required(reference()) => reference()} }
@type transport() :: :stdio | :sse | :unknown
Which transport carries this server's clients, as DECLARED at start.
Server-derived rather than client-asserted, which is what makes it usable in an authorization decision: a policy can tell a stdio caller (which inherits the OS process boundary) from a network one.
Functions
@spec authorization_configured?(GenServer.server()) :: boolean()
Whether authorization on this server was CHOSEN by a caller. Network transport
boot guards use this to fail closed (see Raxol.MCP.Deployment). Returns
false if the server is unreachable.
Deliberately not authorizer != nil. A framework may supply a restrictive
fallback -- a deny-everything allowlist is a sensible default for an
unconfigured production server -- and the value alone cannot be told apart from
a policy an operator wrote. Treating the fallback as configured would satisfy
this gate with a default, which removes exactly the forcing function the gate
exists to be: nobody had to decide that a network transport should serve.
So a server started with authorizer_source: :default answers false however
strict its authorizer is. :default is also the DEFAULT source: for a
predicate whose whole purpose is "nobody had to decide that a network
transport should serve", an embedder who forgets the option must fail closed,
not be taken at its word. Raxol.Application passes :configured
explicitly when mcp_authorizer_source/0 says an operator named a key.
@spec cancel(GenServer.server(), reference()) :: :ok
Cancel a request sent with request/3.
Kills the request's callback worker, if it still runs, or drops it while it
is parked for an elicitation answer (a later answer is then ignored), and
answers the request with {:reply, nil}. A tools/call approved through
elicitation runs under its original request_ref, so this reaches it at
every stage. A request that already completed is left alone.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec handle_message(GenServer.server(), map()) :: {:reply, map() | nil}
Handle a decoded JSON-RPC message.
Returns {:reply, response_map} for requests or {:reply, nil} for
notifications (no response needed). The message is what
Raxol.MCP.Protocol.decode/1 returns: its top-level keys are atoms. A
request with string top-level keys is answered -32600.
@spec handle_message(GenServer.server(), map(), conn_id(), timeout()) :: {:reply, map() | nil}
Handle a decoded JSON-RPC message attributed to a specific connection.
conn_id is what binds an elicitation to the client that triggered it: only
the connection that parked a tools/call may answer its prompt, and the
prompt and result are delivered to that connection alone. A transport that
cannot tell its clients apart must NOT invent ids -- passing an unidentified
connection is safe (it can still make ordinary requests, and simply cannot
elicit), whereas reusing one client's id for another hands over its approvals.
@spec notify(GenServer.server(), String.t(), map()) :: :ok
Send a notification to all subscribed transports.
@spec read_authorization_configured?(GenServer.server()) :: boolean()
Whether the READ seam has an authorizer. The network boot guards use this to
refuse a server that would serve resources/read -- live model state -- to
any client that connects. Returns false if the server is unreachable.
Presence, not source: unlike authorization_configured?/1 there is no
framework fallback here worth telling apart. A read authorizer exists only
because someone configured one, and Raxol.MCP.Deployment's tool-seam gate
is what carries the "nobody decided" argument.
@spec request(GenServer.server(), map(), conn_id()) :: {:gen_server.request_id(), reference()}
Send a decoded JSON-RPC message without waiting for the reply.
The non-blocking form of handle_message/3, for a transport that must keep
reading while earlier requests run. Messages from one caller reach the
server in send order, so methods handled inline keep their order.
message must be what Raxol.MCP.Protocol.decode/1 returns: top-level keys
(:jsonrpc, :id, :method, :params, :result, :error) are atoms,
anything below them keeps its string keys. A request decoded some other way,
with string top-level keys, is answered -32600; such a notification or
response gets {:reply, nil}.
Returns the :gen_server request id and the request_ref that cancel/2
takes. Collect the reply with :gen_server.check_response/2,3: it is
{:reply, map() | nil}, as handle_message/3 returns. Every request gets
exactly one reply; a cancelled one gets {:reply, nil}. Unlike
handle_message/3, a tools/call parked for elicitation is not answered
nil: it stays outstanding, and its reply is the call's eventual response.
The server monitors the calling process while it has requests outstanding, and cancels them if it exits.
@spec start_link(keyword()) :: GenServer.on_start()
Start the server, linked to the calling process.
@spec subscribe(GenServer.server(), pid()) :: :ok
Subscribe a transport process to server notifications.
The subscriber receives {:mcp_notification, notification_map} messages.
Automatically unsubscribes when the subscriber process exits.
@spec subscribe(GenServer.server(), pid(), conn_id()) :: :ok
Subscribe a transport process as a named connection.
The id is what later elicitation prompts and results are addressed to, so it
must be the same value the transport passes to handle_message/3 for that
client's requests.