# `Raxol.MCP.Server`
[🔗](https://github.com/DROOdotFOO/raxol/blob/v2.8.0/lib/raxol/mcp/server.ex#L1)

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.

# `authorizer_source`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@type t() :: %Raxol.MCP.Server{
  authorizer: Raxol.MCP.Authorizer.t() | nil,
  authorizer_source: authorizer_source(),
  callback_timeout_ms: pos_integer(),
  caller_refs: %{required(reference()) =&gt; reference()},
  client_capabilities: %{required(conn_id()) =&gt; {integer(), map()}},
  elicitation_timeout_ms: pos_integer(),
  in_flight: %{required(reference()) =&gt; map()},
  in_flight_counts: %{required(conn_id()) =&gt; 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()) =&gt; pos_integer()},
  pending_elicitations: %{required(String.t()) =&gt; pending_elicitation()},
  read_authorizer: Raxol.MCP.Authorizer.t() | nil,
  registry: GenServer.server(),
  resource_subscriptions: %{required(String.t()) =&gt; boolean()},
  server_version: String.t(),
  subscribers: %{required(conn_id()) =&gt; pid()},
  transport: transport(),
  unknown_tool_reply: (String.t() -&gt; String.t() | nil) | nil,
  worker_refs: %{required(reference()) =&gt; reference()}
}
```

# `transport`

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

# `authorization_configured?`

```elixir
@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`

```elixir
@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`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `handle_message`

```elixir
@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`

```elixir
@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`

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

Send a notification to all subscribed transports.

# `read_authorization_configured?`

```elixir
@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`

```elixir
@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`

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

Start the server, linked to the calling process.

# `subscribe`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
