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 capabilitiesnotifications/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.
The shape matters, because the transport seam is synchronous. tools/call
returns nil immediately and the call is parked:
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 this from deadlocking: the transport's
handle_message does not block, so it stays free to READ the client's
answer. Both the prompt and the eventual response ride the subscriber
channel, so no transport change was needed.
A parked call is always closed, exactly once, by one of four paths: an
approval (runs the tool), a decline/cancel/unapproved accept, an error
response, or a timeout (:elicitation_timeout_ms, default 60s). Every path
but approval resolves to the same deny -- silence is not consent.
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 one is already registered, and denies at
tools/call for one registered afterwards -- the boot check alone would be
bypassable by registering late.
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.
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.
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.
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() }
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.
@type t() :: %Raxol.MCP.Server{ authorizer: Raxol.MCP.Authorizer.t() | nil, authorizer_source: authorizer_source(), client_capabilities: %{required(conn_id()) => map()}, elicitation_timeout_ms: pos_integer(), initialized: boolean(), log_level: :debug | :info | :notice | :warning | :error | :critical | :alert | :emergency, pending_elicitations: %{required(String.t()) => pending_elicitation()}, read_authorizer: Raxol.MCP.Authorizer.t() | nil, registry: GenServer.server(), resource_subscriptions: %{required(String.t()) => boolean()}, subscribers: %{required(conn_id()) => pid()}, transport: transport() }
@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.
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).
@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 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.