Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
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) | 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 |
Expand Down
18 changes: 15 additions & 3 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -230,9 +232,19 @@ and `receipt_hash`. Chain scopes: `session:<id>` 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:<name>:<tool>` |
| 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:<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 | |

### task_runs (Slice 050)
`task_id`, `scheduled_at`, `session_id`, `status`, `summary`, `error`. Unique on `(task_id, scheduled_at)` so a
Expand Down
29 changes: 29 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<server>:<tool>`**, 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:<server>`.
- **Every result is untrusted.** The server's content parts become one `Trinity.Content.Part` tainted
`:untrusted` with origin `tool:mcp:<server>:<tool>` and source `mcp://<server>/<tool>`; 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).
Expand Down
10 changes: 9 additions & 1 deletion docs/08-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) ++
Expand Down
15 changes: 14 additions & 1 deletion lib/trinity/mcp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 32 additions & 0 deletions lib/trinity/mcp/boot.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading