Skip to content

Commit 65cda72

Browse files
authored
Merge pull request #56 from ScriptKittyOS/slice/061-mcp-server
feat(s061): complete slice 061 (MCP server) Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents a130e95 + e1626c6 commit 65cda72

46 files changed

Lines changed: 2309 additions & 47 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.dockerignore‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
_build
2+
_build_pg
3+
deps
4+
burrito_out
5+
src-tauri/target
6+
node_modules
7+
assets/node_modules
8+
*.db
9+
*.db-*
10+
.env*

‎README.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@ that means in practice:
4040
2025-11-25 as the fallback. Every tool a server lists becomes a tool the assistant can call,
4141
namespaced so it can never borrow a built-in tool's permissions, asking you until you write a
4242
rule; every result is marked untrusted; and when a server needs input mid-call it asks you,
43-
on the permissions page, before the call continues.
44-
45-
Not there yet: scheduled tasks and the rest of MCP (M5a: Trinity as an MCP server with approvals,
46-
and the authorization roles), messaging gateways and subagents (M5b),
43+
on the permissions page, before the call continues. The other direction too: Trinity is an
44+
MCP server at `POST /mcp` (and over stdio), exporting the read-only tools by default and
45+
any others you name; a client's call passes the same gate and leaves the same receipts as
46+
the assistant's own, and one that needs your approval waits for it on the permissions page
47+
while the client carries a sealed state it can retry with. A headless release runs the same
48+
tree as a server, in a container or under systemd (`docs/mcp-server.md`).
49+
50+
Not there yet: scheduled tasks and MCP's authorization roles (M5a), messaging gateways and subagents (M5b),
4751
the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every
4852
slice, and the [Milestones](#milestones) section below explains how to read it.
4953

@@ -150,6 +154,7 @@ later without renumbering anything.
150154
| `VERSIONS.md` | The verified dependency versions, generated from `lib/trinity/versions.ex` |
151155
| `CLAUDE.md` | The engineering contract: slice rules, definition of done, proof standard |
152156
| `docs/` | Vision, architecture, tech stack, conventions, slice process, data model, risks, security model, standards; packaging, the FIPS leg, backup and restore, performance measurements |
157+
| `docs/mcp-server.md` | Connecting a client to Trinity's MCP server (Claude Code, VS Code, Codex, goose), stdio, approvals over the wire, the headless profile |
153158
| `slices/059-mcp-library-spike/FINDINGS.md` | What the MCP server core (`beam_mcp`) ships, carries, refuses or leaves open against the 2026-07-28 checklist; the reference for the MCP phase |
154159
| `docs/adr/` | Architecture decision records. One is added whenever a decision changes |
155160
| `slices/` | One folder per slice: specification, notes and proof |

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ standards register names the rows that ask for them.
5454
| 050 | Scheduler: Oban cron agent tasks with delivery targets | 5 Automation | M | 012 | planned |
5555
| 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved |
5656
| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved |
57-
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | planned |
57+
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | done |
5858
| 062 | MCP authorization: OAuth client role, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | planned |
5959
| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned |
6060
| 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned |

‎VERSIONS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ never pin a version hex marks as retired or vulnerable.
9191
| `oban` | ~> 2.24 | 🔍 not yet a dependency | Uses `Oban.Engines.Lite` on SQLite. ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050. |
9292
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
9393
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
94-
| `beam_mcp` | ~> 0.8 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.8.0 on hex.pm, standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. |
94+
| `beam_mcp` | ~> 0.9 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.9.0 on hex.pm (2026-09-22): the :server seam on both transports and the core server as a named behaviour, which slice 061's wrapper implements; standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059, bumped at 061. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. |
9595
| `jido` | not used (ADR-0009, decided 2026-09-20) | 🔍 not a single package | Measured at the Slice 012 checkpoint and not adopted: the agent runtime duplicates PubSub, Oban and the gateways and adds a second tool executor; the action shape is written in-tree at Slice 020 with `jsv` for its schemas. The row stays so the decision is visible where a reader would look for the package. |
9696
| `jason` | ~> 1.2 | ✅ in `mix.lock` | |
9797
| `boundary` | ~> 0.10 | ✅ in `mix.lock` | Compile-time module dependency enforcement. Measured at Slice 000: it compiles and enforces on Elixir 1.20.4 / OTP 28, and it reports violations as **warnings**, so it enforces only while `--warnings-as-errors` is on the compile step. ⚠️ No release since 2024-09-25. |

‎ci/headless/Containerfile‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Slice 061: the headless release in a container. Two stages: the release is assembled on the
5+
# Elixir image whose toolchain is the tree's (.tool-versions: Erlang 28.5.0.5, Elixir 1.20.4),
6+
# then copied onto a slim Debian of the same generation, so the ERTS the release carries finds
7+
# the libc it was built against. No desktop shell, no Burrito: `mix release headless`.
8+
#
9+
# docker build -f ci/headless/Containerfile -t trinity-headless:local .
10+
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:local
11+
#
12+
# The container binds 0.0.0.0 inside its own network namespace (TRINITY_BIND) so the published
13+
# port reaches it; the bearer is required on /mcp whatever the bind. The data directory is
14+
# /data/trinity (XDG_DATA_HOME=/data, a volume): the databases, the receipt keys, the
15+
# envelope key, the generated token.
16+
FROM hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim AS build
17+
18+
RUN apt-get update -y && apt-get install -y --no-install-recommends \
19+
build-essential git curl ca-certificates cmake python3 pkg-config \
20+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
21+
22+
WORKDIR /app
23+
ENV MIX_ENV=prod LANG=C.UTF-8
24+
RUN mix local.hex --force && mix local.rebar --force
25+
26+
COPY mix.exs mix.lock ./
27+
COPY config config
28+
RUN mix deps.get --only prod
29+
30+
COPY lib lib
31+
COPY priv priv
32+
COPY assets assets
33+
RUN mix assets.deploy && mix compile && mix release headless --overwrite
34+
35+
FROM debian:bookworm-20260824-slim AS app
36+
37+
RUN apt-get update -y && apt-get install -y --no-install-recommends \
38+
libstdc++6 openssl libncurses6 ca-certificates curl locales \
39+
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
40+
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
41+
42+
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
43+
ENV TRINITY_MODE=headless TRINITY_BIND=0.0.0.0 PORT=4000 XDG_DATA_HOME=/data
44+
WORKDIR /app
45+
RUN useradd --create-home --uid 10001 trinity && mkdir -p /data && chown trinity:trinity /data /app
46+
COPY --from=build --chown=trinity:trinity /app/_build/prod/rel/headless ./
47+
USER trinity
48+
VOLUME ["/data"]
49+
EXPOSE 4000
50+
CMD ["bin/headless", "start"]

‎config/runtime.exs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ smoke? =
2828
"--smoke" in Enum.map(:init.get_plain_arguments(), &to_string/1) or
2929
System.get_env("TRINITY_SMOKE") == "1"
3030

31-
if System.get_env("PHX_SERVER") || smoke? do
31+
# Slice 061: the headless profile is a server by definition.
32+
headless? = System.get_env("TRINITY_MODE") == "headless"
33+
34+
if System.get_env("PHX_SERVER") || smoke? || headless? do
3235
config :trinity, TrinityWeb.Endpoint, server: true
3336
end
3437

@@ -156,9 +159,33 @@ if config_env() == :prod do
156159
# LAN without asking. PORT still wins where it is set, which is how ex_tauri drives it.
157160
desktop_port = String.to_integer(System.get_env("PORT") || "0")
158161

162+
# Slice 061: the headless profile binds the address it is told (`TRINITY_BIND`, loopback by
163+
# default: a server on a LAN is the operator's decision, made by setting it) on `PORT`,
164+
# 4000 by default, since nobody reads an ephemeral port off a headless machine. The bearer
165+
# on /mcp (`TRINITY_MCP_SERVER_TOKEN`, or the generated token file) is required whatever
166+
# the bind; the web pages carry no authentication yet, which is why the default stays on
167+
# the loopback (docs/mcp-server.md).
168+
{bind_ip, bind_port} =
169+
if System.get_env("TRINITY_MODE") == "headless" do
170+
ip =
171+
case System.get_env("TRINITY_BIND", "127.0.0.1")
172+
|> String.to_charlist()
173+
|> :inet.parse_address() do
174+
{:ok, ip} ->
175+
ip
176+
177+
{:error, _} ->
178+
raise "TRINITY_BIND is not an IP address: #{System.get_env("TRINITY_BIND")}"
179+
end
180+
181+
{ip, String.to_integer(System.get_env("PORT") || "4000")}
182+
else
183+
{{127, 0, 0, 1}, desktop_port}
184+
end
185+
159186
config :trinity, TrinityWeb.Endpoint,
160187
url: [host: host, port: 443, scheme: "https"],
161-
http: [ip: {127, 0, 0, 1}, port: desktop_port],
188+
http: [ip: bind_ip, port: bind_port],
162189
secret_key_base: secret_key_base
163190

164191
# ## SSL Support

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ slice_id percent sha date
2020
041 80.55 ae200d7 2026-09-21
2121
059 80.54 53c9091 2026-09-21
2222
060 80.32 49f40a4 2026-09-21
23+
061 80.05 c6b6faa 2026-09-22

‎docs/01-architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ without anything failing.
9090
| `Trinity.Memory` | Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023) | LLM (summaries/embeddings), Repo |
9191
| `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) |
9292
| `Trinity.Scheduler` | Oban workers for agent tasks, delivery | Sessions, Gateways, **Repo** |
93-
| `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`) |
93+
| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) |
9494
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub |
9595
| `Trinity.Subagents` | Delegation, result collection | Sessions, Tools |
9696
| `Trinity.Sandbox` | Luerl runners, resource limits | none |

‎docs/05-data-model.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,13 @@ One row per server the client connects to; the row's `name` is the namespace seg
246246
| 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:<name>`. 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 |
247247
| last_error | text, nullable | |
248248

249+
### The MCP server's files (Slice 061, not rows)
250+
`<data dir>/mcp-server-token` (the bearer clients present, generated once, mode 0600, overridden by
251+
`TRINITY_MCP_SERVER_TOKEN`) and `<keys dir>/mcp-state.key` (32 bytes, the AES-256-GCM key sealing the
252+
`requestState` of a held call; shared by every instance of the data directory). The MCP session is a
253+
`sessions` row with `origin: "mcp"` and its own persona ("MCP server", no settings); its approvals and
254+
receipts are ordinary rows under it.
255+
249256
### task_runs (Slice 050)
250257
`task_id`, `scheduled_at`, `session_id`, `status`, `summary`, `error`. Unique on `(task_id, scheduled_at)` so a
251258
run is idempotent. Oban holds the job; this holds the outcome.

‎docs/07-security-model.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,42 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES).
166166
argument validation are the core's public functions (`BeamMCP.JSON.decode/1`, `BeamMCP.Schema.validate/2`),
167167
and a census test holds the population to that (060 AC7).
168168

169+
## MCP server (Slice 061, as built)
170+
171+
- **A stateless Plug above beam_mcp's core**, handed to the transport through its `:server` option;
172+
the core answers every method but `tools/call`, which Trinity answers through the same gate and
173+
membrane as the assistant's own calls. Will-not-implement entry 12 stands on the core: the approval
174+
loop lives above it.
175+
- **Every call is attributed** to one session with `origin: "mcp"` and a persona of its own (no
176+
permission settings: an MCP client inherits none of the default persona's allowances), and every
177+
decision and query receipt carries `"origin" => "mcp"`. A `traceparent` in the request's `_meta`
178+
rides into the receipts' meta.
179+
- **Exports are a closed list** the operator configures: core entries with effect `:none` or
180+
`:artifact`; `:catalog` is refused at boot and never listed; the list is sorted, so `tools/list` is
181+
deterministic.
182+
- **A bearer before the body.** `Trinity.MCP.Server.Auth.Local`: the token from the environment or the
183+
generated file, compared in constant time in the transport's `:authorize` hook; a refusal decodes
184+
nothing. Loopback is the default bind; a wider bind is the operator's setting and belongs behind a
185+
proxy that authenticates the web pages too (062 brings the OAuth resource server).
186+
- **Approvals over the wire are the owner's, never the client's.** A held call answers `input_required`
187+
with a `requestState` sealed by `Trinity.MCP.Server.Envelope`: AES-256-GCM under
188+
`<keys dir>/mcp-state.key`, binding the approval id, the session, the call id, the tool, a digest of
189+
the arguments, a nonce and an expiry (15 minutes by default). The client's `inputResponses` decide
190+
nothing: the decision is the row on the permissions page. A retry opens the envelope and is refused
191+
when expired, tampered, replayed or bound to another call, with a reason that names only which; a
192+
retry before the decision is held again on the same approval under a fresh state and never re-enters
193+
the gate; a retry after it runs under the envelope's call id, so the decision consumed is the one
194+
made and a second run is the membrane's duplicate effect.
195+
- **Replay defence is at-most-once per partition plus idempotent effects**, stated as such:
196+
`Trinity.MCP.Server.Replay` holds every nonce this instance has seen until its expiry window passes;
197+
another instance has its own table, and across instances the gate's consumed "once" and the
198+
membrane's idempotency key are the backstop (a replayed state there asks the owner again rather than
199+
running twice). Tests hold the four reds: a replay inside the window, a replay across partitions, an
200+
expired envelope, one tampered byte.
201+
- **stdio has no bearer**, as the core's page says: whoever writes to the process's standard input
202+
already has the host's privileges. Its standard output is the wire and the VM's log is moved to
203+
standard error.
204+
169205
## Secrets
170206

171207
- Env vars in dev; OS keychain via `Trinity.Secrets` from Slice 100 (Tauri stronghold/store or a keychain NIF).

0 commit comments

Comments
 (0)