Raxol.MCP.Server (Raxol MCP v2.7.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
  • 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.

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.

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.

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

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

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.

t()

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

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.

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

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.

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.