# MCP Surface
This module implements the MCP stdio server for `agentmux`.
## Responsibilities
- Advertise and handle MCP tools:
- `list` (requires `command="principals"` or `command="decisions"`; for
`principals`, `args.namespace` selects the scope: omitted/home bundle, a
bundle name, `GLOBAL`, or `*` for fan-out; `decisions` lists the bundle's
pending ACP choice queue)
- `help`
- `look`
- `choose` (submit an ACP-native choice decision)
- `updown` (requires `command="up"` or `command="down"`)
- `new` (requires `command="peer"`)
- `change` (requires `command="psk"`)
- `raww`
- `send`
- Preserve canonical relay `list` and `look` success payloads without adapter
reshaping:
- `list`: relay `ListedBundle` (including the `hosted` flag, per-session
`ready` flag, `state_reason_code` / `state_reason`, and `startup_failure_*`
fields) passes through unchanged. For the unreachable home bundle the
server synthesizes a `Down` bundle with `hosted: false` and `ready: false`
on every configured session, and a relay-unavailable `GLOBAL` view
synthesizes an empty `Down` bundle. Both synthetic shapes carry
`state_reason_code` / `state_reason` so callers can distinguish
`not_started` (no socket) from `relay_unavailable` (socket present but
relay not responding).
- `look` tmux: `snapshot_format="lines"` + `snapshot_lines`.
- `look` ACP: `snapshot_format="structured_entries_v1"` + `snapshot_entries`
plus `entries_total` (full replay-buffer size), `returned_entries_count`
(entries in this window — callers can detect truncation and bound
backward walks), `freshness`, `snapshot_source`, `stale_reason_code`, and
`snapshot_age_ms` (the last two are only populated when the snapshot is
degraded).
- `look` windowing parameters (both forwarded to the relay):
- `lines` (optional): window size. tmux pane lines (default 120) or ACP
replay entries (default 50 — ACP entries are far larger than tmux lines,
so the smaller default keeps responses under the MCP payload limit).
Validated locally to be in `[1, 1000]`; out-of-range values surface as
`validation_invalid_lines` with the rejected `lines` and the configured
`min` / `max` bounds in the details.
- `offset` (optional): entries to skip from the newest end before the
tail-N window, for walking backward through older ACP context. ACP
targets only; a non-zero `offset` against a tmux target is rejected
with `validation_offset_unsupported`.
- `send` and `look` both surface sender-attribution fields when the relay
populates them:
- `authenticated_identity` (optional): the relay-verified `principal_id`
of the caller, present only for store-backed credential connections
and omitted for socket-trust sessions.
- `on_behalf_of` (optional): the delegated principal the caller is
acting for. Reserved for a future delegated-identity delta; surfaced
in the schema so callers can consume it without a breaking change.
Both follow the relay's `skip_serializing_if` semantics: omitted
entirely (not `null`) when absent.
- Validate MCP request payloads before relay submission.
- Forward valid requests to the relay over the bundle Unix socket, using a
single long-lived `RelayStreamSession` (Hello-registered at server
construction) so per-tool calls share the connection. Fan-out for
`list.principals` with `namespace="*"` reuses that same connection per
bundle relay.
- Preserve relay error taxonomy / details when relay returns structured
errors.
- Emit MCP inscriptions for request, success, and error events. Every tool
emits `mcp.tool.<name>.<phase>` (request / success / relay_error /
unexpected_response / io_error) plus `mcp.tool.stream.events_ignored`
when the relay returns stream events that the MCP layer does not
surface.
## Data Flow
1. MCP client calls `list`, `look`, `choose`, `updown`, `raww`, or `send`.
2. MCP client can call `help` to discover tool and command schemas. The
`help` tool does not reach the relay — it composes a JSON response
from the in-memory tool catalog and the parameter schemas in
`params.rs`.
3. `src/mcp/validation.rs` rejects invalid request shapes (empty
`target_session`, empty `message` on `send`, conflicting
`broadcast` / `targets` on `send`, lines outside `[1, 1000]` on
`look`, etc.) before any relay work begins.
4. The per-tool handler in `src/mcp/server/handlers/<tool>.rs` emits a
`mcp.tool.<tool>.request` inscription, resolves the requester session
from `McpState.configuration.sender_session` (or surfaces
`validation_unknown_sender` if the MCP server is unassociated), and
builds a `RelayRequest`.
5. For `send`, `qualify_send_targets` fills in the `@<namespace>` suffix
the relay requires on every target: a target that already carries
`@<namespace>` passes through verbatim; a bare target is qualified
with the MCP server's bound bundle; a bare target on a relay-wide
(unassociated) MCP server has no namespace to borrow and is rejected
as `validation_unqualified_target` rather than silently borrowing
one. `look` and `raww` do not qualify: the caller-supplied
`target_session` must already carry `@<namespace>`.
6. Request is forwarded as a relay contract:
- `list` (`command="principals"`) -> `RelayStreamSession`
(`RelayRequest::List`). For `args.namespace="*"` the MCP server
fans out across configured bundle relays in deterministic
lexicographic order via `list_sessions_all_bundles`; a `GLOBAL`
namespace routes through `list_global_sessions`, which pins the
wire-envelope namespace and surfaces a synthetic empty `Down`
bundle when the relay is unavailable. A `GLOBAL` view is appended
to the response regardless of the requested namespace.
- `list` (`command="decisions"`) -> `RelayStreamSession`
(`RelayRequest::ChoicesList`)
- `look` -> `RelayStreamSession` (`RelayRequest::Look`)
- `choose` -> `RelayStreamSession` (`RelayRequest::ChoicesPick`)
- `updown` (`command="up"`) -> `RelayStreamSession`
(`RelayRequest::Up`)
- `updown` (`command="down"`) -> `RelayStreamSession`
(`RelayRequest::Down`)
- `new` (`command="peer"`) -> `RelayStreamSession`
(`RelayRequest::NewPeer`)
- `change` (`command="psk"`) -> `RelayStreamSession`
(`RelayRequest::ChangePsk`)
- `raww` -> `RelayStreamSession` (`RelayRequest::Raww`)
- `send` -> `RelayStreamSession` (`RelayRequest::Send`)
7. Relay response is mapped back to MCP JSON payload. The handler
matches on the `RelayResponse` variant: the success variant is
mapped to a JSON object (the relay's payload preserved per the
Responsibilities section); `RelayResponse::Error` flows through
`map_relay_error`; any other variant triggers
`internal_unexpected_failure` with the `response` shape preserved
in the details. IO errors are routed through
`map_relay_stream_failure`, which emits the typed
`relay_timeout` / `relay_unavailable` / `internal_unexpected_failure`
codes with the relay socket path and the `io_error_kind` in the
details.
## Bundle Updown
- The `updown` tool administers bundle runtime state for the associated
bundle:
- `command="up"` requests `RelayRequest::Up` (host the bundle).
- `command="down"` requests `RelayRequest::Down` (unhost the bundle).
- Requests ride the MCP server's long-lived `RelayStreamSession`; the
relay authorizes the caller-session principal carried by the
Hello-established connection against the `updown` policy control
(deny by default).
- The MCP tool only addresses the MCP server's associated bundle;
cross-bundle administration requires a separate relay connection
and is out of scope.
- Relay returns `RelayResponse::BundleTransition` on success; the MCP
response forwards `schema_version`, `action`, `bundles`,
`changed_bundle_count`, `skipped_bundle_count`,
`failed_bundle_count`, and `changed_any` unchanged.
- Relay `authorization_forbidden` (capability `updown`) surfaces as a
typed MCP tool error with the relay error code and details preserved.
## Choice Decisions
- The relay ACP choice queue is split across two tools:
- `list` with `command="decisions"` polls the bundle-scoped
pending-request set; the response payload mirrors the
`choices.requested` event fields exactly.
- `choose` submits a decision (`outcome="selected"` requires
`option_id`; `outcome="cancelled"` rejects `option_id`).
- `choose` rejects caller-supplied sender-identity fields
(`decided_by`, `ui_session_id`, `operator_session_id`) before relay
submission; the deciding identity is association-derived and
relay-stamped.
- Both surfaces are gated solely on the sender session's `choose`
policy capability (the transport's `can_give_choices` flag is not
required). The MCP server forwards the request over its relay
stream, and the relay authorizes it against the bundle policy
preset. Sessions whose policy does not enable `choose` receive the
relay submitter-gate rejection.
## Credential Administration
- The `new` tool (`command="peer"`) registers a principal and mints
its PSK: the relay generates the PSK, stores only its SHA-256 hash,
and returns the raw value once (or, with `args.output_path`, writes
it to that absolute path and omits it from the response). `args.scope`
is recorded on the principal (set for `@RELAY` / `@EXTERNAL`).
- The `change` tool (`command="psk"`) rotates an existing principal's
PSK and returns the new value; Slice 1 is a store update only
(no revocation dispatch).
- Both are relay-wide operations: they ride the MCP server's relay
stream, and the relay authorizes the connection's principal
against its policy preset relay-wide, requiring an `all`
`new.peer` / `change.psk` grant. A bundle-relative `home` grant is
insufficient. The MCP server's own identity must therefore carry
an operator policy for these tools to succeed.
## Key Types
- `McpConfiguration`
- startup configuration for runtime roots, bundle paths, and sender
session identity. Constructed once at `McpServer::new()` and held
in `McpState.configuration`.
- `McpState`
- runtime state shared across all per-tool handlers on a single
`McpServer` instance: the original `McpConfiguration` plus a
`Mutex<Option<RelayStreamSession>>`. The `relay_stream` slot is
`Some` when both `sender_session` and `associated_bundle_paths`
were configured at server construction (the normal
bundle-associated case) and `None` for relay-wide (unassociated)
MCP servers.
- `McpServer`
- `pub(crate) state: Arc<McpState>` plus a composed
`ToolRouter<Self>`. The router is built once in
`McpServer::new()` by adding the nine per-tool routers in
registration order — `tool_router_list + tool_router_help +
tool_router_send + tool_router_look + tool_router_raww +
tool_router_choose + tool_router_updown + tool_router_new +
tool_router_change` — via the `Add` impl on `ToolRouter`.
- `SendParams`, `ListParams`, `LookParams`, `ChooseParams`,
`UpdownParams`, `NewParams`, `ChangeParams`, `RawwParams`,
`HelpParams`
- MCP tool parameter and meta-tool argument schemas. See
`params.rs` for the full shape, and `help query="<tool>"` for
the runtime-rendered JSON schema. Each struct uses
`#[schemars(deny_unknown_fields)]` and a flatten-captured
`extra_fields: BTreeMap<String, Value>` so unknown fields are
surfaced as `validation_invalid_params` rather than silently
dropped.
## Module Layout
- `mod.rs`
- Module declarations and public re-exports.
- `server/`
- Pure directory module holding the MCP server surface. See
`server/README.md` for the directory's contract.
- `server/mod.rs`
- Operator-hub: only `mod` declarations and re-exports. Re-exports
`McpConfiguration` and `run` from `service`; `McpServer` is
`pub(crate)`-visible to the per-tool handlers in `handlers/`.
- `server/service.rs`
- `McpConfiguration`, `McpState`, `McpServer`, the shared relay
helpers (`request_relay`, `request_relay_with_namespace`,
`map_relay_stream_failure`, `associated_namespace`), the
`#[tool_handler(router = self.tool_router)] impl
rmcp::ServerHandler for McpServer` block (provides the MCP
`get_info` capability advertisement and tool dispatch), and
`pub async fn run`. `McpServer::new()` composes the per-tool
routers via the `Add` impl on `ToolRouter`.
- `server/handlers/`
- One file per MCP tool: `list.rs`, `help.rs`, `send.rs`,
`look.rs`, `raww.rs`, `choose.rs`, `updown.rs`, `new.rs`,
`change.rs`. See `handlers/README.md` for the per-tool pattern
and tool inventory. The `pub(super) state` field on
`McpServer` (defined in `service.rs`) is visible from these
sibling impl blocks.
- `params.rs`
- MCP tool parameter and meta-tool argument schemas plus shared
command constants.
- `validation.rs`
- request-shape validation, meta-tool argument parsing,
`qualify_send_targets` for `send`, and relay I/O error
classification helpers (`is_relay_unavailable_error`,
`is_relay_timeout_error`).
- `help.rs`
- help catalog responses and generated JSON schemas. Composed at
runtime by `help_tool(params)` and surfaced through the `help`
MCP tool.
- `errors.rs`
- relay / configuration / runtime error mapping and MCP error
payload helpers. The MCP error payload shape is
`{code, message, details}` for both `validation_tool_error` and
`internal_tool_error`.
## Validation and Error Policy
- MCP rejects invalid request shapes before relay submission (for
example empty `target_session`, empty `message` on `send`,
conflicting `broadcast` / `targets` on `send`, `lines` outside
`[1, 1000]` on `look`).
- MCP rejects unknown top-level tool fields and unknown meta-tool
`args` fields with `validation_invalid_params`; error details
include the rejected field paths (for example
`args.bundle_name_typo`).
- `choose` enforces the `selected` / `cancelled` outcome contract: a
`selected` outcome without an `option_id` is rejected with
`validation_invalid_params`; a `cancelled` outcome with an
`option_id` is rejected with `validation_invalid_params`; caller
-supplied sender-identity fields (`decided_by`, `ui_session_id`,
`operator_session_id`) are rejected as unknown parameters.
- `send` enforces target qualification via `qualify_send_targets`:
a bare target on a relay-wide (unassociated) MCP server is
rejected with `validation_unqualified_target` rather than
silently borrowing a namespace.
- Help schemas set `additionalProperties=false` for documented
request shapes.
- MCP does not perform shadow authorization checks.
- Relay `authorization_forbidden` is surfaced as a validation-shaped
MCP tool error (preserving the relay code and details) via
`map_relay_error`. Other `validation_*` relay codes pass through
the same path. Non-validation relay codes surface as
`internal_unexpected_failure` with the relay code in the
details.
- Relay stream I/O errors are classified by
`map_relay_request_failure`:
- `std::io::ErrorKind::TimedOut` -> `relay_timeout` (with
`relay_socket`, `io_error_kind`, and `cause` in details).
- `std::io::ErrorKind::{NotFound, ConnectionRefused,
ConnectionAborted, ConnectionReset, BrokenPipe,
UnexpectedEof}` -> `relay_unavailable` (with the same detail
fields).
- everything else -> `internal_unexpected_failure` with
`relay_socket`, `io_error_kind`, and `cause` in details.
## Event Handling Note
- Relay may return stream events alongside direct responses.
- Current MCP behavior logs these events via the
`mcp.tool.stream.events_ignored` inscription (with `namespace`
and `count`) and ignores them at tool-response level.