# mcp

## Purpose

Loom's whole client side of the Model Context Protocol (issue #106):
JSON-RPC 2.0 and MCP codecs, newline-delimited stdio framing, the
supervised actor that owns one server process over a transport seam, and
the generator that turns a server's `tools/list` into a `cap/mcp/<server>`
Gleam module plus the description surface `code_mode` carries for it. MCP
reaches a model through code mode only — per-server generated modules,
never a registered harness tool and never a generic dispatcher.

**This package generates source text and never compiles it.**
`codegen.generate` runs in the harness VM, at boot, once per configured
server, and hands back a `Generated` whose `source` the caller holds in
memory. Compiling that text is a different verb in a different place:
once per code-mode execution, inside that execution's jailed hermetic
build, and only for the servers the submitted program imported. Nothing
here should grow a compiler, a build root, or a filesystem write —
`docs/architecture/mcp.md` §*Generated at boot, compiled per execution*
is the account of the split.

What is *not* here either: reading `[mcp.<name>]` out of `loom.toml`
(`client/catalog`), starting a client per configured server, widening
the workspace seam, and routing `mcp.<server>` capability calls. That is
harness wiring and it lives in `client/mcp`; this package is the
protocol, the client, the generator and the value translation it is
built out of.

## Key Types

- `mcp/jsonrpc.{Id, Inbound, RpcError, MessageFault}` — the envelope. We
  mint `IdInt`; the decoder accepts `IdString` too because the peer picks
  its own ids. `Inbound` is discriminated the way JSON-RPC discriminates:
  `Response(id, Result(JsonValue, RpcError))` for a message with no
  `method`, `ServerRequest(id, method, params)` for a method with an id,
  `Notification(method, params)` for one without. A hostile line settles
  as `MalformedMessage(CorruptionReport)` or `BadMessage(reason)`.
- `mcp/protocol.{requested_version, supported_versions}` — `"2025-06-18"`
  asked for, and the closed newest-first list accepted (`2025-06-18`,
  `2025-03-26`, `2024-11-05`).
- `mcp/protocol.{InitializeResult, ToolsCapability, ToolDescriptor,
  ToolsPage, CallToolResult, ContentBlock, ProtocolFault}` — the decoded
  handshake (its `protocol_version` is a member of `supported_versions`
  by construction), one listed tool with its `input_schema` carried
  **raw**, one `tools/list` page with its optional `next_cursor`, and a
  `tools/call` result whose `Text` blocks survive and whose every other
  block kind reduces to `Other(kind)`.
- `mcp/stdio.{Buffer, push, frame, max_line_bytes, FramingFault}` — line
  framing both ways. `push` feeds a chunk in and returns completed lines;
  `frame` renders compact single-line JSON plus `\n`; a line past 16 MiB
  is `LineTooLong`.
- `mcp/transport.{Transport, PortTransport, ChannelTransport, Spawn,
  Connection, TransportEvent, open, utf8_prefix}` — the seam the client
  is written against. `Spawn` is executable plus argv (never a shell
  string), explicit env pairs, optional cwd. `utf8_prefix` splits a byte
  chunk into its valid-UTF-8 prefix and a held tail of at most three
  bytes, since the pipe cuts characters in half.
- `mcp/client.{Client, Options, Msg, ClientError, StartError, RetirementError,
  prepare, prepare_owned, connect, start, shutdown, list_tools, call_tool, stop}` — the
  state machine. `Client` and `Msg` are both
  opaque, so nothing outside can forge a settlement or an expiry.
  `ClientError` is `Unavailable | CallTimedOut | ServerError |
  ResultMalformed | TooManyPages`; `StartError` is `TransportFailed |
  HandshakeFailed | VersionUnsupported | ToolsNotDeclared |
  CleanupUnconfirmed(Client)`. `shutdown` returns `RetirementTimedOut` or
  `RetirementUnconfirmed` when explicit retirement cannot be established.
- `mcp/schema.{Plan, Typed, WholeValue, Param, ParamType, Optional,
  plan}` — the total three-tier reading of a raw `inputSchema`.
  `Simple`/`ListOf` are the typed subset, `Structured(reason)` is
  anything else required, `WholeValue(reason)` is an unusable top level.
  No parameter is ever dropped.
- `mcp/name.{mangle, mangle_label, first_collision}` — a server-chosen
  name into a Gleam identifier, with the injected
  `digest: fn(String) -> String` (lowercase hex over UTF-8 bytes;
  SHA-256 in production) supplying the eight-character suffix.
- `mcp/interchange.{InterchangeFault, to_json, to_msgpack, describe,
  max_msgpack_int, min_msgpack_int}` — the value translation between the
  capability wire and the MCP wire, total both ways. A msgpack integer
  always fits `core/json.Int`; a JSON integer outside `[-2^63, 2^64 - 1]`
  fails the **whole** conversion rather than being wrapped or clamped; a
  msgpack binary and a non-string map key are refused in an argument
  (JSON has neither, and both encodings a caller might expect are
  guesses); `NilValue` and `Null` are each other. A fault names the path
  it was found at. Depth needs no ceiling here: both parsers bound
  nesting at the same `max_depth`.
- `mcp/codegen.{Generated, GenerateError, generate, describe, sanitize,
  truncate, escape, scan_for_at}` — the generator.
  `Generated(module_name, source, surface)`; `GenerateError` is
  `TooManyTools | ToolNameCollision | SurfaceTooLarge | SanitizerBreach`.

## Relationships

- **Depends on**: `gleam_stdlib`; `core` (the `JsonValue` ADT with its
  total parser and serializer, and `CorruptionReport`); `gleam_erlang`
  (`Subject`, `Selector`, `Port`, monitors — the client actor and the
  port transport); `gleam_otp` (actor start errors); `weft` (the lifecycle
  state machine and monotonic deadline clock). `mcp/{jsonrpc, protocol, stdio,
  schema, name, codegen, interchange}` import none of the last two and are pure
  functions of their arguments, but the package as a whole is impure and
  is **not** in the portable subset lint R6 gates.
- **Depended on by**: `client`, through `client/mcp` — the wiring that
  starts a client per configured server, generates its module, widens
  the workspace seam's allowlist and description, and answers
  `mcp.<server>` capability calls. `packages/cap` is the counterpart the
  generated modules import, and this package does not depend on it (the
  generator emits import lines as text).
- **FFI**: `mcp/internal/ffi_port` over `src/mcp_ffi.erl` — the package's
  complete inventory of impurity. `erlang:open_port/2` with
  `spawn_executable` (binary stream mode, `exit_status`, deliberately no
  `stderr_to_stdout`), `port_command/2`, `port_info/2`
  for the OS pid, `os:cmd` running `kill -KILL`, and the shim that takes
  a raw port message apart into `PortBytes | PortClosed | PortJunk`.
  There is deliberately **no** `port_close/1` here, where `broker`'s
  sibling module has one: see the SIGKILL-only invariant below.

## Traffic

- **Actor messages**: `mcp/client.Msg`, all sent to the one client actor.
  `Request(build, deadline_ms, reply)` is a call — `build` receives the
  actor-minted `Id` and returns the whole JSON-RPC message — sent by
  `start`'s handshake, `list_tools` and `call_tool`. `Notify(message)` is
  a cast, used for `notifications/initialized`. `FromTransport(event)`
  arrives from the port selector or a `ChannelTransport` peer.
  `Expire(id)` is the actor's own `process.send_after` timer. `Open(reply)`
  opens a parked client after its cleanup handle has been published.
  `Shutdown` is a request-only cast from `stop`; `Retire(reply)` waits
  through `Closing` until `TransportClosed`, returns explicit evidence,
  and then stops normally. A parked client returns explicit no-resource
  evidence without ever opening a transport.
- **Commits**: none. Nothing here touches a session store.
- **Registers**: none.
- **Wire**: the MCP stdio wire — newline-delimited JSON-RPC 2.0, one
  message per line, `\r\n` tolerated inbound, written to the child's
  stdin and read from its stdout. v1 sends exactly `initialize`,
  `notifications/initialized`, `tools/list`, `tools/call`, `ping`, and
  the `-32601` error response to any server-initiated request.
  `mcp/stdio.frame` is the only place the bytes-on-the-wire shape is
  decided.
- **Generated artifacts**: `codegen.generate` returns Gleam source for
  `cap/mcp/<segment>` — importing `cap/internal/mcp as internal`,
  `cap/mcp`, `cap/report`, and `gleam/list` only when a body needs it —
  and a surface rendered in `scripts/gen-prelude.py`'s one-line
  `label: Type` form. Both are values, not files: this package writes
  nothing to disk. `client/mcp` holds the source for the session, and
  `codemode/build` writes it into one execution's build root, after the
  seed clone and before `gleam build`.

## Invariants

- **Publish before third-party execution.** `prepare` allocates a parked
  state machine, never a native process. The harness publishes every prepared
  client in one immutable cleanup census before `connect` opens any server.
  Failed handshakes and listings remain in that census, even though they do
  not contribute tools to the successful layer. `prepare_owned` monitors a
  custodian independently from the startup builder. Builder loss leaves a
  parked client's no-resource proof available to that custodian; loss of both
  lifetimes does not leave an unclaimed parked actor. An active client whose
  custodian dies still waits for real native exit before stopping.
- **Termination requests are not retirement evidence.** The transport keeps
  the exact port open while requesting its current child's termination.
  `shutdown` requires either explicit no-resource evidence or that port's
  native `TransportClosed`, followed by the original normal actor `DOWN`.
  Both receives share one deadline. A timeout or unexpected actor death
  returns uncertainty; it cannot authorize replacement. Existing unjailed
  MCP servers are operator-trusted: PID lookup and SIGKILL have a TOCTOU
  window, and native exit proves neither descendant drain nor rollback of
  remote effects. This is not the sandbox helper's containment contract.
- **Shutdown is SIGKILL only, and the missing stdin EOF is the price of
  the witness.** A stdio server's documented shutdown signal is EOF on
  its stdin, and `erlang:port_close/1` is the only way a BEAM port can
  deliver one — but it destroys the port, and with it the `exit_status`
  message the invariant above rests on. The two cannot both be had
  without an FFI shim that half-closes the child's stdin, which
  `open_port/2` has no supported way to do, so custody wins and
  `mcp/transport.Connection`'s doc carries the argument. The package
  therefore declares no port-closing external at all: an unused one
  would read as an oversight and invite its restoration.
- **Every decoder is total.** Wrong `jsonrpc`, missing fields, wrong
  types anywhere, a float or null id, `result` and `error` both present
  or both absent, a non-object list entry, an oversized line — each
  settles as `MessageFault`, `ProtocolFault` or `FramingFault`, never a
  crash. `mcp/schema.plan` goes further and has no error case at all:
  tier 3 *is* the failure mode, carried as data.
- **Strict envelope, tolerant content** (the `client/protocol` posture).
  Discriminators are refused when wrong; unknown extra fields inside
  known shapes are ignored; `params`, `result`, error `data`, tool input
  and output schemas and `structuredContent` cross raw and
  uninterpreted, to be treated downstream as untrusted data.
- **The declared client capabilities are an empty object, and that is a
  security decision.** No `sampling` (a server must not spend our model),
  no `roots` (a server learns nothing about the filesystem), no
  `elicitation` (a server must not put questions to a human through us).
  A test pins the emptiness, so widening it is a deliberate,
  test-breaking act.
- **A negotiated version outside `supported_versions` is refused** as
  `UnsupportedVersion` carrying both sides — never a connection limping
  along on a revision this client cannot speak.
- **One lying tool fails the whole listing.** A `tools/list` entry that
  is not an object, lacks a non-empty string `name`, or lacks its
  `inputSchema` fails the entire page with a fault naming the index: a
  server lying about one tool is not a server to half-trust.
- **Wire names travel verbatim.** Every generated body closes over the
  original tool name and the original parameter names as escaped string
  literals; `mcp/name`'s output is a display artifact. Renaming can never
  change what crosses the wire, and `escape` is total — `\` and `"`
  escaped, every codepoint outside printable ASCII emitted as `\u{...}` —
  so server text reaches the module only as inert literal content.
- **Mangling digests on any change, and a residual collision refuses the
  server.** Whenever mangling altered anything (or the name ran past 32
  characters) the result carries eight hex characters of
  `digest(original)`, so `createIssue` and `create_issue` stay distinct.
  What survives that is byte-identical originals or an engineered digest
  near-miss, and `codegen` refuses the whole module naming both
  originals rather than repairing it. A *label* collision inside one tool
  degrades that one function to its whole-value form instead.
- **The sanitizer cage plus the `@` backstop.** Server text bound for a
  doc comment loses every control and direction-changing codepoint and is
  capped (400 characters for a description, 120 for a note); every
  comment line the generator writes begins `/// `, and every line break
  comes from the generator's own wrap. `scan_for_at` then proves it held:
  generated code needs no attribute, so an `@` outside a comment or a
  string literal means the sanitizer failed, and generation fails loudly
  rather than handing the compiler an `@external`.
- **Every death path settles the in-flight calls.** `die` answers every
  waiting caller `Unavailable(reason)`, closes the transport, and latches
  the reason, idempotently — reached from a failed write, a closed
  transport, non-UTF-8 bytes, a framing fault, a malformed or non-JSON-RPC
  line, and `Shutdown`. A well-formed message the client merely does not
  act on (an unknown or forgotten response id, a notification, a string
  id) is dropped and the channel stays open. `handle_closed` swaps in an
  inert connection first, so a port close never chases an exited child's
  possibly recycled OS pid with a kill.
- **Callers never `process.call` the actor.** Every public call is a
  monitored send-and-select (the `broker/internal/call.try_call` shape,
  reproduced here because this package does not depend on the broker):
  `process.call` panics on a timeout and on a dead callee, and a caller
  holding a tool-call verdict must answer `Unavailable` instead of dying.
  The cost is one possible stale reply in the caller's mailbox.
- **Pagination and listing size are capped.** `list_tools` follows
  `nextCursor` for at most `max_tool_pages` (64) pages before
  `TooManyPages`, and `codegen` refuses a listing past `max_tools` (256)
  or a surface past `max_surface_bytes` (64 KiB) after truncation — so a
  hostile server cannot loop or flood the harness.
- **Framing is bounded on both sides of the newline.** A pending line
  grown past `max_line_bytes` and a completed line larger than it are
  both `LineTooLong`; the cap matches the cap channel's frame cap, so one
  hostile message has one cost ceiling everywhere. `frame`'s output can
  never contain a literal newline, because `core/json` escapes every
  control character inside strings.
- **The transport seam is where jailing will attach.** `PortTransport` is
  the production primitive and spawns unjailed; **who may spawn a real
  server binary is harness wiring's decision, made elsewhere**. Do not
  read the current spawn as the final security posture, and do not
  collapse the seam — `ChannelTransport` is what makes every client
  behaviour provable in-process.
- **stderr is never merged into stdout.** `stderr_to_stdout` would
  interleave the server's diagnostics into the JSON-RPC line stream and
  corrupt framing, so the child inherits the BEAM's stderr.
- **v1 speaks five methods and subscribes to nothing.** Resources,
  prompts, logging, sampling, roots, elicitation, progress, cancellation,
  completion, `listChanged` handling, HTTP/SSE transports and the
  2026-07-28 stateless mode are refused rather than deferred. A dead peer
  is not restarted or reconnected; that substrate grows teeth in phase 5
  with the LSP client (#25).

## Deep Docs

- [README.md](README.md) — the design stance in prose: why MCP is
  code-mode only, how a hostile `tools/list` is held, and what the module
  map looks like.
- [docs/architecture/mcp.md](../../docs/architecture/mcp.md) — the
  subsystem end to end, including the half this package does not hold:
  one `[mcp.<name>]` table through boot and codegen to one
  `mcp.<server>` capability call, the denial codes a program reads, and
  the worked surface and program a model sees. Three of its sections
  cover what this file states only in summary — *Generated at boot,
  compiled per execution*, *A worked example: an issue triage pass*, and
  *What runs where* with its trust-boundary diagram.
- [docs/architecture/code-mode.md](../../docs/architecture/code-mode.md)
  — the vetting theorem the per-server module granularity rests on, and
  what each layer confines.
- [packages/cap/CLAUDE.md](../cap/CLAUDE.md) — the other end: `cap/mcp`'s
  result vocabulary and the `cap/internal/mcp.invoke` seam every
  generated façade calls.
- [packages/client/CLAUDE.md](../client/CLAUDE.md) — `client/protocol`,
  the house pattern for strict-envelope, tolerant-content wire codecs.
- [docs/gleam-style.md](../../docs/gleam-style.md) — Part IV §2 (total
  decoders) and §4 (FFI confinement), the rules this package is shaped
  by.
- [docs/next.md](../../docs/next.md) — the #106 design rulings and what
  the work still owes. The hostile-`tools/list` corpus is built and lives
  in `codegen_test` and `schema_test`; the open items are filed — #109
  (the jail decision), #110 (an end-to-end against a server from the
  wild), #108, #111, #112.
- [Root CLAUDE.md](../../CLAUDE.md) — repo ground rules and the doc
  graph.
