Raxol.MCP.Server (Raxol MCP v2.8.0)

Copy Markdown View Source

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 and serverInfo (name "raxol"; version the :server_version option, default this package's version)
  • notifications/initialized -- client acknowledgement (no reply)
  • ping -- health check
  • tools/list -- list the registered tools this caller may call
  • tools/call -- invoke a tool
  • resources/list -- list registered resources
  • resources/read -- read a resource
  • prompts/list -- list registered prompts
  • prompts/get -- render a prompt with arguments
  • logging/setLevel -- set server log level
  • completion/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.

t()

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

authorizer_source()

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

conn_id()

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

pending_elicitation()

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

t()

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

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

authorization_configured?(server \\ __MODULE__)

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

cancel(server, request_ref)

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

handle_message(server \\ __MODULE__, message)

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

handle_message(server, message, conn_id, timeout \\ :infinity)

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

notify(server \\ __MODULE__, method, params \\ %{})

@spec notify(GenServer.server(), String.t(), map()) :: :ok

Send a notification to all subscribed transports.

read_authorization_configured?(server \\ __MODULE__)

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

request(server, message, conn_id \\ :default)

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

start_link(init_opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Start the server, linked to the calling process.

subscribe(server \\ __MODULE__, pid)

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

subscribe(server, pid, conn_id)

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