Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
18b3bc9
feat(s061): beam_mcp pinned at ~> 0.9 (the :server seam); ROADMAP row…
HackTuah Sep 22, 2026
c85950e
docs(s061): G1 plan; AC1's legacy half over the stdio wire; Tasks out…
HackTuah Sep 22, 2026
4a364a4
feat(s061): Trinity as an MCP server: the wrapper above the core, the…
HackTuah Sep 22, 2026
c0d9091
test(s061): AC7 green: an exchange begun on one instance completes on…
HackTuah Sep 22, 2026
1b87836
feat(s061): the headless release, its bind and port, the stdio server…
HackTuah Sep 22, 2026
5e240ac
fix(s030): the default persona's SOUL path is resolved when read, not…
HackTuah Sep 22, 2026
ace5fc3
docs(s061): docs/mcp-server.md (clients, stdio, approvals over the wi…
HackTuah Sep 22, 2026
077923a
feat(s061): the approval card names the actor of an MCP call; AC4 scr…
HackTuah Sep 22, 2026
1cd53f4
test(s061): the boundary census names the server side's referrers
HackTuah Sep 22, 2026
6eba5b8
docs(s061): deviations, findings at G3, follow-ups
HackTuah Sep 22, 2026
64ae767
test(s061): the expired envelope's payload bound first (formatter con…
HackTuah Sep 22, 2026
7211fe2
refactor(s061): credo: the alias order, the run-or-hold clause, any? …
HackTuah Sep 22, 2026
c6b6faa
chore(s061): sobelow skips with their reasons on the four file reads …
HackTuah Sep 22, 2026
00ed5a3
feat(s061): complete slice 061 (MCP server)
HackTuah Sep 22, 2026
f180ec1
test(s024): the standalone census reads an accepted connection on the…
HackTuah Sep 22, 2026
a0d4bbf
docs(s061): the two CI timing failures on this branch, recorded
HackTuah Sep 22, 2026
e1626c6
docs(s061): the closing correction: the final tree and its runs
HackTuah Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
_build
_build_pg
deps
burrito_out
src-tauri/target
node_modules
assets/node_modules
*.db
*.db-*
.env*
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ that means in practice:
2025-11-25 as the fallback. Every tool a server lists becomes a tool the assistant can call,
namespaced so it can never borrow a built-in tool's permissions, asking you until you write a
rule; every result is marked untrusted; and when a server needs input mid-call it asks you,
on the permissions page, before the call continues.

Not there yet: scheduled tasks and the rest of MCP (M5a: Trinity as an MCP server with approvals,
and the authorization roles), messaging gateways and subagents (M5b),
on the permissions page, before the call continues. The other direction too: Trinity is an
MCP server at `POST /mcp` (and over stdio), exporting the read-only tools by default and
any others you name; a client's call passes the same gate and leaves the same receipts as
the assistant's own, and one that needs your approval waits for it on the permissions page
while the client carries a sealed state it can retry with. A headless release runs the same
tree as a server, in a container or under systemd (`docs/mcp-server.md`).

Not there yet: scheduled tasks and MCP's authorization roles (M5a), messaging gateways and subagents (M5b),
the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every
slice, and the [Milestones](#milestones) section below explains how to read it.

Expand Down Expand Up @@ -150,6 +154,7 @@ later without renumbering anything.
| `VERSIONS.md` | The verified dependency versions, generated from `lib/trinity/versions.ex` |
| `CLAUDE.md` | The engineering contract: slice rules, definition of done, proof standard |
| `docs/` | Vision, architecture, tech stack, conventions, slice process, data model, risks, security model, standards; packaging, the FIPS leg, backup and restore, performance measurements |
| `docs/mcp-server.md` | Connecting a client to Trinity's MCP server (Claude Code, VS Code, Codex, goose), stdio, approvals over the wire, the headless profile |
| `slices/059-mcp-library-spike/FINDINGS.md` | What the MCP server core (`beam_mcp`) ships, carries, refuses or leaves open against the 2026-07-28 checklist; the reference for the MCP phase |
| `docs/adr/` | Architecture decision records. One is added whenever a decision changes |
| `slices/` | One folder per slice: specification, notes and proof |
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ standards register names the rows that ask for them.
| 050 | Scheduler: Oban cron agent tasks with delivery targets | 5 Automation | M | 012 | planned |
| 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved |
| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved |
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | planned |
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | done |
| 062 | MCP authorization: OAuth client role, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | planned |
| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned |
| 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned |
Expand Down
2 changes: 1 addition & 1 deletion VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ never pin a version hex marks as retired or vulnerable.
| `oban` | ~> 2.24 | 🔍 not yet a dependency | Uses `Oban.Engines.Lite` on SQLite. ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050. |
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
| `beam_mcp` | ~> 0.8 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.8.0 on hex.pm, standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. |
| `beam_mcp` | ~> 0.9 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.9.0 on hex.pm (2026-09-22): the :server seam on both transports and the core server as a named behaviour, which slice 061's wrapper implements; standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059, bumped at 061. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. |
| `jido` | not used (ADR-0009, decided 2026-09-20) | 🔍 not a single package | Measured at the Slice 012 checkpoint and not adopted: the agent runtime duplicates PubSub, Oban and the gateways and adds a second tool executor; the action shape is written in-tree at Slice 020 with `jsv` for its schemas. The row stays so the decision is visible where a reader would look for the package. |
| `jason` | ~> 1.2 | ✅ in `mix.lock` | |
| `boundary` | ~> 0.10 | ✅ in `mix.lock` | Compile-time module dependency enforcement. Measured at Slice 000: it compiles and enforces on Elixir 1.20.4 / OTP 28, and it reports violations as **warnings**, so it enforces only while `--warnings-as-errors` is on the compile step. ⚠️ No release since 2024-09-25. |
Expand Down
50 changes: 50 additions & 0 deletions ci/headless/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# Slice 061: the headless release in a container. Two stages: the release is assembled on the
# Elixir image whose toolchain is the tree's (.tool-versions: Erlang 28.5.0.5, Elixir 1.20.4),
# then copied onto a slim Debian of the same generation, so the ERTS the release carries finds
# the libc it was built against. No desktop shell, no Burrito: `mix release headless`.
#
# docker build -f ci/headless/Containerfile -t trinity-headless:local .
# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:local
#
# The container binds 0.0.0.0 inside its own network namespace (TRINITY_BIND) so the published
# port reaches it; the bearer is required on /mcp whatever the bind. The data directory is
# /data/trinity (XDG_DATA_HOME=/data, a volume): the databases, the receipt keys, the
# envelope key, the generated token.
FROM hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim AS build

RUN apt-get update -y && apt-get install -y --no-install-recommends \
build-essential git curl ca-certificates cmake python3 pkg-config \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

WORKDIR /app
ENV MIX_ENV=prod LANG=C.UTF-8
RUN mix local.hex --force && mix local.rebar --force

COPY mix.exs mix.lock ./
COPY config config
RUN mix deps.get --only prod

COPY lib lib
COPY priv priv
COPY assets assets
RUN mix assets.deploy && mix compile && mix release headless --overwrite

FROM debian:bookworm-20260824-slim AS app

RUN apt-get update -y && apt-get install -y --no-install-recommends \
libstdc++6 openssl libncurses6 ca-certificates curl locales \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
ENV TRINITY_MODE=headless TRINITY_BIND=0.0.0.0 PORT=4000 XDG_DATA_HOME=/data
WORKDIR /app
RUN useradd --create-home --uid 10001 trinity && mkdir -p /data && chown trinity:trinity /data /app
COPY --from=build --chown=trinity:trinity /app/_build/prod/rel/headless ./
USER trinity
VOLUME ["/data"]
EXPOSE 4000
CMD ["bin/headless", "start"]
31 changes: 29 additions & 2 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ smoke? =
"--smoke" in Enum.map(:init.get_plain_arguments(), &to_string/1) or
System.get_env("TRINITY_SMOKE") == "1"

if System.get_env("PHX_SERVER") || smoke? do
# Slice 061: the headless profile is a server by definition.
headless? = System.get_env("TRINITY_MODE") == "headless"

if System.get_env("PHX_SERVER") || smoke? || headless? do
config :trinity, TrinityWeb.Endpoint, server: true
end

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

# Slice 061: the headless profile binds the address it is told (`TRINITY_BIND`, loopback by
# default: a server on a LAN is the operator's decision, made by setting it) on `PORT`,
# 4000 by default, since nobody reads an ephemeral port off a headless machine. The bearer
# on /mcp (`TRINITY_MCP_SERVER_TOKEN`, or the generated token file) is required whatever
# the bind; the web pages carry no authentication yet, which is why the default stays on
# the loopback (docs/mcp-server.md).
{bind_ip, bind_port} =
if System.get_env("TRINITY_MODE") == "headless" do
ip =
case System.get_env("TRINITY_BIND", "127.0.0.1")
|> String.to_charlist()
|> :inet.parse_address() do
{:ok, ip} ->
ip

{:error, _} ->
raise "TRINITY_BIND is not an IP address: #{System.get_env("TRINITY_BIND")}"
end

{ip, String.to_integer(System.get_env("PORT") || "4000")}
else
{{127, 0, 0, 1}, desktop_port}
end

config :trinity, TrinityWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {127, 0, 0, 1}, port: desktop_port],
http: [ip: bind_ip, port: bind_port],
secret_key_base: secret_key_base

# ## SSL Support
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ slice_id percent sha date
041 80.55 ae200d7 2026-09-21
059 80.54 53c9091 2026-09-21
060 80.32 49f40a4 2026-09-21
061 80.05 c6b6faa 2026-09-22
2 changes: 1 addition & 1 deletion docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ without anything failing.
| `Trinity.Memory` | Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023) | LLM (summaries/embeddings), Repo |
| `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools; at 041: staging, promotion, manager, scanner, diff, learn, `skill_manage` and `learn`) | Repo, Permissions, **Effects**, **Receipts**, Sandbox (as built at 041: Tools, Memory, Permissions, Receipts and LLM; Effects is not a dependency: the promotion is not a tool call, it writes its own effect receipt; Tools never depends on Skills) |
| `Trinity.Scheduler` | Oban workers for agent tasks, delivery | Sessions, Gateways, **Repo** |
| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060)) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`) |
| `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) |
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub |
| `Trinity.Subagents` | Delegation, result collection | Sessions, Tools |
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
Expand Down
7 changes: 7 additions & 0 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@ One row per server the client connects to; the row's `name` is the namespace seg
| tool_overrides | map | tool name to `{"effect": …}`; `catalog` is refused by the changeset and, on a row that carries it anyway, at load with a decision receipt on the chain scope `mcp:<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 |
| last_error | text, nullable | |

### The MCP server's files (Slice 061, not rows)
`<data dir>/mcp-server-token` (the bearer clients present, generated once, mode 0600, overridden by
`TRINITY_MCP_SERVER_TOKEN`) and `<keys dir>/mcp-state.key` (32 bytes, the AES-256-GCM key sealing the
`requestState` of a held call; shared by every instance of the data directory). The MCP session is a
`sessions` row with `origin: "mcp"` and its own persona ("MCP server", no settings); its approvals and
receipts are ordinary rows under it.

### task_runs (Slice 050)
`task_id`, `scheduled_at`, `session_id`, `status`, `summary`, `error`. Unique on `(task_id, scheduled_at)` so a
run is idempotent. Oban holds the job; this holds the outcome.
Expand Down
36 changes: 36 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,42 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES).
argument validation are the core's public functions (`BeamMCP.JSON.decode/1`, `BeamMCP.Schema.validate/2`),
and a census test holds the population to that (060 AC7).

## MCP server (Slice 061, as built)

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

## Secrets

- Env vars in dev; OS keychain via `Trinity.Secrets` from Slice 100 (Tauri stronghold/store or a keychain NIF).
Expand Down
6 changes: 5 additions & 1 deletion docs/08-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ Microsoft, OpenAI. Trinity targets AAIF-governed standards first; vendor-specifi
JSON-RPC id; only requests the client declared in its capabilities may be sent, and Trinity's client
declares form elicitation alone, so the owner answers from the permissions page and no sampling or roots
request reaches it. The core (beam_mcp) refuses MRTR on the server side by design; Trinity's server (061)
speaks it above the core.
speaks it above the core, through the `:server` seam beam_mcp 0.9.0 shipped: `input_required` with one
elicitation request and a sealed `requestState`, the decision the owner's on the permissions page,
the retry under the envelope's call id (docs/07, "MCP server"). Over HTTP the server serves
2026-07-28 alone (the core's transport refuses other versions at the header); the 2025-11-25
compatibility is over stdio, where the core is dual-era.
3. **Deprecated:** Roots, Sampling, Logging (use tool params/resource URIs, provider APIs directly, stderr/OTel),
HTTP+SSE transport, DCR. Do not build new code on these.
4. **Extensions framework** with `extensions` in capabilities. Tasks (`io.modelcontextprotocol/tasks`,
Expand Down
Loading
Loading