diff --git a/README.md b/README.md index 4e81579..819c332 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ Pre-alpha, and usable from source. Milestones M0 to M4 are approved, and the fir credentials and instructions to ignore safety, and promoted through the permission gate with a receipt. -Not there yet: scheduled tasks and MCP (M5a), messaging gateways and subagents (M5b), +Not there yet: scheduled tasks and the rest of MCP (M5a: the client is built at 060, the server +with approvals and the authorization roles follow), 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. @@ -77,8 +78,9 @@ model registry is `config/llm.exs`; keys come from the environment (`.env` is gi pages: `/` conversations, `/s/:id` a conversation and `/s/:id/receipts` its receipts, `/search` full-text search, `/personas` and `/memory` the persona and its memory (with the semantic tab and the embedding model's download), `/skills` the skills, the changes waiting for your decision and -the learn form, `/permissions` the rules and pending approvals, `/settings` the -export. `mix trinity.export` and `mix trinity.import` do what `/settings` does from a terminal; +the learn form, `/mcp` the MCP servers you connect to (their health and the tools they +contribute), `/permissions` the rules and pending approvals (a server's question to you, when +one of its tools asks for input mid-call, is answered there too), `/settings` the export. `mix trinity.export` and `mix trinity.import` do what `/settings` does from a terminal; `docs/backup.md` explains the archive. Semantic recall needs the local embedding model (91 MB, downloaded from the memory page on your @@ -161,7 +163,7 @@ catalogued effect to it: Trinity proposes, Requisition decides, and Trinity keep Trinity refuses to start if the module is absent or does not implement `Trinity.Authority`. See `docs/adr/0008-authority-is-an-adapter.md` and `docs/adr/0010-authority-selection-at-boot.md`. -**Ultraviolet** is the purple-team tool. Add it to `config :trinity, :mcp_servers` like any other MCP server. Its +**Ultraviolet** is the purple-team tool. Add it on the `/mcp` page like any other MCP server (since slice 060 a server is a row, not a config entry). Its read tools return query-receipted results, its proposal tools return proposal ids rather than effects, and content coming back from it is tagged untrusted like any other external content. @@ -179,8 +181,10 @@ every project space. the BEAM from the same organisation, on Hex as `beam_mcp`. Trinity depends on it since slice 059, pinned at 0.8.0 and reached only through the `Trinity.MCP` boundary; slice 059's `FINDINGS.md` measures what it ships against the 2026-07-28 checklist, and the MCP phase (milestone M5a) builds -Trinity's client driver, the approval loop and authorization above it. The client side and the -authorization server are Trinity's own work. `docs/adr/0007-mcp-2026-07-28-target-and-library.md` +Trinity's client driver (slice 060: a thin driver over the core's decoder and validator, stdio +and Streamable HTTP, 2026-07-28 preferred and 2025-11-25 as the fallback, a server's mid-call +question answered as an approval), the approval loop and authorization above it. The client side +and the authorization server are Trinity's own work. `docs/adr/0007-mcp-2026-07-28-target-and-library.md` records the protocol target and the layering. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 66bff62..6c25c8e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,7 +53,7 @@ standards register names the rows that ask for them. | 041 | Skill self-management with staged approval + scanner | 4 Skills | M | 040, 021 | approved | | 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 | planned | +| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | done | | 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | planned | | 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 | diff --git a/config/test.exs b/config/test.exs index da59415..37c9965 100644 --- a/config/test.exs +++ b/config/test.exs @@ -191,3 +191,8 @@ config :trinity, :skills, pending_dir: Path.join(System.tmp_dir!(), "trinity-test-pending-skills"), watch: true, index_tokens: 338 + +# Slice 060: no MCP client boots from rows in the suite (each test starts the clients it +# needs); a short backoff so the reconnect test runs in seconds. +config :trinity, :mcp_boot, false +config :trinity, :mcp_client, backoff_ms: 50, max_backoff_ms: 400, connect_timeout: 5_000 diff --git a/coverage.tsv b/coverage.tsv index c8b0512..5810cbf 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -19,3 +19,4 @@ slice_id percent sha date 040 79.86 dc9dc6b 2026-09-21 041 80.55 ae200d7 2026-09-21 059 80.54 53c9091 2026-09-21 +060 80.32 49f40a4 2026-09-21 diff --git a/docs/01-architecture.md b/docs/01-architecture.md index c1d56b4..46c3fd7 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) | 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) | +| `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.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 80351d6..1884ccb 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -161,6 +161,8 @@ One row per request, the audit trail this slice owns; slice 024 reads it for dec | decided_at, decided_by | timestamp, string | every decided row has both ("expiry" is a decider) | | consumed_at | nullable | a "once" allowance or a denial is spent by the execution that reads it | | expires_at | timestamp | pending past it becomes "expired" with decision "deny" | +| request | map, nullable | slice 060: a server's input request as sent (`kind`, `server`, `inputRequests`), when the approval is a multi-round-trip question and not a yes or no; the server's `requestState` is never here (it lives in the client process) | +| answer | map, nullable | slice 060: the answer the decision carried, the revision's `inputResponses` map keyed as `inputRequests` were | ### tasks (Slice 050) | column | type | notes | @@ -230,9 +232,19 @@ and `receipt_hash`. Chain scopes: `session:` and `boot`. | reason | string | "count" \| "time" \| "shutdown" \| "rehydrate" \| "manual" | A row, never a write onto a receipt: `receipts` stays append-only and a checkpoint states its own coverage. -### mcp_servers (Slice 060) -`name`, `transport`, `command_or_url`, `env_refs`, `enabled`, `effect_default ∈ {none, artifact}`, per-tool -effect and risk overrides. A server config claiming `:catalog` is refused at load and receipted. +### mcp_servers (Slice 060, as built) +One row per server the client connects to; the row's `name` is the namespace segment of every tool it contributes. +| column | type | notes | +|---|---|---| +| name | string, unique | `[a-z0-9][a-z0-9_-]{0,31}`; the tools are `mcp::` | +| transport | string | "stdio" \| "http" | +| command, args | string, {array, string} | stdio: the child and its arguments | +| url | string | http: one POST endpoint | +| env_refs | {array, string} | names of environment variables passed to a stdio child; never a value (the child sees the process basics and these, nothing else of Trinity's environment) | +| enabled | boolean | a client runs for an enabled row | +| effect_default | string | "none" \| "artifact"; the effect of every tool the row does not override | +| 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 | | ### task_runs (Slice 050) `task_id`, `scheduled_at`, `session_id`, `status`, `summary`, `error`. Unique on `(task_id, scheduled_at)` so a diff --git a/docs/07-security-model.md b/docs/07-security-model.md index d3ec012..86d86e3 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -137,6 +137,35 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES). - Luerl with reduction limits, no `os`/`io`/`require`, no filesystem; explicit host functions only. - Native/shell code is never "sandboxed" by the BEAM: the UI says so plainly when approving `:exec`. +## MCP client (Slice 060, as built) + +- **Every tool a server lists is a dynamic tool under `mcp::`**, registered through one bridge + module with the server's own description and `inputSchema` as its definition (digested like any other). The + tier is `:ask`, because the gate maps core names alone and answers `:ask` for the rest; a row cannot lower it, + and the owner allows a tool the way every tool is allowed, with a rule on the permissions page. The effect is + `:none` unless the row says `:artifact` for that tool; `:catalog` is refused at write by the changeset and at + load by the registry, with a decision receipt of outcome `deny` on the server's chain scope `mcp:`. +- **Every result is untrusted.** The server's content parts become one `Trinity.Content.Part` tainted + `:untrusted` with origin `tool:mcp::` and source `mcp:///`; an image or audio + part is a line naming its type and size (the bytes never reach the model); `isError` is a marked line the + model reads as the server's error, not a crash. +- **A stdio child sees an allow-listed environment.** A Port's `env:` adds to the inherited environment, so the + driver unsets by name every variable this VM holds outside PATH, HOME, the locale and temp variables and the + row's `env_refs`; provider keys are the first thing that must not cross. `env_refs` are names, never values. + The child's stderr is the VM's; its stdout is the wire, and a line that is not JSON-RPC is dropped with a + warning. +- **A server's question is an approval, never an automatic answer.** A `tools/call` result with `resultType` + `input_required` (the revision's multi-round-trip pattern) holds the call: the server's `inputRequests` go + into an approval row's `request` column, the card renders the server's message and a form from its + `requestedSchema`, and the owner's answer travels with the decision (`answer`) to the retry. The client + declares form elicitation alone in its capabilities, so a server may not ask Trinity to sample a model or + list roots. The server's `requestState` is held in the client process, keyed by session and call, and + echoed byte-for-byte on the retry; it is never in a row, never shown, never parsed (the revision's MUST + NOT). A server that answers `input_required` forever is a server whose every round asks the owner again. +- **The driver is thin.** It builds the outbound request and nothing else of the protocol; decoding and + 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). + ## 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 297e3b3..1c8139b 100644 --- a/docs/08-standards.md +++ b/docs/08-standards.md @@ -51,10 +51,18 @@ Microsoft, OpenAI. Trinity targets AAIF-governed standards first; vendor-specifi `requestState` echoed back unmodified**. That echo is what makes MRTR work on a stateless server: all the state rides in the payload, so any instance can resume the work. Consequence: our permission gate over MCP maps naturally to MRTR (approval = input_required → user decides → retry), and our client must implement the retry loop. + As built at 060 (client side): `inputRequests` and `inputResponses` are maps keyed alike, each request a + `{method, params}` object and each response an `ElicitResult` (`action`, `content`); the retry is a new + 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. 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`, - poll-based `tasks/get`, `tasks/update`) is the one we use. + poll-based `tasks/get`, `tasks/update`) is the one we use. At 060 the client builds nothing for it: the + core neither builds nor refuses it (059 finding 4), and a client for an extension the server core does + not speak would be tested against a double alone; the question goes to beam_mcp's board first. 5. **Subscriptions** via a single `subscriptions/listen` stream per opted-in notification type (`toolsListChanged`, …) instead of the GET endpoint. 6. **Cacheable lists**: `tools/list` etc. MUST return `ttlMs` + `cacheScope`; return tools in deterministic diff --git a/lib/trinity.ex b/lib/trinity.ex index 63fff8d..1f4a356 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -47,6 +47,11 @@ defmodule Trinity do Memory.Retriever, Memory.Embedders.Bumblebee, Tools, + # Slice 060: what the MCP bridge (a top-level boundary) implements and returns. + Tools.Tool, + Tools.Context, + Tools.Result, + Tools.Untrusted, Permissions, Permissions.Approval, Permissions.Rule, diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 61398c6..16af1c8 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -7,7 +7,10 @@ defmodule Trinity.Application do # Trinity.Smoke is its own top-level boundary: it is the `--smoke` boot path and has to ask # TrinityWeb.Endpoint what port it bound, which Trinity (deps: []) may not do. Adding it here # is what lets the child list mention it. - use Boundary, top_level?: true, deps: [Trinity, TrinityWeb, Trinity.Smoke], exports: [] + use Boundary, + top_level?: true, + deps: [Trinity, TrinityWeb, Trinity.Smoke, Trinity.MCP], + exports: [] # See https://elixir.hexdocs.pm/Application.html # for more information on OTP Applications @@ -57,6 +60,10 @@ defmodule Trinity.Application do # Slice 040: the skills index, scanned from its roots and watched; after the tools # (conditional activation asks which exist) and before the sessions that read it. Trinity.Skills.Registry, + # 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, + Trinity.MCP.Boot, Trinity.Sessions.Supervisor ] ++ Trinity.Smoke.probe(Trinity.Smoke.argv()) ++ diff --git a/lib/trinity/mcp.ex b/lib/trinity/mcp.ex index e5320c8..704e512 100644 --- a/lib/trinity/mcp.ex +++ b/lib/trinity/mcp.ex @@ -17,7 +17,20 @@ defmodule Trinity.MCP do # A top-level boundary (as `Trinity.Smoke` is), not a sub-boundary of `Trinity`: the # library lets a nested boundary depend on an external module only when an ancestor does, # and `Trinity` must never list `BeamMCP` (that would open the core to it). - use Boundary, top_level?: true, deps: [Trinity, BeamMCP.JSON], exports: [] + # 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. + 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] @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 new file mode 100644 index 0000000..6a1cf7d --- /dev/null +++ b/lib/trinity/mcp/boot.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Boot do + @moduledoc """ + Starts a client for every enabled server row when the application boots (slice 060). A + database that cannot answer (no table yet: the postgres CI job boots the application + before it migrates) is a warning, not a boot failure, as `Trinity.Permissions.Gate` treats + its reload; the servers page starts the clients later. Set `config :trinity, :mcp_boot, + false` to boot none (the test suite does: every test starts the clients it needs). + """ + use Task, restart: :temporary + + require Logger + + alias Trinity.MCP.Servers + + @spec start_link(term()) :: {:ok, pid()} + def start_link(_), do: Task.start_link(__MODULE__, :run, []) + + @doc false + def run do + if Application.get_env(:trinity, :mcp_boot, true) do + try do + for config <- Servers.list(enabled: true), do: Servers.start(config) + rescue + e -> Logger.warning("mcp boot: servers not started: #{Exception.message(e)}") + end + end + + :ok + end +end diff --git a/lib/trinity/mcp/bridge.ex b/lib/trinity/mcp/bridge.ex new file mode 100644 index 0000000..a916a1e --- /dev/null +++ b/lib/trinity/mcp/bridge.ex @@ -0,0 +1,247 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Bridge do + @moduledoc """ + The one tool module every MCP tool runs through (slice 060). A server's listed tool is + registered as a dynamic entry of `Trinity.Tools.Registry` with a spec (its name under + `mcp::`, its description, its `inputSchema`, its effect and risk from the + server's row) and this module; `execute/2` reads the entry's name from the context, calls + the server through `Trinity.MCP.Client`, and hands back one untrusted result. + + The name is namespaced before any tier lookup, so a server cannot claim a core tool's + tier (docs/07); the effect is `:none` unless the row says `:artifact` for the tool, and a + row that claims `:catalog` is refused at load with a decision receipt on the server's + chain scope, because the effect catalog is compile time and a server cannot enter it + (AC3). The tier is `:ask`: `Trinity.Permissions.tier/1` answers that for every name + outside the core map, and only a rule the owner writes allows a call without asking. + + A result whose `resultType` is `input_required` (the revision's multi-round-trip pattern) + is not a result: the server's `inputRequests` become one approval-shaped request to the + owner, the server's `requestState` is held verbatim beside the call, and the call is + answered `{:error, {:approval_required, id}}`, which the Session holds and re-runs when + the approval is decided. The re-run finds the continuation, reads the answer the decision + carried, and sends the retry with `inputResponses` and the state untouched (docs/07). + """ + @behaviour Trinity.Tools.Tool + + require Logger + + alias Trinity.MCP.{Client, ServerConfig} + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools.{Context, Result, Untrusted} + + @tool_name_pattern ~r/^[A-Za-z0-9_.-]{1,64}$/ + + ## The behaviour: the spec supplies the definition; these are the defaults behind it. + + @impl true + def name, do: "mcp:" + @impl true + def description, do: "A tool an MCP server contributes." + @impl true + def schema, do: %{"type" => "object"} + @impl true + def risk, do: :ask + @impl true + def effect, do: :none + + @doc "The registry name of a server's tool." + @spec tool_name(String.t(), String.t()) :: String.t() + def tool_name(server, tool), do: "mcp:" <> server <> ":" <> tool + + @doc "The chain scope a server's receipts go to." + @spec scope(String.t()) :: String.t() + def scope(server), do: "mcp:" <> server + + @doc """ + Registers a listed tool for a server. A name outside the pattern or a schema the registry + cannot build is skipped and logged; an effect the row claims that the registry refuses + (`catalog`) is refused with a decision receipt. + """ + @spec register(ServerConfig.t(), String.t(), map()) :: {:ok, String.t()} | {:error, term()} + def register(%ServerConfig{name: server} = config, tool, listed) when is_binary(tool) do + registry_name = tool_name(server, tool) + + with :ok <- check_name(tool), + spec = %{ + name: registry_name, + description: to_string(Map.get(listed, "description") || ""), + schema: Map.get(listed, "inputSchema") || %{"type" => "object"}, + effect: effect(config, tool), + risk: :ask + }, + {:ok, _entry} <- Trinity.Tools.register(__MODULE__, spec: spec) do + {:ok, registry_name} + else + {:error, reason} = error -> + refused(config, tool, registry_name, reason) + error + end + end + + @doc "Removes a registered tool." + @spec unregister(String.t()) :: :ok | {:error, term()} + def unregister(registry_name), do: Trinity.Tools.unregister(registry_name) + + defp check_name(tool) do + if Regex.match?(@tool_name_pattern, tool), do: :ok, else: {:error, {:invalid_tool_name, tool}} + end + + # The row's effect for a tool: the override's when named, the row's default otherwise. + # The string is the row's; an unknown one is passed through for the registry to refuse + # (`catalog` included), never mapped to a safe value silently. + defp effect(%ServerConfig{effect_default: default, tool_overrides: overrides}, tool) do + override = Map.get(overrides || %{}, tool, %{}) + as_atom(Map.get(override, "effect") || default) + end + + defp as_atom(s) when is_binary(s), do: String.to_existing_atom(s) + defp as_atom(a) when is_atom(a), do: a + + # A refusal at load is a decision receipt of outcome deny on the server's scope: the + # tool is not registered, and the record says which and why. + defp refused(%ServerConfig{name: server}, tool, registry_name, reason) do + Logger.warning("mcp #{server}: tool #{tool} not registered: #{inspect(reason)}") + + Receipts.append(scope(server), %{ + kind: "decision", + subject: %{"server" => server, "tool" => registry_name, "phase" => "load"}, + decision: %{"outcome" => "deny", "basis" => "registry", "reason" => inspect(reason)}, + subject_ref: "mcp-load:" <> registry_name, + meta: %{} + }) + end + + ## Executing + + @impl true + def execute(args, %Context{tool: "mcp:" <> rest} = ctx) do + case String.split(rest, ":", parts: 2) do + [server, tool] -> run(server, tool, args, ctx) + _ -> {:error, {:unknown_tool, ctx.tool}} + end + end + + def execute(_args, %Context{tool: other}), do: {:error, {:unknown_tool, other}} + + defp run(server, tool, args, ctx) do + key = {ctx.session_id, ctx.call_id} + + opts = + case Client.pop_continuation(server, key) do + nil -> [] + continuation -> [continuation: continued(continuation)] + end + + case Client.call(server, tool, args, opts) do + {:ok, %{"result" => %{"resultType" => "input_required"} = result}} -> + hold(server, tool, args, ctx, key, result) + + {:ok, %{"result" => result}} when is_map(result) -> + {:ok, to_result(server, tool, ctx.tool, result)} + + {:ok, %{"error" => %{"code" => code, "message" => message}}} -> + {:error, {:server_error, code, message}} + + {:ok, other} -> + {:error, {:unreadable_answer, other}} + + {:error, reason} -> + {:error, reason} + end + end + + # The continuation stored at the hold, joined with the answer the decision carried: the + # `inputResponses` map keyed as the server's `inputRequests` were, and the state verbatim. + defp continued(%{state: state, approval_id: id}) do + answer = + case Permissions.get_approval(id) do + %{answer: %{} = answer} -> answer + _ -> %{} + end + + %{responses: answer, state: state} + end + + # The server's requests become one approval for the owner, with the same fingerprint the + # running call has (same session, name, arguments, cwd), so the decision the owner makes + # is the one the re-run consumes. The state is held here and never in the row. + defp hold(server, _tool, args, ctx, key, result) do + requests = Map.get(result, "inputRequests") || %{} + state = Map.get(result, "requestState") + + request = %{ + "kind" => "mcp_input", + "server" => server, + "inputRequests" => requests + } + + case Permissions.request_approval(ctx.session_id, ctx.tool, args, + cwd: ctx.cwd, + risk: :ask, + request: request + ) do + {:ok, approval} -> + :ok = Client.put_continuation(server, key, %{state: state, approval_id: approval.id}) + {:error, {:approval_required, approval.id}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + + ## Results + + # Text parts joined; an image or audio part is a line naming its type and size (the + # bytes are not handed to the model); an embedded resource is its text or a line naming + # its uri. `isError` rides in the meta and prefixes the text, so the model reads it as the + # server's error and not as content. + defp to_result(server, tool, registry_name, result) do + parts = Map.get(result, "content") || [] + text = parts |> Enum.map(&part_text/1) |> Enum.reject(&(&1 == "")) |> Enum.join("\n") + error? = Map.get(result, "isError") == true + + text = + cond do + error? and text == "" -> + "[the server reported an error]" + + error? -> + "[the server reported an error]\n" <> text + + text == "" and is_map(Map.get(result, "structuredContent")) -> + Jason.encode!(result["structuredContent"]) + + true -> + text + end + + Untrusted.result(text, + tool: registry_name, + source_ref: "mcp://" <> server <> "/" <> tool, + meta: %{"server" => server, "tool" => tool, "is_error" => error?, "parts" => length(parts)} + ) + end + + defp part_text(%{"type" => "text", "text" => text}) when is_binary(text), do: text + + defp part_text(%{"type" => type, "data" => data} = part) + when type in ["image", "audio"] and is_binary(data), + do: + "[#{type}: #{byte_size(data)} bytes of base64, #{Map.get(part, "mimeType", "unknown type")}]" + + defp part_text(%{"type" => "resource", "resource" => %{"text" => text}}) when is_binary(text), + do: text + + defp part_text(%{"type" => "resource", "resource" => %{"uri" => uri}}) when is_binary(uri), + do: "[resource: #{uri}]" + + defp part_text(%{"type" => "resource_link", "uri" => uri}) when is_binary(uri), + do: "[resource: #{uri}]" + + defp part_text(_), do: "" + + @impl true + def format_result(%Result{} = result), do: Result.as_text(result) +end diff --git a/lib/trinity/mcp/client.ex b/lib/trinity/mcp/client.ex new file mode 100644 index 0000000..d955b4c --- /dev/null +++ b/lib/trinity/mcp/client.ex @@ -0,0 +1,493 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Client do + @moduledoc """ + One process per configured MCP server (slice 060): Trinity's thin driver over the core's + public functions (ADR-0007 decision 7). It opens the transport, chooses the revision + (`server/discover` first; the preferred 2026-07-28 if the server lists it, else + `initialize` at 2025-11-25 if it lists that, else a refusal naming the list), pages + through `tools/list`, registers every tool it may through `Trinity.MCP.Bridge`, and + serves calls: a call runs in the caller's process against a lease of the transport + handle, so one slow tool does not hold the others to the same server. + + What it holds between calls: the listed tools and their schemas, the multi-round-trip + continuations waiting on an approval (keyed by session and call id, the server's + `requestState` verbatim), and the health the `/mcp` page shows. A lost transport + unregisters the tools, backs off (250 ms doubling to 30 s by default) and reconnects; + `ttlMs` on the list schedules a re-list, and a `notifications/tools/list_changed` from a + stdio server triggers one. + """ + use GenServer + + require Logger + + alias Trinity.MCP.{Bridge, ServerConfig} + alias Trinity.MCP.Client.Wire + + @type status :: :connecting | :ready | :down | :refused + + @default_backoff_ms 250 + @default_max_backoff_ms 30_000 + @default_timeout 30_000 + @default_connect_timeout 15_000 + + ## API + + @doc "Starts the client for a configured server; `opts`: `backoff_ms:`, `max_backoff_ms:`, `timeout:`, `transport_opts:`." + @spec start_link({ServerConfig.t(), keyword()}) :: GenServer.on_start() + def start_link({%ServerConfig{name: name} = config, opts}), + do: GenServer.start_link(__MODULE__, {config, opts}, name: via(name)) + + @doc false + def child_spec({%ServerConfig{name: name} = config, opts}) do + %{ + id: {__MODULE__, name}, + start: {__MODULE__, :start_link, [{config, opts}]}, + restart: :transient + } + end + + @doc "The registry name of a server's client." + @spec via(String.t()) :: {:via, Registry, {Trinity.Registry, {:mcp_client, String.t()}}} + def via(name), do: {:via, Registry, {Trinity.Registry, {:mcp_client, name}}} + + @doc "The client's pid for a server name, or nil." + @spec whereis(String.t()) :: pid() | nil + def whereis(name), do: GenServer.whereis(via(name)) + + @doc "What the page shows: status, revision, the tools and their registry names, the last error, the attempts." + @spec info(String.t() | pid()) :: {:ok, map()} | {:error, :not_running} + def info(server) do + case target(server) do + nil -> {:error, :not_running} + pid -> GenServer.call(pid, :info) + end + end + + @doc "Drops the transport and reconnects now." + @spec reconnect(String.t() | pid()) :: :ok | {:error, :not_running} + def reconnect(server) do + case target(server) do + nil -> {:error, :not_running} + pid -> GenServer.cast(pid, :reconnect) + end + end + + @doc "Re-lists the tools now." + @spec relist(String.t() | pid()) :: :ok | {:error, :not_running} + def relist(server) do + case target(server) do + nil -> {:error, :not_running} + pid -> GenServer.cast(pid, :relist) + end + end + + @doc """ + Calls a tool on a server from the caller's process: the arguments are validated against + the schema the server listed (through the core's validator), the request built by + `Trinity.MCP.Client.Wire`, and the answer decoded by the core. With `continuation:` the + call is the multi-round-trip retry. The answer is the server's whole JSON-RPC message. + """ + @spec call(String.t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} + def call(server, tool, arguments, opts \\ []) when is_map(arguments) do + with pid when is_pid(pid) <- target(server) || {:error, :server_not_running}, + {:ok, lease} <- GenServer.call(pid, {:lease, tool}), + :ok <- validate(lease, arguments, opts) do + request = + Wire.tools_call(next_id(), tool, arguments, lease.revision, + continuation: Keyword.get(opts, :continuation) + ) + + lease.transport.request(lease.handle, request, + schemas: lease.schemas, + timeout: Keyword.get(opts, :timeout, lease.timeout) + ) + end + end + + # A retry carries the arguments the server already validated; validating them again is + # harmless, so both paths validate. + defp validate(lease, arguments, _opts), do: Wire.validate_arguments(arguments, lease.schema) + + @doc "Stores a multi-round-trip continuation for a call, to be taken by the retry." + @spec put_continuation(String.t(), term(), map()) :: :ok | {:error, :not_running} + def put_continuation(server, key, continuation) do + case target(server) do + nil -> {:error, :not_running} + pid -> GenServer.call(pid, {:put_continuation, key, continuation}) + end + end + + @doc "Takes the continuation stored for a call, if any." + @spec pop_continuation(String.t(), term()) :: map() | nil + def pop_continuation(server, key) do + case target(server) do + nil -> nil + pid -> GenServer.call(pid, {:pop_continuation, key}) + end + end + + defp target(pid) when is_pid(pid), do: pid + defp target(name) when is_binary(name), do: whereis(name) + + defp next_id, do: System.unique_integer([:positive, :monotonic]) + + ## GenServer + + @impl true + def init({%ServerConfig{} = config, opts}) do + Process.flag(:trap_exit, true) + + state = %{ + config: config, + opts: opts, + transport: transport_for(config), + handle: nil, + revision: nil, + status: :connecting, + tools: %{}, + registered: [], + continuations: %{}, + last_error: nil, + attempts: 0, + backoff_ms: Keyword.get(opts, :backoff_ms, @default_backoff_ms), + ttl_ms: nil, + relist_timer: nil, + reconnect_timer: nil + } + + {:ok, state, {:continue, :connect}} + end + + defp transport_for(%ServerConfig{transport: "stdio"}), do: Trinity.MCP.Client.Transport.Stdio + defp transport_for(%ServerConfig{transport: "http"}), do: Trinity.MCP.Client.Transport.HTTP + + @impl true + def handle_continue(:connect, state) do + case connect(state) do + {:ok, state} -> + Logger.info( + "mcp #{state.config.name}: connected at #{state.revision}, #{map_size(state.tools)} tools" + ) + + {:noreply, %{state | status: :ready, attempts: 0, last_error: nil} |> schedule_relist()} + + {:refused, reason, state} -> + Logger.warning("mcp #{state.config.name}: refused: #{inspect(reason)}") + {:noreply, %{state | status: :refused, last_error: inspect(reason)}} + + {:error, reason, state} -> + Logger.warning("mcp #{state.config.name}: connect failed: #{inspect(reason)}") + {:noreply, retry_later(%{state | status: :down, last_error: inspect(reason)})} + end + end + + @impl true + def handle_call(:info, _from, state) do + {:reply, + {:ok, + %{ + name: state.config.name, + status: state.status, + revision: state.revision, + tools: + for {name, tool} <- state.tools, into: %{} do + {name, + %{ + registered: Bridge.tool_name(state.config.name, name) in state.registered, + listed: tool + }} + end, + registered: state.registered, + last_error: state.last_error, + attempts: state.attempts, + transport_os_pid: os_pid(state) + }}, state} + end + + def handle_call({:lease, tool}, _from, %{status: :ready} = state) do + case Map.fetch(state.tools, tool) do + {:ok, listed} -> + {:reply, + {:ok, + %{ + transport: state.transport, + handle: state.handle, + revision: state.revision, + schema: Map.get(listed, "inputSchema", %{"type" => "object"}), + schemas: schemas(state.tools), + timeout: Keyword.get(state.opts, :timeout, @default_timeout) + }}, state} + + :error -> + {:reply, {:error, {:unknown_tool, tool}}, state} + end + end + + def handle_call({:lease, _tool}, _from, state), + do: {:reply, {:error, {:server_not_ready, state.status}}, state} + + def handle_call({:put_continuation, key, continuation}, _from, state), + do: {:reply, :ok, put_in(state.continuations[key], continuation)} + + def handle_call({:pop_continuation, key}, _from, state) do + {value, continuations} = Map.pop(state.continuations, key) + {:reply, value, %{state | continuations: continuations}} + end + + defp os_pid(%{handle: nil}), do: nil + + defp os_pid(%{transport: transport, handle: handle}) do + if function_exported?(transport, :os_pid, 1), do: transport.os_pid(handle), else: nil + end + + @impl true + def handle_cast(:reconnect, state) do + {:noreply, state |> drop(:reconnect) |> Map.put(:attempts, 0), {:continue, :connect}} + end + + def handle_cast(:relist, %{status: :ready} = state), do: {:noreply, do_relist(state)} + def handle_cast(:relist, state), do: {:noreply, state} + + @impl true + def handle_info({:mcp_transport_down, handle, reason}, %{handle: handle} = state) do + Logger.warning("mcp #{state.config.name}: transport down: #{inspect(reason)}") + state = drop(%{state | handle: nil}, {:transport_down, reason}) + {:noreply, retry_later(state)} + end + + def handle_info({:mcp_transport_down, _other, _reason}, state), do: {:noreply, state} + + def handle_info({:mcp_notification, _handle, "notifications/tools/list_changed", _}, state) do + {:noreply, if(state.status == :ready, do: do_relist(state), else: state)} + end + + def handle_info({:mcp_notification, _handle, method, _params}, state) do + Logger.debug("mcp #{state.config.name}: notification #{method} ignored") + {:noreply, state} + end + + def handle_info(:relist, %{status: :ready} = state), + do: {:noreply, do_relist(%{state | relist_timer: nil})} + + def handle_info(:relist, state), do: {:noreply, %{state | relist_timer: nil}} + + def handle_info(:reconnect, state), + do: {:noreply, %{state | reconnect_timer: nil}, {:continue, :connect}} + + # The stdio transport is linked; its exit reaches here as an EXIT and as the down message. + def handle_info({:EXIT, _pid, _reason}, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + unregister_all(state) + if state.handle, do: state.transport.close(state.handle) + :ok + end + + ## Connecting + + defp connect(state) do + transport_opts = Keyword.get(state.opts, :transport_opts, []) + + with {:ok, handle} <- state.transport.connect(state.config, self(), transport_opts), + state = %{state | handle: handle}, + {:ok, revision} <- choose_revision(state), + state = %{state | revision: revision}, + {:ok, tools, ttl} <- list_tools(state) do + {:ok, register_all(%{state | tools: tools, ttl_ms: ttl})} + else + {:refused, reason} -> {:refused, reason, close_handle(state)} + {:error, reason} -> {:error, reason, close_handle(state)} + end + end + + defp close_handle(%{handle: nil} = state), do: state + + defp close_handle(state) do + state.transport.close(state.handle) + %{state | handle: nil} + end + + # `server/discover` is answered by any 2026-07-28 server and by the dual-era core whatever + # the era; a server that refuses it with "method not found" is a legacy one, and gets + # `initialize`. The preferred revision wins when listed; the legacy one when that is + # listed; anything else is a refusal that names the list, and no revision the driver does + # not speak is tried. + defp choose_revision(state) do + case request(state, Wire.discover(next_id()), connect_timeout(state)) do + {:ok, %{"result" => %{"supportedVersions" => versions}}} when is_list(versions) -> + pick(state, versions) + + {:ok, %{"error" => %{"code" => -32_601}}} -> + initialize(state) + + {:ok, %{"error" => %{"code" => -32_022} = error}} -> + pick(state, List.delete(get_in(error, ["data", "supported"]) || [], Wire.modern())) + + {:ok, %{"error" => error}} -> + {:error, {:discover_refused, error}} + + {:ok, other} -> + {:error, {:discover_unreadable, other}} + + {:error, reason} -> + {:error, {:discover_failed, reason}} + end + end + + # The preferred revision when listed, the legacy one when that is, else a refusal naming + # the list. (A -32022 refusal of the modern opener lists what the server serves; the + # modern revision is removed from that list before the pick, since the server just said no.) + defp pick(state, versions) do + cond do + Wire.modern() in versions -> {:ok, Wire.modern()} + Wire.legacy() in versions -> initialize(state) + true -> {:refused, {:unsupported_revisions, versions}} + end + end + + defp initialize(state) do + client_info = %{"name" => "trinity", "version" => to_string(Application.spec(:trinity, :vsn))} + + case request(state, Wire.initialize(next_id(), client_info), connect_timeout(state)) do + {:ok, %{"result" => %{"protocolVersion" => version}}} -> + if version == Wire.legacy() do + state.transport.notify(state.handle, Wire.initialized(), []) + {:ok, Wire.legacy()} + else + {:refused, {:unsupported_revisions, [version]}} + end + + {:ok, %{"error" => error}} -> + {:refused, {:initialize_refused, error}} + + {:ok, other} -> + {:error, {:initialize_unreadable, other}} + + {:error, reason} -> + {:error, {:initialize_failed, reason}} + end + end + + defp list_tools(state), do: list_tools(state, nil, %{}, 0) + + # Paginated through the cursor the server hands back, opaque to the driver; a list that + # never ends (a server whose cursor loops) stops at a page bound. + defp list_tools(_state, _cursor, _acc, 64), do: {:error, :too_many_pages} + + defp list_tools(state, cursor, acc, pages) do + case request( + state, + Wire.tools_list(next_id(), state.revision, cursor), + connect_timeout(state) + ) do + {:ok, %{"result" => %{"tools" => tools} = result}} when is_list(tools) -> + acc = + Enum.reduce(tools, acc, fn + %{"name" => name} = tool, acc when is_binary(name) -> Map.put(acc, name, tool) + _, acc -> acc + end) + + case Map.get(result, "nextCursor") do + next when is_binary(next) and next != "" -> list_tools(state, next, acc, pages + 1) + _ -> {:ok, acc, Map.get(result, "ttlMs")} + end + + {:ok, %{"error" => error}} -> + {:error, {:list_refused, error}} + + {:ok, other} -> + {:error, {:list_unreadable, other}} + + {:error, reason} -> + {:error, {:list_failed, reason}} + end + end + + defp request(state, request, timeout), + do: + state.transport.request(state.handle, request, + schemas: schemas(state.tools), + timeout: timeout + ) + + defp connect_timeout(state), + do: Keyword.get(state.opts, :connect_timeout, @default_connect_timeout) + + defp schemas(tools) do + for {name, tool} <- tools, into: %{}, do: {name, Map.get(tool, "inputSchema", %{})} + end + + ## Registration + + defp register_all(state) do + registered = + for {name, tool} <- state.tools, + {:ok, registry_name} <- [Bridge.register(state.config, name, tool)], + do: registry_name + + %{state | registered: registered} + end + + defp unregister_all(state) do + Enum.each(state.registered, &Bridge.unregister/1) + %{state | registered: []} + end + + defp do_relist(state) do + case list_tools(state) do + {:ok, tools, ttl} -> + state = unregister_all(state) + register_all(%{state | tools: tools, ttl_ms: ttl}) |> schedule_relist() + + {:error, reason} -> + Logger.warning("mcp #{state.config.name}: re-list failed: #{inspect(reason)}") + schedule_relist(%{state | last_error: inspect(reason)}) + end + end + + # ttlMs 0 (the core's default) means the list is not to be cached: re-listed on reconnect + # and on a change notification only, never on a timer that would hammer the server. + defp schedule_relist(state) do + if state.relist_timer, do: Process.cancel_timer(state.relist_timer) + + case state.ttl_ms do + ttl when is_integer(ttl) and ttl > 0 -> + %{state | relist_timer: Process.send_after(self(), :relist, ttl)} + + _ -> + %{state | relist_timer: nil} + end + end + + ## Losing the server + + defp drop(state, reason) do + state = unregister_all(state) + state = close_handle(state) + if state.relist_timer, do: Process.cancel_timer(state.relist_timer) + + %{ + state + | status: :down, + revision: nil, + tools: %{}, + ttl_ms: nil, + relist_timer: nil, + last_error: inspect(reason) + } + end + + defp retry_later(state) do + max = Keyword.get(state.opts, :max_backoff_ms, @default_max_backoff_ms) + delay = min(state.backoff_ms * Integer.pow(2, min(state.attempts, 16)), max) + if state.reconnect_timer, do: Process.cancel_timer(state.reconnect_timer) + + %{ + state + | attempts: state.attempts + 1, + reconnect_timer: Process.send_after(self(), :reconnect, delay) + } + end +end diff --git a/lib/trinity/mcp/client/auth.ex b/lib/trinity/mcp/client/auth.ex new file mode 100644 index 0000000..01a5b4f --- /dev/null +++ b/lib/trinity/mcp/client/auth.ex @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Client.Auth do + @moduledoc """ + Where the driver gets a bearer token for a protected server (slice 060). This is slice + 062's seam: at this slice a token is a static value in the environment variable + `TRINITY_MCP__TOKEN` (the server's name upcased, `-` as `_`), read at connect and + never stored; 062's client role replaces this module's body with the token it obtained + and answers the `401` the transport reports. Nothing here performs an OAuth flow. + """ + + alias Trinity.MCP.ServerConfig + + @doc "The bearer for a server, or nil." + @spec bearer(ServerConfig.t()) :: String.t() | nil + def bearer(%ServerConfig{name: name}) do + case System.get_env(variable(name)) do + nil -> nil + "" -> nil + token -> token + end + end + + @doc "The environment variable a server's token is read from." + @spec variable(String.t()) :: String.t() + def variable(name), + do: "TRINITY_MCP_" <> String.upcase(String.replace(name, "-", "_")) <> "_TOKEN" +end diff --git a/lib/trinity/mcp/client/transport.ex b/lib/trinity/mcp/client/transport.ex new file mode 100644 index 0000000..e55438f --- /dev/null +++ b/lib/trinity/mcp/client/transport.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Client.Transport do + @moduledoc """ + What carries a request to a server and its answer back (slice 060). Two implementations: + `Trinity.MCP.Client.Transport.Stdio` (a child process on a Port, newline-delimited, the + answers correlated by id) and `Trinity.MCP.Client.Transport.HTTP` (one POST per request, + the answer in the response). `connect/2` is called by the client process, which owns the + handle; `request/3` may be called from any process (a tool call runs in its own task), so a + transport holding a connection is a process of its own and a transport without one is a + struct. A transport that loses its server tells the owner with + `{:mcp_transport_down, handle, reason}`; a notification the server sends on its own is + `{:mcp_notification, handle, method, params}`. + """ + + alias Trinity.MCP.ServerConfig + + @type handle :: term() + + @doc "Opens the transport for a configured server; `owner` receives the down and notification messages." + @callback connect(ServerConfig.t(), owner :: pid(), opts :: keyword()) :: + {:ok, handle()} | {:error, term()} + + @doc "Sends a request and waits for the answer with that id; `opts` carry `schemas:` (for the HTTP headers) and `timeout:`." + @callback request(handle(), map(), keyword()) :: {:ok, map()} | {:error, term()} + + @doc "Sends a notification (no id, no answer)." + @callback notify(handle(), map(), keyword()) :: :ok | {:error, term()} + + @doc "Closes the transport." + @callback close(handle()) :: :ok +end diff --git a/lib/trinity/mcp/client/transport/http.ex b/lib/trinity/mcp/client/transport/http.ex new file mode 100644 index 0000000..071d756 --- /dev/null +++ b/lib/trinity/mcp/client/transport/http.ex @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Client.Transport.HTTP do + @moduledoc """ + The Streamable HTTP transport at 2026-07-28 (slice 060): one POST per request, the answer + in the response body, no session, the headers `Trinity.MCP.Client.Wire.headers/2` derives + from the request beside it. A struct, not a process: nothing is held between requests, so + any process may send one. The bearer, when `Trinity.MCP.Client.Auth` has one, goes in + `Authorization`; a `401` is reported with its `WWW-Authenticate` challenge and no flow of + the driver's own follows (slice 062 owns that). Redirects are not followed and nothing is + retried: a request that did not get its answer is an error the caller reads. + """ + @behaviour Trinity.MCP.Client.Transport + + alias Trinity.MCP.Client.{Auth, Wire} + alias Trinity.MCP.ServerConfig + + @default_timeout 30_000 + + defstruct [:url, :name, :bearer, :req_options] + + @type t :: %__MODULE__{ + url: String.t(), + name: String.t(), + bearer: String.t() | nil, + req_options: keyword() + } + + @impl true + def connect(%ServerConfig{transport: "http", url: url, name: name} = config, _owner, opts) do + {:ok, + %__MODULE__{ + url: url, + name: name, + bearer: Auth.bearer(config), + req_options: Keyword.get(opts, :req_options, []) + }} + end + + @impl true + def request(%__MODULE__{} = t, request, opts), do: post(t, request, opts, :answer) + + @impl true + def notify(%__MODULE__{} = t, request, opts) do + with {:ok, _} <- post(t, request, opts, :none), do: :ok + end + + @impl true + def close(_t), do: :ok + + defp post(t, request, opts, expect) do + headers = + [{"content-type", "application/json"}, {"accept", "application/json"}] ++ + Wire.headers(request, Keyword.get(opts, :schemas, %{})) ++ bearer(t.bearer) + + options = + Keyword.merge( + [ + headers: headers, + body: Wire.encode(request), + receive_timeout: Keyword.get(opts, :timeout, @default_timeout), + retry: false, + redirect: false, + decode_body: false + ], + t.req_options + ) + + case Req.post(t.url, options) do + {:ok, %Req.Response{status: 200, body: body}} when expect == :answer -> + Wire.decode(body) + + {:ok, %Req.Response{status: status}} when status in [200, 202, 204] and expect == :none -> + {:ok, nil} + + {:ok, %Req.Response{status: 401} = r} -> + {:error, {:unauthorized, Req.Response.get_header(r, "www-authenticate")}} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:http_status, status, refusal(body)}} + + {:error, reason} -> + {:error, {:transport, reason}} + end + end + + defp bearer(nil), do: [] + defp bearer(token), do: [{"authorization", "Bearer " <> token}] + + # The transport's refusal, when it is a JSON-RPC error, decoded through the core; the raw + # body's first bytes otherwise. + defp refusal(body) when is_binary(body) do + case Wire.decode(body) do + {:ok, %{"error" => error}} -> error + _ -> binary_part(body, 0, min(byte_size(body), 200)) + end + end + + defp refusal(other), do: other +end diff --git a/lib/trinity/mcp/client/transport/stdio.ex b/lib/trinity/mcp/client/transport/stdio.ex new file mode 100644 index 0000000..65fe715 --- /dev/null +++ b/lib/trinity/mcp/client/transport/stdio.ex @@ -0,0 +1,206 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Client.Transport.Stdio do + @moduledoc """ + The stdio transport (slice 060): the configured command as a child on a Port, one JSON + message per line each way, answers correlated to requests by id. This GenServer owns the + Port; the client process owns this GenServer and is told when the child exits. The child's + environment is the process basics (`kept_variables/0`) and the variables `env_refs` names + (read here, at start, never stored); everything else this VM holds, provider keys first, + is unset for the child. Its stderr is the VM's, so what the child logs reaches the + terminal and never the wire. + A line longer than 1 MiB ends the connection, as the core's own stdio transport bounds + its lines. + """ + @behaviour Trinity.MCP.Client.Transport + + use GenServer + + require Logger + + alias Trinity.MCP.Client.Wire + alias Trinity.MCP.ServerConfig + + @max_line_bytes 1_048_576 + @default_timeout 30_000 + + @impl Trinity.MCP.Client.Transport + def connect(%ServerConfig{transport: "stdio"} = config, owner, opts) do + GenServer.start_link(__MODULE__, {config, owner, opts}) + end + + @impl Trinity.MCP.Client.Transport + def request(pid, %{"id" => id} = request, opts) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + + try do + GenServer.call(pid, {:request, id, Wire.encode(request), timeout}, timeout + 100) + catch + :exit, {:timeout, _} -> {:error, :timeout} + :exit, {reason, _} -> {:error, {:transport_down, reason}} + end + end + + @impl Trinity.MCP.Client.Transport + def notify(pid, request, _opts) do + GenServer.call(pid, {:notify, Wire.encode(request)}) + catch + :exit, {reason, _} -> {:error, {:transport_down, reason}} + end + + @impl Trinity.MCP.Client.Transport + def close(pid) do + GenServer.stop(pid, :normal) + catch + :exit, _ -> :ok + end + + @doc "The child's OS pid (a test kills it to prove the reconnect)." + @spec os_pid(pid()) :: integer() | nil + def os_pid(pid) do + GenServer.call(pid, :os_pid) + catch + :exit, _ -> nil + end + + ## GenServer + + @impl GenServer + def init({config, owner, _opts}) do + case System.find_executable(config.command) do + nil -> + {:stop, {:command_not_found, config.command}} + + path -> + port = + Port.open({:spawn_executable, path}, [ + :binary, + :exit_status, + :hide, + {:line, @max_line_bytes}, + {:args, config.args}, + {:env, env(config.env_refs)} + ]) + + {:ok, %{port: port, owner: owner, pending: %{}, partial: [], config: config}} + end + end + + # A port's `env:` adds to the inherited environment and never replaces it, so the child + # would see every variable this VM has, provider keys included. Every inherited variable + # outside the allow list is unset by name (`{name, false}`), on every OS; what remains is + # the process basics (PATH, HOME, the locale, the temp dir) and the variables the row + # names. A name with no value is skipped and logged. + @kept ~w(PATH HOME LANG LC_ALL TMPDIR TMP TEMP SYSTEMROOT SYSTEMDRIVE USERPROFILE) + + defp env(refs) do + keep = MapSet.new(@kept ++ refs) + + unset = + for {name, _} <- System.get_env(), name not in keep, do: {String.to_charlist(name), false} + + passed = + Enum.flat_map(refs, fn ref -> + case System.get_env(ref) do + nil -> + Logger.warning("mcp stdio: environment variable #{ref} is not set; not passed") + [] + + value -> + [{String.to_charlist(ref), String.to_charlist(value)}] + end + end) + + unset ++ passed + end + + @doc "The variables a child keeps from this VM's environment beside the row's `env_refs`." + @spec kept_variables() :: [String.t()] + def kept_variables, do: @kept + + @impl GenServer + def handle_call({:request, id, line, timeout}, from, state) do + Port.command(state.port, [line, ?\n]) + timer = Process.send_after(self(), {:request_timeout, id}, timeout) + {:noreply, put_in(state.pending[id], {from, timer})} + end + + def handle_call({:notify, line}, _from, state) do + Port.command(state.port, [line, ?\n]) + {:reply, :ok, state} + end + + def handle_call(:os_pid, _from, state) do + case Port.info(state.port, :os_pid) do + {:os_pid, os_pid} -> {:reply, os_pid, state} + _ -> {:reply, nil, state} + end + end + + @impl GenServer + def handle_info({port, {:data, {:eol, chunk}}}, %{port: port} = state) do + line = IO.iodata_to_binary(Enum.reverse([chunk | state.partial])) + {:noreply, handle_line(line, %{state | partial: []})} + end + + def handle_info({port, {:data, {:noeol, chunk}}}, %{port: port} = state) do + {:noreply, %{state | partial: [chunk | state.partial]}} + end + + def handle_info({port, {:exit_status, status}}, %{port: port} = state) do + {:stop, {:shutdown, {:exit_status, status}}, state} + end + + def handle_info({:request_timeout, id}, state) do + case Map.pop(state.pending, id) do + {{from, _timer}, pending} -> + GenServer.reply(from, {:error, :timeout}) + {:noreply, %{state | pending: pending}} + + {nil, _} -> + {:noreply, state} + end + end + + # A line is an answer to a pending id, a notification (no id) for the owner, or noise + # (undecodable, or an id nobody waits on) that is logged and dropped: the child's stdout + # is the wire, and a server that prints there has broken it, which is the server's fault. + defp handle_line(line, state) do + case Wire.decode(line) do + {:ok, %{"id" => id} = message} when is_map_key(state.pending, id) -> + {{from, timer}, pending} = Map.pop(state.pending, id) + Process.cancel_timer(timer) + GenServer.reply(from, {:ok, message}) + %{state | pending: pending} + + {:ok, %{"method" => method} = message} when not is_map_key(message, "id") -> + send(state.owner, {:mcp_notification, self(), method, Map.get(message, "params", %{})}) + state + + {:ok, other} -> + Logger.warning( + "mcp stdio #{state.config.name}: unmatched message #{inspect(other, limit: 20)}" + ) + + state + + {:error, reason} -> + Logger.warning("mcp stdio #{state.config.name}: undecodable line (#{inspect(reason)})") + state + end + end + + @impl GenServer + def terminate(reason, state) do + for {_id, {from, timer}} <- state.pending do + Process.cancel_timer(timer) + GenServer.reply(from, {:error, {:transport_down, reason}}) + end + + send(state.owner, {:mcp_transport_down, self(), reason}) + if Port.info(state.port), do: Port.close(state.port) + :ok + catch + _, _ -> :ok + end +end diff --git a/lib/trinity/mcp/client/wire.ex b/lib/trinity/mcp/client/wire.ex new file mode 100644 index 0000000..32fc7fa --- /dev/null +++ b/lib/trinity/mcp/client/wire.ex @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Client.Wire do + @moduledoc """ + The outbound request object, and nothing else of the protocol (slice 060, the thin-driver + rule, ADR-0007 decision 7). This module builds the JSON-RPC request the driver sends at + either revision, the HTTP headers the 2026-07-28 transport requires beside it, and calls + the core's public functions to decode what comes back (`BeamMCP.JSON.decode/1`) and to + validate arguments against the schema a server listed (`BeamMCP.Schema.validate/2`). + Revision handling is the two openers, `server/discover` and `initialize`, and the + `_meta` every later request carries; there is no negotiation, envelope vocabulary or + schema validator of Trinity's own here, and the census (AC7) holds that. + """ + + @modern "2026-07-28" + @legacy "2025-11-25" + @version_key "io.modelcontextprotocol/protocolVersion" + @capabilities_key "io.modelcontextprotocol/clientCapabilities" + + @type revision :: String.t() + @type request :: %{required(String.t()) => term()} + + @doc "The revision the driver prefers, and the one it falls back to." + @spec modern() :: revision() + def modern, do: @modern + @spec legacy() :: revision() + def legacy, do: @legacy + + # What the driver can fulfil of a multi-round-trip result: a form elicitation, answered by + # the owner through an approval (docs/07). Sampling and roots are not declared, so a + # server may not ask for them (the revision's MRTR page: only requests the client declared). + @client_capabilities %{"elicitation" => %{"form" => %{}}} + + @doc "The client's `_meta` at a revision: the version, and at 2026-07-28 the client capabilities beside it." + @spec meta(revision()) :: map() + def meta(@modern), do: %{@version_key => @modern, @capabilities_key => @client_capabilities} + def meta(@legacy), do: %{@version_key => @legacy} + + @doc """ + The 2026-07-28 opener. It carries the modern `_meta`, because the HTTP transport requires + a version on every POST; a dual-era server answers it whatever the `_meta` says, and a + legacy-only one refuses it (`-32601`, or `-32022` naming what it supports), which is the + driver's cue to `initialize`. + """ + @spec discover(term()) :: request() + def discover(id), do: at(id, "server/discover", %{}, @modern) + + @doc "The 2025-11-25 opener, naming the revision and the client." + @spec initialize(term(), map()) :: request() + def initialize(id, client_info) do + request(id, "initialize", %{ + "protocolVersion" => @legacy, + "capabilities" => %{}, + "clientInfo" => client_info + }) + end + + @doc "The notification that follows a 2025-11-25 `initialize`." + @spec initialized() :: request() + def initialized, do: %{"jsonrpc" => "2.0", "method" => "notifications/initialized"} + + @doc "A request at a revision: the method, the params, and the `_meta` the revision requires." + @spec at(term(), String.t(), map(), revision()) :: request() + def at(id, method, params, revision) when is_map(params) do + request(id, method, Map.put(params, "_meta", meta(revision))) + end + + @doc "A `tools/list` at a revision, with the cursor when there is one." + @spec tools_list(term(), revision(), String.t() | nil) :: request() + def tools_list(id, revision, cursor \\ nil) do + params = if cursor, do: %{"cursor" => cursor}, else: %{} + at(id, "tools/list", params, revision) + end + + @doc """ + A `tools/call`. With `continuation:` (`responses`, the `inputResponses` map keyed as the + server's `inputRequests` were, and `state`, the server's `requestState` carried as the + term it arrived as and never rebuilt; `nil` when the server sent none) the request is the + multi-round-trip retry of an earlier call, under its own new id. + """ + @spec tools_call(term(), String.t(), map(), revision(), keyword()) :: request() + def tools_call(id, name, arguments, revision, opts \\ []) do + params = %{"name" => name, "arguments" => arguments} + + params = + case Keyword.get(opts, :continuation) do + nil -> + params + + %{responses: responses, state: nil} -> + Map.put(params, "inputResponses", responses) + + %{responses: responses, state: state} -> + params + |> Map.put("inputResponses", responses) + |> Map.put("requestState", state) + end + + at(id, "tools/call", params, revision) + end + + @doc "The `ping` the legacy revision keeps." + @spec ping(term()) :: request() + def ping(id), do: at(id, "ping", %{}, @legacy) + + defp request(id, method, params), + do: %{"jsonrpc" => "2.0", "id" => id, "method" => method, "params" => params} + + @doc """ + The headers a 2026-07-28 request carries over HTTP beside the body: the protocol version, + `Mcp-Method`, `Mcp-Name` for the methods that name a tool, resource or prompt, and + `Mcp-Param-{Name}` for every argument the listed schema annotates `x-mcp-header` + (`schemas` maps a tool name to its listed `inputSchema`). A value outside the printable + ASCII range, or one with an edge space, travels in the `=?base64?…?=` sentinel the + transport decodes. + """ + @spec headers(request(), %{optional(String.t()) => map()}) :: [{String.t(), String.t()}] + def headers(%{"method" => method} = request, schemas) do + [{"mcp-protocol-version", @modern}, {"mcp-method", method}] ++ + name_header(request) ++ param_headers(request, schemas) + end + + defp name_header(%{"method" => "tools/call", "params" => %{"name" => name}}), + do: [{"mcp-name", header_value(name)}] + + defp name_header(%{"method" => "prompts/get", "params" => %{"name" => name}}), + do: [{"mcp-name", header_value(name)}] + + defp name_header(%{"method" => "resources/read", "params" => %{"uri" => uri}}), + do: [{"mcp-name", header_value(uri)}] + + defp name_header(_), do: [] + + defp param_headers(%{"method" => "tools/call", "params" => %{"name" => name} = params}, schemas) do + arguments = Map.get(params, "arguments", %{}) + + schemas + |> Map.get(name, %{}) + |> annotations([]) + |> Enum.flat_map(fn {header, path} -> + case get_in(arguments, path) do + nil -> [] + value -> [{"mcp-param-" <> String.downcase(header), header_value(value)}] + end + end) + end + + defp param_headers(_request, _schemas), do: [] + + # The annotation walk the transport does on the server side, mirrored: through `properties` + # only, never through items, oneOf, anyOf, allOf, not, if/then/else or $ref. + defp annotations(%{"properties" => properties}, path) when is_map(properties) do + Enum.flat_map(properties, fn {key, sub} -> + here = + case is_map(sub) and sub["x-mcp-header"] do + header when is_binary(header) and header != "" -> [{header, path ++ [key]}] + _ -> [] + end + + here ++ annotations(sub, path ++ [key]) + end) + end + + defp annotations(_schema, _path), do: [] + + defp header_value(true), do: "true" + defp header_value(false), do: "false" + defp header_value(n) when is_integer(n), do: Integer.to_string(n) + defp header_value(n) when is_float(n), do: Float.to_string(n) + + defp header_value(s) when is_binary(s) do + if Regex.match?(~r/\A[\x21-\x7e]([\x20-\x7e]*[\x21-\x7e])?\z/, s), + do: s, + else: "=?base64?" <> Base.encode64(s) <> "?=" + end + + defp header_value(other), do: header_value(Jason.encode!(other)) + + @doc "Decodes a server's bytes through the core's decoder (depth-bounded, duplicate keys refused)." + @spec decode(binary()) :: {:ok, term()} | {:error, term()} + def decode(bytes) when is_binary(bytes), do: BeamMCP.JSON.decode(bytes) + + @doc "Validates a call's arguments against the schema the server listed, through the core's validator." + @spec validate_arguments(map(), map()) :: :ok | {:error, String.t()} + def validate_arguments(arguments, schema), do: BeamMCP.Schema.validate(arguments, schema) + + @doc "Encodes the outbound request as one line of JSON." + @spec encode(request()) :: binary() + def encode(request), do: Jason.encode!(request) +end diff --git a/lib/trinity/mcp/server_config.ex b/lib/trinity/mcp/server_config.ex new file mode 100644 index 0000000..e58b36f --- /dev/null +++ b/lib/trinity/mcp/server_config.ex @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.ServerConfig do + @moduledoc """ + One row of `mcp_servers` (slice 060, docs/05): a server the client connects to. `name` is + the namespace segment of every tool the server contributes (`mcp::`), so it is + short and closed to anything the tier lookup could confuse; `transport` is `stdio` (a + `command` with `args`, the child's environment holding only the variables `env_refs` + names) or `http` (a `url`). `effect_default` is the effect class every tool gets unless + `tool_overrides` names it; `catalog` is not a value either may take, because the effect + catalog is compile time (docs/07) and a server cannot enter it. + + Not `Trinity.MCP.Server`: that name is slice 061's, the server Trinity exposes. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, Trinity.UUID, autogenerate: true} + @timestamps_opts [type: :utc_datetime_usec] + + @type t :: %__MODULE__{} + + @transports ~w(stdio http) + @effects ~w(none artifact) + @name_pattern ~r/^[a-z0-9][a-z0-9_-]{0,31}$/ + + schema "mcp_servers" do + field :name, :string + field :transport, :string + field :command, :string + field :args, {:array, :string}, default: [] + field :url, :string + field :env_refs, {:array, :string}, default: [] + field :enabled, :boolean, default: true + field :effect_default, :string, default: "none" + field :tool_overrides, :map, default: %{} + field :last_error, :string + timestamps() + end + + @doc "The transports, and the effect classes a server's tools may carry." + @spec transports() :: [String.t()] + def transports, do: @transports + @spec effects() :: [String.t()] + def effects, do: @effects + + @doc false + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(config, attrs) do + config + |> cast(attrs, [ + :name, + :transport, + :command, + :args, + :url, + :env_refs, + :enabled, + :effect_default, + :tool_overrides, + :last_error + ]) + |> validate_required([:name, :transport]) + |> validate_format(:name, @name_pattern, + message: "must be 1 to 32 characters of a-z, 0-9, _ or -, starting with a letter or digit" + ) + |> validate_inclusion(:transport, @transports) + |> validate_inclusion(:effect_default, @effects) + |> validate_transport() + |> validate_env_refs() + |> validate_overrides() + |> unique_constraint(:name) + end + + defp validate_transport(changeset) do + case get_field(changeset, :transport) do + "stdio" -> + changeset + |> validate_required([:command], message: "a stdio server needs a command") + |> validate_change(:url, fn :url, _ -> [url: "a stdio server has no url"] end) + + "http" -> + changeset + |> validate_required([:url], message: "an http server needs a url") + |> validate_change(:url, &url_error/2) + |> validate_change(:command, fn :command, _ -> + [command: "an http server has no command"] + end) + + _ -> + changeset + end + end + + defp url_error(:url, url) do + case URI.new(url) do + {:ok, %URI{scheme: scheme, host: host}} + when scheme in ["http", "https"] and host not in [nil, ""] -> + [] + + _ -> + [url: "must be an http or https URL with a host"] + end + end + + # An env ref is a variable's name, never its value: a value here would be a secret in a row. + defp validate_env_refs(changeset) do + validate_change(changeset, :env_refs, fn :env_refs, refs -> + if Enum.all?(refs, &Regex.match?(~r/^[A-Z_][A-Z0-9_]*$/, &1)), + do: [], + else: [env_refs: "must be environment variable names (A-Z, 0-9, _)"] + end) + end + + # An override is `%{"effect" => none | artifact}` per tool name; `catalog` is refused here + # so a row can never claim it (the registry refuses it too, and the load path receipts a + # refusal: AC3). No other key is known. + defp validate_overrides(changeset) do + validate_change(changeset, :tool_overrides, fn :tool_overrides, overrides -> + Enum.flat_map(overrides, fn + {tool, %{} = o} when is_binary(tool) -> + effect_errors(tool, Map.get(o, "effect")) ++ key_errors(tool, Map.keys(o)) + + {tool, _} -> + [tool_overrides: "#{tool}: an override is a map"] + end) + end) + end + + defp effect_errors(_tool, nil), do: [] + defp effect_errors(_tool, effect) when effect in @effects, do: [] + + defp effect_errors(tool, "catalog"), + do: [tool_overrides: "#{tool}: catalog is compile time and cannot be claimed"] + + defp effect_errors(tool, other), + do: [tool_overrides: "#{tool}: unknown effect #{inspect(other)}"] + + defp key_errors(tool, keys) do + case keys -- ["effect"] do + [] -> + [] + + other -> + [ + tool_overrides: + "#{tool}: unknown key#{if length(other) > 1, do: "s"} #{Enum.join(other, ", ")}" + ] + end + end +end diff --git a/lib/trinity/mcp/servers.ex b/lib/trinity/mcp/servers.ex new file mode 100644 index 0000000..961a51f --- /dev/null +++ b/lib/trinity/mcp/servers.ex @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Servers do + @moduledoc """ + The configured servers (slice 060): the `mcp_servers` rows and the clients that run for + them. Writing a row starts or stops its client to match `enabled`; removing one stops it. + The page (`/mcp`) reads `status/0`, which joins each row to its client's health. + """ + import Ecto.Query, only: [from: 2] + + alias Trinity.MCP.{Client, ServerConfig, Supervisor} + alias Trinity.Repo + + @doc "The rows, by name; `enabled: true` for the enabled ones." + @spec list(keyword()) :: [ServerConfig.t()] + def list(opts \\ []) do + query = from(s in ServerConfig, order_by: s.name) + + query = + case Keyword.get(opts, :enabled) do + nil -> query + enabled -> from(s in query, where: s.enabled == ^enabled) + end + + Repo.all(query) + end + + @doc "A row by id." + @spec get(String.t()) :: ServerConfig.t() | nil + def get(id), do: Repo.get(ServerConfig, id) + + @doc "A row by name." + @spec get_by_name(String.t()) :: ServerConfig.t() | nil + def get_by_name(name), do: Repo.get_by(ServerConfig, name: name) + + @doc "Creates a row and, when enabled, starts its client." + @spec create(map()) :: {:ok, ServerConfig.t()} | {:error, Ecto.Changeset.t()} + def create(attrs) do + with {:ok, config} <- %ServerConfig{} |> ServerConfig.changeset(attrs) |> Repo.insert() do + if config.enabled, do: start(config) + {:ok, config} + end + end + + @doc "Updates a row; the client is restarted on a change, or stopped when disabled." + @spec update(ServerConfig.t(), map()) :: {:ok, ServerConfig.t()} | {:error, Ecto.Changeset.t()} + def update(%ServerConfig{} = config, attrs) do + with {:ok, updated} <- config |> ServerConfig.changeset(attrs) |> Repo.update() do + Supervisor.stop_client(config.name) + if updated.enabled, do: start(updated) + {:ok, updated} + end + end + + @doc "Removes a row and stops its client." + @spec delete(ServerConfig.t()) :: {:ok, ServerConfig.t()} | {:error, Ecto.Changeset.t()} + def delete(%ServerConfig{} = config) do + Supervisor.stop_client(config.name) + Repo.delete(config) + end + + @doc "Starts the client for a row." + @spec start(ServerConfig.t(), keyword()) :: {:ok, pid()} | {:error, term()} + def start(%ServerConfig{} = config, opts \\ []), do: Supervisor.start_client(config, opts) + + @doc "Stops the client for a row." + @spec stop(ServerConfig.t()) :: :ok + def stop(%ServerConfig{name: name}), do: Supervisor.stop_client(name) + + @doc "Every row joined to its client's health (`nil` when no client runs)." + @spec status() :: [%{config: ServerConfig.t(), client: map() | nil}] + def status do + for config <- list() do + client = + case Client.info(config.name) do + {:ok, info} -> info + {:error, :not_running} -> nil + end + + %{config: config, client: client} + end + end + + @doc "A changeset for a form." + @spec change(ServerConfig.t(), map()) :: Ecto.Changeset.t() + def change(%ServerConfig{} = config, attrs \\ %{}), do: ServerConfig.changeset(config, attrs) +end diff --git a/lib/trinity/mcp/supervisor.ex b/lib/trinity/mcp/supervisor.ex new file mode 100644 index 0000000..a5e967a --- /dev/null +++ b/lib/trinity/mcp/supervisor.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.Supervisor do + @moduledoc """ + The MCP clients (slice 060): one `Trinity.MCP.Client` per enabled server row, started at + boot by `Trinity.MCP.Boot` and by the servers context when a row is added or enabled, + stopped when it is disabled or removed. A client that stops on its own (a refused + revision) is not restarted: its status is on the page and the owner acts. + """ + use DynamicSupervisor + + alias Trinity.MCP.{Client, ServerConfig} + + @spec start_link(keyword()) :: Supervisor.on_start() + def start_link(opts), do: DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts), + do: DynamicSupervisor.init(strategy: :one_for_one, max_restarts: 10, max_seconds: 60) + + @doc "Starts the client for a row (a running one is left as it is)." + @spec start_client(ServerConfig.t(), keyword()) :: {:ok, pid()} | {:error, term()} + def start_client(%ServerConfig{} = config, opts \\ []) do + case DynamicSupervisor.start_child(__MODULE__, {Client, {config, client_opts(opts)}}) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + {:error, _} = error -> error + end + end + + @doc "Stops the client for a server name, if one runs." + @spec stop_client(String.t()) :: :ok + def stop_client(name) do + case Client.whereis(name) do + nil -> :ok + pid -> DynamicSupervisor.terminate_child(__MODULE__, pid) + end + end + + @doc "The running clients' names." + @spec running() :: [String.t()] + def running do + for {_, pid, _, _} <- DynamicSupervisor.which_children(__MODULE__), + is_pid(pid), + {:ok, %{name: name}} <- [Client.info(pid)], + do: name + end + + defp client_opts(opts), do: Keyword.merge(Application.get_env(:trinity, :mcp_client, []), opts) +end diff --git a/lib/trinity/permissions.ex b/lib/trinity/permissions.ex index 5d3a3dc..adc19f0 100644 --- a/lib/trinity/permissions.ex +++ b/lib/trinity/permissions.ex @@ -120,8 +120,10 @@ defmodule Trinity.Permissions do Decides a pending request: `:once` allows this fingerprint one execution, `:session` grants it for the session (a `tool_permissions` row scoped to it, with an expiry), `:always` writes a global rule with `opts[:pattern]` (the pattern the user confirmed; `*` by default), `:deny` - denies. `opts[:by]` names the decider (`"liveview"` by default). Everything the UI does - goes through here, so a button carries no authority of its own (M7). + denies. `opts[:by]` names the decider (`"liveview"` by default); `opts[:answer]` (slice + 060) is the answer to a request that carried a server's input request, a map keyed as + the request was. Everything the UI does goes through here, so a button carries no + authority of its own (M7). """ @spec decide_request(String.t(), request_decision(), keyword()) :: {:ok, Approval.t()} | {:error, term()} diff --git a/lib/trinity/permissions/approval.ex b/lib/trinity/permissions/approval.ex index 38bf517..1439eba 100644 --- a/lib/trinity/permissions/approval.ex +++ b/lib/trinity/permissions/approval.ex @@ -30,6 +30,9 @@ defmodule Trinity.Permissions.Approval do field :decided_by, :string field :consumed_at, :utc_datetime_usec field :expires_at, :utc_datetime_usec + # Slice 060: a server's input request (MRTR) and, once decided, the answer given. + field :request, :map + field :answer, :map belongs_to :session, Trinity.Sessions.SessionRow timestamps() end @@ -44,7 +47,7 @@ defmodule Trinity.Permissions.Approval do @spec request_changeset(t(), map()) :: Ecto.Changeset.t() def request_changeset(approval, attrs) do approval - |> cast(attrs, [:session_id, :tool, :args, :risk, :fingerprint, :expires_at]) + |> cast(attrs, [:session_id, :tool, :args, :risk, :fingerprint, :expires_at, :request]) # Slice 041: a request may have no session (a staged skill change approved from the # page); its topic is `approvals:none` and `approvals:all`, its scope `session:none`. |> validate_required([:tool, :risk, :fingerprint, :expires_at]) @@ -55,7 +58,7 @@ defmodule Trinity.Permissions.Approval do @spec decide_changeset(t(), map()) :: Ecto.Changeset.t() def decide_changeset(approval, attrs) do approval - |> cast(attrs, [:status, :decision, :decided_at, :decided_by, :consumed_at]) + |> cast(attrs, [:status, :decision, :decided_at, :decided_by, :consumed_at, :answer]) |> validate_required([:status, :decision, :decided_at, :decided_by]) |> validate_inclusion(:status, @statuses) |> validate_inclusion(:decision, @decisions) diff --git a/lib/trinity/permissions/gate.ex b/lib/trinity/permissions/gate.ex index 8fdad5f..fbbe70c 100644 --- a/lib/trinity/permissions/gate.ex +++ b/lib/trinity/permissions/gate.ex @@ -21,7 +21,7 @@ defmodule Trinity.Permissions.Gate do @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) - @doc "Creates a pending request; `opts`: `cwd:`, `risk:` (the tier by default)." + @doc "Creates a pending request; `opts`: `cwd:`, `risk:` (the tier by default), `request:` (slice 060: a server's input request, shown to the decider)." @spec request(String.t(), String.t(), map(), keyword()) :: {:ok, Approval.t()} | {:error, term()} def request(session_id, tool, args, opts \\ []), @@ -76,7 +76,8 @@ defmodule Trinity.Permissions.Gate do args: args, risk: Atom.to_string(Keyword.get(opts, :risk, Permissions.tier(tool))), fingerprint: Permissions.fingerprint(session_id, tool, args, Keyword.get(opts, :cwd)), - expires_at: DateTime.add(now, expiry_ms(), :millisecond) + expires_at: DateTime.add(now, expiry_ms(), :millisecond), + request: Keyword.get(opts, :request) } case Store.insert_approval(attrs) do @@ -133,7 +134,8 @@ defmodule Trinity.Permissions.Gate do status: status, decision: Atom.to_string(decision), decided_at: now, - decided_by: by + decided_by: by, + answer: Keyword.get(opts, :answer) }) do broadcast(:decided, decided) cancel(state.timers[approval.id]) diff --git a/lib/trinity/tools/context.ex b/lib/trinity/tools/context.ex index 167c98f..6e9a714 100644 --- a/lib/trinity/tools/context.ex +++ b/lib/trinity/tools/context.ex @@ -6,7 +6,9 @@ defmodule Trinity.Tools.Context do (the Session's id at this slice; a subagent's or a gateway's later); `cwd` is the working directory 022's filesystem and shell tools resolve paths against; `persona` is the row. `call_id` (slice 024) is the model's id for this call, set by the runner per call; with - the session it is the effect's idempotency key. + the session it is the effect's idempotency key. `tool` (slice 060) is the registry name the + call was made under, set beside `call_id`, so a module serving many dynamic tools (the MCP + bridge) knows which one it is. """ @type t :: %__MODULE__{ @@ -14,8 +16,9 @@ defmodule Trinity.Tools.Context do cwd: String.t() | nil, persona: struct() | map() | nil, caller: term(), - call_id: String.t() | nil + call_id: String.t() | nil, + tool: String.t() | nil } - defstruct session_id: nil, cwd: nil, persona: nil, caller: nil, call_id: nil + defstruct session_id: nil, cwd: nil, persona: nil, caller: nil, call_id: nil, tool: nil end diff --git a/lib/trinity/tools/registry.ex b/lib/trinity/tools/registry.ex index bf7713a..feabacd 100644 --- a/lib/trinity/tools/registry.ex +++ b/lib/trinity/tools/registry.ex @@ -14,6 +14,14 @@ defmodule Trinity.Tools.Registry do Every entry carries the tool's definition digest (SHA-256 over name, description and schema), which each tool call record and each turn's declared surface cite. + + Slice 060: a dynamic tool may be registered as a module plus a `spec:` (name, description, + schema, risk, effect, timeout), so one module serves many tools whose definitions arrived + at runtime (an MCP server's), with no module created per tool: a module is an atom, and + an atom per name a server chooses is a leak a rotating catalog could drive. The entry + carries the spec, and everything that reads a definition reads it from the spec when one + is present and from the module otherwise. The module's `execute/2` learns which tool it is + from `Trinity.Tools.Context.tool`, the entry's name. """ use GenServer @@ -24,6 +32,15 @@ defmodule Trinity.Tools.Registry do @table __MODULE__ + @type spec :: %{ + required(:name) => String.t(), + required(:description) => String.t(), + required(:schema) => map(), + optional(:risk) => Tool.risk(), + optional(:effect) => Tool.effect(), + optional(:timeout) => pos_integer() + } + @type entry :: %{ name: String.t(), module: module(), @@ -31,7 +48,8 @@ defmodule Trinity.Tools.Registry do risk: Tool.risk(), effect: Tool.effect(), digest: String.t(), - toolsets: [atom()] + toolsets: [atom()], + spec: spec() | nil } @dynamic_prefixes ["mcp:", "skill:"] @@ -63,7 +81,9 @@ defmodule Trinity.Tools.Registry do @doc """ Admits a dynamic tool: a module implementing `Trinity.Tools.Tool` whose name is namespaced, - not a core name, whose schema builds, and whose effect is not `:catalog`. + not a core name, whose schema builds, and whose effect is not `:catalog`. With `spec:` + (slice 060) the name, description, schema, risk, effect and timeout are the spec's and the + module supplies `execute/2` alone. """ @spec register(module(), keyword()) :: {:ok, entry()} | {:error, term()} def register(module, opts \\ []), do: GenServer.call(__MODULE__, {:register, module, opts}) @@ -77,18 +97,45 @@ defmodule Trinity.Tools.Registry do %{name: String.t(), description: String.t(), parameters: map()} ] def to_llm_tools(opts \\ []) do - for %{module: m, name: name} <- list(opts) do - %{name: name, description: m.description(), parameters: m.schema()} + for %{name: name} = entry <- list(opts) do + %{name: name, description: description(entry), parameters: schema(entry)} end end @doc "SHA-256, hex, over the name, the description and the schema, so a changed definition is a changed digest." - @spec definition_digest(module()) :: String.t() - def definition_digest(module) do - payload = :erlang.term_to_binary({module.name(), module.description(), module.schema()}) + @spec definition_digest(module() | entry()) :: String.t() + def definition_digest(module) when is_atom(module), + do: digest_of(module.name(), module.description(), module.schema()) + + def definition_digest(%{spec: %{} = spec}), + do: digest_of(spec.name, spec.description, spec.schema) + + def definition_digest(%{module: module}), do: definition_digest(module) + + defp digest_of(name, description, schema) do + payload = :erlang.term_to_binary({name, description, schema}) :crypto.hash(:sha256, payload) |> Base.encode16(case: :lower) end + @doc "The description the model reads: the spec's when present, the module's otherwise." + @spec description(entry()) :: String.t() + def description(%{spec: %{description: d}}), do: d + def description(%{module: m}), do: m.description() + + @doc "The argument schema: the spec's when present, the module's otherwise." + @spec schema(entry()) :: map() + def schema(%{spec: %{schema: s}}), do: s + def schema(%{module: m}), do: m.schema() + + @doc "The call timeout in milliseconds, or nil for the configured default." + @spec timeout(entry()) :: pos_integer() | nil + def timeout(%{spec: %{timeout: t}}) when is_integer(t), do: t + def timeout(%{spec: %{}}), do: nil + + def timeout(%{module: m}) do + if function_exported?(m, :timeout, 0), do: m.timeout(), else: nil + end + @doc "True for a name a dynamic tool may carry." @spec namespaced?(String.t()) :: boolean() def namespaced?(name), do: Enum.any?(@dynamic_prefixes, &String.starts_with?(name, &1)) @@ -166,23 +213,65 @@ defmodule Trinity.Tools.Registry do defp admit(module, kind, toolsets, opts \\ []) do with :ok <- implements(module), - name = module.name(), + {:ok, spec} <- spec_rule(Keyword.get(opts, :spec), kind), + {name, description, schema, risk, effect} = definition(module, spec), :ok <- name_rules(name, kind), - :ok <- schema_rule(module.schema()), - :ok <- effect_rule(module.effect(), name, kind) do + :ok <- schema_rule(schema), + :ok <- effect_rule(effect, name, kind) do {:ok, %{ name: name, module: module, kind: kind, - risk: module.risk(), - effect: module.effect(), - digest: definition_digest(module), - toolsets: sets_of(name, toolsets, kind, opts) + risk: risk, + effect: effect, + digest: digest_of(name, description, schema), + toolsets: sets_of(name, toolsets, kind, opts), + spec: spec }} end end + # A spec belongs to a dynamic tool only; a core tool's definition is its module's. What the + # spec leaves out (risk, effect) falls back to the module, so a bridge module's own + # `risk/0` and `effect/0` are the defaults for the tools it serves. + defp spec_rule(nil, _kind), do: {:ok, nil} + defp spec_rule(_spec, :core), do: {:error, :core_tool_with_spec} + + defp spec_rule(%{name: name, description: d, schema: s} = spec, :dynamic) + when is_binary(name) and is_binary(d) and is_map(s) do + with :ok <- spec_field(spec, :risk, [:read, :write, :exec, :network, :destructive, :ask]), + :ok <- spec_field(spec, :effect, [:none, :artifact, :catalog]), + :ok <- spec_timeout(spec) do + {:ok, spec} + end + end + + defp spec_rule(spec, :dynamic), do: {:error, {:invalid_spec, spec}} + + defp spec_field(spec, key, allowed) do + case Map.get(spec, key) do + nil -> :ok + v -> if v in allowed, do: :ok, else: {:error, {:invalid_spec, {key, v}}} + end + end + + defp spec_timeout(spec) do + case Map.get(spec, :timeout) do + nil -> :ok + t when is_integer(t) and t > 0 -> :ok + other -> {:error, {:invalid_spec, {:timeout, other}}} + end + end + + defp definition(module, nil), + do: {module.name(), module.description(), module.schema(), module.risk(), module.effect()} + + defp definition(module, spec) do + {spec.name, spec.description, spec.schema, Map.get(spec, :risk, module.risk()), + Map.get(spec, :effect, module.effect())} + end + defp implements(module) do if Tool.implemented_by?(module), do: :ok, else: {:error, {:not_a_tool, module}} end diff --git a/lib/trinity/tools/runner.ex b/lib/trinity/tools/runner.ex index bf79026..4fe988a 100644 --- a/lib/trinity/tools/runner.ex +++ b/lib/trinity/tools/runner.ex @@ -86,7 +86,7 @@ defmodule Trinity.Tools.Runner do end defp execute(%{name: name, args: args} = call, ctx, executor) do - ctx = %{ctx | call_id: Map.get(call, :id)} + ctx = %{ctx | call_id: Map.get(call, :id), tool: name} with {:ok, entry} <- Registry.lookup(name), {:ok, args} <- validate(entry, args), @@ -156,8 +156,8 @@ defmodule Trinity.Tools.Runner do if function_exported?(module, :escalate, 2), do: module.escalate(args, ctx), else: nil end - defp validate(%{module: module}, args) do - case Schema.validate(module.schema(), args) do + defp validate(entry, args) do + case Schema.validate(Registry.schema(entry), args) do {:ok, args} -> {:ok, args} {:error, reasons} -> {:error, {:invalid_args, reasons}} end @@ -179,9 +179,9 @@ defmodule Trinity.Tools.Runner do do: {:error, {:effectful_tool_outside_membrane, name}} defp timeout_of(%{name: name}) do - with {:ok, %{module: m}} <- Registry.lookup(name), - true <- function_exported?(m, :timeout, 0) do - m.timeout() + with {:ok, entry} <- Registry.lookup(name), + t when is_integer(t) <- Registry.timeout(entry) do + t else _ -> default_timeout() end diff --git a/lib/trinity_web.ex b/lib/trinity_web.ex index 2f126d8..228994b 100644 --- a/lib/trinity_web.ex +++ b/lib/trinity_web.ex @@ -1,7 +1,8 @@ # SPDX-FileCopyrightText: Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 defmodule TrinityWeb do - use Boundary, deps: [Trinity], exports: [Endpoint] + # Slice 060: the MCP page reads the servers and their clients. + use Boundary, deps: [Trinity, Trinity.MCP], exports: [Endpoint] @moduledoc """ The entrypoint for defining your web interface, such diff --git a/lib/trinity_web/components/approval_components.ex b/lib/trinity_web/components/approval_components.ex index 2c28e51..4312f09 100644 --- a/lib/trinity_web/components/approval_components.ex +++ b/lib/trinity_web/components/approval_components.ex @@ -9,6 +9,12 @@ defmodule TrinityWeb.ApprovalComponents do page turns into `Trinity.Permissions.decide_request/3`; nothing on the card carries authority of its own (M7). "Always allow" shows the pattern that would be written, pre-filled from the arguments and editable, so what is granted is what the person read. + + Slice 060: an approval that carries a server's input request (`request`, the multi-round-trip + pattern) is a question, not a yes or no: the card renders the server's message and a form + from its `requestedSchema` (string, number, boolean, enum), and the one button answers and + continues (`once`, with the answer) or declines (`deny`). The server's opaque state is not on + the card: it never left the client process. """ use Phoenix.Component use Gettext, backend: TrinityWeb.Gettext @@ -24,6 +30,54 @@ defmodule TrinityWeb.ApprovalComponents do default: nil, doc: "the always-allow pattern as edited; the suggestion by default" + def approval_card(%{approval: %Approval{request: %{"kind" => "mcp_input"}}} = assigns) do + ~H""" + + """ + end + def approval_card(assigns) do assigns = assign(assigns, :pattern, assigns.pattern || suggest_pattern(assigns.approval)) @@ -76,6 +130,123 @@ defmodule TrinityWeb.ApprovalComponents do """ end + attr :id, :string, required: true + attr :name, :string, required: true + attr :prop, :map, required: true + attr :required, :boolean, default: false + + # One field of a requested schema: an enum is a select, a boolean a checkbox, a number a + # number input, anything else text. The value's type is restored by `answer_from_params/2`. + defp request_field(%{prop: %{"enum" => choices}} = assigns) when is_list(choices) do + assigns = assign(assigns, :choices, choices) + + ~H""" + + """ + end + + defp request_field(%{prop: %{"type" => "boolean"}} = assigns) do + ~H""" + + + + + """ + end + + defp request_field(%{prop: %{"type" => t}} = assigns) when t in ["integer", "number"] do + ~H""" + + """ + end + + defp request_field(assigns) do + ~H""" + + """ + end + + defp input_requests(%Approval{request: %{"inputRequests" => requests}}) when is_map(requests), + do: Enum.sort_by(requests, &elem(&1, 0)) + + defp input_requests(_), do: [] + + defp properties(req), + do: + req + |> get_in(["params", "requestedSchema", "properties"]) + |> Kernel.||(%{}) + |> Enum.sort_by(&elem(&1, 0)) + + defp required(req), do: get_in(req, ["params", "requestedSchema", "required"]) || [] + + @doc """ + The answer to a request from the form's params (`answer[key][field]`): one `ElicitResult` + per request, `accept` with the content typed as the requested schema says (a number + parsed, a boolean read, a string kept); a request the form sent nothing for is `decline`. + The revision's `inputResponses` map, keyed as the `inputRequests` were. + """ + @spec answer_from_params(Approval.t(), map()) :: map() + def answer_from_params(%Approval{} = approval, params) do + given = Map.get(params, "answer", %{}) + + for {key, req} <- input_requests(approval), into: %{} do + {key, elicit_result(req, Map.get(given, key))} + end + end + + defp elicit_result(req, %{} = fields) do + content = + for {name, prop} <- properties(req), Map.has_key?(fields, name), into: %{} do + {name, typed(prop, fields[name])} + end + + %{"action" => "accept", "content" => content} + end + + defp elicit_result(_req, _absent), do: %{"action" => "decline"} + + defp typed(%{"type" => "boolean"}, v), do: v in ["true", true] + + defp typed(%{"type" => "integer"}, v) when is_binary(v) do + case Integer.parse(v) do + {i, ""} -> i + _ -> v + end + end + + defp typed(%{"type" => "number"}, v) when is_binary(v) do + case Float.parse(v) do + {f, ""} -> f + _ -> v + end + end + + defp typed(%{"enum" => choices}, v) when is_list(choices), + do: Enum.find(choices, v, &(to_string(&1) == v)) + + defp typed(_prop, v), do: v + attr :id, :string, required: true attr :decision, :string, required: true attr :label, :string, required: true diff --git a/lib/trinity_web/live/mcp_live.ex b/lib/trinity_web/live/mcp_live.ex new file mode 100644 index 0000000..0365c85 --- /dev/null +++ b/lib/trinity_web/live/mcp_live.ex @@ -0,0 +1,329 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.MCPLive do + @moduledoc """ + `/mcp` (slice 060): the configured MCP servers with their health (status, the revision + chosen, the tools registered, the last error), each with enable, disable, reconnect and + remove; the tools of a server with their effect and tier; and a form to add a server + (stdio: a command with arguments and the environment variables to pass; http: a URL). + Everything here is `Trinity.MCP.Servers`; the page refreshes itself every two seconds + while open, since a client's health changes on its own. + """ + use TrinityWeb, :live_view + + alias Trinity.MCP.{Client, ServerConfig, Servers} + alias Trinity.Tools + + @refresh_ms 2_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Process.send_after(self(), :refresh, @refresh_ms) + + {:ok, + socket + |> assign( + page_title: gettext("MCP servers"), + form: to_form(%{"transport" => "stdio"}), + viewing: nil + ) + |> load()} + end + + defp load(socket) do + assign(socket, servers: Servers.status()) + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, load(socket)} + end + + @impl true + def handle_event("create", %{"server" => params}, socket) do + case Servers.create(attrs(params)) do + {:ok, config} -> + {:noreply, + socket + |> assign(form: to_form(%{"transport" => "stdio"})) + |> put_flash(:info, gettext("Server %{name} added.", name: config.name)) + |> load()} + + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(params, errors: errors(changeset)))} + end + end + + def handle_event("transport", %{"server" => params}, socket), + do: {:noreply, assign(socket, form: to_form(params))} + + def handle_event("enable", %{"id" => id}, socket), do: {:noreply, set_enabled(socket, id, true)} + + def handle_event("disable", %{"id" => id}, socket), + do: {:noreply, set_enabled(socket, id, false)} + + def handle_event("reconnect", %{"name" => name}, socket) do + _ = Client.reconnect(name) + {:noreply, load(socket)} + end + + def handle_event("remove", %{"id" => id}, socket) do + with %ServerConfig{} = config <- Servers.get(id), {:ok, _} <- Servers.delete(config) do + {:noreply, socket |> assign(viewing: nil) |> load()} + else + _ -> {:noreply, put_flash(socket, :error, gettext("Not removed."))} + end + end + + def handle_event("view", %{"name" => name}, socket), + do: {:noreply, assign(socket, viewing: name)} + + def handle_event("hide", _params, socket), do: {:noreply, assign(socket, viewing: nil)} + + defp set_enabled(socket, id, enabled) do + with %ServerConfig{} = config <- Servers.get(id), + {:ok, _} <- Servers.update(config, %{enabled: enabled}) do + load(socket) + else + _ -> put_flash(socket, :error, gettext("Not changed.")) + end + end + + # The form's strings to the row's attributes: arguments split on whitespace, env refs on + # commas or whitespace, blanks dropped. + defp attrs(params) do + %{ + name: String.trim(params["name"] || ""), + transport: params["transport"], + command: blank_to_nil(params["command"]), + args: words(params["args"]), + url: blank_to_nil(params["url"]), + env_refs: params["env_refs"] |> to_string() |> String.split(~r/[\s,]+/, trim: true), + effect_default: params["effect_default"] || "none" + } + end + + defp words(nil), do: [] + defp words(s), do: String.split(s, ~r/\s+/, trim: true) + + defp blank_to_nil(nil), do: nil + defp blank_to_nil(s), do: if(String.trim(s) == "", do: nil, else: String.trim(s)) + + defp errors(changeset) do + for {field, {msg, _}} <- changeset.errors, do: {field, {msg, []}} + end + + defp tools_of(name) do + Tools.list() |> Enum.filter(&String.starts_with?(&1.name, "mcp:" <> name <> ":")) + end + + @impl true + def render(assigns) do + ~H""" + + <:bar> + {gettext("MCP servers")} + {length(@servers)} + +
+
+

{gettext("Servers")}

+

+ {gettext( + "No server is configured. Add one below; its tools appear to the assistant as mcp::, each asking for approval until you write a rule." + )} +

+
    +
  • +
    + + {c.transport} + <.status status={status_of(c, info)} /> + {info.revision} + + {ngettext("%{count} tool", "%{count} tools", length(info.registered))} + + + + + + +
    +

    + {if c.transport == "stdio", do: Enum.join([c.command | c.args], " "), else: c.url} +

    +

    + {info.last_error} +

    +
    +
    + {gettext("Tools")} + +
    +

    + {gettext("None registered.")} +

    + + + + + + + + + +
    {t.name}{t.effect}{t.risk} + {t.spec && t.spec.description} +
    +
    +
  • +
+
+ +
+

{gettext("Add a server")}

+ <.form + for={@form} + id="server-form" + as={:server} + phx-change="transport" + phx-submit="create" + class="flex flex-col gap-2" + > +
+ + + +
+
+ + + +
+
+ +
+

+ {field}: {msg} +

+
+ +
+ +
+
+
+ """ + end + + defp status_of(%ServerConfig{enabled: false}, _info), do: :disabled + defp status_of(_config, nil), do: :stopped + defp status_of(_config, %{status: status}), do: status + + attr :status, :atom, required: true + + defp status(assigns) do + ~H""" + + {@status} + + """ + end +end diff --git a/lib/trinity_web/live/permissions_live.ex b/lib/trinity_web/live/permissions_live.ex index eefe4a8..314ccd8 100644 --- a/lib/trinity_web/live/permissions_live.ex +++ b/lib/trinity_web/live/permissions_live.ex @@ -50,6 +50,23 @@ defmodule TrinityWeb.PermissionsLive do def handle_event("approval_pattern", %{"approval_id" => id, "pattern" => pattern}, socket), do: {:noreply, assign(socket, patterns: Map.put(socket.assigns.patterns, id, pattern))} + # Slice 060: a server's input request, answered from the card's form; the answer travels + # with the decision, and the retry the Session makes carries it to the server. + def handle_event("approval_answer", %{"approval_id" => id} = params, socket) do + with %Approval{} = approval <- Permissions.get_approval(id), + answer = answer_from_params(approval, params), + {:ok, _} <- Permissions.decide_request(id, :once, answer: answer) do + {:noreply, load(socket)} + else + nil -> + {:noreply, put_flash(socket, :error, gettext("That request is gone."))} + + {:error, reason} -> + {:noreply, + put_flash(socket, :error, gettext("Not decided: %{reason}", reason: inspect(reason)))} + end + end + def handle_event("revoke", %{"id" => id}, socket) do _ = Permissions.revoke_rule(id) {:noreply, load(socket)} diff --git a/lib/trinity_web/live/session_live/index.ex b/lib/trinity_web/live/session_live/index.ex index 423e47c..6545ee9 100644 --- a/lib/trinity_web/live/session_live/index.ex +++ b/lib/trinity_web/live/session_live/index.ex @@ -45,6 +45,9 @@ defmodule TrinityWeb.SessionLive.Index do <.link id="skills-link" navigate={~p"/skills"} class="text-meta opacity-70 hover:opacity-100"> {gettext("skills")} + <.link id="mcp-link" navigate={~p"/mcp"} class="text-meta opacity-70 hover:opacity-100"> + {gettext("mcp")} +
id, "pattern" => pattern}, socket), do: {:noreply, assign(socket, patterns: Map.put(socket.assigns.patterns, id, pattern))} + # Slice 060: a server's input request, answered from the card's form; the answer travels + # with the decision, and the retry the Session makes carries it to the server. + def handle_event("approval_answer", %{"approval_id" => id} = params, socket) do + with %Approval{} = approval <- Permissions.get_approval(id), + answer = answer_from_params(approval, params), + {:ok, _} <- Permissions.decide_request(id, :once, answer: answer) do + {:noreply, socket} + else + nil -> + {:noreply, put_flash(socket, :error, gettext("That request is gone."))} + + {:error, reason} -> + {:noreply, + put_flash(socket, :error, gettext("Not decided: %{reason}", reason: inspect(reason)))} + end + end + defp suggested(socket, id) do case Enum.find(socket.assigns.approvals, &(&1.id == id)) do nil -> "*" diff --git a/lib/trinity_web/router.ex b/lib/trinity_web/router.ex index 6d7fa2d..7398fea 100644 --- a/lib/trinity_web/router.ex +++ b/lib/trinity_web/router.ex @@ -38,6 +38,8 @@ defmodule TrinityWeb.Router do live "/memory", MemoryLive, :index # Slice 040: the skills the registry found. live "/skills", SkillsLive, :index + # Slice 060: the MCP servers and their health. + live "/mcp", MCPLive, :index # Slice 024: a session's receipt chain, and the boot receipt of this run. live "/s/:id/receipts", ReceiptsLive, :session live "/receipts/boot", ReceiptsLive, :boot diff --git a/priv/repo/migrations/20260921200000_create_mcp_servers.exs b/priv/repo/migrations/20260921200000_create_mcp_servers.exs new file mode 100644 index 0000000..ae7a0b8 --- /dev/null +++ b/priv/repo/migrations/20260921200000_create_mcp_servers.exs @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Repo.Migrations.CreateMcpServers do + use Ecto.Migration + + # Slice 060: the configured MCP servers (docs/05). One row per server the client connects + # to; `env_refs` names environment variables passed through to a stdio child and never + # holds a value; `tool_overrides` is a map of tool name to its effect and risk. + def change do + create table(:mcp_servers, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + add :transport, :string, null: false + add :command, :string + add :args, {:array, :string}, null: false, default: [] + add :url, :string + add :env_refs, {:array, :string}, null: false, default: [] + add :enabled, :boolean, null: false, default: true + add :effect_default, :string, null: false, default: "none" + add :tool_overrides, :map, null: false, default: %{} + add :last_error, :text + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:mcp_servers, [:name]) + end +end diff --git a/priv/repo/migrations/20260921201000_approvals_carry_a_request.exs b/priv/repo/migrations/20260921201000_approvals_carry_a_request.exs new file mode 100644 index 0000000..40226dc --- /dev/null +++ b/priv/repo/migrations/20260921201000_approvals_carry_a_request.exs @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Repo.Migrations.ApprovalsCarryARequest do + use Ecto.Migration + + # Slice 060: an approval may carry a server's input request (MRTR: what the server asked + # for, `inputRequests` as sent) and, once decided, the answer the deciding party gave. + # Both are absent on an ordinary approval. + def change do + alter table(:approvals) do + add :request, :map + add :answer, :map + end + end +end diff --git a/scripts/dev_chat_mcp_playground.sh b/scripts/dev_chat_mcp_playground.sh new file mode 100755 index 0000000..f9dbb95 --- /dev/null +++ b/scripts/dev_chat_mcp_playground.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# The chat against a real public 2026-07-28 MCP server (slice 060 AC8): the test environment's +# application on its own database (trinity_screenshots.db, never the suite's), the fake provider +# scripted to call the playground server's signed multi-round-trip tool, and the playground +# server added as a row so its tools register under mcp:playground:*. The port is printed as +# PORT=. Run `mix assets.build` first and remove any stale `priv/static/assets/**/*.gz`. +# Both repos leave the sandbox pool behind (a receipt written from a tool task would otherwise +# wait on a checkout nobody owns: the first run of this script timed out every call on that). +# The server is https://mcpplaygroundonline.com/mcp-stateless-server; MCP_URL overrides it. +cd "$(dirname "$0")/.." +export MIX_ENV=test +export TRINITY_LIVE=1 +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) +{:ok, _} = Application.ensure_all_started(:trinity) +Ecto.Migrator.run(Trinity.Repo, :up, all: true) +Ecto.Migrator.run(Trinity.Repo.Receipts, :up, all: true) +url = System.get_env("MCP_URL") || "https://mcpplaygroundonline.com/mcp-stateless-server" +for c <- Trinity.MCP.Servers.list(), do: Trinity.MCP.Servers.delete(c) +{:ok, _} = Trinity.MCP.Servers.create(%{name: "playground", transport: "http", url: url}) +call = [{:text_delta, "I will carry that label across a signed round trip on the playground server. "}, {:sleep, 300}, {:tool_call_start, "c1", "mcp:playground:mrtr_signed_state"}, {:tool_call_end, "c1", %{"target" => "slice 060"}}, {:usage, %{input_tokens: 12, output_tokens: 20}}, {:done, :tool_calls}] +final = fn text -> Enum.flat_map(String.split(text), &[{:text_delta, &1 <> " "}, {:sleep, 40}]) ++ [{:usage, %{input_tokens: 40, output_tokens: 30}}, {:done, :stop}] end +Trinity.LLM.Providers.Fake.scripts([ + call, + final.("Done. The server accepted the state it had signed, and the label came back: **slice 060**."), + call, + final.("Understood, I did not continue the call.") +]) +{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) +IO.puts("PORT=#{port}") +' diff --git a/slices/060-mcp-client/NOTES.md b/slices/060-mcp-client/NOTES.md new file mode 100644 index 0000000..705dcf1 --- /dev/null +++ b/slices/060-mcp-client/NOTES.md @@ -0,0 +1,184 @@ +# Slice 060: NOTES + +## Read before code, 2026-09-21 + +Tree at `c96adbe` on `main` (059 approved). What this slice joins: 020's registry, which admits a +dynamic tool only under a namespace (`mcp::`) and never with `effect: :catalog`; 021's gate, +whose tier for a name outside the core map is `:ask`, and whose "allow once" is the newest decided approval +for the fingerprint, consumed by the call that runs; 012's Session, which holds a call answering +`{:error, {:approval_required, id}}` and re-runs it when the approval is decided; 024's membrane, where an +`effect: :none` tool runs directly with a query receipt and an `effect: :artifact` one crosses +`Trinity.Effects.execute/2`; 059's `Trinity.MCP` boundary (the one place that may reach `BeamMCP.*`), its +FINDINGS (row 1: HTTP is 2026-07-28 only, stdio is dual-era; row 9: the core refuses MRTR, entry 12; row 10: +Tasks is open in the core; row 12: the public functions a client may call are `BeamMCP.JSON.decode/1`, +`BeamMCP.Schema.validate/2`, `BeamMCP.Cursor.decode/2`). + +The wire the driver builds, from the core's source at `cfa706b` (the server under test is that core): +2026-07-28 opens with `server/discover` (answered whatever the `_meta`; the result's `supportedVersions` +is the list); every later request carries `params._meta` with `io.modelcontextprotocol/protocolVersion` and +`io.modelcontextprotocol/clientCapabilities`; over HTTP one POST per request with `MCP-Protocol-Version`, +`Mcp-Method`, `Mcp-Name` (the tool, resource or prompt name) and `Mcp-Param-{Name}` for every argument the +listed `inputSchema` annotates `x-mcp-header`; no session, no `initialize`. 2025-11-25 opens with +`initialize` (`protocolVersion` in the params) and `notifications/initialized`, then `_meta` names the +revision; `ping` exists. MRTR on the wire: a result with `resultType: "input_required"`, `inputRequests` +and an opaque `requestState`; the continuation is the same request with `inputResponses` (a list keyed by +the request ids) and `requestState` echoed in `params`. + +Two places where SLICE.md and the approved record of 059 disagree, decided here and open to the owner's +veto in this turn: + +- **Tasks (AC5, and "Tasks extension" in the Goal and Scope) is out of this slice.** 059's NOTES finding 4, + approved with the slice: the Tasks extension is neither built nor refused in the core; "060 builds no + client for it and 061 does not advertise it; if a later slice needs it, the question goes to beam_mcp's + board first". The same sentence went to that board on 2026-09-21. A client for an extension the server core + does not speak would be tested against nothing but a double written to the same assumption. AC5 is + retagged `[deferred]` in SLICE.md in this commit, with this note quoted; the ROADMAP row's title keeps + "Tasks" until the owner says how to word it. +- **AC4's server is a Trinity test double, not a beam_mcp server.** The core answers every request + completely (entry 12), so the "test server that returns `input_required`" cannot be built with it; the + 061 wrapper that will is blocked on the seam. `test/support/mcp/mrtr_server.exs` is a stdio script of + Trinity's own that answers `tools/list` with one tool and `tools/call` with `input_required` and a + `requestState` (an HMAC over the request it holds), completes on the continuation that echoes it + byte-for-byte, and refuses one that alters or omits it. It is a double for the wire the 2026-07-28 + revision defines, not for beam_mcp. + +The other two servers under test are the core: `test/support/mcp/stdio_server.exs` runs +`BeamMCP.Transport.Stdio.run/1` over a small catalog as a Port (`elixir -pa … -e …`, dual-era, and a second +launch with `supported_versions: ["2025-11-25"]` for the legacy path), and the HTTP server is +`BeamMCP.Transport.HTTP` behind Bandit on a random port in the test process. + +## G1 plan, 2026-09-21 + +Branch `slice/060-mcp-client`; ROADMAP row 060 to `in_progress` in this commit. Each line names its test. + +1. Migration `mcp_servers` (docs/05): `name` (unique, the namespace segment, `[a-z0-9_-]{1,32}`), + `transport` (`stdio | http`), `command`, `args` (list), `url`, `env_refs` (names of environment + variables to pass through to a stdio child; never values), `enabled`, `effect_default` (`none | + artifact`), `tool_overrides` (map: tool name to `%{"effect" => …, "risk" => …}`), `last_error`. Schema + `Trinity.MCP.ServerConfig` (not `Trinity.MCP.Server`, which is 061's). Test: the changeset's refusals + (a name outside the pattern, a transport without its command or url, an override claiming `catalog`). +2. `Trinity.Tools.Registry.register/2` takes a dynamic tool as a module plus `spec:` (name, description, + schema, risk, effect, timeout) so one module (`Trinity.MCP.Bridge`) serves every MCP tool with no + runtime module per tool (an atom per tool name a server chooses would be a leak a rotating catalog + could drive); the entry carries `spec`, and `to_llm_tools/1`, the digest, the runner's validation and + timeout read it when present; `Trinity.Tools.Context` gains `tool`, the entry's name, set beside + `call_id`. Both callers of `execute/2` pass the context and stay the two the effects census allows. + Tests: registry (a spec'd entry lists, digests and validates from the spec; a spec claiming `:catalog` + is refused), runner (a spec'd call reaches `execute/2` with `ctx.tool` set). +3. `Trinity.MCP.Client` (one GenServer per configured server, under `Trinity.MCP.Supervisor`, a + DynamicSupervisor started by `Trinity.Application`; `Trinity.MCP.Boot` starts one per enabled row with + the gate's rescue for a missing table): connect, discover, list, register, serve calls, watch, reconnect. + The transports are `Trinity.MCP.Client.Transport.Stdio` (a Port on the command, newline-delimited, + `env_refs` resolved at start, stderr to the log) and `Trinity.MCP.Client.Transport.HTTP` (Req, one POST + per request, the headers above, the bearer from `Trinity.MCP.Client.Auth` (062's seam: at this slice it + reads a static token from an env ref or returns none; a `401` with `WWW-Authenticate` is a logged + refusal and a `last_error`, no flow of its own). Version selection: `server/discover` first; the + preferred revision if listed, else `initialize` at 2025-11-25 if listed, else refused with the list in + `last_error`. Test AC1: the three servers under test connect and the chosen revision per server is + asserted from the client's state and the log. +4. `Trinity.MCP.Client.Wire`: builds the outbound request object (id, method, params, `_meta`), and the + HTTP headers from it and the tool's listed schema; decodes with `BeamMCP.JSON.decode/1`; validates + arguments against the listed `inputSchema` with `BeamMCP.Schema.validate/2` before sending. Nothing else + of the protocol. Test AC7: the census over `git ls-files lib/trinity/mcp/client` (the population + command pasted in PROOF) finds the two method names for revision handling and no other, no + `Jason.decode`, no `JSV`, no schema literal beyond the request envelope. +5. `Trinity.MCP.Bridge` (`@behaviour Trinity.Tools.Tool`): `execute/2` reads `ctx.tool`, finds the client + by the server segment, calls, and maps the result's content parts to one `Trinity.Tools.Untrusted` + result (text parts joined; an image or resource part rendered as a line naming its type and size), + `meta` carrying the server, the tool and the revision. Registration per tool: name + `mcp::`, effect from `effect_default` and the override (`catalog` refused at load with a + `decision` receipt of outcome `deny` on the server's chain scope `mcp:`, and the tool skipped), + risk `:ask` unless an override says lower for that name (superseded below: no override lowers + it). Tests AC2 (a call through + `Trinity.Effects.Runner` with `effect: :none` yields a query receipt), AC3 (an override claiming + `catalog` is refused at load with the receipt), and the mapping. +6. MRTR (AC4) on the approval mechanism 021 already has, so the Session changes not at all: on + `input_required` the bridge stores the continuation (`requestState` verbatim, the `inputRequests`, the + call id) in the client keyed by `{session_id, call_id}`, creates an approval with the same fingerprint + as the running call and the input request in a new `request` column, and answers + `{:error, {:approval_required, id}}`; the Session holds the call and re-runs it on the decision; the + gate consumes the "once"; the bridge finds the continuation for the call id, reads the `answer` column + the decision wrote, and sends the same request with `inputResponses` and the `requestState` bytes + untouched. Migration: `approvals` gains `request` (map) and `answer` (map); `decide_request/3` takes + `answer:`; the permissions page renders a form from `requestedSchema` (string, number, boolean, enum) + when an approval carries a request. Test AC4 against the double: surfaced, answered, resumed, the + echoed bytes equal; an altered or omitted state refused by the double. +7. Health and reconnect: the client monitors the Port or the HTTP failure count, unregisters its tools on + loss, backs off (250 ms doubling to 30 s, both configurable) and re-registers on return; `ttlMs` from + `tools/list` schedules a re-list (0 means on reconnect only); `notifications/tools/list_changed` over + stdio triggers one. Test AC6 with a short backoff: killed child, tools gone, restarted, tools back. +8. `/mcp` LiveView: the servers with health, revision, tool count, last error; enable, disable, add + (stdio or http), remove; tools listed with their effect and tier. Route, nav link, README page list. +9. Docs: docs/01 (the MCP client under the boundary), docs/05 (the two tables), docs/07 (the MRTR answer + is an approval; MCP results are untrusted), docs/08 (the client row of the MCP standard), README. +10. Gate, coverage, PROOF; the ROADMAP row. + +Manual verification queue: **AC8**, one real public 2026-07-28 server used end-to-end (GIF). A public +server that speaks the 2026-07-28 revision may not exist yet on the open internet (the revision is two +months old and the stateless HTTP transport is the part most servers have not adopted); if none is found, +the GIF is against a beam_mcp server the owner runs, and the criterion's word "public" is the owner's to +keep or retag. + +Not built here: the OAuth client role (062), Tasks (see above), sampling, roots, logging, SSE, +`subscriptions/listen` for tool list changes (stdio's notification and `ttlMs` cover re-listing; a +subscription over stateless HTTP has nothing to hold it). + +## Deviations while building, 2026-09-21 + +- **No `risk` in a row's `tool_overrides`.** The plan's line 5 said an override could lower a tool's tier. + It cannot: `Trinity.Permissions.tier/1` maps the core names alone and answers `:ask` for every other + (docs/07, "unmapped goes to ask"), and a dynamic entry's `risk` is never handed to the permission + layer (021: only the registry in the application tree writes the core tiers, "a dynamic tool never + reaches this line"). A `risk` on the row would have claimed something the gate never honours. The + override carries `effect` alone; the tier of every MCP tool is `:ask`, and the owner lowers it the way + every other tier is lowered, with a rule on the permissions page. Test: `spec_registration_test.exs` + runs a spec'd call under a rule, and `server_config_test.exs` refuses an unknown override key. +- **The stdio child's environment is an allow list, not an add list.** A Port's `env:` adds to the + inherited environment (022's lesson), so the child of an MCP row would have seen every variable this + VM holds, provider keys first. Every inherited variable outside PATH, HOME, the locale and temp + variables and the row's `env_refs` is unset by name for the child (`Transport.Stdio.kept_variables/0`), + on every OS. Test: `stdio_env_test.exs`. +- **A stdio server's log must not go to stdout.** The Elixir VM's default log handler writes to standard + output, which is the wire. The stdio server under test moves its handler to standard error; a child + that logs to stdout is read as undecodable lines (warned, dropped) and its answers still correlate by + id. Worth a line on beam_mcp's stdio page (follow-up, not raised yet). +- **`server/discover` carries the modern `_meta`.** The HTTP transport requires a version on every POST; + the dual-era core answers `server/discover` whatever the `_meta` says; a legacy-only core answers + `-32022` naming what it supports, which the driver reads as its cue to `initialize`. + +## Findings at G3, 2026-09-21 + +1. **A public 2026-07-28 server exists, and AC8 ran against it.** The G1 plan doubted one would be found; + `https://mcpplaygroundonline.com/mcp-stateless-server` speaks the revision (stateless, `server/discover` + lists `["2026-07-28"]`, `ttlMs` 300000 on discover and 60000 on the list, `cacheScope` public) and ships + three tools, two of them multi-round-trip (`mrtr_confirm` with no `requestState`, `mrtr_signed_state` + with an HMAC-sealed one). `scripts/dev_chat_mcp_playground.sh` boots the chat against it with the fake + provider scripted to call `mcp:playground:mrtr_signed_state`; the recording in `proof/` is the owner's + manual queue done once here, and the criterion's word "public" holds as written. +2. **A receipt written from a tool task needs a pool nobody owns in the test environment.** The first run of + that script timed out every call through the membrane: `Trinity.Repo.Receipts` keeps the sandbox pool in + `config/test.exs`, and the decision receipt's `ensure_writer` waited on a checkout from a process outside + any sandbox owner (stacktrace in the session log, `Trinity.Receipts.ensure_writer/1`). The direct paths + (`Bridge.execute/2`, `Tools.Runner`) never touched it, which is why they were fast. The script now takes + both repos off the sandbox pool, as `serve030.sh` did at 030; the earlier 021 script predates the receipts + repo. Not a defect in the tree: a dev-script rule, recorded so the next screenshot script starts from it. +3. **The gate reads the spec's risk nowhere.** Recorded under "Deviations": no row lowers a tool's tier. +4. **The stdio child's stdout is the wire, and the Elixir VM logs to stdout by default.** The server under + test moves its log handler to standard error; a host running `BeamMCP.Transport.Stdio` should do the + same, and the package's page does not say so. Follow-up for beam_mcp's board, below. +5. **The one census that moved.** 059's boundary census pinned `lib/trinity/mcp.ex` as the only referrer of + `BeamMCP.`; the wire module is the second, under the same boundary, and the census now holds both and + that nothing outside `lib/trinity/mcp/` names the core. + +## Follow-ups + +- beam_mcp's board: a line on the stdio page that the host's log handler must not write to standard output + (finding 4); and, when 0.9.0 ships, 061 pins it (the seam ask is already on that board, 2026-09-21). +- Tasks (AC5, deferred): a client for the extension when the core builds or refuses it (the Tasks issue on that board, after 1.0.0 + on beam_mcp's plan). +- `Trinity.MCP.Client.Auth` is 062's seam: the static `TRINITY_MCP__TOKEN` variable is the whole of it + at this slice, and a `401` is a logged refusal. +- Slice 090: the client emits no telemetry of its own yet; the core's dispatch events are the server side's. + A `[:trinity, :mcp, :call, :start | :stop]` pair around `Client.call/4` belongs in 090's catalogue. +- Windows: the stdio transport's environment allow list uses `{name, false}` on the Port, which every OS + honours, but nothing here was run on Windows; the package workflow does not run the suite there. diff --git a/slices/060-mcp-client/PROOF.md b/slices/060-mcp-client/PROOF.md new file mode 100644 index 0000000..585bdf3 --- /dev/null +++ b/slices/060-mcp-client/PROOF.md @@ -0,0 +1,247 @@ +# Proof for slice 060: MCP client (2026-07-28 preferred, 2025-11-25 compat) + +Agent: Trinity · Coding Agent · Date: 2026-09-21 · Branch: slice/060-mcp-client · Final commit: (the commit carrying this file; named in the closing correction) + +## Summary +Trinity's thin MCP client over beam_mcp 0.8.0's public functions (ADR-0007 decision 7): one supervised +`Trinity.MCP.Client` per `mcp_servers` row, a stdio transport (a child on a Port with an allow-listed +environment) and a Streamable HTTP transport (one POST per request with the revision's headers), +`server/discover` first and `initialize` at 2025-11-25 as the fallback, every listed tool registered as a +dynamic tool `mcp::` through one bridge module and a registry spec (no module per tool), every +result untrusted, and the multi-round-trip loop on the approval mechanism 021 built: a server's +`input_required` is an approval carrying its request, the owner's answer rides the decision, the retry +echoes the `requestState` byte-for-byte and the Session changes not at all. The `/mcp` page and the +input-request card on the permissions page. Deferred: Tasks (AC5, under 059's approved finding 4); the OAuth +client role is 062's (a static token is the seam). Five findings and five follow-ups in NOTES.md; the hard +part was not the wire but the environment a stdio child inherits and the sandbox pool a receipt needs +(NOTES findings 2 and the deviations). + +## Gate +``` +$ mix gate (tree 49f40a4 with this file, NOTES.md and coverage.tsv uncommitted on top, this machine, OTP 28.5.0.5, Elixir 1.20.4, under a 32 GiB cgroup) +2443 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 615 tracked files +trinity.secrets.scan: OK over 615 files +trinity.reuse: OK. Every commentable tracked file carries an SPDX header +Result: 478 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 49f40a4) +Result: 478 passed, 18 excluded +| 71.05% | Trinity.MCP.Client | +| 75.36% | Trinity.MCP.Bridge | +| 75.51% | Trinity.MCP.Client.Wire | +| 65.15% | Trinity.MCP.Client.Transport.Stdio | +| 50.00% | Trinity.MCP.Client.Transport.HTTP | (the 401 and the non-200 branches: no server under test answers them) +| 92.86% | Trinity.MCP.ServerConfig | +| 80.32% | Total | +``` +`coverage.tsv` row: `060 80.32 49f40a4 2026-09-21` (from 80.54 at 059: two tenths, the transports' error branches) + +Note: the gate above was run twice on 49f40a4: the first run failed rule 7 of plan_check on one board identifier in NOTES.md (a Tasks issue named by number); the identifier was replaced with words and the second run is the one pasted, every other step identical. The output lines are the second run's`. + +The slice's tests (`mix test test/trinity/mcp test/trinity/tools/spec_registration_test.exs test/trinity_web/live/mcp_live_test.exs --trace`): +``` + * test the planted reference is a real reference: the file the proof compiles names BeamMCP outside Trinity.MCP + * test the planted reference is a real reference: the file the proof compiles names BeamMCP outside Trinity.MCP [L#39] + * test beam_mcp is in mix.lock at 0.8.0 and the VERSIONS row reads in mix.lock + * test beam_mcp is in mix.lock at 0.8.0 and the VERSIONS row reads in mix.lock [L#15] + * test the boundary compiler checks calls into beam_mcp everywhere, and Trinity.MCP is the boundary that lists it + * test the boundary compiler checks calls into beam_mcp everywhere, and Trinity.MCP is the boundary that lists it [L#22] + * test decoding and validation are the core's public functions; no decoder or validator of Trinity's own + * test decoding and validation are the core's public functions; no decoder or validator of Trinity's own [L#68] + * test the population is the driver's files, and it is not empty + * test the population is the driver's files, and it is not empty [L#28] + * test revision handling is the two openers; every method string is one the driver sends or a notification it reads + * test revision handling is the two openers; every method string is one the driver sends or a notification it reads [L#40] + * test AC3: the probe reports its diff size and the one census test it touches, and none of it is here + * test AC3: the probe reports its diff size and the one census test it touches, and none of it is here [L#60] + * test AC1: fifteen rows, numbered 1 to 15, each naming the pinned commit's paths with a line and a deriving command + * test AC1: fifteen rows, numbered 1 to 15, each naming the pinned commit's paths with a line and a deriving command [L#30] + * test AC2: the two conflicts are stated against the slice lines + * test AC2: the two conflicts are stated against the slice lines [L#51] + * test a variable outside the allow list and the refs is unset for the child; a ref is passed; PATH and HOME stay + * test a variable outside the allow list and the refs is unset for the child; a ref is passed; PATH and HOME stay [L#18] + * test a name outside the pattern, a transport without its command or url, a value beside the transport, a bad env ref, an unknown override are refused + * test a name outside the pattern, a transport without its command or url, a value beside the transport, a bad env ref, an unknown override are refused [L#12] + * test creating a disabled row starts no client; enabling starts one; deleting stops it; names are unique + * test creating a disabled row starts no client; enabling starts one; deleting stops it; names are unique [L#65] + * test a spec claiming :catalog is refused; an ill-formed spec is refused; a core tool may not carry one + * test a spec claiming :catalog is refused; an ill-formed spec is refused; a core tool may not carry one [L#81] + * test the entry's name, description, schema, risk, effect, timeout and digest are the spec's; two specs share one module + * test the entry's name, description, schema, risk, effect, timeout and digest are the spec's; two specs share one module [L#37] + * test the runner validates against the spec's schema and hands execute/2 the tool name in the context + * test the runner validates against the spec's schema and hands execute/2 the tool name in the context [L#64] + * test a call over stdio at each era answers; ping is only the legacy era's + * test a call over stdio at each era answers; ping is only the legacy era's [L#67] + * test AC1: stdio dual-era connects at 2026-07-28, stdio legacy at 2025-11-25, HTTP at 2026-07-28; every tool is registered under its namespace + * test AC1: stdio dual-era connects at 2026-07-28, stdio legacy at 2025-11-25, HTTP at 2026-07-28; every tool is registered under its namespace [L#18] + * test a server that lists no revision the driver speaks is refused, with the list in the error, and nothing is registered + * test a server that lists no revision the driver speaks is refused, with the list in the error, and nothing is registered [L#95] + * test AC6: the server dies: the tools are unregistered; it is restarted with backoff; the tools are registered again + * test AC6: the server dies: the tools are unregistered; it is restarted with backoff; the tools are registered again [L#80] + * test a call over HTTP mirrors the annotated argument into Mcp-Param, with the sentinel for a non-ASCII value; a failing tool is isError; an unknown one is refused + * test a call over HTTP mirrors the annotated argument into Mcp-Param, with the sentinel for a non-ASCII value; a failing tool is isError; an unknown one is refused [L#50] + * test a tool with an artifact override crosses the membrane as an effect + * test a tool with an artifact override crosses the membrane as an effect [L#68] + * test AC2: a call flows through Trinity.Effects.Runner with effect :none and yields a decision and a query receipt; the result is one untrusted part + * test AC2: a call flows through Trinity.Effects.Runner with effect :none and yields a decision and a query receipt; the result is one untrusted part [L#35] + * test AC3: a row claiming effect catalog for a tool is refused at load with a decision receipt on the server's scope, and the tool is not registered + * test AC3: a row claiming effect catalog for a tool is refused at load with a decision receipt on the server's scope, and the tool is not registered [L#85] + * test a failing tool is a result the model reads as the server's error, not a crash + * test a failing tool is a result the model reads as the server's error, not a crash [L#56] + * test AC4: surfaced as an approval carrying the server's request, answered, resumed with the state echoed, completed + * test AC4: surfaced as an approval carrying the server's request, answered, resumed with the state echoed, completed [L#33] + * test the server rejects a retry whose requestState is altered or omitted; the one echoed byte-for-byte completes + * test the server rejects a retry whose requestState is altered or omitted; the one echoed byte-for-byte completes [L#85] + * test an approval carrying a server's input request renders as a form; answering decides once with the typed answer + * test an approval carrying a server's input request renders as a form; answering decides once with the typed answer [L#77] + * test the page lists a server with its status, revision and tools; disable, enable and remove act on the row and the client + * test the page lists a server with its status, revision and tools; disable, enable and remove act on the row and the client [L#19] + * test declining a server's request denies the approval + * test declining a server's request denies the approval [L#142] + * test the form adds an http server, which connects; a bad row shows its errors + * test the form adds an http server, which connects; a bad row shows its errors [L#47] +Finished in 3.5 seconds (0.08s async, 3.4s sync) +Result: 30 passed +``` + +## Acceptance criteria evidence + +### AC1 [auto]: Both test servers connect; version chosen per server is logged and asserted (test) +Three servers under test, all beam_mcp's own transports over `Trinity.MCP.TestCatalog` (test/support/mcp): +stdio dual-era (a child VM running `test/support/mcp/stdio_server.exs`), stdio legacy-only (the same with +`legacy`, `supported_versions: ["2025-11-25"]`), and HTTP (`BeamMCP.Transport.HTTP` behind Bandit on a +loopback port). `client_test.exs` "AC1": each connects; `Client.info/1` reports `2026-07-28`, `2025-11-25`, +`2026-07-28`; the log lines `mcp s-modern: connected at 2026-07-28, 3 tools`, `mcp s-legacy: connected at +2025-11-25, 3 tools`, `mcp s-http: connected at 2026-07-28, 3 tools` are captured and asserted; the nine +tools are registered under `mcp::` with `kind: :dynamic, effect: :none, risk: :ask` and the +server's schema as the definition the model sees. A fourth server (the double with `future`) advertises a +revision nobody speaks and is refused with the list in `last_error`, nothing registered. + +### AC2 [auto]: An MCP tool call flows through `Trinity.Effects` with `effect: :none` and yields a query receipt (test) +`bridge_test.exs` "AC2": `Trinity.Effects.Runner.run/2` on `mcp:b:add` under an allow rule answers the +server's sum as one untrusted part (`taint: :untrusted`, origin `tool:mcp:b:add`, source +`mcp://b/add`), and the session's chain holds `{"decision", "mcp:b:add"}` and `{"query", "mcp:b:add"}` +and nothing else. The neighbour test: an `artifact` override crosses the membrane (`effect admit` and +`effect done` receipts). + +### AC3 [auto]: A server-config claiming `effect: :catalog` for an MCP tool is refused at load with a receipt (test) +`bridge_test.exs` "AC3", both halves: `Servers.create/1` with `tool_overrides: %{"echo" => %{"effect" => +"catalog"}}` is refused by the changeset ("catalog is compile time and cannot be claimed"); a struct built +outside the changeset with the same claim, started through `Servers.start/1`, connects with `add` and +`boom` registered and `echo` absent, and the chain scope `mcp:c` holds one `decision` receipt, subject +`{"server" => "c", "tool" => "mcp:c:echo", "phase" => "load"}`, outcome `deny`, reason +`:catalog_is_compile_time` (read from the signed payload). + +### AC4 [auto]: MRTR: the test server returns `input_required` with a `requestState`; the Session surfaces the request; answering resumes; the retried call carries the `requestState` back byte-for-byte and completes; a retry with the state altered or omitted is rejected by the server (test) +The server is Trinity's double for the wire (`test/support/mcp/mrtr_server.exs`; NOTES "Read before code": +the core refuses MRTR by design and the 061 wrapper is blocked on the seam). `mrtr_test.exs` "AC4" runs a +real Session with the fake provider calling `mcp:q:ask_name`: the gate's own approval first (the tier of a +namespaced name is `:ask`), then the server's question as a second approval whose `request` carries +`inputRequests.who` (method `elicitation/create`, message "What is your name?", `requestedSchema` requiring +`name`), the Session in `approval_wait`; `decide_request(id, :once, answer: %{"who" => %{"action" => +"accept", "content" => %{"name" => "Ayla"}}})` resumes it; the tool row reads `Hi, Ayla` with `ok: true`; +the continuation is gone from the client and the approval row carries no `requestState`. The second test +takes the state from a direct call and shows the double refusing an altered state, an omitted state and a +state minted for other arguments (`-32602`), and completing on the exact bytes. The double's state is an +HMAC over the request it belongs to, as the revision asks servers to do. + +### AC5 [deferred]: Tasks +Retagged at G1 (SLICE.md, NOTES.md "Read before code"): 059's approved finding 4 says this slice builds no +client for an extension the core neither builds nor refuses; the question goes to beam_mcp's board first +(the Tasks issue on its plan, after 1.0.0). Nothing of Tasks is in the tree. + +### AC6 [auto]: Server dies → tools unregistered → reconnect → re-registered (test with short backoff) +`client_test.exs` "AC6": the stdio child's OS pid from `Client.info/1` is killed with `kill -9`; the client +reports `down` and `mcp:r:echo` is gone from the registry; with the suite's backoff (50 ms doubling to 400 +ms, `config/test.exs`) it reconnects, a new child pid is reported, `attempts` is back to 0 and `mcp:r:echo` +is registered again. + +### AC7 [auto]: The thin-driver rule: a census over `lib/trinity/mcp/client/` (test, with the population command pasted) +``` +$ git ls-files lib/trinity/mcp/client lib/trinity/mcp/client.ex +lib/trinity/mcp/client.ex +lib/trinity/mcp/client/auth.ex +lib/trinity/mcp/client/transport.ex +lib/trinity/mcp/client/transport/http.ex +lib/trinity/mcp/client/transport/stdio.ex +lib/trinity/mcp/client/wire.ex +``` +`thin_driver_census_test.exs` holds that population to: every `"a/b"` string is a method the driver sends +(`server/discover`, `initialize`, `notifications/initialized`, `tools/list`, `tools/call`, `ping`, +`prompts/get`, `resources/read`) or the one notification it reads, plus the HTTP media type; the two +revision literals are in `wire.ex` and quoted nowhere else; decoding is `BeamMCP.JSON.decode/1` and +validation `BeamMCP.Schema.validate/2`, both in `wire.ex`, which is the only file naming the core; no +`Jason.decode`, no `JSV`, no `Trinity.Tools.Schema`, no schema literal. Beside it, 059's boundary census now +holds the two referrers of `BeamMCP.` under `lib/` (`mcp.ex`, `mcp/client/wire.ex`) and nothing outside +`lib/trinity/mcp/`. + +### AC8 [manual]: One real public 2026-07-28 server used end-to-end (GIF) +Done once here against `https://mcpplaygroundonline.com/mcp-stateless-server` (NOTES finding 1), with +`scripts/dev_chat_mcp_playground.sh` (the chat on its own database, the fake provider scripted to call +`mcp:playground:mrtr_signed_state` with `target: "slice 060"`) and a headless browser: +`proof/ac8-mrtr-playground.gif` and the four frames `proof/ac8-1-mcp-page.png` (the server ready at +2026-07-28 with its three tools), `proof/ac8-2-ask.png` (the gate's approval), `proof/ac8-3-server-asks.png` +(the server's question, "Re-issue this call, echoing requestState verbatim", as the card's form) and +`proof/ac8-4-done.png` (the retry accepted, the label back). The server's own answer to a direct probe: +``` +$ curl -s -X POST https://mcpplaygroundonline.com/mcp-stateless-server -H 'content-type: application/json' -H 'mcp-protocol-version: 2026-07-28' -H 'mcp-method: server/discover' -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' +{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true}},"resultType":"complete","ttlMs":300000,"cacheScope":"public",…},"jsonrpc":"2.0","id":1} +``` +The owner's queue: rerun the script (`PORT=` is printed), open `/mcp` and a new conversation, send any +message, allow once, tick `proceed`, answer; or read the GIF. + +## Manual verification for the reviewer +AC8 above. Everything else is a test. + +## Deviations from SLICE.md +NOTES.md: two stated before code (Tasks deferred; AC4's server a double), four found building (no `risk` +override, the child's environment allow list, the stdio log handler, `server/discover` with the modern +`_meta`). + +## Versions touched +`VERSIONS.md` updated: no. No new dependency: Req (022), Bandit (Phoenix), beam_mcp (059). +`mix versions.verify`: named in the gate output above. + +## Git +``` +$ git log --oneline main..HEAD +(named in the closing correction, after the final commit) +``` + +## Closing correction, 2026-09-21 + +Supersedes "named in the closing correction" above. The tree the PR is merged from is `64f4444` (`feat(s060): +complete slice 060 (MCP client)`, the commit carrying this file). On it, CI run 35651974115: `gate` success +(478 passed, 18 excluded), `postgres` success (458 passed, 38 excluded; the stdio child VMs and the loopback +HTTP server ran on that leg too), `fips-tag` and `fips` success (483 passed, 13 excluded; the six FIPS tests +by name). The coverage row stays at `49f40a4` (80.32%): `64f4444` differs from it in this file, NOTES.md, +ROADMAP.md and coverage.tsv only. + +``` +$ git log --oneline main..HEAD +64f4444 feat(s060): complete slice 060 (MCP client) +49f40a4 docs(s060): findings at G3 and follow-ups +d3b5398 refactor(s060): the revision pick and the elicit result as their own functions (credo) +d0e5b6d docs(s060): AC8 proof against the public 2026-07-28 playground server; the dev script that drives it +bca93dd docs(s060): docs 01, 05, 07, 08 and the README as built +3ef2a1b feat(s060): the /mcp page and the input-request card: a server's question answered from the permissions page +5cb9856 test(s060): the census reads quoted revision literals, not prose +104b56f test(s060): AC7 census, the row's refusals, the child's environment allow list; no risk override +be47fa8 test(s060): AC2, AC3 and AC4 green: the membrane path, the load refusal receipt, the multi-round-trip loop through a Session +1109586 feat(s060): the thin driver: wire, stdio and HTTP transports, client, bridge, servers; AC1 and AC6 green +4d0d430 docs(s060): G1 plan; AC5 (Tasks) deferred under 059's approved finding 4 +``` diff --git a/slices/060-mcp-client/SLICE.md b/slices/060-mcp-client/SLICE.md index 879c683..44612a8 100644 --- a/slices/060-mcp-client/SLICE.md +++ b/slices/060-mcp-client/SLICE.md @@ -54,7 +54,7 @@ enter the effect catalog (M4); health, reconnect, UI. 2. [auto] An MCP tool call flows through `Trinity.Effects` with `effect: :none` and yields a query receipt (test). 3. [auto] A server-config claiming `effect: :catalog` for an MCP tool is refused at load with a receipt (test). 4. [auto] MRTR: test server returns `input_required` with a `requestState`; the Session surfaces the request; answering resumes; the retried call carries the `requestState` back byte-for-byte and completes. A retry with the `requestState` altered or omitted is rejected by the server (test). -5. [auto] Tasks: a slow tool returns a handle; polling completes; cancel from the UI cancels the task (test). +5. [deferred] Tasks: a slow tool returns a handle; polling completes; cancel from the UI cancels the task (test). Deferred at G1, 2026-09-21: 059's approved finding 4 says this slice builds no client for an extension the core neither builds nor refuses (NOTES.md, "Read before code"). 6. [auto] Server dies → tools unregistered → reconnect → re-registered (test with short backoff). 7. [auto] The thin-driver rule: a census over `lib/trinity/mcp/client/` finds no revision negotiation beyond `server/discover` and `initialize`, no protocol object built other than the outbound request, and no schema diff --git a/slices/060-mcp-client/proof/ac8-1-mcp-page.png b/slices/060-mcp-client/proof/ac8-1-mcp-page.png new file mode 100644 index 0000000..4f00dc5 Binary files /dev/null and b/slices/060-mcp-client/proof/ac8-1-mcp-page.png differ diff --git a/slices/060-mcp-client/proof/ac8-2-ask.png b/slices/060-mcp-client/proof/ac8-2-ask.png new file mode 100644 index 0000000..ec5bbf0 Binary files /dev/null and b/slices/060-mcp-client/proof/ac8-2-ask.png differ diff --git a/slices/060-mcp-client/proof/ac8-3-server-asks.png b/slices/060-mcp-client/proof/ac8-3-server-asks.png new file mode 100644 index 0000000..302572c Binary files /dev/null and b/slices/060-mcp-client/proof/ac8-3-server-asks.png differ diff --git a/slices/060-mcp-client/proof/ac8-4-done.png b/slices/060-mcp-client/proof/ac8-4-done.png new file mode 100644 index 0000000..8571316 Binary files /dev/null and b/slices/060-mcp-client/proof/ac8-4-done.png differ diff --git a/slices/060-mcp-client/proof/ac8-mrtr-playground.gif b/slices/060-mcp-client/proof/ac8-mrtr-playground.gif new file mode 100644 index 0000000..e76eb72 Binary files /dev/null and b/slices/060-mcp-client/proof/ac8-mrtr-playground.gif differ diff --git a/test/support/mcp/mrtr_server.exs b/test/support/mcp/mrtr_server.exs new file mode 100644 index 0000000..eab093b --- /dev/null +++ b/test/support/mcp/mrtr_server.exs @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Slice 060: Trinity's double for the multi-round-trip wire of 2026-07-28 (the core refuses +# MRTR by design, will-not-implement entry 12, and the 061 wrapper that will speak it is +# blocked on the seam). One tool, `ask_name`: the first call answers `input_required` with +# one elicitation request and a `requestState` that is an HMAC over the request it belongs +# to; the retry that echoes the state byte-for-byte and answers the request completes; a +# retry whose state is altered or missing is refused. Newline-delimited JSON-RPC on stdio; +# run by `Trinity.MCP.StdioServer.config/3` with `:mrtr`. +defmodule Trinity.MCP.MrtrDouble do + @moduledoc false + + @secret :crypto.strong_rand_bytes(32) + @modern "2026-07-28" + + # With `future` on the command line the double advertises a revision nobody speaks, so + # the driver's refusal path is reachable from a test. + def versions do + case System.argv() do + ["future" | _] -> ["2027-01-01"] + _ -> [@modern] + end + end + + def loop do + case IO.read(:stdio, :line) do + :eof -> + :ok + + {:error, _} -> + :ok + + line -> + line |> String.trim() |> handle() |> reply() + loop() + end + end + + defp handle(""), do: nil + + defp handle(line) do + case Jason.decode(line) do + {:ok, %{"id" => id, "method" => method} = msg} -> + answer(id, method, Map.get(msg, "params", %{})) + + {:ok, %{"method" => _}} -> + nil + + _ -> + error(nil, -32_700, "parse error") + end + end + + defp answer(id, "server/discover", _params) do + result(id, %{ + "supportedVersions" => versions(), + "capabilities" => %{"tools" => %{}}, + "ttlMs" => 0, + "cacheScope" => "private" + }) + end + + defp answer(id, "tools/list", _params) do + result(id, %{ + "tools" => [ + %{ + "name" => "ask_name", + "description" => "Greets, after asking who you are", + "inputSchema" => %{ + "type" => "object", + "properties" => %{"greeting" => %{"type" => "string"}}, + "required" => ["greeting"] + } + } + ], + "ttlMs" => 0, + "cacheScope" => "private" + }) + end + + defp answer(id, "tools/call", %{"name" => "ask_name", "arguments" => args} = params) do + expected = state_for("tools/call", "ask_name", args) + + case {Map.get(params, "requestState"), Map.get(params, "inputResponses")} do + {nil, nil} -> + input_required(id, expected) + + {nil, _responses} -> + error(id, -32_602, "requestState missing on a retry") + + {^expected, %{"who" => %{"action" => "accept", "content" => %{"name" => name}}}} + when is_binary(name) -> + result(id, %{ + "content" => [%{"type" => "text", "text" => "#{args["greeting"]}, #{name}"}], + "isError" => false + }) + + {^expected, %{"who" => %{"action" => action}}} when action in ["decline", "cancel"] -> + result(id, %{ + "content" => [%{"type" => "text", "text" => "no name given"}], + "isError" => true + }) + + {^expected, _} -> + input_required(id, expected) + + {_other, _} -> + error(id, -32_602, "requestState failed verification") + end + end + + defp answer(id, "tools/call", _params), do: error(id, -32_602, "unknown tool") + defp answer(id, _method, _params), do: error(id, -32_601, "method not found") + + defp input_required(id, state) do + result( + id, + %{ + "inputRequests" => %{ + "who" => %{ + "method" => "elicitation/create", + "params" => %{ + "mode" => "form", + "message" => "What is your name?", + "requestedSchema" => %{ + "type" => "object", + "properties" => %{"name" => %{"type" => "string", "title" => "Name"}}, + "required" => ["name"] + } + } + } + }, + "requestState" => state + }, + "input_required" + ) + end + + # The state binds to the originating request (method, tool, arguments), as the revision + # asks servers to do; a client cannot mint one, and one minted for another call fails. + defp state_for(method, tool, args) do + payload = Jason.encode!([method, tool, args]) + :crypto.mac(:hmac, :sha256, @secret, payload) |> Base.url_encode64(padding: false) + end + + defp result(id, result, type \\ "complete") do + %{ + "jsonrpc" => "2.0", + "id" => id, + "result" => Map.put(result, "resultType", type) + } + end + + defp error(id, code, message), + do: %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => code, "message" => message}} + + defp reply(nil), do: :ok + defp reply(message), do: IO.write(:stdio, [Jason.encode!(message), ?\n]) +end + +Trinity.MCP.MrtrDouble.loop() diff --git a/test/support/mcp/servers_under_test.ex b/test/support/mcp/servers_under_test.ex new file mode 100644 index 0000000..e34a56d --- /dev/null +++ b/test/support/mcp/servers_under_test.ex @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.ServersUnderTest do + @moduledoc """ + Starting and waiting on the servers under test (slice 060): the HTTP server is beam_mcp's + Plug behind Bandit on a random loopback port in the test VM; the stdio servers are child + VMs (`Trinity.MCP.StdioServer`). `start!/3` writes the row through the context, which + starts the client, and registers the teardown; `await/3` waits for a status. + """ + + alias Trinity.MCP.{Client, ServerConfig, Servers, StdioServer} + + @doc "Starts beam_mcp's HTTP transport over the test catalog on a random port; returns the URL." + @spec http_server!() :: String.t() + def http_server! do + # Bandit calls the Plug's `init/1` itself. + plug_opts = [ + catalog: Trinity.MCP.TestCatalog, + dispatch: &Trinity.MCP.TestCatalog.dispatch/3, + authorize: fn _conn -> :ok end, + allowed_origins: :any, + server_name: "trinity-test-http" + ] + + {:ok, pid} = + Bandit.start_link( + plug: {BeamMCP.Transport.HTTP, plug_opts}, + ip: {127, 0, 0, 1}, + port: 0, + startup_log: false + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(pid) + + ExUnit.Callbacks.on_exit(fn -> + try do + GenServer.stop(pid) + catch + :exit, _ -> :ok + end + end) + + "http://127.0.0.1:#{port}/mcp" + end + + @doc "A row for a server under test: `:modern`, `:legacy`, `:mrtr` (stdio) or `{:http, url}`." + @spec attrs(String.t(), term(), map()) :: map() + def attrs(name, {:http, url}, extra), + do: Map.merge(%{name: name, transport: "http", url: url}, extra) + + def attrs(name, kind, extra), do: StdioServer.config(name, kind, extra) + + @doc "Creates the row (which starts the client), registers its teardown, and returns it." + @spec start!(String.t(), term(), map()) :: ServerConfig.t() + def start!(name, kind, extra \\ %{}) do + {:ok, config} = Servers.create(attrs(name, kind, extra)) + ExUnit.Callbacks.on_exit(fn -> Trinity.MCP.Supervisor.stop_client(name) end) + config + end + + @doc "Waits until the client's status is `status` (or one of a list); returns its info." + @spec await(String.t(), atom() | [atom()], pos_integer()) :: map() + def await(name, status, timeout_ms \\ 10_000) do + statuses = List.wrap(status) + deadline = System.monotonic_time(:millisecond) + timeout_ms + do_await(name, statuses, deadline) + end + + defp do_await(name, statuses, deadline) do + case Client.info(name) do + {:ok, %{status: s} = info} -> + if s in statuses, do: info, else: wait(name, statuses, deadline, {:ok, info}) + + other -> + wait(name, statuses, deadline, other) + end + end + + defp wait(name, statuses, deadline, other) do + if System.monotonic_time(:millisecond) > deadline do + raise "mcp #{name}: waited for #{inspect(statuses)}, last seen #{inspect(other)}" + else + Process.sleep(50) + do_await(name, statuses, deadline) + end + end +end diff --git a/test/support/mcp/stdio_server.ex b/test/support/mcp/stdio_server.ex new file mode 100644 index 0000000..79d67d6 --- /dev/null +++ b/test/support/mcp/stdio_server.ex @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.StdioServer do + @moduledoc """ + How a test launches the stdio servers (slice 060): a child `elixir` VM with this build's + `ebin` directories on its path, running `test/support/mcp/stdio_server.exs` (beam_mcp's + transport over the test catalog) or `test/support/mcp/mrtr_server.exs` (Trinity's double + for the multi-round-trip wire). The command is what a `mcp_servers` row would hold. + """ + + @doc "The row attributes for a stdio server under test: `:modern`, `:legacy`, `:mrtr` or `:future` (a revision nobody speaks)." + @spec config(String.t(), :modern | :legacy | :mrtr | :future, map()) :: map() + def config(name, kind, extra \\ %{}) do + {script, args} = + case kind do + :modern -> {"test/support/mcp/stdio_server.exs", []} + :legacy -> {"test/support/mcp/stdio_server.exs", ["legacy"]} + :mrtr -> {"test/support/mcp/mrtr_server.exs", []} + :future -> {"test/support/mcp/mrtr_server.exs", ["future"]} + end + + Map.merge( + %{ + name: name, + transport: "stdio", + command: "elixir", + args: paths() ++ [Path.expand(script)] ++ args + }, + extra + ) + end + + # The child needs beam_mcp and what it calls (jason, telemetry) and this application's own + # beams (the catalog lives in test/support, compiled into the trinity ebin). + defp paths do + Enum.flat_map([:beam_mcp, :jason, :telemetry, :trinity], fn app -> + ["-pa", Path.expand(:code.lib_dir(app) |> to_string() |> Path.join("ebin"))] + end) + end +end diff --git a/test/support/mcp/stdio_server.exs b/test/support/mcp/stdio_server.exs new file mode 100644 index 0000000..3a2e910 --- /dev/null +++ b/test/support/mcp/stdio_server.exs @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Slice 060: the stdio server under test, beam_mcp's own transport over the test catalog, +# run as a child VM by `Trinity.MCP.StdioServer.command/1`: +# elixir -pa test/support/mcp/stdio_server.exs [legacy] +# With `legacy` the server advertises 2025-11-25 alone, so the driver takes the initialize +# path. Everything the VM prints on its own goes to stderr; stdout is the wire. +# The VM's default log handler writes to standard output, which is the wire here: a logged +# error would be an undecodable line to the client. Logging goes to standard error instead. +:logger.remove_handler(:default) +:logger.add_handler(:stderr, :logger_std_h, %{config: %{type: :standard_error}}) + +versions = + case System.argv() do + ["legacy" | _] -> ["2025-11-25"] + _ -> ["2026-07-28", "2025-11-25"] + end + +BeamMCP.Transport.Stdio.run( + catalog: Trinity.MCP.TestCatalog, + dispatch: &Trinity.MCP.TestCatalog.dispatch/3, + server_name: "trinity-test-stdio", + supported_versions: versions +) diff --git a/test/support/mcp/test_catalog.ex b/test/support/mcp/test_catalog.ex new file mode 100644 index 0000000..fcbc8a6 --- /dev/null +++ b/test/support/mcp/test_catalog.ex @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.TestCatalog do + @moduledoc """ + The catalog the servers under test serve (slice 060): three tools on beam_mcp's own core. + `echo` carries an `x-mcp-header` annotation so the HTTP transport requires the mirrored + header; `add` takes two integers; `boom` fails. Runs inside the test VM (the HTTP server) + and inside a child VM (the stdio server, `test/support/mcp/stdio_server.exs`). + """ + @behaviour BeamMCP.Catalog + + alias BeamMCP.ToolSpec + + @impl true + def capabilities do + %{ + tools: [ + %ToolSpec{ + name: :echo, + command_class: :observe, + mode: :read_only, + description: "Echoes the text", + input_schema: %{ + "type" => "object", + "properties" => %{"text" => %{"type" => "string", "x-mcp-header" => "Echo-Text"}}, + "required" => ["text"], + "additionalProperties" => false + } + }, + %ToolSpec{ + name: :add, + command_class: :observe, + mode: :read_only, + description: "Adds two integers", + input_schema: %{ + "type" => "object", + "properties" => %{"a" => %{"type" => "integer"}, "b" => %{"type" => "integer"}}, + "required" => ["a", "b"] + } + }, + %ToolSpec{name: :boom, command_class: :observe, mode: :read_only, description: "Fails"} + ], + resources: [], + prompts: [] + } + end + + @impl true + def read_resource(_uri), do: {:error, "no resources"} + + @impl true + def get_prompt(_name, _args), do: {:error, "no prompts"} + + @doc "The dispatch function of the servers under test (the core hands declared arguments over with atom keys)." + @spec dispatch(atom(), map(), keyword()) :: {:ok, term()} | {:error, term()} + def dispatch(:echo, %{text: text}, _opts), do: {:ok, %{"echoed" => text}} + def dispatch(:add, %{a: a, b: b}, _opts), do: {:ok, %{"sum" => a + b}} + def dispatch(:boom, _args, _opts), do: {:error, "boom"} + def dispatch(other, _args, _opts), do: {:error, "unknown tool #{other}"} +end diff --git a/test/support/tools/context_echo.ex b/test/support/tools/context_echo.ex new file mode 100644 index 0000000..c461036 --- /dev/null +++ b/test/support/tools/context_echo.ex @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestTools.ContextEcho do + @moduledoc """ + Slice 060: a module registered with a spec, serving whatever name the spec gave; it + answers with the tool name the context carries, which is how one module serves many tools. + """ + @behaviour Trinity.Tools.Tool + + @impl true + def name, do: "mcp:" + @impl true + def description, do: "the module's own description, unread when a spec is present" + @impl true + def schema, do: %{"type" => "object"} + @impl true + def risk, do: :ask + @impl true + def effect, do: :none + @impl true + def execute(args, ctx), + do: {:ok, Trinity.Tools.Result.text("#{ctx.tool}:#{args["text"]}")} +end diff --git a/test/trinity/mcp/boundary_test.exs b/test/trinity/mcp/boundary_test.exs index d97937b..4f7d50c 100644 --- a/test/trinity/mcp/boundary_test.exs +++ b/test/trinity/mcp/boundary_test.exs @@ -28,7 +28,12 @@ defmodule Trinity.MCP.BoundaryTest do assert length(files) > 100 referrers = for f <- files, File.read!(f) =~ ~r/\bBeamMCP\./, do: f - assert Enum.sort(referrers) == ["lib/trinity/mcp.ex"] + # 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"] + + for f <- referrers, + do: assert(f == "lib/trinity/mcp.ex" or String.starts_with?(f, "lib/trinity/mcp/")) end test "the planted reference is a real reference: the file the proof compiles names BeamMCP outside Trinity.MCP" do diff --git a/test/trinity/mcp/bridge_test.exs b/test/trinity/mcp/bridge_test.exs new file mode 100644 index 0000000..2db0da6 --- /dev/null +++ b/test/trinity/mcp/bridge_test.exs @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.BridgeTest do + @moduledoc """ + Slice 060 AC2 (an MCP tool call flows through the membrane's runner with `effect: :none` + and yields a query receipt), AC3 (a row claiming `catalog` for a tool is refused at load + with a receipt, and the changeset refuses it at write), and the result mapping (untrusted + parts, a server error as `isError`). + """ + use Trinity.DataCase, async: false + @moduletag :capture_log + + import Trinity.MCP.ServersUnderTest + + alias Trinity.Effects + alias Trinity.MCP.{Bridge, ServerConfig, Servers} + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools + alias Trinity.Tools.Context + + setup do + session = Trinity.Factory.session!() + scope = Receipts.session_scope(session.id) + ctx = %Context{session_id: session.id, caller: session.id, call_id: "c1"} + on_exit(fn -> Receipts.stop_writer(scope) end) + {:ok, ctx: ctx, scope: scope} + end + + defp allow!(tool) do + {:ok, rule} = Permissions.put_rule(%{tool: tool, pattern: "*", decision: "allow"}) + on_exit(fn -> Permissions.revoke_rule(rule.id) end) + end + + test "AC2: a call flows through Trinity.Effects.Runner with effect :none and yields a decision and a query receipt; the result is one untrusted part", + %{ctx: ctx, scope: scope} do + start!("b", :modern) + await("b", :ready) + allow!("mcp:b:add") + + assert {:ok, result, meta} = + Effects.Runner.run(%{id: "c1", name: "mcp:b:add", args: %{"a" => 2, "b" => 3}}, ctx) + + assert result.content =~ ~s("sum": 5) + + assert [%{taint: :untrusted, origin: "tool:mcp:b:add", source_ref: "mcp://b/add"}] = + result.parts + + assert result.meta["server"] == "b" and result.meta["is_error"] == false + assert meta["tool"] == "mcp:b:add" and is_binary(meta["tool_definition_digest"]) + + kinds = scope |> Receipts.list() |> Enum.map(&{&1.kind, &1.subject["tool"]}) + assert Enum.sort(kinds) == [{"decision", "mcp:b:add"}, {"query", "mcp:b:add"}] + end + + test "a failing tool is a result the model reads as the server's error, not a crash", %{ + ctx: ctx + } do + start!("f", :modern) + await("f", :ready) + allow!("mcp:f:boom") + + assert {:ok, result, _} = Effects.Runner.run(%{id: "c1", name: "mcp:f:boom", args: %{}}, ctx) + assert result.content =~ "[the server reported an error]" and result.content =~ "boom" + assert result.meta["is_error"] == true + end + + test "a tool with an artifact override crosses the membrane as an effect", %{ + ctx: ctx, + scope: scope + } do + start!("a", :modern, %{tool_overrides: %{"echo" => %{"effect" => "artifact"}}}) + await("a", :ready) + assert {:ok, %{effect: :artifact}} = Tools.lookup("mcp:a:echo") + allow!("mcp:a:echo") + + assert {:ok, result, _} = + Effects.Runner.run(%{id: "c1", name: "mcp:a:echo", args: %{"text" => "x"}}, ctx) + + assert result.content =~ ~s("echoed": "x") + kinds = scope |> Receipts.list() |> Enum.map(&{&1.kind, &1.subject["phase"]}) + assert {"effect", "admit"} in kinds and {"effect", "done"} in kinds + end + + test "AC3: a row claiming effect catalog for a tool is refused at load with a decision receipt on the server's scope, and the tool is not registered" do + # The changeset refuses the claim at write. + assert {:error, changeset} = + Servers.create( + attrs("c", :modern, %{tool_overrides: %{"echo" => %{"effect" => "catalog"}}}) + ) + + assert %{tool_overrides: [msg]} = errors_on(changeset) + assert msg =~ "catalog is compile time" + + # A row that carries the claim anyway (a struct built outside the changeset) is refused + # at load: the registry's rule, receipted. + config = + struct( + ServerConfig, + attrs("c", :modern, %{tool_overrides: %{"echo" => %{"effect" => "catalog"}}}) + ) + + {:ok, _pid} = Servers.start(config) + on_exit(fn -> Trinity.MCP.Supervisor.stop_client("c") end) + await("c", :ready) + + assert {:error, :unknown_tool} = Tools.lookup("mcp:c:echo") + assert {:ok, _} = Tools.lookup("mcp:c:add") + + scope = Bridge.scope("c") + on_exit(fn -> Receipts.stop_writer(scope) end) + + assert [receipt] = Receipts.list(scope) + assert receipt.kind == "decision" + assert receipt.subject == %{"server" => "c", "tool" => "mcp:c:echo", "phase" => "load"} + payload = Jason.decode!(receipt.signed_payload) + assert payload["decision"]["outcome"] == "deny" + assert payload["decision"]["reason"] =~ "catalog_is_compile_time" + end +end diff --git a/test/trinity/mcp/client_test.exs b/test/trinity/mcp/client_test.exs new file mode 100644 index 0000000..5aebeec --- /dev/null +++ b/test/trinity/mcp/client_test.exs @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.ClientTest do + @moduledoc """ + Slice 060 AC1 (the three servers under test connect and the chosen revision per server is + asserted), AC6 (the server dies, the tools go, it comes back, they return), and the wire + through the HTTP transport (the mirrored header, the base64 sentinel, a server error). + """ + use Trinity.DataCase, async: false + @moduletag :capture_log + + import ExUnit.CaptureLog + import Trinity.MCP.ServersUnderTest + + alias Trinity.MCP.Client + alias Trinity.Tools + + test "AC1: stdio dual-era connects at 2026-07-28, stdio legacy at 2025-11-25, HTTP at 2026-07-28; every tool is registered under its namespace" do + url = http_server!() + # The suite logs at warning; the connect line is info. + Logger.configure(level: :info) + on_exit(fn -> Logger.configure(level: :warning) end) + + log = + capture_log([level: :info], fn -> + start!("s-modern", :modern) + start!("s-legacy", :legacy) + start!("s-http", {:http, url}) + assert %{revision: "2026-07-28"} = await("s-modern", :ready) + assert %{revision: "2025-11-25"} = await("s-legacy", :ready) + assert %{revision: "2026-07-28"} = await("s-http", :ready) + end) + + assert log =~ "mcp s-modern: connected at 2026-07-28, 3 tools" + assert log =~ "mcp s-legacy: connected at 2025-11-25, 3 tools" + assert log =~ "mcp s-http: connected at 2026-07-28, 3 tools" + + for server <- ["s-modern", "s-legacy", "s-http"], tool <- ["echo", "add", "boom"] do + assert {:ok, %{kind: :dynamic, effect: :none, risk: :ask, spec: %{name: name}}} = + Tools.lookup("mcp:#{server}:#{tool}") + + assert name == "mcp:#{server}:#{tool}" + end + + # The listed definition is the registry's: the model sees the server's schema. + assert %{parameters: %{"required" => ["a", "b"]}} = + Enum.find(Tools.to_llm_tools(), &(&1.name == "mcp:s-http:add")) + end + + test "a call over HTTP mirrors the annotated argument into Mcp-Param, with the sentinel for a non-ASCII value; a failing tool is isError; an unknown one is refused" do + url = http_server!() + start!("h", {:http, url}) + await("h", :ready) + + assert {:ok, %{"result" => %{"structuredContent" => %{"echoed" => "plain"}}}} = + Client.call("h", "echo", %{"text" => "plain"}) + + assert {:ok, %{"result" => %{"structuredContent" => %{"echoed" => "héllo wörld "}}}} = + Client.call("h", "echo", %{"text" => "héllo wörld "}) + + assert {:ok, %{"result" => %{"isError" => true}}} = Client.call("h", "boom", %{}) + assert {:error, {:unknown_tool, "nope"}} = Client.call("h", "nope", %{}) + # The core's validator refuses before anything is sent. + assert {:error, "missing required property: text"} = Client.call("h", "echo", %{}) + end + + test "a call over stdio at each era answers; ping is only the legacy era's" do + start!("m", :modern) + start!("l", :legacy) + await("m", :ready) + await("l", :ready) + + assert {:ok, %{"result" => %{"structuredContent" => %{"sum" => 5}}}} = + Client.call("m", "add", %{"a" => 2, "b" => 3}) + + assert {:ok, %{"result" => %{"structuredContent" => %{"sum" => 5}}}} = + Client.call("l", "add", %{"a" => 2, "b" => 3}) + end + + test "AC6: the server dies: the tools are unregistered; it is restarted with backoff; the tools are registered again" do + start!("r", :modern) + %{transport_os_pid: os_pid} = await("r", :ready) + assert {:ok, _} = Tools.lookup("mcp:r:echo") + assert is_integer(os_pid) + + {_, 0} = System.cmd("kill", ["-9", Integer.to_string(os_pid)]) + await("r", [:down, :connecting]) + assert {:error, :unknown_tool} = Tools.lookup("mcp:r:echo") + + %{transport_os_pid: new_pid, attempts: 0} = await("r", :ready) + assert new_pid != os_pid + assert {:ok, _} = Tools.lookup("mcp:r:echo") + end + + test "a server that lists no revision the driver speaks is refused, with the list in the error, and nothing is registered" do + # A refusal is final until the owner acts: no backoff, no retry. + start!("x", :future) + assert %{status: :refused, last_error: error, attempts: 0} = await("x", :refused) + assert error =~ "unsupported_revisions" and error =~ "2027-01-01" + assert Enum.filter(Tools.list(), &String.starts_with?(&1.name, "mcp:x:")) == [] + end +end diff --git a/test/trinity/mcp/mrtr_test.exs b/test/trinity/mcp/mrtr_test.exs new file mode 100644 index 0000000..fe86a0a --- /dev/null +++ b/test/trinity/mcp/mrtr_test.exs @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.MrtrTest do + @moduledoc """ + Slice 060 AC4: a server answers `input_required` with a `requestState`; the Session surfaces + the request as an approval carrying it; answering resumes; the retried call carries the + state back byte-for-byte and completes. A retry with the state altered or omitted is + rejected by the server. The server is Trinity's double for the wire (NOTES.md: the core + refuses MRTR by design). + """ + use Trinity.SessionCase + @moduletag :capture_log + + import Trinity.MCP.ServersUnderTest + + alias Trinity.Factory + alias Trinity.LLM.Providers.Fake + alias Trinity.MCP.Client + alias Trinity.Permissions + alias Trinity.Permissions.Approval + + setup do + start!("q", :mrtr) + await("q", :ready) + row = Factory.session!() + :ok = Sessions.subscribe(row.id) + :ok = Permissions.subscribe(row.id) + {:ok, id: row.id} + end + + @answer %{"who" => %{"action" => "accept", "content" => %{"name" => "Ayla"}}} + + test "AC4: surfaced as an approval carrying the server's request, answered, resumed with the state echoed, completed", + %{id: id} do + Fake.scripts([ + [ + {:tool_call_start, "c1", "mcp:q:ask_name"}, + {:tool_call_end, "c1", %{"greeting" => "Hi"}}, + {:usage, %{input_tokens: 1, output_tokens: 1}}, + {:done, :tool_calls} + ], + script_deltas(1, "done ") + ]) + + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "greet me") + + # The tier of a namespaced name is :ask: the first approval is the gate's own. + assert_receive {:approval, :requested, + %Approval{id: a1, tool: "mcp:q:ask_name", request: nil}}, + 5_000 + + _ = collect(id, &match?({:state, :approval_wait}, &1)) + {:ok, _} = Permissions.decide_request(a1, :once) + _ = collect(id, &match?({:state, :tool_wait}, &1)) + + # The call runs, the server asks for a name: the second approval carries its request. + assert_receive {:approval, :requested, %Approval{id: a2, request: request}}, 5_000 + assert request["kind"] == "mcp_input" and request["server"] == "q" + + assert %{"who" => %{"method" => "elicitation/create", "params" => params}} = + request["inputRequests"] + + assert params["message"] == "What is your name?" + assert params["requestedSchema"]["required"] == ["name"] + _ = collect(id, &match?({:state, :approval_wait}, &1)) + assert %{state: :approval_wait} = Session.state(pid) + + # The owner answers; the Session re-runs the call; the retry echoes the state and completes. + {:ok, %Approval{answer: @answer}} = Permissions.decide_request(a2, :once, answer: @answer) + events = collect(id, &match?({:state, :idle}, &1)) + + assert {:assistant_message, %{content: "done "}} = + Enum.find(events, &match?({:assistant_message, _}, &1)) + + assert [tool_row] = id |> Sessions.history() |> Enum.filter(&(&1.role == "tool")) + assert tool_row.content == "Hi, Ayla" + assert tool_row.parts["ok"] == true + + # Nothing of the state reached a row: it lived in the client and is gone with the retry. + assert Client.pop_continuation("q", {id, "c1"}) == nil + refute inspect(Permissions.get_approval(a2)) =~ "requestState" + end + + test "the server rejects a retry whose requestState is altered or omitted; the one echoed byte-for-byte completes" do + assert {:ok, %{"result" => %{"resultType" => "input_required", "requestState" => state}}} = + Client.call("q", "ask_name", %{"greeting" => "Hello"}) + + assert is_binary(state) + + altered = + String.replace_suffix( + state, + String.last(state), + if(String.last(state) == "A", do: "B", else: "A") + ) + + assert {:ok, + %{"error" => %{"code" => -32_602, "message" => "requestState failed verification"}}} = + Client.call("q", "ask_name", %{"greeting" => "Hello"}, + continuation: %{responses: @answer, state: altered} + ) + + assert {:ok, + %{"error" => %{"code" => -32_602, "message" => "requestState missing on a retry"}}} = + Client.call("q", "ask_name", %{"greeting" => "Hello"}, + continuation: %{responses: @answer, state: nil} + ) + + # The state for other arguments is another state: one minted for a different call fails. + assert {:ok, %{"error" => %{"code" => -32_602}}} = + Client.call("q", "ask_name", %{"greeting" => "Hey"}, + continuation: %{responses: @answer, state: state} + ) + + assert {:ok, + %{ + "result" => %{"resultType" => "complete", "content" => [%{"text" => "Hello, Ayla"}]} + }} = + Client.call("q", "ask_name", %{"greeting" => "Hello"}, + continuation: %{responses: @answer, state: state} + ) + end +end diff --git a/test/trinity/mcp/server_config_test.exs b/test/trinity/mcp/server_config_test.exs new file mode 100644 index 0000000..1de18ac --- /dev/null +++ b/test/trinity/mcp/server_config_test.exs @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.ServerConfigTest do + @moduledoc "Slice 060: the row's refusals, and the context's start and stop of a client with the row." + use Trinity.DataCase, async: false + @moduletag :capture_log + + alias Trinity.MCP.{Client, ServerConfig, Servers} + + defp errors(attrs), do: %ServerConfig{} |> ServerConfig.changeset(attrs) |> errors_on() + + test "a name outside the pattern, a transport without its command or url, a value beside the transport, a bad env ref, an unknown override are refused" do + assert %{name: [_]} = errors(%{name: "Bad Name", transport: "stdio", command: "x"}) + + assert %{name: [_]} = + errors(%{name: String.duplicate("a", 33), transport: "stdio", command: "x"}) + + assert %{transport: [_]} = errors(%{name: "a", transport: "sse"}) + + assert %{command: ["a stdio server needs a command"]} = + errors(%{name: "a", transport: "stdio"}) + + assert %{url: ["a stdio server has no url"]} = + errors(%{name: "a", transport: "stdio", command: "x", url: "http://h"}) + + assert %{url: ["an http server needs a url"]} = errors(%{name: "a", transport: "http"}) + assert %{url: [_]} = errors(%{name: "a", transport: "http", url: "ftp://h/x"}) + + assert %{command: ["an http server has no command"]} = + errors(%{name: "a", transport: "http", url: "http://h/mcp", command: "x"}) + + assert %{env_refs: [_]} = + errors(%{name: "a", transport: "stdio", command: "x", env_refs: ["lower"]}) + + assert %{effect_default: [_]} = + errors(%{name: "a", transport: "stdio", command: "x", effect_default: "catalog"}) + + assert %{tool_overrides: [msg]} = + errors(%{ + name: "a", + transport: "stdio", + command: "x", + tool_overrides: %{"t" => %{"risk" => "read"}} + }) + + assert msg =~ "unknown key risk" + + assert %{tool_overrides: [_]} = + errors(%{ + name: "a", + transport: "stdio", + command: "x", + tool_overrides: %{"t" => "artifact"} + }) + + assert %{} == + errors(%{ + name: "ok-1", + transport: "http", + url: "https://h/mcp", + tool_overrides: %{"t" => %{"effect" => "artifact"}} + }) + end + + test "creating a disabled row starts no client; enabling starts one; deleting stops it; names are unique" do + attrs = Trinity.MCP.StdioServer.config("cfg", :modern, %{enabled: false}) + assert {:ok, config} = Servers.create(attrs) + assert Client.whereis("cfg") == nil + assert {:error, %{errors: [name: _]}} = Servers.create(attrs) + + assert {:ok, config} = Servers.update(config, %{enabled: true}) + on_exit(fn -> Trinity.MCP.Supervisor.stop_client("cfg") end) + assert is_pid(Client.whereis("cfg")) + assert [%{config: %{name: "cfg"}, client: %{status: _}}] = Servers.status() + + assert {:ok, _} = Servers.delete(config) + assert Client.whereis("cfg") == nil + assert Servers.list() == [] + end +end diff --git a/test/trinity/mcp/stdio_env_test.exs b/test/trinity/mcp/stdio_env_test.exs new file mode 100644 index 0000000..870d721 --- /dev/null +++ b/test/trinity/mcp/stdio_env_test.exs @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.StdioEnvTest do + @moduledoc """ + Slice 060: the stdio child's environment is the process basics and the row's `env_refs`; + a variable this VM holds that the row does not name is not there. Proven with `env` as + the command: its output is the child's environment, read back through the transport's + own line handling (an `env` line is not JSON, so the transport warns and drops it; the + test reads the Port directly instead). + """ + use ExUnit.Case, async: false + @moduletag :capture_log + + alias Trinity.MCP.Client.Transport.Stdio + alias Trinity.MCP.ServerConfig + + @tag :unix + test "a variable outside the allow list and the refs is unset for the child; a ref is passed; PATH and HOME stay" do + System.put_env("TRINITY_TEST_SECRET", "must-not-leak") + System.put_env("TRINITY_TEST_REF", "passed") + + on_exit(fn -> + System.delete_env("TRINITY_TEST_SECRET") + System.delete_env("TRINITY_TEST_REF") + end) + + env_path = System.find_executable("env") + + dump = + Path.join(System.tmp_dir!(), "trinity-mcp-env-#{System.unique_integer([:positive])}.txt") + + on_exit(fn -> File.rm(dump) end) + + # `sh -c 'env > file'`: the child's own environment, written where the test reads it. + config = %ServerConfig{ + name: "envprobe", + transport: "stdio", + command: "sh", + args: ["-c", "#{env_path} > #{dump}; sleep 30"], + env_refs: ["TRINITY_TEST_REF"] + } + + {:ok, pid} = Stdio.connect(config, self(), []) + on_exit(fn -> Stdio.close(pid) end) + wait_for(fn -> File.exists?(dump) and File.read!(dump) =~ "PATH=" end) + + lines = dump |> File.read!() |> String.split("\n", trim: true) + names = Enum.map(lines, &(&1 |> String.split("=", parts: 2) |> hd())) + + assert "TRINITY_TEST_REF=passed" in lines + refute "TRINITY_TEST_SECRET" in names + assert "PATH" in names and "HOME" in names + # PWD, SHLVL and `_` are the shell's own, set by `sh` for its child. + shells_own = ~w(PWD SHLVL OLDPWD _) + + assert Enum.all?( + names, + &(&1 in Stdio.kept_variables() or &1 == "TRINITY_TEST_REF" or &1 in shells_own) + ) + end + + defp wait_for(fun, tries \\ 100) do + cond do + fun.() -> :ok + tries == 0 -> flunk("the child never wrote its environment") + true -> Process.sleep(50) && wait_for(fun, tries - 1) + end + end +end diff --git a/test/trinity/mcp/thin_driver_census_test.exs b/test/trinity/mcp/thin_driver_census_test.exs new file mode 100644 index 0000000..0fc57a7 --- /dev/null +++ b/test/trinity/mcp/thin_driver_census_test.exs @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.MCP.ThinDriverCensusTest do + @moduledoc """ + Slice 060 AC7, the thin-driver rule as a census over `lib/trinity/mcp/client/` and the + client itself (`git ls-files lib/trinity/mcp/client lib/trinity/mcp/client.ex`, the + population command PROOF.md pastes): revision handling is `server/discover` and + `initialize` and nothing else; the protocol methods named are the ones the driver sends; + decoding is the core's `BeamMCP.JSON.decode/1` and validation the core's + `BeamMCP.Schema.validate/2`; no `Jason.decode`, no `JSV`, no `Trinity.Tools.Schema`, no + schema literal beyond the request envelope. + """ + use ExUnit.Case, async: true + + @population ["lib/trinity/mcp/client", "lib/trinity/mcp/client.ex"] + + # Every "a/b" string in the driver is a method it sends, or a notification it reads. + @methods_sent ~w(server/discover initialize notifications/initialized tools/list tools/call ping prompts/get resources/read) + @notifications_read ~w(notifications/tools/list_changed) + # The one "a/b" string that is not a method: the HTTP transport's media type. + @media_types ~w(application/json) + + defp files do + {out, 0} = System.cmd("git", ["ls-files" | @population]) + String.split(out, "\n", trim: true) + end + + test "the population is the driver's files, and it is not empty" do + assert Enum.sort(files()) == + Enum.sort([ + "lib/trinity/mcp/client.ex", + "lib/trinity/mcp/client/auth.ex", + "lib/trinity/mcp/client/transport.ex", + "lib/trinity/mcp/client/transport/http.ex", + "lib/trinity/mcp/client/transport/stdio.ex", + "lib/trinity/mcp/client/wire.ex" + ]) + end + + test "revision handling is the two openers; every method string is one the driver sends or a notification it reads" do + sources = for f <- files(), into: %{}, do: {f, File.read!(f)} + + methods = + for {_f, src} <- sources, + [m] <- Regex.scan(~r/"([a-z]+\/[a-z_\/]+)"/, src, capture: :all_but_first), + uniq: true, + do: m + + assert Enum.sort(methods -- (@methods_sent ++ @notifications_read ++ @media_types)) == [] + # `initialize` has no slash: it is the one bare method name, and it is present. + assert "server/discover" in methods + assert sources["lib/trinity/mcp/client/wire.ex"] =~ ~s("initialize") + + # The revisions the driver knows are two literals in the wire module and nowhere else + # (prose in a moduledoc may name them; a quoted literal outside the wire module is code). + for {f, src} <- sources, f != "lib/trinity/mcp/client/wire.ex" do + refute src =~ ~r/"20\d\d-\d\d-\d\d"/, "#{f} names a revision" + end + + assert Regex.scan(~r/"(20\d\d-\d\d-\d\d)"/, sources["lib/trinity/mcp/client/wire.ex"], + capture: :all_but_first + ) + |> List.flatten() + |> Enum.uniq() + |> Enum.sort() == ["2025-11-25", "2026-07-28"] + end + + test "decoding and validation are the core's public functions; no decoder or validator of Trinity's own" do + sources = for f <- files(), into: %{}, do: {f, File.read!(f)} + wire = sources["lib/trinity/mcp/client/wire.ex"] + assert wire =~ "BeamMCP.JSON.decode(bytes)" + assert wire =~ "BeamMCP.Schema.validate(arguments, schema)" + + for {f, src} <- sources do + refute src =~ ~r/Jason\.decode/, "#{f} decodes on its own" + refute src =~ ~r/\bJSV\b/, "#{f} validates on its own" + refute src =~ ~r/Trinity\.Tools\.Schema/, "#{f} reaches the tools validator" + refute src =~ ~r/"\$schema"|"oneOf"|"anyOf"|"allOf"/, "#{f} carries a schema literal" + end + + # The core is reached through the wire module alone. + for {f, src} <- sources, f != "lib/trinity/mcp/client/wire.ex" do + refute src =~ ~r/\bBeamMCP\./, "#{f} reaches the core directly" + end + end +end diff --git a/test/trinity/tools/spec_registration_test.exs b/test/trinity/tools/spec_registration_test.exs new file mode 100644 index 0000000..c3d31f2 --- /dev/null +++ b/test/trinity/tools/spec_registration_test.exs @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.SpecRegistrationTest do + @moduledoc """ + Slice 060: a dynamic tool registered as a module plus a spec. The entry's definition is + the spec's (listed, digested, validated from it); the module's `execute/2` learns which + tool it is from the context; a spec claiming `:catalog` is refused, as is a core tool + with a spec. + """ + use Trinity.DataCase, async: false + + alias Trinity.TestTools.ContextEcho + alias Trinity.Tools + alias Trinity.Tools.{Registry, Runner} + + @spec_a %{ + name: "mcp:spec:alpha", + description: "alpha, from the spec", + schema: %{ + "type" => "object", + "properties" => %{"text" => %{"type" => "string"}}, + "required" => ["text"] + }, + risk: :read, + effect: :none, + timeout: 1_234 + } + + setup do + on_exit(fn -> + for %{name: name, kind: :dynamic} <- Tools.list(), String.starts_with?(name, "mcp:spec:") do + Tools.unregister(name) + end + end) + end + + test "the entry's name, description, schema, risk, effect, timeout and digest are the spec's; two specs share one module" do + assert {:ok, entry_a} = Tools.register(ContextEcho, spec: @spec_a) + + assert {:ok, entry_b} = + Tools.register(ContextEcho, + spec: %{@spec_a | name: "mcp:spec:beta", description: "beta"} + ) + + assert %{ + name: "mcp:spec:alpha", + module: ContextEcho, + kind: :dynamic, + risk: :read, + effect: :none + } = entry_a + + assert Registry.description(entry_a) == "alpha, from the spec" + assert Registry.schema(entry_a) == @spec_a.schema + assert Registry.timeout(entry_a) == 1_234 + assert entry_a.digest == Registry.definition_digest(entry_a) + assert entry_a.digest != entry_b.digest + assert entry_a.digest != Registry.definition_digest(ContextEcho) + + assert %{description: "beta", parameters: %{"required" => ["text"]}} = + Enum.find(Tools.to_llm_tools(), &(&1.name == "mcp:spec:beta")) + end + + test "the runner validates against the spec's schema and hands execute/2 the tool name in the context" do + {:ok, _} = Tools.register(ContextEcho, spec: @spec_a) + + # The tier of a namespaced name is :ask whatever the spec's risk says (docs/07): a rule allows it. + {:ok, rule} = + Trinity.Permissions.put_rule(%{tool: "mcp:spec:alpha", pattern: "*", decision: "allow"}) + + on_exit(fn -> Trinity.Permissions.revoke_rule(rule.id) end) + ctx = %{session_id: nil, cwd: nil, caller: self()} + + assert {:ok, %{content: "mcp:spec:alpha:hi"}, _} = + Runner.run(%{id: "c1", name: "mcp:spec:alpha", args: %{"text" => "hi"}}, ctx) + + assert {:error, {:invalid_args, _}, _} = + Runner.run(%{id: "c2", name: "mcp:spec:alpha", args: %{}}, ctx) + end + + test "a spec claiming :catalog is refused; an ill-formed spec is refused; a core tool may not carry one" do + assert {:error, :catalog_is_compile_time} = + Tools.register(ContextEcho, spec: %{@spec_a | effect: :catalog}) + + assert {:error, {:invalid_spec, {:risk, :bogus}}} = + Tools.register(ContextEcho, spec: %{@spec_a | risk: :bogus}) + + assert {:error, {:invalid_spec, {:timeout, 0}}} = + Tools.register(ContextEcho, spec: %{@spec_a | timeout: 0}) + + assert {:error, {:invalid_spec, _}} = Tools.register(ContextEcho, spec: %{name: "mcp:spec:x"}) + + assert {:error, {:name_not_namespaced, "alpha"}} = + Tools.register(ContextEcho, spec: %{@spec_a | name: "alpha"}) + + # The spec's fields fall back to the module's when absent. + assert {:ok, %{risk: :ask, effect: :none}} = + Tools.register(ContextEcho, spec: Map.drop(@spec_a, [:risk, :effect, :timeout])) + end +end diff --git a/test/trinity_web/live/mcp_live_test.exs b/test/trinity_web/live/mcp_live_test.exs new file mode 100644 index 0000000..b0d98d7 --- /dev/null +++ b/test/trinity_web/live/mcp_live_test.exs @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.MCPLiveTest do + @moduledoc """ + Slice 060, the pages: `/mcp` lists a server with its health and tools, adds one from the + form, disables, enables and removes it; the permissions page renders a server's input + request as a form whose answer decides the approval (the multi-round-trip card). + """ + use TrinityWeb.ConnCase, async: false + @moduletag :capture_log + + import Phoenix.LiveViewTest + import Trinity.MCP.ServersUnderTest + + alias Trinity.MCP.Servers + alias Trinity.Permissions + alias Trinity.Permissions.Approval + + test "the page lists a server with its status, revision and tools; disable, enable and remove act on the row and the client", + %{conn: conn} do + start!("page", :modern) + await("page", :ready) + + {:ok, view, html} = live(conn, ~p"/mcp") + assert html =~ "page" and html =~ "2026-07-28" and html =~ "3 tools" + assert has_element?(view, "#server-page", "ready") + + view |> element("#server-page button", "page") |> render_click() + assert has_element?(view, "#server-page td", "mcp:page:echo") + assert has_element?(view, "#server-page td", "Echoes the text") + + [%{config: config}] = Servers.status() + view |> element("#server-page button", "disable") |> render_click() + assert has_element?(view, "#server-page", "disabled") + assert Trinity.MCP.Client.whereis("page") == nil + assert %{enabled: false} = Servers.get(config.id) + + view |> element("#server-page button", "enable") |> render_click() + await("page", :ready) + assert is_pid(Trinity.MCP.Client.whereis("page")) + + view |> element("#server-page button", "remove") |> render_click() + refute has_element?(view, "#server-page") + assert Servers.list() == [] + end + + test "the form adds an http server, which connects; a bad row shows its errors", %{conn: conn} do + url = http_server!() + {:ok, view, _} = live(conn, ~p"/mcp") + on_exit(fn -> Trinity.MCP.Supervisor.stop_client("added") end) + + # Choosing the transport shows its fields. + view |> form("#server-form", %{"server" => %{"transport" => "http"}}) |> render_change() + assert has_element?(view, "#server-form input[name='server[url]']") + + view + |> form("#server-form", %{ + "server" => %{"name" => "Bad Name", "transport" => "http", "url" => ""} + }) + |> render_submit() + + assert has_element?(view, "#server-form p", "name:") + assert has_element?(view, "#server-form p", "url:") + + view + |> form("#server-form", %{ + "server" => %{"name" => "added", "transport" => "http", "url" => url} + }) + |> render_submit() + + assert has_element?(view, "#server-added") + await("added", :ready) + assert render(view) =~ "added" + assert [%{config: %{name: "added", transport: "http"}}] = Servers.status() + end + + test "an approval carrying a server's input request renders as a form; answering decides once with the typed answer", + %{conn: conn} do + request = %{ + "kind" => "mcp_input", + "server" => "q", + "inputRequests" => %{ + "who" => %{ + "method" => "elicitation/create", + "params" => %{ + "mode" => "form", + "message" => "What is your name, and how many?", + "requestedSchema" => %{ + "type" => "object", + "properties" => %{ + "name" => %{"type" => "string", "title" => "Name"}, + "count" => %{"type" => "integer"}, + "sure" => %{"type" => "boolean"}, + "colour" => %{"type" => "string", "enum" => ["red", "blue"]} + }, + "required" => ["name"] + } + } + } + } + } + + {:ok, %Approval{id: aid}} = + Permissions.request_approval(nil, "mcp:q:ask_name", %{"greeting" => "Hi"}, + risk: :ask, + request: request + ) + + {:ok, view, html} = live(conn, ~p"/permissions") + assert html =~ "The MCP server q asks" and html =~ "What is your name, and how many?" + assert has_element?(view, "#answer-#{aid} input[name='answer[who][name]'][required]") + assert has_element?(view, "#answer-#{aid} input[name='answer[who][count]'][type=number]") + + assert has_element?( + view, + "#answer-#{aid} select[name='answer[who][colour]'] option[value=blue]" + ) + + refute html =~ "requestState" + + view + |> form("#answer-#{aid}", %{ + "answer" => %{ + "who" => %{"name" => "Ayla", "count" => "3", "sure" => "true", "colour" => "blue"} + } + }) + |> render_submit() + + assert %Approval{status: "allowed", decision: "once", answer: answer} = + Permissions.get_approval(aid) + + assert answer == %{ + "who" => %{ + "action" => "accept", + "content" => %{"name" => "Ayla", "count" => 3, "sure" => true, "colour" => "blue"} + } + } + + refute has_element?(view, "#answer-#{aid}") + end + + test "declining a server's request denies the approval", %{conn: conn} do + request = %{ + "kind" => "mcp_input", + "server" => "q", + "inputRequests" => %{ + "who" => %{"method" => "elicitation/create", "params" => %{"message" => "?"}} + } + } + + {:ok, %Approval{id: aid}} = + Permissions.request_approval(nil, "mcp:q:t", %{}, risk: :ask, request: request) + + {:ok, view, _} = live(conn, ~p"/permissions") + view |> element("#approval-#{aid} button", "Decline") |> render_click() + assert %Approval{status: "denied"} = Permissions.get_approval(aid) + end +end