diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b5ef46 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +_build +_build_pg +deps +burrito_out +src-tauri/target +node_modules +assets/node_modules +*.db +*.db-* +.env* diff --git a/README.md b/README.md index bf7b6a7..8bd2fab 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,14 @@ that means in practice: 2025-11-25 as the fallback. Every tool a server lists becomes a tool the assistant can call, namespaced so it can never borrow a built-in tool's permissions, asking you until you write a rule; every result is marked untrusted; and when a server needs input mid-call it asks you, - on the permissions page, before the call continues. - -Not there yet: scheduled tasks and the rest of MCP (M5a: Trinity as an MCP server with approvals, -and the authorization roles), messaging gateways and subagents (M5b), + on the permissions page, before the call continues. The other direction too: Trinity is an + MCP server at `POST /mcp` (and over stdio), exporting the read-only tools by default and + any others you name; a client's call passes the same gate and leaves the same receipts as + the assistant's own, and one that needs your approval waits for it on the permissions page + while the client carries a sealed state it can retry with. A headless release runs the same + tree as a server, in a container or under systemd (`docs/mcp-server.md`). + +Not there yet: scheduled tasks and MCP's authorization roles (M5a), messaging gateways and subagents (M5b), the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every slice, and the [Milestones](#milestones) section below explains how to read it. @@ -150,6 +154,7 @@ later without renumbering anything. | `VERSIONS.md` | The verified dependency versions, generated from `lib/trinity/versions.ex` | | `CLAUDE.md` | The engineering contract: slice rules, definition of done, proof standard | | `docs/` | Vision, architecture, tech stack, conventions, slice process, data model, risks, security model, standards; packaging, the FIPS leg, backup and restore, performance measurements | +| `docs/mcp-server.md` | Connecting a client to Trinity's MCP server (Claude Code, VS Code, Codex, goose), stdio, approvals over the wire, the headless profile | | `slices/059-mcp-library-spike/FINDINGS.md` | What the MCP server core (`beam_mcp`) ships, carries, refuses or leaves open against the 2026-07-28 checklist; the reference for the MCP phase | | `docs/adr/` | Architecture decision records. One is added whenever a decision changes | | `slices/` | One folder per slice: specification, notes and proof | diff --git a/ROADMAP.md b/ROADMAP.md index 314bb7f..7ae7f2e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,7 +54,7 @@ standards register names the rows that ask for them. | 050 | Scheduler: Oban cron agent tasks with delivery targets | 5 Automation | M | 012 | planned | | 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved | | 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved | -| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | planned | +| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | done | | 062 | MCP authorization: OAuth client role, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | planned | | 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned | | 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned | diff --git a/VERSIONS.md b/VERSIONS.md index 1981861..36d47a7 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -91,7 +91,7 @@ never pin a version hex marks as retired or vulnerable. | `oban` | ~> 2.24 | 🔍 not yet a dependency | Uses `Oban.Engines.Lite` on SQLite. ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050. | | `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. | | `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. | -| `beam_mcp` | ~> 0.8 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.8.0 on hex.pm, standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. | +| `beam_mcp` | ~> 0.9 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.9.0 on hex.pm (2026-09-22): the :server seam on both transports and the core server as a named behaviour, which slice 061's wrapper implements; standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059, bumped at 061. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. | | `jido` | not used (ADR-0009, decided 2026-09-20) | 🔍 not a single package | Measured at the Slice 012 checkpoint and not adopted: the agent runtime duplicates PubSub, Oban and the gateways and adds a second tool executor; the action shape is written in-tree at Slice 020 with `jsv` for its schemas. The row stays so the decision is visible where a reader would look for the package. | | `jason` | ~> 1.2 | ✅ in `mix.lock` | | | `boundary` | ~> 0.10 | ✅ in `mix.lock` | Compile-time module dependency enforcement. Measured at Slice 000: it compiles and enforces on Elixir 1.20.4 / OTP 28, and it reports violations as **warnings**, so it enforces only while `--warnings-as-errors` is on the compile step. ⚠️ No release since 2024-09-25. | diff --git a/ci/headless/Containerfile b/ci/headless/Containerfile new file mode 100644 index 0000000..293be5d --- /dev/null +++ b/ci/headless/Containerfile @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Slice 061: the headless release in a container. Two stages: the release is assembled on the +# Elixir image whose toolchain is the tree's (.tool-versions: Erlang 28.5.0.5, Elixir 1.20.4), +# then copied onto a slim Debian of the same generation, so the ERTS the release carries finds +# the libc it was built against. No desktop shell, no Burrito: `mix release headless`. +# +# docker build -f ci/headless/Containerfile -t trinity-headless:local . +# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:local +# +# The container binds 0.0.0.0 inside its own network namespace (TRINITY_BIND) so the published +# port reaches it; the bearer is required on /mcp whatever the bind. The data directory is +# /data/trinity (XDG_DATA_HOME=/data, a volume): the databases, the receipt keys, the +# envelope key, the generated token. +FROM hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim AS build + +RUN apt-get update -y && apt-get install -y --no-install-recommends \ + build-essential git curl ca-certificates cmake python3 pkg-config \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV MIX_ENV=prod LANG=C.UTF-8 +RUN mix local.hex --force && mix local.rebar --force + +COPY mix.exs mix.lock ./ +COPY config config +RUN mix deps.get --only prod + +COPY lib lib +COPY priv priv +COPY assets assets +RUN mix assets.deploy && mix compile && mix release headless --overwrite + +FROM debian:bookworm-20260824-slim AS app + +RUN apt-get update -y && apt-get install -y --no-install-recommends \ + libstdc++6 openssl libncurses6 ca-certificates curl locales \ + && apt-get clean && rm -rf /var/lib/apt/lists/* \ + && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen + +ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 +ENV TRINITY_MODE=headless TRINITY_BIND=0.0.0.0 PORT=4000 XDG_DATA_HOME=/data +WORKDIR /app +RUN useradd --create-home --uid 10001 trinity && mkdir -p /data && chown trinity:trinity /data /app +COPY --from=build --chown=trinity:trinity /app/_build/prod/rel/headless ./ +USER trinity +VOLUME ["/data"] +EXPOSE 4000 +CMD ["bin/headless", "start"] diff --git a/config/runtime.exs b/config/runtime.exs index 0383395..bd53464 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -28,7 +28,10 @@ smoke? = "--smoke" in Enum.map(:init.get_plain_arguments(), &to_string/1) or System.get_env("TRINITY_SMOKE") == "1" -if System.get_env("PHX_SERVER") || smoke? do +# Slice 061: the headless profile is a server by definition. +headless? = System.get_env("TRINITY_MODE") == "headless" + +if System.get_env("PHX_SERVER") || smoke? || headless? do config :trinity, TrinityWeb.Endpoint, server: true end @@ -156,9 +159,33 @@ if config_env() == :prod do # LAN without asking. PORT still wins where it is set, which is how ex_tauri drives it. desktop_port = String.to_integer(System.get_env("PORT") || "0") + # Slice 061: the headless profile binds the address it is told (`TRINITY_BIND`, loopback by + # default: a server on a LAN is the operator's decision, made by setting it) on `PORT`, + # 4000 by default, since nobody reads an ephemeral port off a headless machine. The bearer + # on /mcp (`TRINITY_MCP_SERVER_TOKEN`, or the generated token file) is required whatever + # the bind; the web pages carry no authentication yet, which is why the default stays on + # the loopback (docs/mcp-server.md). + {bind_ip, bind_port} = + if System.get_env("TRINITY_MODE") == "headless" do + ip = + case System.get_env("TRINITY_BIND", "127.0.0.1") + |> String.to_charlist() + |> :inet.parse_address() do + {:ok, ip} -> + ip + + {:error, _} -> + raise "TRINITY_BIND is not an IP address: #{System.get_env("TRINITY_BIND")}" + end + + {ip, String.to_integer(System.get_env("PORT") || "4000")} + else + {{127, 0, 0, 1}, desktop_port} + end + config :trinity, TrinityWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], - http: [ip: {127, 0, 0, 1}, port: desktop_port], + http: [ip: bind_ip, port: bind_port], secret_key_base: secret_key_base # ## SSL Support diff --git a/coverage.tsv b/coverage.tsv index 5810cbf..384a6a0 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -20,3 +20,4 @@ slice_id percent sha date 041 80.55 ae200d7 2026-09-21 059 80.54 53c9091 2026-09-21 060 80.32 49f40a4 2026-09-21 +061 80.05 c6b6faa 2026-09-22 diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 46c3fd7..a2528b3 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -90,7 +90,7 @@ without anything failing. | `Trinity.Memory` | Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023) | LLM (summaries/embeddings), Repo | | `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools; at 041: staging, promotion, manager, scanner, diff, learn, `skill_manage` and `learn`) | Repo, Permissions, **Effects**, **Receipts**, Sandbox (as built at 041: Tools, Memory, Permissions, Receipts and LLM; Effects is not a dependency: the promotion is not a tool call, it writes its own effect receipt; Tools never depends on Skills) | | `Trinity.Scheduler` | Oban workers for agent tasks, delivery | Sessions, Gateways, **Repo** | -| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060)) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`) | +| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) | | `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub | | `Trinity.Subagents` | Delegation, result collection | Sessions, Tools | | `Trinity.Sandbox` | Luerl runners, resource limits | none | diff --git a/docs/05-data-model.md b/docs/05-data-model.md index 1884ccb..7ce636d 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -246,6 +246,13 @@ One row per server the client connects to; the row's `name` is the namespace seg | tool_overrides | map | tool name to `{"effect": …}`; `catalog` is refused by the changeset and, on a row that carries it anyway, at load with a decision receipt on the chain scope `mcp:`. No override lowers a tool's tier: it is `:ask` for every namespaced name, and a rule on the permissions page is what allows one | | last_error | text, nullable | | +### The MCP server's files (Slice 061, not rows) +`/mcp-server-token` (the bearer clients present, generated once, mode 0600, overridden by +`TRINITY_MCP_SERVER_TOKEN`) and `/mcp-state.key` (32 bytes, the AES-256-GCM key sealing the +`requestState` of a held call; shared by every instance of the data directory). The MCP session is a +`sessions` row with `origin: "mcp"` and its own persona ("MCP server", no settings); its approvals and +receipts are ordinary rows under it. + ### task_runs (Slice 050) `task_id`, `scheduled_at`, `session_id`, `status`, `summary`, `error`. Unique on `(task_id, scheduled_at)` so a run is idempotent. Oban holds the job; this holds the outcome. diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 86d86e3..d06dda8 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -166,6 +166,42 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES). argument validation are the core's public functions (`BeamMCP.JSON.decode/1`, `BeamMCP.Schema.validate/2`), and a census test holds the population to that (060 AC7). +## MCP server (Slice 061, as built) + +- **A stateless Plug above beam_mcp's core**, handed to the transport through its `:server` option; + the core answers every method but `tools/call`, which Trinity answers through the same gate and + membrane as the assistant's own calls. Will-not-implement entry 12 stands on the core: the approval + loop lives above it. +- **Every call is attributed** to one session with `origin: "mcp"` and a persona of its own (no + permission settings: an MCP client inherits none of the default persona's allowances), and every + decision and query receipt carries `"origin" => "mcp"`. A `traceparent` in the request's `_meta` + rides into the receipts' meta. +- **Exports are a closed list** the operator configures: core entries with effect `:none` or + `:artifact`; `:catalog` is refused at boot and never listed; the list is sorted, so `tools/list` is + deterministic. +- **A bearer before the body.** `Trinity.MCP.Server.Auth.Local`: the token from the environment or the + generated file, compared in constant time in the transport's `:authorize` hook; a refusal decodes + nothing. Loopback is the default bind; a wider bind is the operator's setting and belongs behind a + proxy that authenticates the web pages too (062 brings the OAuth resource server). +- **Approvals over the wire are the owner's, never the client's.** A held call answers `input_required` + with a `requestState` sealed by `Trinity.MCP.Server.Envelope`: AES-256-GCM under + `/mcp-state.key`, binding the approval id, the session, the call id, the tool, a digest of + the arguments, a nonce and an expiry (15 minutes by default). The client's `inputResponses` decide + nothing: the decision is the row on the permissions page. A retry opens the envelope and is refused + when expired, tampered, replayed or bound to another call, with a reason that names only which; a + retry before the decision is held again on the same approval under a fresh state and never re-enters + the gate; a retry after it runs under the envelope's call id, so the decision consumed is the one + made and a second run is the membrane's duplicate effect. +- **Replay defence is at-most-once per partition plus idempotent effects**, stated as such: + `Trinity.MCP.Server.Replay` holds every nonce this instance has seen until its expiry window passes; + another instance has its own table, and across instances the gate's consumed "once" and the + membrane's idempotency key are the backstop (a replayed state there asks the owner again rather than + running twice). Tests hold the four reds: a replay inside the window, a replay across partitions, an + expired envelope, one tampered byte. +- **stdio has no bearer**, as the core's page says: whoever writes to the process's standard input + already has the host's privileges. Its standard output is the wire and the VM's log is moved to + standard error. + ## Secrets - Env vars in dev; OS keychain via `Trinity.Secrets` from Slice 100 (Tauri stronghold/store or a keychain NIF). diff --git a/docs/08-standards.md b/docs/08-standards.md index 1c8139b..0d36bf9 100644 --- a/docs/08-standards.md +++ b/docs/08-standards.md @@ -56,7 +56,11 @@ Microsoft, OpenAI. Trinity targets AAIF-governed standards first; vendor-specifi JSON-RPC id; only requests the client declared in its capabilities may be sent, and Trinity's client declares form elicitation alone, so the owner answers from the permissions page and no sampling or roots request reaches it. The core (beam_mcp) refuses MRTR on the server side by design; Trinity's server (061) - speaks it above the core. + speaks it above the core, through the `:server` seam beam_mcp 0.9.0 shipped: `input_required` with one + elicitation request and a sealed `requestState`, the decision the owner's on the permissions page, + the retry under the envelope's call id (docs/07, "MCP server"). Over HTTP the server serves + 2026-07-28 alone (the core's transport refuses other versions at the header); the 2025-11-25 + compatibility is over stdio, where the core is dual-era. 3. **Deprecated:** Roots, Sampling, Logging (use tool params/resource URIs, provider APIs directly, stderr/OTel), HTTP+SSE transport, DCR. Do not build new code on these. 4. **Extensions framework** with `extensions` in capabilities. Tasks (`io.modelcontextprotocol/tasks`, diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 0000000..7d4f468 --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,153 @@ +# Trinity as an MCP server + +Trinity serves the Model Context Protocol at the 2026-07-28 revision on `POST /mcp`, and at +both 2026-07-28 and 2025-11-25 over stdio. A client that connects sees the tools you chose to +export (the read-only set by default), calls them through the same permission gate and the same +membrane as the assistant's own calls, and gets an audited answer: every call leaves a receipt on +the "MCP server" session's chain with `origin: "mcp"`. A tool that needs your approval does not run +until you give it, on the permissions page; the client is told to retry once you have. + +## What is exported + +`config :trinity, :mcp_server, tools: [...]` names the tools; the default set is `recall`, +`session_search`, `skills_list`, `skill_view` and `skill_file`, all reads. An `:artifact` tool +(`memory`, `skill_manage`, `learn`, the filesystem writes) is exported when you name it, and every +call to it asks you first. A tool whose effect is `:catalog` (the shell) cannot be exported: the +configuration is refused at boot with a logged reason, and the tool never appears in +`tools/list`. The list is sorted, and it carries `ttlMs` (60 s by default) and `cacheScope` +(`private`). + +Two files the server keeps in the data directory: `mcp-server-token`, the bearer clients present +(generated once, mode 0600; `TRINITY_MCP_SERVER_TOKEN` overrides it), and `keys/mcp-state.key`, the +key that seals the state a held call carries between round trips (docs/07, "MCP server"). + +## Connecting a client over HTTP + +The URL is `http://127.0.0.1:/mcp` (the port the app prints at start, `4000` in the headless +profile) and the bearer is the token above: + +``` +$ cat ~/.local/share/trinity/mcp-server-token # Linux; the data directory differs per OS +``` + +**Claude Code** (its `mcp add` command, or `.mcp.json` in a project): + +``` +claude mcp add --transport http trinity http://127.0.0.1:4000/mcp \ + --header "Authorization: Bearer " +``` + +```json +{ + "mcpServers": { + "trinity": { + "type": "http", + "url": "http://127.0.0.1:4000/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +**VS Code** (`.vscode/mcp.json`, or the user-level `mcp.json`): + +```json +{ + "servers": { + "trinity": { + "type": "http", + "url": "http://127.0.0.1:4000/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +**Codex** (`~/.codex/config.toml`, or `.codex/config.toml` in a trusted project): + +```toml +[mcp_servers.trinity] +url = "http://127.0.0.1:4000/mcp" +bearer_token_env_var = "TRINITY_MCP_SERVER_TOKEN" +``` + +**goose**: `goose configure`, then Add Extension, Remote Extension (Streaming HTTP), the URL above, +and the `Authorization` header when it asks for headers. + +The shapes above are each client's as its documentation showed them on 2026-09-22; the file +locations differ per operating system, and the clients change. What does not change on Trinity's +side is the URL, the `Authorization: Bearer` header and the revision. + +## Connecting a client over stdio + +For a client that launches its servers as child processes, and for a 2025-11-25 client (the HTTP +transport serves 2026-07-28 alone, by the core's design): + +``` +claude mcp add --transport stdio trinity -- mix trinity.mcp.stdio +``` + +run from the Trinity source tree (compile first: a `mix compile` line on standard output would be +an undecodable message to the client), or `bin/headless eval 'Trinity.MCP.Server.Stdio.serve()'` +from the headless release. This is a whole Trinity, and a data directory admits one at a time: run +it when the desktop or the headless server does not, or give it its own data directory with +`XDG_DATA_HOME` (Linux). The VM's log goes to standard error; standard output is the wire. + +## Approvals over the wire + +When a client calls a tool that needs your approval, the answer is not the result but an +`input_required` result (the 2026-07-28 multi-round-trip pattern): one elicitation request naming +the approval and this page, and an opaque `requestState`. You decide on `/permissions`, where the +request appears under the "MCP server" session like any other; the client retries the same call +with the same `requestState`, and the retry runs (once, under the decision you made) or reports the +denial. A retry before you have decided is held again, with a fresh state; a retry with the state +altered, expired (15 minutes by default, `state_ttl_s`), replayed or taken from another call is +refused, and the reason names only which of those it was. A 2025-11-25 client, whose revision has +no `input_required`, is told the same in a tool error and retries the same way. + +The state is what makes the headless profile stateless: it carries everything a retry needs, sealed +under the data directory's key, so a call held by one instance completes on any other instance of +the same data directory (docs/07 says what it binds and what the replay defence holds). + +## The headless profile + +`mix release headless` assembles the tree as an ordinary OTP release: no desktop shell, no +self-extracting binary. `TRINITY_MODE=headless` makes it a server: it binds `TRINITY_BIND` (the +loopback by default; a LAN address is your decision, made by setting it) on `PORT` (4000 by default) +and serves the web pages and `/mcp`. The bearer is required on `/mcp` whatever the bind; the web +pages carry no authentication yet, which is why the default bind is the loopback and why a LAN bind +belongs behind a reverse proxy that authenticates. + +A container: `ci/headless/Containerfile` builds the release on the Elixir image whose toolchain is +the tree's and runs it on a slim Debian, the data directory on the `/data` volume: + +``` +docker build -f ci/headless/Containerfile -t trinity-headless:local . +docker run --rm -p 4000:4000 -v trinity-data:/data \ + -e TRINITY_MCP_SERVER_TOKEN= trinity-headless:local +``` + +A systemd unit for the release on a host: + +```ini +[Unit] +Description=Trinity (headless) +After=network-online.target + +[Service] +User=trinity +Environment=TRINITY_MODE=headless +Environment=PORT=4000 +Environment=SECRET_KEY_BASE=<64 random bytes, base64> +Environment=TRINITY_MCP_SERVER_TOKEN= +EnvironmentFile=-/etc/trinity/env +ExecStart=/opt/trinity/bin/headless start +ExecStop=/opt/trinity/bin/headless stop +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +`SECRET_KEY_BASE` is worth setting on a server: the fallback is generated per boot and signed +cookies do not survive a restart without it (config/runtime.exs says why that is the default). diff --git a/lib/mix/tasks/trinity.mcp.stdio.ex b/lib/mix/tasks/trinity.mcp.stdio.ex new file mode 100644 index 0000000..e21c255 --- /dev/null +++ b/lib/mix/tasks/trinity.mcp.stdio.ex @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Mcp.Stdio do + @shortdoc "Serves Trinity as an MCP server over stdio (dual-era: 2026-07-28 and 2025-11-25)" + @moduledoc """ + Slice 061. Starts the application and runs beam_mcp's stdio transport with Trinity's server + above the core, for a client that launches its servers as child processes (the common + configuration of Claude Code and its peers) and for a 2025-11-25 client, which the HTTP + route does not serve (the core's transport is 2026-07-28 alone by design). + + mix trinity.mcp.stdio + + Two things a client's configuration must know. This is a whole Trinity, and the data + directory admits one at a time (`Trinity.DataDir.Lock`): it runs when the desktop or the + headless server does not, or against its own data directory (`TRINITY_DATA_DIR`). And the + VM's log goes to standard error here, never to standard output, which is the wire. + """ + # Classified into the MCP boundary, the one that may reach the server (the other tasks are + # `Trinity`'s, which never names `Trinity.MCP`). + use Boundary, classify_to: Trinity.MCP + use Mix.Task + + @impl Mix.Task + def run(_argv) do + # Before the application starts (what boots logs too), and again inside `run/1`. + Trinity.MCP.Server.Stdio.log_to_stderr() + Mix.Task.run("app.start") + Trinity.MCP.Server.Stdio.run() + end +end diff --git a/lib/trinity.ex b/lib/trinity.ex index 1f4a356..46042bd 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -52,6 +52,10 @@ defmodule Trinity do Tools.Context, Tools.Result, Tools.Untrusted, + # Slice 061: the MCP server reads the exported entries' definitions and runs calls + # through the membrane's runner. + Tools.Registry, + Effects.Runner, Permissions, Permissions.Approval, Permissions.Rule, diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 16af1c8..373e142 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -63,6 +63,8 @@ defmodule Trinity.Application do # Slice 060: the MCP clients, one per enabled server row, after the tools they # register into and the gate their approvals go through. Trinity.MCP.Supervisor, + # Slice 061: the server's replay table, before the endpoint that serves /mcp. + Trinity.MCP.Server.Replay, Trinity.MCP.Boot, Trinity.Sessions.Supervisor ] ++ diff --git a/lib/trinity/effects/runner.ex b/lib/trinity/effects/runner.ex index f1f8e5a..4efdc80 100644 --- a/lib/trinity/effects/runner.ex +++ b/lib/trinity/effects/runner.ex @@ -88,12 +88,13 @@ defmodule Trinity.Effects.Runner do ) do Receipts.append(scope, %{ kind: "decision", - subject: %{ - "session_id" => ctx.session_id, - "call_id" => ctx.call_id, - "tool" => name, - "effect" => Atom.to_string(effect) - }, + subject: + origin(ctx, %{ + "session_id" => ctx.session_id, + "call_id" => ctx.call_id, + "tool" => name, + "effect" => Atom.to_string(effect) + }), decision: %{ "outcome" => Atom.to_string(decision), "basis" => basis, @@ -101,7 +102,7 @@ defmodule Trinity.Effects.Runner do }, fingerprint: fp, subject_ref: "decision:#{ctx.session_id || "none"}:#{ctx.call_id || "none"}", - meta: %{"tool_definition_digest" => digest} + meta: trace(ctx, %{"tool_definition_digest" => digest}) }) end @@ -115,10 +116,22 @@ defmodule Trinity.Effects.Runner do Receipts.append(scope, %{ kind: "query", - subject: %{"session_id" => ctx.session_id, "call_id" => ctx.call_id, "tool" => name}, + subject: + origin(ctx, %{"session_id" => ctx.session_id, "call_id" => ctx.call_id, "tool" => name}), decision: outcome, subject_ref: "query:#{ctx.session_id || "none"}:#{ctx.call_id || "none"}", - meta: %{"tool_definition_digest" => digest} + meta: trace(ctx, %{"tool_definition_digest" => digest}) }) end + + # Slice 061: a call that did not come from the desktop says where it came from (`"mcp"`), + # and the caller's trace context rides in the meta for 090; a desktop call carries neither, + # so every receipt written before this slice reads the same. + defp origin(%Context{origin: nil}, subject), do: subject + defp origin(%Context{origin: origin}, subject), do: Map.put(subject, "origin", origin) + + defp trace(%Context{trace: %{} = trace}, meta) when map_size(trace) > 0, + do: Map.put(meta, "trace", trace) + + defp trace(_ctx, meta), do: meta end diff --git a/lib/trinity/mcp.ex b/lib/trinity/mcp.ex index 704e512..0fd287f 100644 --- a/lib/trinity/mcp.ex +++ b/lib/trinity/mcp.ex @@ -20,17 +20,35 @@ defmodule Trinity.MCP do # Slice 060: the client reaches the core's decoder and validator, and Trinity's tools, # permissions and receipts. # The suite's servers under test (test/support/mcp) are the core's own transports over a - # test catalog, and live under this boundary too, so the modules they reach are listed - # for the test environment alone. + # test catalog, and live under this boundary too; since 061 the server side reaches the + # same modules, so the list is one for every environment. use Boundary, top_level?: true, - deps: - [Trinity, BeamMCP.JSON, BeamMCP.Schema] ++ - if(Mix.env() == :test, - do: [BeamMCP.Catalog, BeamMCP.ToolSpec, BeamMCP.Transport.HTTP], - else: [] - ), - exports: [Bridge, Client, Servers, ServerConfig, Supervisor, Boot] + # Slice 061: the server reaches the core's server, catalog, tool spec and HTTP transport. + deps: [ + Trinity, + BeamMCP.JSON, + BeamMCP.Schema, + BeamMCP.Server, + BeamMCP.Catalog, + BeamMCP.ToolSpec, + BeamMCP.Transport.HTTP, + BeamMCP.Transport.Stdio + ], + exports: [ + Bridge, + Client, + Servers, + ServerConfig, + Supervisor, + Boot, + Server, + Server.Plug, + Server.Stdio, + Server.Exports, + Server.Auth.Local, + Server.Replay + ] @doc "The core's version, from its application spec." @spec core_version() :: String.t() diff --git a/lib/trinity/mcp/boot.ex b/lib/trinity/mcp/boot.ex index 6a1cf7d..f1e6969 100644 --- a/lib/trinity/mcp/boot.ex +++ b/lib/trinity/mcp/boot.ex @@ -25,8 +25,29 @@ defmodule Trinity.MCP.Boot do rescue e -> Logger.warning("mcp boot: servers not started: #{Exception.message(e)}") end + + server_side() end :ok end + + # Slice 061: the server side's files (the envelope key, the bearer token when no variable + # sets it) and the export list's refusals, said once at boot rather than on a client's first + # request. + defp server_side do + Trinity.MCP.Server.Envelope.ensure_key!() + + if System.get_env(Trinity.MCP.Server.Auth.Local.variable()) in [nil, ""], + do: Trinity.MCP.Server.Auth.Local.ensure_token!() + + {entries, refusals} = Trinity.MCP.Server.Exports.resolve() + + for {name, reason} <- refusals, + do: Logger.warning("mcp server: tool #{name} not exported: #{reason}") + + Logger.info("mcp server: exporting #{length(entries)} tools at /mcp") + rescue + e -> Logger.warning("mcp server: not prepared: #{Exception.message(e)}") + end end diff --git a/lib/trinity/mcp/server.ex b/lib/trinity/mcp/server.ex new file mode 100644 index 0000000..9ae3467 --- /dev/null +++ b/lib/trinity/mcp/server.ex @@ -0,0 +1,317 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server do + @moduledoc """ + Trinity as an MCP server (slice 061): the module above beam_mcp's core, handed to both of + its transports through the `:server` option (ADR-0007 decision 6, shipped in beam_mcp + 0.9.0), implementing `BeamMCP.Server`. Every message but `tools/call` is the core's + (`server/discover`, `initialize`, `tools/list` over `Trinity.MCP.Server.Catalog`, `ping` at + the legacy era, every refusal); `tools/call` is answered here, because it is where authority + lives: the arguments are validated by the core's validator against the listed schema, the + call runs through `Trinity.Effects.Runner` in the MCP context (the `origin: "mcp"` session, + the gate's decision receipted, the membrane for an `:artifact` tool), and the result is + mapped to the wire at the era the request declared. + + The multi-round-trip pattern, for approvals: a call the gate holds for the owner is answered + `input_required` with one elicitation request (which approval, and where to decide it) and + a `requestState` sealed by `Trinity.MCP.Server.Envelope`; the retry opens the envelope, + refuses one that is expired, tampered, replayed (`Trinity.MCP.Server.Replay`) or bound to + another call, runs under the envelope's call id (so the approval the owner decided is the + one consumed, and a second run is the membrane's duplicate), and answers `input_required` + again while the owner has not decided, the result once allowed, an `isError` once denied. + A 2025-11-25 client, whose revision has no `input_required`, is answered an `isError` + naming the approval; its retry after the decision runs the same way. The core never sees + any of it: will-not-implement entry 12 stands, and so does the layering the ADR chose. + """ + @behaviour BeamMCP.Server + + alias Trinity.Effects + alias Trinity.MCP.Client.Wire + alias Trinity.MCP.Server.{Envelope, Exports, Replay, Session} + alias Trinity.Tools.{Context, Result} + + @version_key "io.modelcontextprotocol/protocolVersion" + @capabilities_key "io.modelcontextprotocol/clientCapabilities" + @server_info_key "io.modelcontextprotocol/serverInfo" + + @type state :: %{core: BeamMCP.Server.state(), server_name: String.t()} + + ## The behaviour + + @impl true + def new(opts) do + core = BeamMCP.Server.new(opts) + %{core: core, server_name: Keyword.get(opts, :server_name, "trinity")} + end + + @impl true + def handle_message( + %{core: core} = state, + %{"jsonrpc" => "2.0", "id" => id, "method" => "tools/call"} = message + ) + when not is_nil(id) do + case era(core, message) do + nil -> + delegate(state, message) + + era -> + params = Map.get(message, "params", %{}) + + case {Map.get(params, "name"), Map.get(params, "arguments", %{})} do + {name, args} when is_binary(name) and is_map(args) -> + {state, answer(id, era, state, name, args, params)} + + _ -> + delegate(state, message) + end + end + end + + def handle_message(state, message), do: delegate(state, message) + + @impl true + def shutdown?(%{core: core}), do: BeamMCP.Server.shutdown?(core) + + defp delegate(%{core: core} = state, message) do + {core, response} = BeamMCP.Server.handle_message(core, message) + {%{state | core: core}, response} + end + + # The era the request declared, read as the core reads it: the `_meta` version (the modern + # one needs the capabilities beside it, or the core's own refusal is the right answer), or + # the legacy state an `initialize` set when the request carries no version. Anything else is + # the core's to refuse, so this answers nil and the message is delegated. + defp era(_core, %{"params" => %{"_meta" => %{@version_key => version} = meta}}) do + cond do + version == Wire.modern() and Map.has_key?(meta, @capabilities_key) -> :modern + version == Wire.legacy() -> :legacy + true -> nil + end + end + + defp era(%{initialized?: true}, %{"params" => params}) when not is_map_key(params, "_meta"), + do: :legacy + + defp era(_core, _message), do: nil + + ## tools/call + + defp answer(id, era, state, name, args, params) do + case Exports.entry(name) do + nil -> + result(id, era, state, tool_error("unknown tool #{name}")) + + entry -> + with :ok <- validate(args, entry), + {:ok, ctx, pending} <- context(name, args, params) do + run_or_hold(id, era, state, entry, args, ctx, pending) + else + {:error, {:invalid_arguments, reason}} -> + error(id, -32_602, "invalid arguments: #{reason}") + + {:error, {:state, reason}} -> + error(id, -32_602, "requestState #{reason}") + end + end + end + + defp run_or_hold(id, era, state, entry, args, ctx, nil), + do: run(id, era, state, entry, args, ctx) + + defp run_or_hold(id, era, state, entry, args, ctx, approval_id), + do: approval_required(id, era, state, entry, args, ctx, approval_id) + + defp validate(args, entry) do + case Wire.validate_arguments(args, Trinity.Tools.Registry.schema(entry)) do + :ok -> :ok + {:error, reason} -> {:error, {:invalid_arguments, reason}} + end + end + + # A first call runs under a fresh call id; a retry runs under the envelope's, once the + # envelope opens and binds to this very call (tool and arguments), has not expired, and its + # nonce has not been used on this instance. A retry whose approval the owner has not yet + # decided does not reach the gate (it would mint a second request and mask the first): it + # is held again, on the same approval, under a fresh envelope; the third element says so. + defp context(name, args, params) do + session = Session.row() + trace = trace(params) + + case Map.get(params, "requestState") do + nil -> + {:ok, ctx(session, name, Trinity.UUID.generate(), trace), nil} + + state -> + with {:ok, payload} <- open(state), + :ok <- bound(payload, session.id, name, args), + :ok <- unused(payload) do + {:ok, ctx(session, name, payload.call_id, trace), pending(payload.approval_id)} + end + end + end + + defp pending(approval_id) do + case Trinity.Permissions.get_approval(approval_id) do + %{status: "pending"} -> approval_id + _ -> nil + end + end + + # The persona is the session's, as the Session process hands its tools (the memory tools + # take the persona from the context and nowhere else). + defp ctx(session, name, call_id, trace) do + %Context{ + session_id: session.id, + persona: session.persona_id && Trinity.Sessions.get_persona(session.persona_id), + caller: "mcp", + call_id: call_id, + tool: name, + origin: "mcp", + trace: trace + } + end + + defp open(state) do + case Envelope.open(state) do + {:ok, payload} -> {:ok, payload} + {:error, reason} -> {:error, {:state, Atom.to_string(reason)}} + end + end + + defp bound(payload, session_id, name, args) do + if payload.session_id == session_id and payload.tool == name and + payload.args_digest == Envelope.args_digest(args), + do: :ok, + else: {:error, {:state, "does not belong to this call"}} + end + + defp unused(%{nonce: nonce, exp: exp}) do + case Replay.use(nonce, exp) do + :ok -> :ok + {:error, :replayed} -> {:error, {:state, "already used"}} + end + end + + defp trace(%{"_meta" => meta}) when is_map(meta) do + case Map.take(meta, ["traceparent", "tracestate"]) do + m when map_size(m) == 0 -> nil + m -> m + end + end + + defp trace(_), do: nil + + defp run(id, era, state, entry, args, ctx) do + case Effects.Runner.run(%{id: ctx.call_id, name: entry.name, args: args}, ctx) do + {:ok, %Result{} = r, _meta} -> + result(id, era, state, tool_result(r)) + + {:error, {:approval_required, approval_id}, _meta} -> + approval_required(id, era, state, entry, args, ctx, approval_id) + + {:error, :denied, _meta} -> + result(id, era, state, tool_error("denied: the owner refused this call")) + + {:error, reason, _meta} -> + result(id, era, state, tool_error("the call failed: #{describe(reason)}")) + end + end + + # The hold, on the wire: at 2026-07-28 an `input_required` result with the sealed state; at + # 2025-11-25 a tool error naming the approval, since that revision has no `input_required`. + defp approval_required(id, :modern, state, entry, args, ctx, approval_id) do + sealed = + Envelope.seal(%{ + approval_id: approval_id, + session_id: ctx.session_id, + call_id: ctx.call_id, + tool: entry.name, + args_digest: Envelope.args_digest(args) + }) + + payload = %{ + "resultType" => "input_required", + "inputRequests" => %{ + "approval" => %{ + "method" => "elicitation/create", + "params" => %{ + "mode" => "form", + "message" => + "Trinity needs the owner's approval to run #{entry.name} (approval #{approval_id}). " <> + "The owner decides on Trinity's permissions page; retry this call with the requestState once decided.", + "requestedSchema" => %{ + "type" => "object", + "properties" => %{ + "retry" => %{ + "type" => "boolean", + "description" => "true to retry after the owner's decision" + } + } + } + } + } + }, + "requestState" => sealed, + "_meta" => %{@server_info_key => server_info(state)} + } + + %{"jsonrpc" => "2.0", "id" => id, "result" => payload} + end + + defp approval_required(id, :legacy, state, entry, _args, _ctx, approval_id) do + result( + id, + :legacy, + state, + tool_error( + "approval required: the owner decides on Trinity's permissions page (approval #{approval_id}); " <> + "retry #{entry.name} once decided" + ) + ) + end + + ## The wire + + defp tool_result(%Result{content: content, meta: meta} = r) do + base = %{ + "content" => [%{"type" => "text", "text" => Result.as_text(r)}], + "isError" => meta["is_error"] == true + } + + if is_map(content), do: Map.put(base, "structuredContent", content), else: base + end + + defp tool_error(text), + do: %{"content" => [%{"type" => "text", "text" => text}], "isError" => true} + + defp result(id, :modern, state, payload) do + %{ + "jsonrpc" => "2.0", + "id" => id, + "result" => + payload + |> Map.put("resultType", "complete") + |> Map.put("_meta", %{@server_info_key => server_info(state)}) + } + end + + defp result(id, :legacy, _state, payload), + do: %{"jsonrpc" => "2.0", "id" => id, "result" => payload} + + defp error(id, code, message), + do: %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => code, "message" => message}} + + defp server_info(%{server_name: name}), + do: %{"name" => name, "version" => to_string(Application.spec(:trinity, :vsn))} + + # A reason a client may read: a word or two, never a term with Trinity's internals in it. + defp describe(:timeout), do: "timed out" + defp describe({:invalid_args, _}), do: "invalid arguments" + defp describe({:crash, _}), do: "crashed" + defp describe({:denied, {:duplicate_effect, _}}), do: "already run (duplicate effect)" + defp describe(:approval_required), do: "approval required" + defp describe({:request_failed, _}), do: "the approval could not be requested" + defp describe(other) when is_atom(other), do: Atom.to_string(other) + defp describe({tag, _}) when is_atom(tag), do: Atom.to_string(tag) + defp describe(_), do: "error" +end diff --git a/lib/trinity/mcp/server/auth/local.ex b/lib/trinity/mcp/server/auth/local.ex new file mode 100644 index 0000000..3a504d6 --- /dev/null +++ b/lib/trinity/mcp/server/auth/local.ex @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Auth.Local do + @moduledoc """ + The default `:authorize` hook of Trinity's MCP server (slice 061): a static bearer, compared + in constant time, before the body is read. The token is `TRINITY_MCP_SERVER_TOKEN` when + set; otherwise one generated at boot into `/mcp-server-token` (mode 0600), which + the owner copies into a client's configuration. The full OAuth 2.1 resource-server profile + is slice 062's; this module is what stands until then, beside the loopback bind the + headless profile defaults to. + """ + import Plug.Conn, only: [get_req_header: 2] + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @env "TRINITY_MCP_SERVER_TOKEN" + @token_file "mcp-server-token" + + @doc "The hook: `:ok` with the right bearer, `{:error, reason}` otherwise (the reason goes to the log, never to the caller)." + @spec authorize(Plug.Conn.t()) :: :ok | {:error, term()} + def authorize(conn) do + expected = token() + + case get_req_header(conn, "authorization") do + ["Bearer " <> given] when byte_size(given) > 0 -> + if byte_size(given) == byte_size(expected) and :crypto.hash_equals(given, expected), + do: :ok, + else: {:error, :wrong_bearer} + + [] -> + {:error, :no_bearer} + + _ -> + {:error, :malformed_authorization} + end + end + + @doc "The token in force: the environment variable, else the generated file's." + # sobelow_skip reason: Traversal.FileModule: the path is the data directory's plus a constant + # name (token_path/0), never a request's. + @sobelow_skip ["Traversal.FileModule"] + @spec token() :: String.t() + def token do + case System.get_env(@env) do + t when is_binary(t) and t != "" -> t + _ -> File.read!(ensure_token!()) |> String.trim() + end + end + + @doc "The token file's path." + @spec token_path() :: Path.t() + def token_path, do: Path.join(Trinity.Paths.ensure_data_dir(), @token_file) + + @doc "Generates the token file when absent (mode 0600); returns its path." + # sobelow_skip reason: Traversal.FileModule: the path is the data directory's, never a request's. + @sobelow_skip ["Traversal.FileModule"] + @spec ensure_token!() :: Path.t() + def ensure_token! do + path = token_path() + + unless File.exists?(path) do + File.write!(path, Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) <> "\n") + File.chmod!(path, 0o600) + end + + path + end + + @doc "The environment variable's name." + @spec variable() :: String.t() + def variable, do: @env +end diff --git a/lib/trinity/mcp/server/catalog.ex b/lib/trinity/mcp/server/catalog.ex new file mode 100644 index 0000000..b27c4df --- /dev/null +++ b/lib/trinity/mcp/server/catalog.ex @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Catalog do + @moduledoc """ + The catalog the core serves for Trinity (slice 061): one `BeamMCP.ToolSpec` per exported + registry entry, sorted by name so `tools/list` is deterministic (the core keeps the + catalog's order, 059 finding 3), the description and `input_schema` the registry holds, + `mode: :read_only` for an `effect: :none` tool and `:proposal` for an `:artifact` one (the + core renders those as the `readOnlyHint` and `destructiveHint` annotations). No resources, + no prompts, and the core's connectome surface is not exported (the SLICE's default). + + The core validates the catalog's shape at every `new/1`; `tools/call` never reaches its + `dispatch` because `Trinity.MCP.Server` answers that method itself, through the membrane. + """ + @behaviour BeamMCP.Catalog + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + alias BeamMCP.ToolSpec + alias Trinity.MCP.Server.Exports + alias Trinity.Tools.Registry + + @impl true + def capabilities do + %{tools: Enum.map(Exports.entries(), &spec/1), resources: [], prompts: []} + end + + @impl true + def read_resource(_uri), do: {:error, "no resources"} + + @impl true + def get_prompt(_name, _args), do: {:error, "no prompts"} + + # The name is a core tool's, from the registry's core entries the operator's configuration + # named (Exports refuses anything else), a finite set fixed at boot, so the atom is safe. + # sobelow_skip reason: DOS.StringToAtom: the input is the export list, never a request. + @sobelow_skip ["DOS.StringToAtom"] + defp spec(%{name: name, effect: effect} = entry) do + %ToolSpec{ + name: String.to_atom(name), + command_class: if(effect == :none, do: :observe, else: :propose), + mode: if(effect == :none, do: :read_only, else: :proposal), + description: Registry.description(entry), + input_schema: Registry.schema(entry) + } + end +end diff --git a/lib/trinity/mcp/server/envelope.ex b/lib/trinity/mcp/server/envelope.ex new file mode 100644 index 0000000..ca6279d --- /dev/null +++ b/lib/trinity/mcp/server/envelope.ex @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Envelope do + @moduledoc """ + The `requestState` Trinity mints for a multi-round-trip approval (slice 061): everything a + retry needs to resume, sealed so the client carries it and reads nothing. AES-256-GCM (a + FIPS-approved AEAD; the receipt scheme's FIPS leg runs this suite) under a 32-byte key in + `/mcp-state.key`, generated once with mode 0600 and shared by every instance of + the same data directory, which is what lets an exchange begun on one instance complete on + another (AC7). The plaintext binds the approval id, the session, the call id, the tool, a + digest of the arguments, a nonce and an expiry; the wire form is `v1.` and the IV, tag and + ciphertext base64url-encoded. A tampered byte, an expired envelope, a version this module + does not mint or a wrong key all open to `{:error, reason}` with nothing of the plaintext + in the reason. + """ + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @version "v1." + @aad "trinity-mcp-state-v1" + @default_ttl_s 900 + + @type payload :: %{ + approval_id: String.t(), + session_id: String.t(), + call_id: String.t(), + tool: String.t(), + args_digest: String.t(), + nonce: String.t(), + exp: integer() + } + + @doc "Seals a payload for `ttl_s` seconds (15 minutes by default); the nonce is minted here." + @spec seal(map(), keyword()) :: String.t() + def seal( + %{approval_id: _, session_id: _, call_id: _, tool: _, args_digest: _} = payload, + opts \\ [] + ) do + ttl = Keyword.get(opts, :ttl_s, ttl_s()) + + plain = + payload + |> Map.take([:approval_id, :session_id, :call_id, :tool, :args_digest]) + |> Map.put(:nonce, Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false)) + |> Map.put(:exp, System.os_time(:second) + ttl) + |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) + |> Jason.encode!() + + iv = :crypto.strong_rand_bytes(12) + {ct, tag} = :crypto.crypto_one_time_aead(:aes_256_gcm, key(), iv, plain, @aad, true) + @version <> Base.url_encode64(iv <> tag <> ct, padding: false) + end + + @doc "Opens a sealed state: the payload, or why not (`:malformed`, `:tampered`, `:expired`)." + @spec open(term()) :: {:ok, payload()} | {:error, :malformed | :tampered | :expired} + def open(@version <> rest) when is_binary(rest) do + with {:ok, <>} <- + Base.url_decode64(rest, padding: false), + plain when is_binary(plain) <- + :crypto.crypto_one_time_aead(:aes_256_gcm, key(), iv, ct, @aad, tag, false), + {:ok, %{"exp" => exp} = map} when is_integer(exp) <- Jason.decode(plain) do + if exp < System.os_time(:second) do + {:error, :expired} + else + {:ok, + %{ + approval_id: map["approval_id"], + session_id: map["session_id"], + call_id: map["call_id"], + tool: map["tool"], + args_digest: map["args_digest"], + nonce: map["nonce"], + exp: exp + }} + end + else + :error -> {:error, :tampered} + {:error, _} -> {:error, :tampered} + _ -> {:error, :malformed} + end + end + + def open(_other), do: {:error, :malformed} + + @doc "SHA-256, hex, over the canonical JSON of the arguments: what the envelope binds a retry to." + @spec args_digest(map()) :: String.t() + def args_digest(args) when is_map(args) do + :crypto.hash(:sha256, Jason.encode!(args |> Enum.sort() |> Jason.OrderedObject.new())) + |> Base.encode16(case: :lower) + end + + @doc "The envelope's lifetime in seconds (`config :trinity, :mcp_server, state_ttl_s`)." + @spec ttl_s() :: pos_integer() + def ttl_s, + do: + Application.get_env(:trinity, :mcp_server, []) |> Keyword.get(:state_ttl_s, @default_ttl_s) + + @doc "The key file's path." + @spec key_path() :: Path.t() + def key_path, do: Path.join(Trinity.Receipts.KeyCustody.keys_dir(), "mcp-state.key") + + @doc "Generates the key file when absent (mode 0600); returns its path." + # sobelow_skip reason: Traversal.FileModule: the path is the keys directory's, never a request's. + @sobelow_skip ["Traversal.FileModule"] + @spec ensure_key!() :: Path.t() + def ensure_key! do + path = key_path() + + unless File.exists?(path) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, :crypto.strong_rand_bytes(32)) + File.chmod!(path, 0o600) + end + + :persistent_term.erase({__MODULE__, :key}) + path + end + + # Read once per VM, the file being the record; a missing file is generated (a fresh data + # directory) rather than refused, since no envelope minted under another key can open anyway. + # sobelow_skip reason: Traversal.FileModule: the path is the keys directory's plus a constant + # name (key_path/0), never a request's. + @sobelow_skip ["Traversal.FileModule"] + defp key do + case :persistent_term.get({__MODULE__, :key}, nil) do + nil -> + path = ensure_key!() + <> = File.read!(path) + :persistent_term.put({__MODULE__, :key}, key) + key + + key -> + key + end + end +end diff --git a/lib/trinity/mcp/server/exports.ex b/lib/trinity/mcp/server/exports.ex new file mode 100644 index 0000000..2d1339a --- /dev/null +++ b/lib/trinity/mcp/server/exports.ex @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Exports do + @moduledoc """ + Which of Trinity's tools the MCP server exports (slice 061): `config :trinity, :mcp_server, + tools:` names them; the default is the read-only set. Every name must be a core entry of the + registry whose effect is `:none` or `:artifact`. A name that is not registered, or whose + effect is `:catalog`, is refused with a reason and not exported (AC5): the effect catalog is + compile time (docs/07), and a server on the network is not a place a catalogued effect + becomes callable by configuration. + """ + + alias Trinity.Tools + + @default_tools ~w(recall session_search skills_list skill_view skill_file) + + @type refusal :: {String.t(), :unknown_tool | :catalog_is_not_exportable | :dynamic_tool} + + @doc "The configured names, the default set when unset." + @spec configured() :: [String.t()] + def configured do + Application.get_env(:trinity, :mcp_server, []) |> Keyword.get(:tools, @default_tools) + end + + @doc "The default set." + @spec defaults() :: [String.t()] + def defaults, do: @default_tools + + @doc "The exported registry entries (sorted by name) and the refusals, from the configured names." + @spec resolve([String.t()]) :: {[Tools.Registry.entry()], [refusal()]} + def resolve(names \\ configured()) do + {entries, refusals} = + Enum.reduce(Enum.uniq(names), {[], []}, fn name, {ok, bad} -> + case Tools.lookup(name) do + {:ok, %{kind: :core, effect: effect} = entry} when effect in [:none, :artifact] -> + {[entry | ok], bad} + + {:ok, %{kind: :core, effect: :catalog}} -> + {ok, [{name, :catalog_is_not_exportable} | bad]} + + {:ok, %{kind: :dynamic}} -> + {ok, [{name, :dynamic_tool} | bad]} + + {:error, :unknown_tool} -> + {ok, [{name, :unknown_tool} | bad]} + end + end) + + {Enum.sort_by(entries, & &1.name), Enum.reverse(refusals)} + end + + @doc "The exported entries alone." + @spec entries() :: [Tools.Registry.entry()] + def entries, do: resolve() |> elem(0) + + @doc "The exported entry for a name, or nil." + @spec entry(String.t()) :: Tools.Registry.entry() | nil + def entry(name), do: Enum.find(entries(), &(&1.name == name)) +end diff --git a/lib/trinity/mcp/server/plug.ex b/lib/trinity/mcp/server/plug.ex new file mode 100644 index 0000000..83ae80b --- /dev/null +++ b/lib/trinity/mcp/server/plug.ex @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Plug do + @moduledoc """ + The `/mcp` endpoint (slice 061): beam_mcp's Streamable HTTP transport with Trinity's options, + mounted in the endpoint ahead of `Plug.Parsers` (the transport reads the raw body itself) + and answering `POST /mcp` alone; every other request passes through to the router (the + `/mcp` page keeps GET). The options: the wrapper as + `:server`, `Trinity.MCP.Server.Catalog`, `Trinity.MCP.Server.Auth.Local` as `:authorize`, the + loopback origins (a request with no `Origin`, which every CLI client sends, is served; a + browser page on another origin is refused), and `tools_ttl_ms` and `tools_cache_scope` from + `config :trinity, :mcp_server`. This module exists so the router, in the web boundary, never + names the core. + """ + @behaviour Plug + + alias Trinity.MCP.Server.{Auth, Catalog} + + @impl true + def init(_opts) do + config = Application.get_env(:trinity, :mcp_server, []) + + BeamMCP.Transport.HTTP.init( + server: Trinity.MCP.Server, + catalog: Catalog, + dispatch: nil, + authorize: &Auth.Local.authorize/1, + allowed_origins: Keyword.get(config, :allowed_origins, loopback_origins()), + server_name: Keyword.get(config, :server_name, "trinity"), + tools_ttl_ms: Keyword.get(config, :tools_ttl_ms, 60_000), + tools_cache_scope: Keyword.get(config, :tools_cache_scope, "private") + ) + end + + @impl true + def call(%Plug.Conn{method: "POST", path_info: ["mcp"]} = conn, opts), + do: conn |> BeamMCP.Transport.HTTP.call(opts) |> Plug.Conn.halt() + + def call(conn, _opts), do: conn + + @doc "The origins a browser on this machine may present." + @spec loopback_origins() :: [String.t()] + def loopback_origins do + for host <- ["localhost", "127.0.0.1", "[::1]"], + scheme <- ["http", "https"], + do: "#{scheme}://#{host}" + end +end diff --git a/lib/trinity/mcp/server/replay.ex b/lib/trinity/mcp/server/replay.ex new file mode 100644 index 0000000..accc9ff --- /dev/null +++ b/lib/trinity/mcp/server/replay.ex @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Replay do + @moduledoc """ + The partition-local replay defence for `requestState` (slice 061): a nonce is used at most + once on this instance. An ETS table owned by this process holds the nonces seen with their + expiry; `use/2` admits a nonce the table has not seen and refuses one it has; entries are + pruned by expiry window, so the table holds at most one lifetime of nonces. Another + instance has its own table, which is why this is at-most-once *per partition*: across + partitions the membrane's idempotency key (session and call id, refused as a duplicate + effect) is the backstop, and the approval's "once" is consumed by the first run. + """ + use GenServer + + @table __MODULE__ + @prune_ms 60_000 + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @doc "Admits a nonce not yet seen on this instance; refuses a replay." + @spec use(String.t(), integer()) :: :ok | {:error, :replayed} + def use(nonce, exp) when is_binary(nonce) and is_integer(exp) do + if :ets.insert_new(@table, {nonce, exp}), do: :ok, else: {:error, :replayed} + end + + @doc "Forgets every nonce (a test's reset, or a second partition's fresh table)." + @spec reset() :: :ok + def reset, do: GenServer.call(__MODULE__, :reset) + + @doc "How many nonces the table holds." + @spec size() :: non_neg_integer() + def size, do: :ets.info(@table, :size) + + @impl true + def init(_opts) do + :ets.new(@table, [:named_table, :set, :public, write_concurrency: true]) + Process.send_after(self(), :prune, @prune_ms) + {:ok, %{}} + end + + @impl true + def handle_call(:reset, _from, state) do + :ets.delete_all_objects(@table) + {:reply, :ok, state} + end + + @impl true + def handle_info(:prune, state) do + now = System.os_time(:second) + :ets.select_delete(@table, [{{:_, :"$1"}, [{:<, :"$1", now}], [true]}]) + Process.send_after(self(), :prune, @prune_ms) + {:noreply, state} + end +end diff --git a/lib/trinity/mcp/server/session.ex b/lib/trinity/mcp/server/session.ex new file mode 100644 index 0000000..dcfc1de --- /dev/null +++ b/lib/trinity/mcp/server/session.ex @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Session do + @moduledoc """ + The session every MCP call is attributed to (slice 061): one row with `origin: "mcp"` and a + persona of its own ("MCP server", no permission settings, so an external caller inherits + none of the default persona's allowances: the default persona lets the assistant write its + own memory without asking, and an MCP client is not the assistant), found or created on + first use. The gate's scope, the approvals' session and + the receipts' chain scope are all this row's, so a call made by an MCP client is audited + where every other call is, under a session the permissions page shows as "MCP server". + No `Trinity.Sessions.Session` process runs for it: the server is stateless, and the + membrane needs a session id, not a process. + """ + import Ecto.Query, only: [from: 2] + + alias Trinity.Repo + alias Trinity.Sessions + alias Trinity.Sessions.SessionRow + + @title "MCP server" + @persona "MCP server" + + @doc "The row's id, creating the row when none exists." + @spec id() :: String.t() + def id, do: row().id + + @doc "The row." + @spec row() :: SessionRow.t() + def row do + case Repo.one( + from(s in SessionRow, where: s.origin == "mcp", order_by: s.inserted_at, limit: 1) + ) do + %SessionRow{} = row -> + row + + nil -> + case Sessions.create_session(%{persona_id: persona().id, origin: "mcp", title: @title}) do + {:ok, row} -> row + {:error, _} -> row() + end + end + end + + @doc "The MCP persona: found by name, created with no settings when absent." + @spec persona() :: struct() + def persona do + case Sessions.get_persona_by_name(@persona) do + nil -> + case Trinity.Personas.create(%{ + name: @persona, + soul: "Trinity, serving MCP clients.", + settings: %{} + }) do + {:ok, persona} -> persona + {:error, _} -> Sessions.get_persona_by_name(@persona) + end + + persona -> + persona + end + end +end diff --git a/lib/trinity/mcp/server/stdio.ex b/lib/trinity/mcp/server/stdio.ex new file mode 100644 index 0000000..ac429c6 --- /dev/null +++ b/lib/trinity/mcp/server/stdio.ex @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Server.Stdio do + @moduledoc """ + Trinity's MCP server over stdio (slice 061): beam_mcp's stdio transport with + `Trinity.MCP.Server` above the core, dual-era (the core serves 2026-07-28 and 2025-11-25 on + stdio; the HTTP transport serves the modern revision alone). Whoever can write to this + process's standard input already has the host's privileges, which is why there is no bearer + here, as the core's own page says. `run/1` blocks until end of input. + """ + + alias Trinity.MCP.Server.Catalog + + @doc "Runs the loop on standard input and output until end of input; the VM's log is moved to standard error first." + @spec run(keyword()) :: :ok + def run(opts \\ []) do + log_to_stderr() + config = Application.get_env(:trinity, :mcp_server, []) + + BeamMCP.Transport.Stdio.run( + Keyword.merge( + [ + server: Trinity.MCP.Server, + catalog: Catalog, + server_name: Keyword.get(config, :server_name, "trinity"), + tools_ttl_ms: Keyword.get(config, :tools_ttl_ms, 60_000), + tools_cache_scope: Keyword.get(config, :tools_cache_scope, "private") + ], + opts + ) + ) + end + + @doc """ + For a release: moves the log to standard error, starts the application and runs the loop + (`bin/headless eval 'Trinity.MCP.Server.Stdio.serve()'`). Under Mix, `mix trinity.mcp.stdio`. + """ + @spec serve() :: :ok + def serve do + log_to_stderr() + {:ok, _} = Application.ensure_all_started(:trinity) + run() + end + + # The default log handler writes to standard output, which is the wire here (a logged line + # is an undecodable message to the client); it is replaced after the application started, + # since the logger application installs it again on start. Idempotent. + @doc false + def log_to_stderr do + # The logger application would install its default handler again on start; told not to. + Application.put_env(:logger, :default_handler, false) + _ = :logger.remove_handler(:default) + _ = :logger.remove_handler(:stderr) + :logger.add_handler(:stderr, :logger_std_h, %{config: %{type: :standard_error}}) + :ok + end +end diff --git a/lib/trinity/sessions.ex b/lib/trinity/sessions.ex index 8f90c3b..7b4b57d 100644 --- a/lib/trinity/sessions.ex +++ b/lib/trinity/sessions.ex @@ -11,6 +11,8 @@ defmodule Trinity.Sessions do # Slice 012: Sessions reaches the LLM (docs/01: Sessions depends on LLM, Repo, PubSub). # Slice 020: and the tool runtime, for the declared surface and the runner in force. # Slice 023: and Memory, for the estimate and the compaction before a model call. + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + use Boundary, # Slice 030: Receipts, for the prompt truncation receipt (docs/01's row as built). deps: [Trinity, Trinity.LLM, Trinity.Tools, Trinity.Memory, Trinity.Receipts], @@ -66,7 +68,11 @@ defmodule Trinity.Sessions do def archive(%SessionRow{} = session), do: Store.update_session(session, %{status: "archived"}) @default_persona_name "default" - @default_soul_path Path.join(:code.priv_dir(:trinity), "personas/default/SOUL.md") + # Resolved when read, not at compile time: a module attribute baked the build tree's + # `_build/prod/lib/trinity/priv` into the release, where the priv directory is elsewhere, + # and the first page of the headless release answered 500 on a fresh data directory (slice + # 061 AC6's probe). The Burrito binary hid it: its payload keeps the build tree's layout. + @default_soul_relative "personas/default/SOUL.md" @doc """ The persona new sessions belong to: the row named `default`, created on first use. Slice @@ -96,10 +102,13 @@ defmodule Trinity.Sessions do end @doc "The default persona's seed: the SOUL file and the settings it ships with." + # sobelow_skip reason: Traversal.FileModule: the path is this application's priv directory + # plus a constant, never a request's (resolved at read time since slice 061's fix). + @sobelow_skip ["Traversal.FileModule"] @spec default_seed() :: map() def default_seed do %{ - soul: File.read!(@default_soul_path), + soul: File.read!(Path.join(:code.priv_dir(:trinity), @default_soul_relative)), settings: %{"permissions" => %{"memory" => "allow"}} } end diff --git a/lib/trinity/tools/context.ex b/lib/trinity/tools/context.ex index 6e9a714..88f2a56 100644 --- a/lib/trinity/tools/context.ex +++ b/lib/trinity/tools/context.ex @@ -17,8 +17,17 @@ defmodule Trinity.Tools.Context do persona: struct() | map() | nil, caller: term(), call_id: String.t() | nil, - tool: String.t() | nil + tool: String.t() | nil, + origin: String.t() | nil, + trace: map() | nil } - defstruct session_id: nil, cwd: nil, persona: nil, caller: nil, call_id: nil, tool: nil + defstruct session_id: nil, + cwd: nil, + persona: nil, + caller: nil, + call_id: nil, + tool: nil, + origin: nil, + trace: nil end diff --git a/lib/trinity/tools/runner.ex b/lib/trinity/tools/runner.ex index 4fe988a..a0f1298 100644 --- a/lib/trinity/tools/runner.ex +++ b/lib/trinity/tools/runner.ex @@ -143,8 +143,11 @@ defmodule Trinity.Tools.Runner do defp ask(%Context{session_id: sid, cwd: cwd} = ctx, %{name: name} = entry, args) do risk = Permissions.effective_tier(name, escalation(entry, args, ctx)) + # Slice 061: a call that did not come from the desktop says so on the request, so the card + # names the right actor ("an MCP client asks" rather than "Trinity wants to run"). + request = if ctx.origin, do: %{"kind" => "call", "origin" => ctx.origin}, else: nil - case Permissions.request_approval(sid, name, args, cwd: cwd, risk: risk) do + case Permissions.request_approval(sid, name, args, cwd: cwd, risk: risk, request: request) do {:ok, approval} -> {:approval_required, approval.id} {:error, reason} -> {:request_failed, reason} end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index ef16456..b4a99b4 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -163,10 +163,10 @@ defmodule Trinity.Versions do }, %{ name: "beam_mcp", - pin: "~> 0.8", + pin: "~> 0.9", lock: "beam_mcp", note: - "MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.8.0 on hex.pm, standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history." + "MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.9.0 on hex.pm (2026-09-22): the :server seam on both transports and the core server as a named behaviour, which slice 061's wrapper implements; standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059, bumped at 061. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history." }, %{ name: "jido", diff --git a/lib/trinity_web/components/approval_components.ex b/lib/trinity_web/components/approval_components.ex index 4312f09..9543b88 100644 --- a/lib/trinity_web/components/approval_components.ex +++ b/lib/trinity_web/components/approval_components.ex @@ -90,7 +90,7 @@ defmodule TrinityWeb.ApprovalComponents do >
<.icon name="hero-hand-raised-micro" class="size-5 text-warning" /> - {gettext("Trinity wants to run")} + {actor(@approval)} {@approval.tool} <.risk_badge risk={@approval.risk} />
@@ -306,6 +306,16 @@ defmodule TrinityWeb.ApprovalComponents do """ end + # Slice 061: who is asking. A call from an MCP client names the client as the actor; the + # assistant's own calls read as before. + defp actor(%Approval{request: %{"origin" => "mcp"}}), + do: gettext("An MCP client asks Trinity to run") + + defp actor(%Approval{request: %{"origin" => origin}}) when is_binary(origin), + do: gettext("A %{origin} caller asks Trinity to run", origin: origin) + + defp actor(_approval), do: gettext("Trinity wants to run") + @doc "The pattern proposed for always-allow: the first path-like argument as a glob on its directory, else `*`." @spec suggest_pattern(Approval.t()) :: String.t() def suggest_pattern(%Approval{args: args}) do diff --git a/lib/trinity_web/endpoint.ex b/lib/trinity_web/endpoint.ex index b77ffc8..8037999 100644 --- a/lib/trinity_web/endpoint.ex +++ b/lib/trinity_web/endpoint.ex @@ -45,6 +45,11 @@ defmodule TrinityWeb.Endpoint do plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + # Slice 061: Trinity as an MCP server, at POST /mcp, ahead of the parsers: the core's + # transport reads the raw body itself (its size bound, its nesting bound, its duplicate-key + # refusal), so the body must reach it unread. Every other request passes through. + plug Trinity.MCP.Server.Plug + plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], diff --git a/mix.exs b/mix.exs index cebc523..5d2173b 100644 --- a/mix.exs +++ b/mix.exs @@ -48,6 +48,15 @@ defmodule Trinity.MixProject do # (see NOTES.md deviation D1). defp releases do [ + # Slice 061: the headless release. The same tree assembled as an ordinary OTP release + # (no Burrito, no desktop shell), for a server that runs Trinity as an MCP server and + # the web pages on a bind address it is told (`TRINITY_MODE=headless`, `TRINITY_BIND`, + # `PORT`; config/runtime.exs). `ci/headless/Containerfile` builds and runs it. + headless: [ + steps: [:assemble], + include_executables_for: [:unix], + applications: exla_release_applications() + ], desktop: [ steps: [:assemble, &Burrito.wrap/1], # Slice 032: exla in the release, loaded and not started (see `exla_deps/0`); on a @@ -162,7 +171,7 @@ defmodule Trinity.MixProject do {:file_system, "~> 1.1"}, # Slice 059: the MCP server core (ADR-0007 decision 5), reached only through the # Trinity.MCP boundary; the slice measures its gap, 060 and 061 build on it. - {:beam_mcp, "~> 0.8"}, + {:beam_mcp, "~> 0.9"}, # Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from # source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs), # because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13). diff --git a/mix.lock b/mix.lock index 9cbef91..c160927 100644 --- a/mix.lock +++ b/mix.lock @@ -2,7 +2,7 @@ "abnf_parsec": {:hex, :abnf_parsec, "2.1.0", "c4e88d5d089f1698297c0daced12be1fb404e6e577ecf261313ebba5477941f9", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e0ed6290c7cc7e5020c006d1003520390c9bdd20f7c3f776bd49bfe3c5cd362a"}, "axon": {:hex, :axon, "0.8.1", "c4a975e62a14ab6c374997b77367ec3a4c2740952ac474d3b0f202c91b7f75c4", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.10", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1 or ~> 4.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "682a3517489300507ac9345f28341e7fa95bc5b4960d645816074ce551795d37"}, "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, - "beam_mcp": {:hex, :beam_mcp, "0.8.0", "49f2607a92da4137cd1d11fc8669c396e1d93eedf7a3e29aaab1531dd41bd9c2", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b5a1beba2c936c2c50e164c43240ac31bc8b159a994980083ec0f6562392801e"}, + "beam_mcp": {:hex, :beam_mcp, "0.9.0", "05def1a4d6b4d24fa37531c975d6e4799d14fd3a7bc3557d1446748fdac0c762", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2bf9615c901174210a09b31e4f88e3abb1ff6a092fffd2de0b7b02a25b9caa0d"}, "boundary": {:hex, :boundary, "0.10.4", "5fec5d2736c12f9bfe1720c3a2bd8c48c3547c24d6002ebf8e087570afd5bd2f", [:mix], [], "hexpm", "8baf6f23987afdb1483033ed0bde75c9c703613c22ed58d5f23bf948f203247c"}, "bumblebee": {:hex, :bumblebee, "0.7.1", "0d6a757f955671a379ebcf4ed07565513fa767b1869169b989f65ba7958a80f4", [:mix], [{:axon, "~> 0.8.0", [hex: :axon, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.12.0 or ~> 0.13.0", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.3", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "~> 0.12.0 or ~> 0.13.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "f26b3355700e3c7580014d41ce477d87d9fd71a60b76d5b64701dacb07830a58"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, diff --git a/scripts/dev_mcp_server_approval.sh b/scripts/dev_mcp_server_approval.sh new file mode 100755 index 0000000..8fbf744 --- /dev/null +++ b/scripts/dev_mcp_server_approval.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# Slice 061 AC4's screenshot: the test environment's application on its own databases (never the +# suite's; both repos off the sandbox pool), the memory tool exported, and one MCP call to it made +# against the wrapper, so an approval from the "MCP server" session waits on /permissions. Prints +# PORT= and the sealed requestState the client would carry. `mix assets.build` first; remove any +# stale priv/static/assets/**/*.gz. +cd "$(dirname "$0")/.." +export MIX_ENV=test +exec systemd-run --user --scope -p MemoryMax=32G --quiet -- mix run --no-start --no-halt -e ' +repo = Application.get_env(:trinity, Trinity.Repo) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots.db") +Application.put_env(:trinity, Trinity.Repo, repo) +rrepo = Application.get_env(:trinity, Trinity.Repo.Receipts) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots_receipts.db") +Application.put_env(:trinity, Trinity.Repo.Receipts, rrepo) +endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false) +Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint) +Application.put_env(:trinity, :permissions, expiry_ms: 600_000, session_grant_ms: 3_600_000) +Application.put_env(:trinity, :mcp_boot, false) +Application.put_env(:trinity, :mcp_server, tools: Trinity.MCP.Server.Exports.defaults() ++ ["memory"]) +# The receipts database is migrated before the application boots, or the boot receipt fails +# on a fresh file (031 NOTES); the primary follows the same order for symmetry. +{:ok, _} = Application.ensure_all_started(:ecto_sql) +for repo <- [Trinity.Repo, Trinity.Repo.Receipts] do + {:ok, pid} = repo.start_link() + Ecto.Migrator.run(repo, :up, all: true) + GenServer.stop(pid) +end +{:ok, _} = Application.ensure_all_started(:trinity) +meta = %{"io.modelcontextprotocol/protocolVersion" => "2026-07-28", "io.modelcontextprotocol/clientCapabilities" => %{"elicitation" => %{"form" => %{}}}} +state = Trinity.MCP.Server.new(catalog: Trinity.MCP.Server.Catalog, server_name: "trinity") +call = %{"jsonrpc" => "2.0", "id" => 1, "method" => "tools/call", "params" => %{"name" => "memory", "arguments" => %{"action" => "add", "key" => "favourite_editor", "body" => "the one that is open"}, "_meta" => meta}} +{_state, %{"result" => %{"resultType" => "input_required", "requestState" => sealed}}} = Trinity.MCP.Server.handle_message(state, call) +{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) +IO.puts("PORT=#{port}") +IO.puts("STATE=#{sealed}") +' diff --git a/slices/061-mcp-server/NOTES.md b/slices/061-mcp-server/NOTES.md new file mode 100644 index 0000000..d0dde23 --- /dev/null +++ b/slices/061-mcp-server/NOTES.md @@ -0,0 +1,201 @@ +# Slice 061: NOTES + +## Read before code, 2026-09-22 + +Tree at `a130e95` on `main` (060 approved), then beam_mcp pinned at `~> 0.9` in this branch's first commit: +0.9.0 on hex.pm 2026-09-22, tag `v0.9.0` at `10e4252`, the lock's package checksum `2bf9615c…` the one the +release record on beam_mcp's board names; the 060 client suite passes on it unchanged (`mix test +test/trinity/mcp` → 23 passed), which is the "no break" claim measured from the consumer's side. + +What 0.9.0 gives this slice, from its CHANGELOG and `docs/public-api.txt` at `10e4252`: the `:server` +option on `BeamMCP.Transport.HTTP.init/1` and `BeamMCP.Transport.Stdio.run/1`, default `BeamMCP.Server`, +validated at init structurally (`new/1` and `handle_message/2` exported; stdio asks `shutdown?/1` too); +`BeamMCP.Server` as a behaviour (`c:new/1`, `c:handle_message/2`, `c:shutdown?/1` optional), the answer to +the question 059's board note left open; `:server` popped before the rest of the options reach the wrapper's +`new/1`; will-not-implement entry 12 unchanged (the core still answers every request completely). + +What this slice joins: 060's client (AC1's first half is our own driver connecting to our own server), 024's +membrane (`Trinity.Effects.Runner.run/2` with a context: decision receipt, then a query receipt or the +membrane), 021's approvals (the gate holds an `:ask` call as a pending row the owner decides on the +permissions page; "allow once" is consumed by the call that runs), 020's registry (the exported tools are core +entries; `:catalog` never leaves the compile-time list), the data directory's keys (`Trinity.Receipts.KeyCustody` +keeps its key files under `/keys/`, the place the envelope key goes too), and the +`Trinity.DataDir.Lock`'s `headless` mode, which already reads `TRINITY_MODE`. + +Two facts the SLICE's 2026-09-20 blocker list carries that are now settled, so they do not shape the plan: +`resultType` is stamped on every 2026-07-28 result at 0.8.0 (059 FINDINGS row 8; the line saying "only +`tools/call`" is superseded), and the seam is shipped, so the ADR's fallback (a fork of the dispatch +function) is not taken. + +Three places where SLICE.md and the core as shipped disagree, decided here and open to the owner's veto in +this turn: + +- **A 2025-11-25 client does not connect over the `/mcp` route, by the transport's design.** 059 FINDINGS + row 1: `BeamMCP.Transport.HTTP` serves 2026-07-28 alone and refuses any other version at the header + (`-32022`), and the README says why (HTTP is the carrier that stamps every request modern; `initialize` + is refused at the transport). The core underneath is dual-era, and so is the wrapper this slice builds + above it: AC1's second half is proven against the wrapper on the wire the stdio transport carries + (`initialize`, then `_meta` naming 2025-11-25), and a stdio entry point (`mix trinity.mcp.stdio`, the + same wrapper under `BeamMCP.Transport.Stdio.run/1`) is the route a 2025-11-25 client has. The goal line's + "from the same route" is not met over HTTP and cannot be without a change in beam_mcp that its README + refuses; recorded, not raised. +- **Tasks is out**, as at 060: 059's approved finding 4 ("061 does not advertise it"). The goal's "Tasks + for long tools" and the scope line are not built; `server/discover` advertises no extension. +- **MRTR for approvals answers `input_required` to a 2026-07-28 client; a 2025-11-25 client gets a tool + error instead.** MRTR is the modern revision's pattern; the legacy revision has no `input_required`, so a + legacy call that needs approval is answered `isError` with the approval's id and the instruction to retry + after deciding, and the retry after the decision runs (the gate's "once" is consumed by it). + +The envelope (`requestState`), as the SLICE asks: minted and validated by Trinity, opaque to the client, +sealed with AES-256-GCM (FIPS-approved; the receipt scheme's FIPS leg runs this suite) under a key in +`/keys/mcp-state.key` (32 bytes, generated once, mode 0600, shared by every instance of the same +data directory, which is what AC7's "different instance of the same release" needs). The plaintext binds +the approval id, the session, the call id, the tool, a digest of the arguments, a nonce and an expiry. Replay +defence is the SLICE's: a partition-local nonce cache keyed by expiry window (an ETS table, pruned by +window), and the membrane's idempotency key (`session_id` + `call_id`, refused as `duplicate_effect` on a +second run) as the cross-partition backstop; the property is at-most-once per partition plus idempotent +effects, stated as such, with reds for a replay inside the window, a replay across partitions, an expired +envelope and one tampered byte. + +## G1 plan, 2026-09-22 + +Branch `slice/061-mcp-server`; ROADMAP row 061 to `in_progress` in the pin commit. Each line names its test. + +1. `Trinity.MCP.Server.Exports`: the exported tools from `config :trinity, :mcp_server, tools:` (default the + read-only set `recall`, `session_search`, `skills_list`, `skill_view`, `skill_file`; `memory` and the other + `:artifact` tools opt-in by name), validated at boot against the registry: a name that is not a core + entry or whose effect is `:catalog` is refused with a reason and not exported. Test AC5 (a config naming + `shell` is refused; the catalog never lists it). +2. `Trinity.MCP.Server.Catalog` (`@behaviour BeamMCP.Catalog`): one `ToolSpec` per exported entry, name, + description and `input_schema` from the registry, `mode: :read_only` for `:none` and `:proposal` for + `:artifact`, **sorted by name** (059 finding 3: the core keeps the catalog's order); no resources, no + prompts; the connectome surface not exported (the SLICE's default, off). Test AC2 (two `tools/list` + answers are identical, ordered, and carry `ttlMs` and `cacheScope` from the host's options). +3. `Trinity.MCP.Server` (`@behaviour BeamMCP.Server`): `new/1` wraps the core's state; `handle_message/2` + intercepts `tools/call` (arguments validated by `BeamMCP.Schema.validate/2` against the listed schema, + then `Trinity.Effects.Runner.run/2` in the MCP context; the result mapped to content, `isError`, + `structuredContent`, and at 2026-07-28 `resultType` and the server's `_meta`) and delegates every other + message to the core; `shutdown?/1` is the core's. The era of a request is read as the core reads it: the + `_meta` version, or the legacy state an `initialize` set. Tests AC1 (our 060 client connects at + 2026-07-28 over the route and lists the exported tools; a 2025-11-25 wire against the wrapper connects + with `initialize` and calls a tool) and AC3. +4. The MCP context: one session row per server with `origin: "mcp"` (found or created by + `Trinity.MCP.Server.Session`, the default persona, title "MCP server"), the effects run with + `Trinity.Tools.Context` carrying `origin: "mcp"` (a new field, written into the decision and query + receipts' subject by `Trinity.Effects.Runner`), and the incoming `_meta` trace context (`traceparent`, + when present) copied into the receipts' meta for 090. Test AC3 (`recall` over MCP yields a query receipt + whose subject carries `"origin" => "mcp"`). +5. `Trinity.MCP.Server.Envelope` (seal, open, the key file) and `Trinity.MCP.Server.Replay` (the nonce + cache). On `{:error, {:approval_required, id}}` a 2026-07-28 call answers `input_required` with one + elicitation request (the approval named, the permissions page named, a `requestedSchema` with one + boolean so a generic client can retry) and the sealed envelope; a retry opens the envelope, refuses an + expired, tampered, replayed or mismatched one (`-32602`, the reason named, nothing of the plaintext + echoed), runs under the envelope's call id, and answers `input_required` again with a fresh envelope + while the approval is pending, the result once it is allowed, an `isError` denial once it is denied. + Tests AC4 (approve in the UI, the retry succeeds; deny, the retry gets a denial and a receipt) and AC7 + (two Plug instances of the wrapper with different server names share the data directory; an exchange + begun on one completes on the other with the state alone; altered, missing, expired and replayed states + are refused). +6. `Trinity.MCP.Server.Auth.Local` (`:authorize`): the bearer from `TRINITY_MCP_SERVER_TOKEN`, or a token + generated at boot into `/mcp-server-token` (mode 0600) when the variable is unset; a request + without the right bearer is refused before the body is read. `Trinity.MCP.Server.Plug` wraps + `BeamMCP.Transport.HTTP` with the options (the wrapper as `:server`, the catalog, `authorize`, + `allowed_origins` the loopback origins, `tools_ttl_ms` and `tools_cache_scope` from config); the router + mounts it at `POST /mcp` in a pipeline of its own (the `/mcp` page keeps GET). Tests: a wrong or absent + bearer is 403 with nothing decoded; the right one reaches `server/discover`. +7. `mix trinity.mcp.stdio`: the wrapper under the core's stdio transport for a client that speaks stdio (a + 2025-11-25 client's route; Claude Code's common configuration), with the data-directory lock's rule + stated: it is a second Trinity and needs the data directory to itself, so it runs when the desktop or + the headless server does not. +8. Headless: a `headless` release in `mix.exs` (assemble only, no Burrito), `TRINITY_MODE=headless` in + `config/runtime.exs` (the endpoint binds `TRINITY_BIND`, loopback by default, on `PORT`, 4000 by + default; the server flag on), `ci/headless/Containerfile` (a builder stage on the Elixir image, a slim + runtime stage), a systemd unit example in `docs/mcp-server.md`. Test AC6: the image built and run here + with docker, no display, `server/discover` answered through the bearer; the log pasted. +9. Docs: `docs/mcp-server.md` (connecting Claude Code, Codex, goose and VS Code; the token; headless), + docs/01 (the server under the boundary), docs/05 (the token file, the key file), docs/07 (the MRTR + approval: what the envelope binds, what replay defence holds), docs/08 (the server row), README (the + Connects bullet gains the server side; the pages list). +10. Gate, coverage, PROOF; the ROADMAP row. + +Manual verification queue: **AC8**, Claude Code connected and calling `recall` (screenshot). The steps for +the owner are in `docs/mcp-server.md`; PROOF.md carries our own 060 client doing the same call as the +automatic half. + +Not built here: Tasks (above), MCP Apps (the SLICE's follow-up), internet exposure, the OAuth resource +server (062; `Auth.Local` is a static bearer on a loopback bind), sampling or roots requests from the +server (never: the server asks the owner, not the client's model), the connectome surface. + +## Deviations while building, 2026-09-22 + +- **The plug sits in the endpoint, not the router.** Phoenix's `Plug.Parsers` consumes a JSON body + before the router runs, and the core's transport reads the raw body itself (its size bound, its + nesting bound, its duplicate-key refusal); mounted in the router, every request answered + `-32700 Parse error: empty body`. `Trinity.MCP.Server.Plug` is mounted in `TrinityWeb.Endpoint` + ahead of the parsers, answers `POST /mcp` alone and passes everything else through. +- **The MCP session has a persona of its own.** With the default persona, the first `memory` call from + an MCP client ran without asking: the default persona's settings allow the assistant its own + memory tool (030). An external caller inherits none of that: the "MCP server" persona has no settings. +- **A retry before the decision does not re-enter the gate.** It did at first, and the gate minted a + second pending approval for the same fingerprint, which then masked the one the owner decided (the + policy reads the newest). A retry whose envelope names a pending approval is held again on that + approval under a fresh envelope; the gate is entered only once the approval is decided. +- **The card names the actor.** "Trinity wants to run memory" was wrong for a call an MCP client made; + the runner puts the origin on the request and the card reads "An MCP client asks Trinity to run". +- **The stdio entry's log handler.** The logger application installs its default handler (standard + output, the wire) again when the application starts, so the handler is disabled by configuration + before start and the standard-error handler added; `Trinity.MCP.Server.Stdio.serve/0` does it for a + release, `mix trinity.mcp.stdio` for the source tree. + +## Findings at G3, 2026-09-22 + +1. **A defect in 030 found by AC6's probe, fixed here as `fix(s030)`.** `Trinity.Sessions.default_seed/0` + read the default SOUL from a module attribute computed at compile time, `_build/prod/lib/trinity/priv`, + which a release does not carry; the headless release's first `GET /` answered 500 on a fresh data + directory. The path is now resolved when read. The Burrito binary hid it because its payload keeps + the build tree's layout; every other `priv_dir` use in the tree (one, in `Skills.Sources`) already + resolved at run time. +2. **The container.** `ci/headless/Containerfile`, built here from the tree (`docker build`, the deps and + exla compiled on the Elixir image, 644 MB as an image), runs as an unprivileged user with no display, + the data directory on a volume, the keys at 0600; `server/discover` answered through the bearer, 403 + without, `tools/list` the five defaults (`proof/ac6-headless-container.log`). +3. **The legacy client over stdio, measured.** `mix trinity.mcp.stdio` fed an `initialize` at 2025-11-25, + a `tools/list` and a `tools/call` answered all three (the call's query receipt written); standard + output carried the wire alone once the log handler was moved. A `mix compile` line from a stale tree + would reach standard output: the docs say to compile first, or to use the release's `serve/0`. +4. **A replayed state across partitions asks again rather than running twice.** AC7's second partition + (a fresh replay table) admits a nonce the first partition spent; the gate's "once" is consumed, so + the gate holds the call on a new approval. The property, at-most-once per partition plus idempotent + effects, is exactly what the SLICE stated, and the test counts one `effect done` receipt. +5. **The core's `serverInfo` on the legacy `initialize` and on `server/discover` names beam_mcp's version** + (`0.9.0`), since those answers are the core's; Trinity's own answers (`tools/call`) name Trinity's. + Recorded, not changed: a wrapper rewriting the core's answers would be the layering ADR-0007 refused. + +## Follow-ups + +- 062: `Trinity.MCP.Server.Auth.Local` is the seam for the OAuth 2.1 resource server; the web pages carry + no authentication in the headless profile, which is why the default bind is the loopback. +- 090: the receipts' `meta.trace` carries the caller's `traceparent`; the catalogue should read it, and + the server should emit a span of its own around `tools/call`. +- beam_mcp's board: the stdio page could say the host's log handler must not write to standard output + (060 finding 4, still unposted); and the `serverInfo` version on the core's answers (finding 5) is + worth a sentence there, since a host cannot set it. +- MCP Apps (the SLICE's follow-up); Tasks (059 finding 4); internet exposure (behind 062). +- The `.dockerignore` added here keeps the build context to the tree; the package workflow does not use it. + +## Two CI timing failures on this branch, 2026-09-22 + +- Run 35676874669 (pull_request event), `postgres` job: 024's standalone census (`selection_test.exs:60`) + read a Bandit handler's accepted socket (this suite's own client connected to this VM's `/mcp`, the + client's pool keeping the connection alive past its test) as an outbound peer. The `push` event's run of + the same commit passed: ordering. Fixed in `f180ec1` (`test(s024)`): the census reads an accepted + connection on the endpoint's listener as inbound, which it is. +- Run 35677465879 (pull_request event), `gate` job, seed 166026, max_cases 8: `archive/round_trip_test.exs:129`'s + `on_exit` (`unseed/1`, four `delete_all`) timed out after 60 s on a database checkout + (`DBConnection.Holder.checkout_call/5`). The `push` event's run of the same commit passed (468 on + postgres, 488 on gate), and `mix test --seed 166026` here passes (488, max_cases 64, so not the same + order). What held the connection is not known from the log. The job was rerun (`gh run rerun --failed`); + the closing correction names its result. If it recurs, the candidate is a `fix(s034)` giving that + module's teardown its own checkout, and the question whether this slice's suite leaves a process holding + one; nothing in it reaches the database after its tests end (the client is stopped on exit, the receipt + writers are stopped on exit), but the record is here so the next reader does not start from nothing. diff --git a/slices/061-mcp-server/PROOF.md b/slices/061-mcp-server/PROOF.md new file mode 100644 index 0000000..f672080 --- /dev/null +++ b/slices/061-mcp-server/PROOF.md @@ -0,0 +1,199 @@ +# Proof for slice 061: MCP server (stateless 2026-07-28, compat for 2025-11-25) + +Agent: Trinity · Coding Agent · Date: 2026-09-22 · Branch: slice/061-mcp-server · Final commit: (the commit carrying this file; named in the closing correction) + +## Summary +Trinity as an MCP server above beam_mcp 0.9.0's core, through the `:server` seam that release shipped: +`Trinity.MCP.Server` implements `BeamMCP.Server`, answers `tools/call` through the permission gate and the +membrane in an `origin: "mcp"` session with a persona of its own, and delegates every other message to +the core. The exported tools are a configured, sorted list (the read-only set by default; `:catalog` +refused). A held call answers `input_required` with a `requestState` sealed by AES-256-GCM under a key in the +data directory, so the exchange completes on any instance of the same data directory; the replay defence +is a partition-local nonce table plus the gate's consumed "once" and the membrane's idempotency key. A +static bearer before the body (`Auth.Local`, 062's seam), the plug in the endpoint ahead of the parsers, a +stdio entry point (dual-era; the HTTP route serves 2026-07-28 alone by the core's design), the `headless` +release with its bind and port and a container recipe built and run here. Deferred: Tasks (059 finding 4); +the OAuth roles (062). One defect in 030 found and fixed (the default SOUL path). Five findings and five +deviations in NOTES.md. + +## Gate +``` +$ mix gate (tree c6b6faa, before this file and the coverage row were added, this machine, OTP 28.5.0.5, Elixir 1.20.4, under a 32 GiB cgroup) +2548 mods/funs, found no issues. +... SCAN COMPLETE ... +No retired or security advisory packages found +No vulnerabilities found. +versions.verify: OK. 103 locked packages, none disagreeing with 53 pins +versions.gen: VERSIONS.md already matches Trinity.Versions and mix.lock +trinity.version_form: OK +trinity.names: OK over 637 tracked files +trinity.secrets.scan: OK over 637 files +trinity.reuse: OK. Every commentable tracked file carries an SPDX header +Result: 488 passed, 18 excluded +trinity.coverage: 060 80.32% vs 059 80.54%: OK +plan_check: PASS +exit=0 +``` +CI: named in the closing correction. + +## Tests +``` +$ mix test --cover (tree c6b6faa) +Result: 488 passed, 18 excluded +| 75.00% | Trinity.MCP.Server | +| 84.85% | Trinity.MCP.Server.Envelope | +| 92.31% | Trinity.MCP.Server.Replay | +| 91.67% | Trinity.MCP.Server.Exports | +| 66.67% | Trinity.MCP.Server.Catalog | +| 40.00% | Trinity.MCP.Server.Auth.Local | (the generated token file: the suite sets the variable) +| 0.00% | Trinity.MCP.Server.Stdio | (measured by hand, NOTES finding 3: a stdio loop is not a test) +| 100.00% | Trinity.MCP.Server.Plug | +| 80.05% | Total | +``` +`coverage.tsv` row: `061 80.05 c6b6faa 2026-09-22` (from 80.32 at 060: the stdio entry and the token file) + +The gate line `trinity.coverage: 060 80.32% vs 059 80.54%` is the gate run before this row was appended; `mix trinity.coverage` on the row reads `061 80.05% vs 060 80.32%: OK``. + +The slice's tests (`mix test test/trinity/mcp/server_test.exs test/trinity/mcp/server_mrtr_test.exs --trace`): +``` +* test a replay inside the partition, one tampered byte, an expired envelope and a state bound to other arguments are refused; a missing state is a first call + * test AC7: begun on instance A, decided by the owner, completed on instance B with the state alone; the effect ran once + * test AC2: tools/list is sorted, identical across two calls, and carries ttlMs and cacheScope + * test the trace context in _meta rides into the receipts' meta + * test AC4: an artifact tool is held as input_required with a sealed state; approving on the permissions page lets the retry complete; denying makes the retry an error with its receipt + * test AC3: recall over /mcp yields a decision and a query receipt whose subject carries origin mcp + * test AC1: a 2025-11-25 client connects to the wrapper with initialize and calls a tool; the answers carry no resultType + * test the bearer: a wrong or absent one is refused before anything is decoded; the right one reaches discover + * test AC1: our client connects at 2026-07-28 over /mcp and lists the exported tools under mcp:self:* + * test AC5: a catalog tool is not exportable; an unknown name and a dynamic name are refused too +Result: 10 passed +``` + +## Acceptance criteria evidence + +### AC1 [auto]: Our 060 client connects at 2026-07-28 and lists exported tools; a 2025-11-25 test client connects to the same wrapper over the stdio wire (amended at G1) +`server_test.exs` "AC1", first test: a `mcp_servers` row named `self` pointing at this VM's own endpoint +(`http://127.0.0.1:/mcp`, the bearer in the client's `TRINITY_MCP_SELF_TOKEN` and the server's +`TRINITY_MCP_SERVER_TOKEN`) connects at `2026-07-28` and registers `mcp:self:recall`, `mcp:self:session_search`, +`mcp:self:skills_list`, `mcp:self:skill_view`, `mcp:self:skill_file` and `mcp:self:memory` (the test exports +`memory` too), with the server's schema as each definition. Second test: the 2025-11-25 wire against the +wrapper (`initialize` with `protocolVersion: "2025-11-25"`, `notifications/initialized`, `tools/list` and +`tools/call skills_list` without `_meta`) answers all three, the answers carrying no `resultType`. The same +wire through the stdio transport was measured by hand (NOTES finding 3): +``` +$ printf '\n\n\n' | mix trinity.mcp.stdio 2>stdio.err +{"id":1,"jsonrpc":"2.0","result":{"capabilities":{…},"protocolVersion":"2025-11-25","serverInfo":{"name":"trinity","version":"0.9.0"}}} +{"id":2,"jsonrpc":"2.0","result":{"cacheScope":"private","tools":[{"annotations":{…},"description":"Recalls what is relevant … +{"id":3,"jsonrpc":"2.0","result":{"content":[{"text":"## Skills\ndevelopment:\n- elixir-project-conventions: … +``` +Over HTTP the core's transport refuses any version but 2026-07-28 at the header (059 FINDINGS row 1); the +deviation is in NOTES.md "Read before code" and SLICE.md's AC1 line. + +### AC2 [auto]: `tools/list` is deterministic and carries `ttlMs`/`cacheScope`; two calls return identical order +`server_test.exs` "AC2": two `tools/list` answers against the wrapper are identical; the names are sorted +(`memory`, `recall`, `session_search`, `skill_file`, `skill_view`, `skills_list`); `ttlMs` and `cacheScope` +are present (`0` and `private` on the bare wrapper; `60000` and `private` through the plug, whose options +carry the configured values, as the AC6 log shows); `resultType` is `complete`; `recall` is +`readOnlyHint: true` and `memory` `destructiveHint: true` from the catalog's modes. + +### AC3 [auto]: `recall` via MCP yields a query receipt with `origin: "mcp"` +`server_test.exs` "AC3": through the route and our client, `recall` under an allow rule answers `complete`, +and the MCP session's chain holds `{"decision", "recall", "mcp"}` and `{"query", "recall", "mcp"}` (kind, +subject tool, subject origin). The neighbour test: a `traceparent` in the request's `_meta` is in the +receipts' `meta.trace`. + +### AC4 [auto]: An `:artifact` tool via MCP → `input_required` → approval in UI → retry succeeds; deny → retry gets a denial result and a receipt (tests + screenshot) +`server_test.exs` "AC4": `memory` (exported for the test) answers `input_required` with one elicitation +request naming the approval and the permissions page and a `v1.` state; the approval row is on the MCP +session; a retry before the decision is held again under a fresh state; after `decide_request(id, :once)` +the retry with the current state answers `complete` ("Kept always_on colour") and the chain holds `effect +admit` and `effect done`; a second call denied on the page makes the retry an `isError` "denied…" and a +decision receipt of outcome `deny`. The screenshots: `proof/ac4-mcp-approval-pending.png` (the card on +`/permissions`, "An MCP client asks Trinity to run memory", the arguments, the four buttons) and +`proof/ac4-mcp-approval-decided.png` (the row "allowed · once"), taken with `scripts/dev_mcp_server_approval.sh` +and a headless browser. + +### AC5 [auto]: A tool claiming `:catalog` is not exportable (config validation test) +`server_test.exs` "AC5": `Exports.resolve(["shell"])` is `{[], [{"shell", :catalog_is_not_exportable}]}`; an +unknown name is `:unknown_tool`; with `tools: ["shell", "recall"]` configured, `Catalog.capabilities/0` lists +`recall` alone. `Trinity.MCP.Boot` logs each refusal at boot. + +### AC6 [auto]: Headless release boots in a container with no display and serves `server/discover` (log) +`ci/headless/Containerfile`, built from this tree with `docker build` (the release assembled on +`hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim`, run on `debian:bookworm-20260824-slim`, +644 MB), run with `docker run -d -p 127.0.0.1:4061:4000 -v trinity-ac6-data:/data -e +TRINITY_MCP_SERVER_TOKEN=… -e SECRET_KEY_BASE=…`. `proof/ac6-headless-container.log` is the run: `GET /` 200; +`server/discover` through the bearer answers `supportedVersions: ["2026-07-28"]`, `resultType: complete`; +the same POST without the bearer is 403 (`refused by host authorize/1: :no_bearer` in the log); `tools/list` +names the five defaults with `ttlMs 60000`, `cacheScope private`; the container's log reads `Running +TrinityWeb.Endpoint with Bandit 1.12.5 at 0.0.0.0:4000`, `mcp server: exporting 5 tools at /mcp`; inside, +`uid=10001(trinity)`, `DISPLAY=unset`, `/data/trinity/keys/mcp-state.key` at mode 0600. The first run of the +image found the 030 defect (`GET /` 500, NOTES finding 1); the log in `proof/` is the rerun on the fixed image. + +### AC7 [auto]: An MRTR exchange begun against one server instance completes against a different instance of the same release, carrying only the `requestState`; a retry with it altered or missing is refused +`server_mrtr_test.exs`, two Plug instances (`server_name` `instance-a` and `instance-b`) through the core's +HTTP transport with `Plug.Test`, sharing the data directory and the database. First test: begun on A +(`input_required`, `_meta.serverInfo.name: instance-a`), decided by the owner, completed on B with the state +alone (`instance-b`), one `effect done` receipt; the same state on A after a fresh replay table asks the owner +again and nothing runs twice (NOTES finding 4). Second test: a replay inside the partition is `requestState +already used`; one altered byte `requestState tampered`; a wrong version `requestState malformed`; an +envelope sealed for zero seconds `requestState expired`; a state carried to other arguments `requestState +does not belong to this call`; a call with no state is a first call, held on a new approval, with no effect +receipt at all. "Missing" is refused in the sense that matters: nothing runs without a decided approval. + +### AC8 [manual]: Claude Code connected and calling `recall` (screenshot) +The owner's queue. The steps are in `docs/mcp-server.md` ("Connecting a client over HTTP"): run Trinity +(`mix phx.server`, or the headless release), read `/mcp-server-token`, then +`claude mcp add --transport http trinity http://127.0.0.1:/mcp --header "Authorization: Bearer "` +and ask Claude Code to recall something; the call appears on `/s//receipts` with `origin: mcp`. +The automatic half is AC1 and AC3 above: our own client connecting to the same route and calling `recall`. + +## Manual verification for the reviewer +AC8 above; and `proof/ac4-mcp-approval-pending.png` if the card's wording is worth a look. + +## Deviations from SLICE.md +NOTES.md: three stated before code (the 2025-11-25 client over stdio, not the HTTP route; Tasks out; a +legacy client's approval as a tool error) and five found building (the plug in the endpoint; the MCP +persona; a pending retry held without the gate; the card's actor; the stdio log handler). + +## Versions touched +`VERSIONS.md` updated: yes, by `mix versions.gen`: the `beam_mcp` row from `~> 0.8` to `~> 0.9` (0.9.0, hex +checksum `2bf9615c…`, the one beam_mcp's release record names). No other dependency changed. + +## Git +``` +$ git log --oneline main..HEAD +(named in the closing correction, after the final commit) +``` + +## Closing correction, 2026-09-22 + +Supersedes "named in the closing correction" above. The tree the PR is merged from is `a0d4bbf`; the code is +`00ed5a3` (`feat(s061): complete slice 061 (MCP server)`, the commit carrying this file) plus `f180ec1` +(`test(s024)`, the standalone census reading an accepted connection as inbound) and `a0d4bbf` (NOTES.md: +the two CI timing failures recorded). On `a0d4bbf`, CI run 35678149570: `gate` success (488 passed, 18 +excluded), `postgres` success (468 passed, 38 excluded), `fips-tag` and `fips` success (493 passed, 13 +excluded; the six FIPS tests by name); the `push` event's run 35678146729 the same numbers. The two earlier +runs on this branch each had one job red on one event and green on the other (NOTES.md, "Two CI timing +failures"); the rerun of run 35677465879's `gate` job passed (488). The coverage row stays at `c6b6faa` +(80.05%): the commits after it change tests, NOTES.md, ROADMAP.md, coverage.tsv and this file only. + +``` +$ git log --oneline main..HEAD +a0d4bbf docs(s061): the two CI timing failures on this branch, recorded +f180ec1 test(s024): the standalone census reads an accepted connection on the endpoint's listener as inbound +00ed5a3 feat(s061): complete slice 061 (MCP server) +c6b6faa chore(s061): sobelow skips with their reasons on the four file reads and the catalog's atom +7211fe2 refactor(s061): credo: the alias order, the run-or-hold clause, any? over count +64ae767 test(s061): the expired envelope's payload bound first (formatter convergence) +6eba5b8 docs(s061): deviations, findings at G3, follow-ups +1cd53f4 test(s061): the boundary census names the server side's referrers +077923a feat(s061): the approval card names the actor of an MCP call; AC4 screenshots; AC6 rerun on the fixed image; the README +ace5fc3 docs(s061): docs/mcp-server.md (clients, stdio, approvals over the wire, the headless profile), docs 01, 05, 07, 08; the AC6 container log +5e240ac fix(s030): the default persona's SOUL path is resolved when read, not at compile time +1b87836 feat(s061): the headless release, its bind and port, the stdio server and task, the container recipe +c0d9091 test(s061): AC7 green: an exchange begun on one instance completes on another; replay, tamper, expiry and binding refused +4a364a4 feat(s061): Trinity as an MCP server: the wrapper above the core, the catalog, the MCP session, the sealed requestState, the bearer; AC1 to AC5 green +c85950e docs(s061): G1 plan; AC1's legacy half over the stdio wire; Tasks out under 059's finding 4 +18b3bc9 feat(s061): beam_mcp pinned at ~> 0.9 (the :server seam); ROADMAP row 061 in progress +``` diff --git a/slices/061-mcp-server/SLICE.md b/slices/061-mcp-server/SLICE.md index 45cc9ef..2de4f68 100644 --- a/slices/061-mcp-server/SLICE.md +++ b/slices/061-mcp-server/SLICE.md @@ -49,7 +49,7 @@ attributed to a system persona with `origin: "mcp"` and crossing `Trinity.Effect ## Acceptance criteria 1. [auto] Our 060 client connects at 2026-07-28 and lists exported tools; a 2025-11-25 test client connects to the same - route (tests). + route (tests). Amended at G1, 2026-09-22: the 2025-11-25 client connects to the same wrapper over the stdio wire, since the core's HTTP transport serves 2026-07-28 alone by its design (NOTES.md, "Read before code"). 2. [auto] `tools/list` is deterministic and carries `ttlMs`/`cacheScope`; two calls return identical order (test). 3. [auto] `recall` via MCP yields a query receipt with `origin: "mcp"` (test). 4. [auto] An `:artifact` tool via MCP → `input_required` → approval in UI → retry succeeds; deny → retry gets a denial diff --git a/slices/061-mcp-server/proof/ac4-mcp-approval-decided.png b/slices/061-mcp-server/proof/ac4-mcp-approval-decided.png new file mode 100644 index 0000000..7d8f899 Binary files /dev/null and b/slices/061-mcp-server/proof/ac4-mcp-approval-decided.png differ diff --git a/slices/061-mcp-server/proof/ac4-mcp-approval-pending.png b/slices/061-mcp-server/proof/ac4-mcp-approval-pending.png new file mode 100644 index 0000000..67e24c0 Binary files /dev/null and b/slices/061-mcp-server/proof/ac4-mcp-approval-pending.png differ diff --git a/slices/061-mcp-server/proof/ac6-headless-container.log b/slices/061-mcp-server/proof/ac6-headless-container.log new file mode 100644 index 0000000..af79a42 --- /dev/null +++ b/slices/061-mcp-server/proof/ac6-headless-container.log @@ -0,0 +1,42 @@ +$ docker run -d --name trinity-headless-ac6 -p 127.0.0.1:4061:4000 -v trinity-ac6-data:/data -e TRINITY_MCP_SERVER_TOKEN= -e SECRET_KEY_BASE= trinity-headless:local +$ curl -o /dev/null -w '%{http_code}' http://127.0.0.1:4061/ +200 +$ curl -X POST http://127.0.0.1:4061/mcp -H 'authorization: Bearer ' ... server/discover +{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"trinity","version":"0.9.0"}},"cacheScope":"private","capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0}} +$ the same POST without the bearer +HTTP 403 +$ tools/list (names, ttlMs, cacheScope) +['recall', 'session_search', 'skill_file', 'skill_view', 'skills_list'] 60000 private +$ docker logs trinity-headless-ac6 (migrations and queries elided) +01:31:50.973 [warning] ExTauri currently targets OTP 27 but you are running OTP 28. +Burrito may not have pre-compiled ERTS for OTP 28 yet. +Production builds may fail. Development should work if Burrito is skipped. + +01:31:50.990 [info] [ExTauri.ShutdownManager] Started - heartbeat monitoring active on /tmp/tauri_heartbeat_trinity.sock +01:31:51.160 [info] memory: semantic recall is unavailable: the local model is not downloaded +01:31:51.177 [info] receipts: signer ed25519, key XSSkXiRhxTTDAAn6TgqUqmUrNrqTvXOo5xxT96yq3i0 +01:31:51.190 [info] Polling file changes every 1000ms... +01:31:51.190 [info] receipts: boot receipt boot/1 7ca94607590a26d9857363355cd6e5592c875d643811cfc2fab2e05cc7a191e0 +01:31:51.191 [info] mcp server: exporting 5 tools at /mcp +01:31:51.192 [info] Running TrinityWeb.Endpoint with Bandit 1.12.5 at 0.0.0.0:4000 (http) +01:31:51.192 [info] Access TrinityWeb.Endpoint at https://example.com +01:31:52.027 request_id=GNeAiyXlqMKg9pAAAFwa [info] GET / +01:31:52.030 request_id=GNeAiyXlqMKg9pAAAFwa [info] Sent 200 in 2ms +01:31:52.036 request_id=GNeAiyZr4m8biq0AAF6a [info] GET / +01:31:52.037 request_id=GNeAiyZr4m8biq0AAF6a [info] Sent 200 in 1ms +01:31:52.041 request_id=GNeAiya9Rabu_ZUAAF-a [info] POST /mcp +01:31:52.042 request_id=GNeAiya9Rabu_ZUAAF-a [info] Sent 200 in 309µs +01:31:52.047 request_id=GNeAiycUeB3sY8wAAGAa [info] POST /mcp +01:31:52.047 request_id=GNeAiycUeB3sY8wAAGAa [info] beam_mcp: refused by host authorize/1: :no_bearer +01:31:52.047 request_id=GNeAiycUeB3sY8wAAGAa [info] Sent 403 in 89µs +01:31:52.052 request_id=GNeAiydZsj5kwzQAAAAZ [info] POST /mcp +01:31:52.052 request_id=GNeAiydZsj5kwzQAAAAZ [info] Sent 200 in 170µs +$ docker exec trinity-headless-ac6 sh -c 'id; echo DISPLAY=${DISPLAY:-unset}; ls -la /data/trinity/keys' +uid=10001(trinity) gid=10001(trinity) groups=10001(trinity) +DISPLAY=unset +total 20 +drwx------ 2 trinity trinity 4096 Sep 22 01:31 . +drwxr-xr-x 4 trinity trinity 4096 Sep 22 01:31 .. +-rw------- 1 trinity trinity 32 Sep 22 01:31 mcp-state.key +-rw------- 1 trinity trinity 199 Sep 22 01:31 receipts-ed25519.key +-rw------- 1 trinity trinity 442 Sep 22 01:31 registry.json diff --git a/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs index 9bde01c..97fb059 100644 --- a/test/trinity/authority/selection_test.exs +++ b/test/trinity/authority/selection_test.exs @@ -89,7 +89,15 @@ defmodule Trinity.Authority.SelectionTest do {:connected, pid} <- [Port.info(port, :connected)], do: {port, peer, describe(pid)} - trinity_peers = Enum.filter(peers, fn {_, _, %{app: app}} -> app == {:ok, :trinity} end) + # An accepted connection on the endpoint's own listener (a Bandit handler's socket) is + # inbound, not outbound: slice 061's suite connects Trinity's MCP client to Trinity's own + # /mcp, and the client's pool keeps the connection alive past its test (found on run + # 35676874669, one ordering of the postgres job). The census asks about connections this + # application opened, and the endpoint opened none of those. + trinity_peers = + Enum.filter(peers, fn {_, _, %{app: app, initial_call: call}} -> + app == {:ok, :trinity} and not match?({Bandit.DelegatingHandler, _, _}, call) + end) for {port, peer, %{initial_call: call} = who} <- trinity_peers do assert call in [{DBConnection.Connection, :init, 1}, {Postgrex.Protocol, :init, 1}], diff --git a/test/trinity/mcp/boundary_test.exs b/test/trinity/mcp/boundary_test.exs index 4f7d50c..93ed4c6 100644 --- a/test/trinity/mcp/boundary_test.exs +++ b/test/trinity/mcp/boundary_test.exs @@ -12,11 +12,12 @@ defmodule Trinity.MCP.BoundaryTest do """ use ExUnit.Case, async: true - test "beam_mcp is in mix.lock at 0.8.0 and the VERSIONS row reads in mix.lock" do + # Slice 061 bumped the pin to 0.9.0 (the :server seam); the row and the lock moved together. + test "beam_mcp is in mix.lock at 0.9.0 and the VERSIONS row reads in mix.lock" do lock = Mix.Dep.Lock.read() - assert {:hex, :beam_mcp, "0.8.0", _, _, _, _, _} = lock[:beam_mcp] - assert File.read!("VERSIONS.md") =~ ~r/`beam_mcp` \| ~> 0\.8 \| ✅ in `mix\.lock`/ - assert Trinity.MCP.core_version() == "0.8.0" + assert {:hex, :beam_mcp, "0.9.0", _, _, _, _, _} = lock[:beam_mcp] + assert File.read!("VERSIONS.md") =~ ~r/`beam_mcp` \| ~> 0\.9 \| ✅ in `mix\.lock`/ + assert Trinity.MCP.core_version() == "0.9.0" end test "the boundary compiler checks calls into beam_mcp everywhere, and Trinity.MCP is the boundary that lists it" do @@ -28,9 +29,17 @@ defmodule Trinity.MCP.BoundaryTest do assert length(files) > 100 referrers = for f <- files, File.read!(f) =~ ~r/\bBeamMCP\./, do: f - # Slice 060: the wire builder is the second referrer, under the same boundary; nothing - # outside `lib/trinity/mcp.ex` and `lib/trinity/mcp/` names the core. - assert Enum.sort(referrers) == ["lib/trinity/mcp.ex", "lib/trinity/mcp/client/wire.ex"] + # Slice 060: the wire builder is the second referrer, under the same boundary; 061 adds + # the server side (the wrapper, its catalog, the plug, the stdio entry). Nothing outside + # `lib/trinity/mcp.ex` and `lib/trinity/mcp/` names the core. + assert Enum.sort(referrers) == [ + "lib/trinity/mcp.ex", + "lib/trinity/mcp/client/wire.ex", + "lib/trinity/mcp/server.ex", + "lib/trinity/mcp/server/catalog.ex", + "lib/trinity/mcp/server/plug.ex", + "lib/trinity/mcp/server/stdio.ex" + ] for f <- referrers, do: assert(f == "lib/trinity/mcp.ex" or String.starts_with?(f, "lib/trinity/mcp/")) diff --git a/test/trinity/mcp/server_mrtr_test.exs b/test/trinity/mcp/server_mrtr_test.exs new file mode 100644 index 0000000..cc73bc4 --- /dev/null +++ b/test/trinity/mcp/server_mrtr_test.exs @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.ServerMrtrTest do + @moduledoc """ + Slice 061 AC7: an exchange begun against one instance of the server completes against another + of the same release carrying only the `requestState`; a retry with the state altered, expired, + replayed or bound to another call is refused; a retry with no state is a first call and is held + on a new approval (a stateless server cannot tell the two apart, and the property that + matters, no effect without a decided approval, holds). Two Plug instances with different + server names, through the core's HTTP transport with `Plug.Test`, sharing the data directory + (the envelope key) and the database (the approval); the replay table is reset between them + where a second partition's fresh table is the point. + """ + use Trinity.DataCase, async: false + @moduletag :capture_log + + import Plug.Test, only: [conn: 3] + import Plug.Conn, only: [put_req_header: 3] + + alias Trinity.MCP.Server.{Envelope, Exports, Replay} + alias Trinity.Permissions + alias Trinity.Receipts + + @token "mrtr-" <> Base.url_encode64(:crypto.strong_rand_bytes(9), padding: false) + @meta %{ + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientCapabilities" => %{"elicitation" => %{"form" => %{}}} + } + @args %{"action" => "add", "key" => "planet", "body" => "mars"} + + setup do + System.put_env("TRINITY_MCP_SERVER_TOKEN", @token) + Application.put_env(:trinity, :mcp_server, tools: Exports.defaults() ++ ["memory"]) + Replay.reset() + :ok = Permissions.subscribe(:all) + scope = Receipts.session_scope(Trinity.MCP.Server.Session.id()) + + on_exit(fn -> + System.delete_env("TRINITY_MCP_SERVER_TOKEN") + Application.delete_env(:trinity, :mcp_server) + Receipts.stop_writer(scope) + end) + + a = + Application.put_env(:trinity, :mcp_server, + tools: Exports.defaults() ++ ["memory"], + server_name: "instance-a" + ) && Trinity.MCP.Server.Plug.init([]) + + b = + Application.put_env(:trinity, :mcp_server, + tools: Exports.defaults() ++ ["memory"], + server_name: "instance-b" + ) && Trinity.MCP.Server.Plug.init([]) + + {:ok, a: a, b: b, scope: scope} + end + + defp call(instance, params, id \\ System.unique_integer([:positive])) do + body = %{ + "jsonrpc" => "2.0", + "id" => id, + "method" => "tools/call", + "params" => Map.put(params, "_meta", @meta) + } + + conn = + :post + |> conn("/mcp", Jason.encode!(body)) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer " <> @token) + |> put_req_header("mcp-protocol-version", "2026-07-28") + |> put_req_header("mcp-method", "tools/call") + |> put_req_header("mcp-name", params["name"]) + |> Trinity.MCP.Server.Plug.call(instance) + + {conn.status, Jason.decode!(conn.resp_body)} + end + + defp memory(instance, extra \\ %{}), + do: call(instance, Map.merge(%{"name" => "memory", "arguments" => @args}, extra)) + + defp retry(instance, state), + do: memory(instance, %{"requestState" => state, "inputResponses" => %{}}) + + test "AC7: begun on instance A, decided by the owner, completed on instance B with the state alone; the effect ran once", + %{a: a, b: b, scope: scope} do + {200, %{"result" => %{"resultType" => "input_required", "requestState" => state} = held}} = + memory(a) + + assert held["_meta"]["io.modelcontextprotocol/serverInfo"]["name"] == "instance-a" + assert_receive {:approval, :requested, %{id: aid}}, 2_000 + {:ok, _} = Permissions.decide_request(aid, :once, by: "test") + + # B is another partition: a fresh replay table, the same key and database. + Replay.reset() + + {200, %{"result" => %{"resultType" => "complete", "isError" => false} = done}} = + retry(b, state) + + assert done["_meta"]["io.modelcontextprotocol/serverInfo"]["name"] == "instance-b" + + assert Enum.count( + Receipts.list(scope), + &(&1.kind == "effect" and &1.subject["phase"] == "done") + ) == 1 + + # The same state on A again (its table never saw the nonce: another partition, so the + # table is reset once more) is the backstop's case: the once is consumed, so the gate asks + # again and nothing runs twice. + Replay.reset() + {200, %{"result" => %{"resultType" => "input_required"}}} = retry(a, state) + + assert Enum.count( + Receipts.list(scope), + &(&1.kind == "effect" and &1.subject["phase"] == "done") + ) == 1 + end + + test "a replay inside the partition, one tampered byte, an expired envelope and a state bound to other arguments are refused; a missing state is a first call", + %{a: a, b: b, scope: scope} do + {200, %{"result" => %{"resultType" => "input_required", "requestState" => state}}} = memory(a) + assert_receive {:approval, :requested, %{id: aid}}, 2_000 + + # Replay: the pending retry spends the nonce; the same state again is refused. + {200, %{"result" => %{"resultType" => "input_required", "requestState" => fresh}}} = + retry(a, state) + + {200, %{"error" => %{"code" => -32_602, "message" => "requestState already used"}}} = + retry(a, state) + + # One byte altered: tampered, the plaintext never seen. + {head, last} = String.split_at(fresh, -2) + altered = head <> if(last == "AA", do: "AB", else: "AA") + + {200, %{"error" => %{"code" => -32_602, "message" => "requestState tampered"}}} = + retry(a, altered) + + {200, %{"error" => %{"code" => -32_602, "message" => "requestState malformed"}}} = + retry(a, "v9.nope") + + # Expired: sealed for zero seconds, opened a second later. + # The formatter does not converge on `f(%{multi-line}, kw: v)`: the map is bound first. + payload = %{ + approval_id: aid, + session_id: Trinity.MCP.Server.Session.id(), + call_id: Trinity.UUID.generate(), + tool: "memory", + args_digest: Envelope.args_digest(@args) + } + + expired = Envelope.seal(payload, ttl_s: 0) + + Process.sleep(1_100) + + {200, %{"error" => %{"code" => -32_602, "message" => "requestState expired"}}} = + retry(b, expired) + + # Bound to other arguments: refused as not this call's. + {200, + %{"error" => %{"code" => -32_602, "message" => "requestState does not belong to this call"}}} = + call(b, %{ + "name" => "memory", + "arguments" => Map.put(@args, "body", "venus"), + "requestState" => fresh, + "inputResponses" => %{} + }) + + # Missing: a first call, held on a new approval; nothing ran. + {200, %{"result" => %{"resultType" => "input_required"}}} = memory(b) + assert_receive {:approval, :requested, %{id: other}}, 2_000 + assert other != aid + refute Enum.any?(Receipts.list(scope), &(&1.kind == "effect")) + end +end diff --git a/test/trinity/mcp/server_test.exs b/test/trinity/mcp/server_test.exs new file mode 100644 index 0000000..0f93bfc --- /dev/null +++ b/test/trinity/mcp/server_test.exs @@ -0,0 +1,312 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.ServerTest do + @moduledoc """ + Slice 061 AC1 (our 060 client connects to our own `/mcp` at 2026-07-28 and lists the exported + tools; a 2025-11-25 wire against the wrapper connects with `initialize` and calls a tool), AC2 + (`tools/list` deterministic with `ttlMs` and `cacheScope`), AC3 (`recall` over MCP yields a + query receipt with `origin: "mcp"`), AC4 (an `:artifact` tool: `input_required`, the approval + on the permissions page, the retry succeeds; a denial is an error and a receipt), AC5 (a + `:catalog` tool is not exportable), and the bearer. + """ + use Trinity.DataCase, async: false + @moduletag :capture_log + + import Trinity.MCP.ServersUnderTest, only: [start!: 3, await: 2] + + alias Trinity.MCP.Client + alias Trinity.MCP.Server + alias Trinity.MCP.Server.{Catalog, Exports, Replay} + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools + + @token "test-bearer-" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false) + @meta %{ + "io.modelcontextprotocol/protocolVersion" => "2026-07-28", + "io.modelcontextprotocol/clientCapabilities" => %{"elicitation" => %{"form" => %{}}} + } + + setup do + System.put_env("TRINITY_MCP_SERVER_TOKEN", @token) + System.put_env("TRINITY_MCP_SELF_TOKEN", @token) + Application.put_env(:trinity, :mcp_server, tools: Exports.defaults() ++ ["memory"]) + Replay.reset() + scope = Receipts.session_scope(Trinity.MCP.Server.Session.id()) + + on_exit(fn -> + System.delete_env("TRINITY_MCP_SERVER_TOKEN") + System.delete_env("TRINITY_MCP_SELF_TOKEN") + Application.delete_env(:trinity, :mcp_server) + Receipts.stop_writer(scope) + end) + + {:ok, url: url(), scope: scope} + end + + defp url do + {:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) + "http://127.0.0.1:#{port}/mcp" + end + + defp connect!(url) do + start!("self", {:http, url}, %{}) + await("self", :ready) + end + + defp wrapper, do: Server.new(catalog: Catalog, server_name: "trinity-test") + + defp request(id, method, params, meta \\ @meta), + do: %{ + "jsonrpc" => "2.0", + "id" => id, + "method" => method, + "params" => Map.put(params, "_meta", meta) + } + + test "AC1: our client connects at 2026-07-28 over /mcp and lists the exported tools under mcp:self:*", + %{url: url} do + assert %{revision: "2026-07-28", registered: registered} = connect!(url) + + assert Enum.sort(registered) == + Enum.sort(for t <- Exports.defaults() ++ ["memory"], do: "mcp:self:" <> t) + + assert {:ok, %{spec: %{schema: %{"properties" => %{"query" => _}}}}} = + Tools.lookup("mcp:self:recall") + end + + test "AC1: a 2025-11-25 client connects to the wrapper with initialize and calls a tool; the answers carry no resultType" do + state = wrapper() + + init = %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "initialize", + "params" => %{ + "protocolVersion" => "2025-11-25", + "capabilities" => %{}, + "clientInfo" => %{"name" => "old", "version" => "1"} + } + } + + {state, %{"result" => %{"protocolVersion" => "2025-11-25"}}} = + Server.handle_message(state, init) + + {state, nil} = + Server.handle_message(state, %{"jsonrpc" => "2.0", "method" => "notifications/initialized"}) + + {state, %{"result" => %{"tools" => tools} = list}} = + Server.handle_message(state, %{ + "jsonrpc" => "2.0", + "id" => 2, + "method" => "tools/list", + "params" => %{} + }) + + assert Enum.map(tools, & &1["name"]) == Enum.sort(Exports.defaults() ++ ["memory"]) + refute Map.has_key?(list, "resultType") + + {_state, %{"result" => result}} = + Server.handle_message(state, %{ + "jsonrpc" => "2.0", + "id" => 3, + "method" => "tools/call", + "params" => %{"name" => "skills_list", "arguments" => %{}} + }) + + assert [%{"type" => "text"}] = result["content"] + assert result["isError"] == false + refute Map.has_key?(result, "resultType") + end + + test "AC2: tools/list is sorted, identical across two calls, and carries ttlMs and cacheScope" do + state = wrapper() + {state, %{"result" => a}} = Server.handle_message(state, request(1, "tools/list", %{})) + {_state, %{"result" => b}} = Server.handle_message(state, request(2, "tools/list", %{})) + names = Enum.map(a["tools"], & &1["name"]) + assert names == Enum.sort(names) and names == Enum.sort(Exports.defaults() ++ ["memory"]) + assert a["tools"] == b["tools"] + assert a["ttlMs"] == 0 and a["cacheScope"] == "private" + assert a["resultType"] == "complete" + + assert %{"readOnlyHint" => true} = + Enum.find(a["tools"], &(&1["name"] == "recall"))["annotations"] + + assert %{"readOnlyHint" => false, "destructiveHint" => true} = + Enum.find(a["tools"], &(&1["name"] == "memory"))["annotations"] + end + + test "AC3: recall over /mcp yields a decision and a query receipt whose subject carries origin mcp", + %{url: url, scope: scope} do + connect!(url) + {:ok, rule} = Permissions.put_rule(%{tool: "recall", pattern: "*", decision: "allow"}) + on_exit(fn -> Permissions.revoke_rule(rule.id) end) + + assert {:ok, + %{ + "result" => %{ + "resultType" => "complete", + "isError" => false, + "content" => [%{"type" => "text"}] + } + }} = + Client.call("self", "recall", %{"query" => "anything at all"}) + + kinds = + scope |> Receipts.list() |> Enum.map(&{&1.kind, &1.subject["tool"], &1.subject["origin"]}) + + assert {"decision", "recall", "mcp"} in kinds and {"query", "recall", "mcp"} in kinds + end + + test "the trace context in _meta rides into the receipts' meta", %{scope: scope} do + {:ok, rule} = Permissions.put_rule(%{tool: "skills_list", pattern: "*", decision: "allow"}) + on_exit(fn -> Permissions.revoke_rule(rule.id) end) + + meta = + Map.put(@meta, "traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + + {_state, %{"result" => %{"resultType" => "complete"}}} = + Server.handle_message( + wrapper(), + request(1, "tools/call", %{"name" => "skills_list", "arguments" => %{}}, meta) + ) + + assert Enum.any?( + Receipts.list(scope), + &(&1.meta["trace"]["traceparent"] == + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + ) + end + + test "AC4: an artifact tool is held as input_required with a sealed state; approving on the permissions page lets the retry complete; denying makes the retry an error with its receipt", + %{url: url, scope: scope} do + connect!(url) + :ok = Permissions.subscribe(:all) + args = %{"action" => "add", "key" => "colour", "body" => "blue"} + + assert {:ok, + %{ + "result" => %{ + "resultType" => "input_required", + "requestState" => state, + "inputRequests" => reqs + } + }} = + Client.call("self", "memory", args) + + assert %{ + "approval" => %{ + "method" => "elicitation/create", + "params" => %{"message" => message} + } + } = reqs + + assert message =~ "approval" and message =~ "permissions page" + assert String.starts_with?(state, "v1.") + assert_receive {:approval, :requested, %{id: aid, tool: "memory", session_id: sid}}, 2_000 + assert sid == Trinity.MCP.Server.Session.id() + + # Retried before the decision: held again, under a fresh state; the old nonce is spent. + assert {:ok, %{"result" => %{"resultType" => "input_required", "requestState" => state2}}} = + Client.call("self", "memory", args, + continuation: %{ + responses: %{ + "approval" => %{"action" => "accept", "content" => %{"retry" => true}} + }, + state: state + } + ) + + assert state2 != state + + # The owner decides on the page; the retry with the current state completes the call. + {:ok, _} = Permissions.decide_request(aid, :once, by: "test") + + assert {:ok, + %{ + "result" => %{ + "resultType" => "complete", + "isError" => false, + "content" => [%{"text" => text}] + } + }} = + Client.call("self", "memory", args, + continuation: %{ + responses: %{ + "approval" => %{"action" => "accept", "content" => %{"retry" => true}} + }, + state: state2 + } + ) + + assert text =~ "colour" + kinds = scope |> Receipts.list() |> Enum.map(&{&1.kind, &1.subject["phase"]}) + assert {"effect", "admit"} in kinds and {"effect", "done"} in kinds + + # Denied: the retry is a tool error, and the decision receipt says deny. + args2 = %{"action" => "add", "key" => "shape", "body" => "round"} + + assert {:ok, %{"result" => %{"resultType" => "input_required", "requestState" => s3}}} = + Client.call("self", "memory", args2) + + assert_receive {:approval, :requested, %{id: aid2}}, 2_000 + {:ok, _} = Permissions.decide_request(aid2, :deny, by: "test") + + assert {:ok, + %{ + "result" => %{ + "resultType" => "complete", + "isError" => true, + "content" => [%{"text" => "denied" <> _}] + } + }} = + Client.call("self", "memory", args2, continuation: %{responses: %{}, state: s3}) + + denied = + scope + |> Receipts.list() + |> Enum.filter(&(&1.kind == "decision")) + |> Enum.map(&Jason.decode!(&1.signed_payload)["decision"]["outcome"]) + + assert "deny" in denied + end + + test "AC5: a catalog tool is not exportable; an unknown name and a dynamic name are refused too" do + assert {[], [{"shell", :catalog_is_not_exportable}]} = Exports.resolve(["shell"]) + assert {[], [{"nope", :unknown_tool}]} = Exports.resolve(["nope"]) + Application.put_env(:trinity, :mcp_server, tools: ["shell", "recall"]) + assert %{tools: [%{name: :recall}]} = Catalog.capabilities() + end + + test "the bearer: a wrong or absent one is refused before anything is decoded; the right one reaches discover", + %{url: url} do + body = Jason.encode!(request(1, "server/discover", %{})) + + headers = [ + {"content-type", "application/json"}, + {"mcp-protocol-version", "2026-07-28"}, + {"mcp-method", "server/discover"} + ] + + assert {:ok, %Req.Response{status: 403}} = + Req.post(url, headers: headers, body: body, retry: false) + + assert {:ok, %Req.Response{status: 403}} = + Req.post(url, + headers: [{"authorization", "Bearer nope"} | headers], + body: body, + retry: false + ) + + assert {:ok, %Req.Response{status: 200, body: answer}} = + Req.post(url, + headers: [{"authorization", "Bearer " <> @token} | headers], + body: body, + retry: false, + decode_body: false + ) + + assert %{"result" => %{"supportedVersions" => ["2026-07-28"], "resultType" => "complete"}} = + Jason.decode!(answer) + end +end