Raxol.MCP.Client.Transport behaviour (Raxol MCP v2.8.0)

Copy Markdown View Source

The seam between Raxol.MCP.Client's one session machine and a wire.

ADR-0037 decision 1. Before it, the client was a JSON-RPC session machine welded to a port: the config, the connect, the write, the read and the teardown each assumed stdio, and roughly 60 of 395 lines were the part that could not reach a URL. Everything else -- pending, next_id, status, the tool cache and every handle_result/3 branch -- is transport-independent and stays in the client. A transport owns connection, framing and delivery, and nothing else.

Why send/3 carries the id and returns a handle

An HTTP request is a round trip, not a write. A transport has to associate a response with the request that caused it, and it has to be able to fail ONE in-flight request without failing the connection, which is the {:failed, id, reason, handle} arm of decode_info/2.

A stdio transport ignores the id on the way out: one pipe carries every request and the id only matters on the way back.

{:notify, id} is a notification: encoded with no JSON-RPC id, because nothing answers one, and reported under id all the same. It is still a request in flight -- one POST on the session -- so the client holds an entry for it and the transport says when it is done. A bare nil id is the same notification with nothing tracking it, which no in-tree caller uses any more.

Why cancel/2 exists

The client's own call_timeout can expire while the transport is still working: an HTTP exchange's wall time is the dial plus the read deadline plus a chunk overshoot, which together exceed the default 30 s. The entry leaves pending at that point, so without cancel/2 the next queued request is admitted BESIDE a task that is still holding a socket -- two concurrent requests on a :serialized session. cancel/2 drops the work for one id and returns the handle; for a transport with nothing to cancel it is the identity.

Why the request is a map rather than iodata()

ADR-0037 decision 1 specifies send(handle, id, iodata()), and decision 2's own table then requires Mcp-Method and Mcp-Name headers on every modern-era post. A transport handed opaque iodata cannot set either header without re-parsing the body it was just given, so the third argument is the request as %{method:, params:} (plus an optional :reservation) and encoding belongs to the transport, which is also where framing already lives. One encoder per framing, not one encoder plus one parser.

Why session/1 exists

A modern-era origin is specified not to answer initialize, and a legacy one requires it. The client cannot know which without asking, and it must know before it sends anything: offering a handshake to a modern server leaves the client in :initializing forever. session/1 is answered after connect/1 and reports the negotiated session profile: whether to handshake, the protocol revision in force for this connection, and the concurrency policy the origin's era implies (ADR-0037 decision 6). A spec that declares :concurrency overrides the last of those.

Why decode_info/2 has :closed and :settled arms

ADR-0037 lists three returns. A fourth is forced by the code the same decision requires to keep working unchanged: {:exit_status, code} fails EVERY pending entry and marks the session closed (client.ex:236-244 before this change), which neither {:messages, _, _} nor a single-id {:failed, id, _, _} can express. A fifth, {:settled, id, handle}, is what a notification's completion is: a request that finished with nothing to deliver, whose in-flight slot must still be released. :ignore preserves the catch-all clause that was at client.ex:246.

Why respond/3 exists

Every other callback carries work the CLIENT started. respond/3 carries the one message the server starts: a JSON-RPC request (id + method), which the spec obliges the receiver to answer. The client answered none of them -- handle_message/2 dropped anything with a method -- so a server that sent elicitation/create waited on a response that was never coming while the session looked healthy from both ends.

method is passed alongside the response because the modern era routes every POST on Mcp-Method, and the routing key for a response is the method it answers. A stdio transport ignores it, exactly as it ignores the id in send/3.

Summary

Types

Concurrency policy for one origin (ADR-0037 decision 6).

Opaque per-connection transport state, threaded back through every call.

One outbound JSON-RPC message.

The negotiated session profile, answered once after connect/1.

Functions

Whether a spec of this provenance may reach target -- the URL a remote spec dials, or the command a stdio spec spawns.

A spec with every credential-bearing value replaced by "[redacted]".

Pick the transport a spec asks for: :command XOR :url.

Types

concurrency()

@type concurrency() :: :stateless | :pooled | :serialized

Concurrency policy for one origin (ADR-0037 decision 6).

handle()

@type handle() :: term()

Opaque per-connection transport state, threaded back through every call.

request()

@type request() :: %{
  :method => String.t(),
  :params => map(),
  optional(:reservation) => term()
}

One outbound JSON-RPC message.

:reservation is the spend-gate handle for a priced tool call. Its ABSENCE on a priced tool is what Transport.Http refuses; its value is opaque here.

session()

@type session() ::
  {:handshake | :ready, %{version: String.t(), concurrency: concurrency()}}

The negotiated session profile, answered once after connect/1.

Callbacks

cancel(handle, arg2)

@callback cancel(handle(), pos_integer() | {:notify, pos_integer()}) :: handle()

close(handle)

@callback close(handle()) :: :ok

connect(config)

@callback connect(config :: map()) :: {:ok, handle()} | {:error, term()}

decode_info(handle, message)

@callback decode_info(handle(), message :: term()) ::
  {:messages, [binary()], handle()}
  | {:failed, pos_integer(), term(), handle()}
  | {:settled, pos_integer(), handle()}
  | {:closed, term(), handle()}
  | :ignore

respond(handle, method, response)

@callback respond(handle(), method :: String.t(), response :: map()) ::
  {:ok, handle()} | {:error, term()}

send(handle, arg2, request)

@callback send(handle(), pos_integer() | nil | {:notify, pos_integer()}, request()) ::
  {:ok, handle()} | {:error, term()}

session(handle)

@callback session(handle()) :: session()

Functions

permit(config, target)

@spec permit(map(), term()) :: :ok | {:error, {:blocked, :source}}

Whether a spec of this provenance may reach target -- the URL a remote spec dials, or the command a stdio spec spawns.

A spec carries :source (:user when absent: a spec assembled in code is the operator's own program) and, for anything else, a :permit function of arity one that answers :ok or an error for that target. The rule the function enforces is not this package's to know: Raxol.Agent.McpHosts owns it, because the allowlist it reads is an operator file in ~/.raxol and .mcp.json is parsed one layer up.

This is asked INSIDE each connect/1, not only by the caller that built the spec. Raxol.Agent.McpBundle asks first, for the operator-facing refusal; a future caller that maps a workspace spec straight to Raxol.MCP.Client.start_link/1 gets the check anyway, because the socket and the subprocess are here.

A refusal is {:blocked, :source} whatever the gate said. The gate's own reason names the host or the command, which this package's closed error taxonomy does not carry, and the caller that supplied the gate has already logged it.

redact(config)

@spec redact(map()) :: map()

A spec with every credential-bearing value replaced by "[redacted]".

Public because the client reports refused specs too, and one redaction is better than two.

Four keys, not one. :headers was the whole of it, and a STDIO spec carries none -- so the fall-through clause handed back :env, which is where .mcp.json puts GITHUB_TOKEN, and :args, which is where a CLI server takes --token=. :url was never redacted on either leg, so ?api_key= and user:pass@host rode out with it.

Names survive, values do not: an operator reading {:invalid_spec, _} needs to see WHICH variable and how many arguments, and neither is the secret. The URL keeps scheme, host and path for the same reason -- that is the target, and the userinfo and query are the credential.

select(spec)

@spec select(map() | keyword()) :: {:ok, module(), map()} | {:error, term()}

Pick the transport a spec asks for: :command XOR :url.

Both keys, or neither, is {:error, {:invalid_spec, spec}} rather than a silent preference for one. A remote spec in a build without mint is {:error, :no_http_client}, which Raxol.Agent.McpBundle.load/2 already handles as a per-server fail-open skip.

The refused spec is echoed back through redact/1, because a spec carries resolved credentials and an error term is one of the four places ADR-0033 section 7 names as where they leak.