Skip to content

Commit 64f4444

Browse files
committed
feat(s060): complete slice 060 (MCP client)
Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 49f40a4 commit 64f4444

4 files changed

Lines changed: 226 additions & 2 deletions

File tree

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ standards register names the rows that ask for them.
5353
| 041 | Skill self-management with staged approval + scanner | 4 Skills | M | 040, 021 | approved |
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 |
56-
| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | in_progress |
56+
| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | done |
5757
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | planned |
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 |

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ slice_id percent sha date
1919
040 79.86 dc9dc6b 2026-09-21
2020
041 80.55 ae200d7 2026-09-21
2121
059 80.54 53c9091 2026-09-21
22+
060 80.32 49f40a4 2026-09-21

‎slices/060-mcp-client/NOTES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ subscription over stateless HTTP has nothing to hold it).
174174

175175
- beam_mcp's board: a line on the stdio page that the host's log handler must not write to standard output
176176
(finding 4); and, when 0.9.0 ships, 061 pins it (the seam ask is already on that board, 2026-09-21).
177-
- Tasks (AC5, deferred): a client for the extension when the core builds or refuses it (SCR-286, after 1.0.0
177+
- 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
178178
on beam_mcp's plan).
179179
- `Trinity.MCP.Client.Auth` is 062's seam: the static `TRINITY_MCP_<NAME>_TOKEN` variable is the whole of it
180180
at this slice, and a `401` is a logged refusal.

‎slices/060-mcp-client/PROOF.md‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# Proof for slice 060: MCP client (2026-07-28 preferred, 2025-11-25 compat)
2+
3+
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)
4+
5+
## Summary
6+
Trinity's thin MCP client over beam_mcp 0.8.0's public functions (ADR-0007 decision 7): one supervised
7+
`Trinity.MCP.Client` per `mcp_servers` row, a stdio transport (a child on a Port with an allow-listed
8+
environment) and a Streamable HTTP transport (one POST per request with the revision's headers),
9+
`server/discover` first and `initialize` at 2025-11-25 as the fallback, every listed tool registered as a
10+
dynamic tool `mcp:<server>:<tool>` through one bridge module and a registry spec (no module per tool), every
11+
result untrusted, and the multi-round-trip loop on the approval mechanism 021 built: a server's
12+
`input_required` is an approval carrying its request, the owner's answer rides the decision, the retry
13+
echoes the `requestState` byte-for-byte and the Session changes not at all. The `/mcp` page and the
14+
input-request card on the permissions page. Deferred: Tasks (AC5, under 059's approved finding 4); the OAuth
15+
client role is 062's (a static token is the seam). Five findings and five follow-ups in NOTES.md; the hard
16+
part was not the wire but the environment a stdio child inherits and the sandbox pool a receipt needs
17+
(NOTES findings 2 and the deviations).
18+
19+
## Gate
20+
```
21+
$ 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)
22+
2443 mods/funs, found no issues.
23+
... SCAN COMPLETE ...
24+
No retired or security advisory packages found
25+
No vulnerabilities found.
26+
versions.verify: OK. 103 locked packages, none disagreeing with 53 pins
27+
versions.gen: VERSIONS.md already matches Trinity.Versions and mix.lock
28+
trinity.version_form: OK
29+
trinity.names: OK over 615 tracked files
30+
trinity.secrets.scan: OK over 615 files
31+
trinity.reuse: OK. Every commentable tracked file carries an SPDX header
32+
Result: 478 passed, 18 excluded
33+
trinity.coverage: 060 80.32% vs 059 80.54%: OK
34+
plan_check: PASS
35+
exit=0
36+
```
37+
CI: named in the closing correction.
38+
39+
## Tests
40+
```
41+
$ mix test --cover (tree 49f40a4)
42+
Result: 478 passed, 18 excluded
43+
| 71.05% | Trinity.MCP.Client |
44+
| 75.36% | Trinity.MCP.Bridge |
45+
| 75.51% | Trinity.MCP.Client.Wire |
46+
| 65.15% | Trinity.MCP.Client.Transport.Stdio |
47+
| 50.00% | Trinity.MCP.Client.Transport.HTTP | (the 401 and the non-200 branches: no server under test answers them)
48+
| 92.86% | Trinity.MCP.ServerConfig |
49+
| 80.32% | Total |
50+
```
51+
`coverage.tsv` row: `060 80.32 49f40a4 2026-09-21` (from 80.54 at 059: two tenths, the transports' error branches)
52+
53+
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`.
54+
55+
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`):
56+
```
57+
* test the planted reference is a real reference: the file the proof compiles names BeamMCP outside Trinity.MCP
58+
* test the planted reference is a real reference: the file the proof compiles names BeamMCP outside Trinity.MCP [L#39]
59+
* test beam_mcp is in mix.lock at 0.8.0 and the VERSIONS row reads in mix.lock
60+
* test beam_mcp is in mix.lock at 0.8.0 and the VERSIONS row reads in mix.lock [L#15]
61+
* test the boundary compiler checks calls into beam_mcp everywhere, and Trinity.MCP is the boundary that lists it
62+
* test the boundary compiler checks calls into beam_mcp everywhere, and Trinity.MCP is the boundary that lists it [L#22]
63+
* test decoding and validation are the core's public functions; no decoder or validator of Trinity's own
64+
* test decoding and validation are the core's public functions; no decoder or validator of Trinity's own [L#68]
65+
* test the population is the driver's files, and it is not empty
66+
* test the population is the driver's files, and it is not empty [L#28]
67+
* test revision handling is the two openers; every method string is one the driver sends or a notification it reads
68+
* test revision handling is the two openers; every method string is one the driver sends or a notification it reads [L#40]
69+
* test AC3: the probe reports its diff size and the one census test it touches, and none of it is here
70+
* test AC3: the probe reports its diff size and the one census test it touches, and none of it is here [L#60]
71+
* test AC1: fifteen rows, numbered 1 to 15, each naming the pinned commit's paths with a line and a deriving command
72+
* test AC1: fifteen rows, numbered 1 to 15, each naming the pinned commit's paths with a line and a deriving command [L#30]
73+
* test AC2: the two conflicts are stated against the slice lines
74+
* test AC2: the two conflicts are stated against the slice lines [L#51]
75+
* test a variable outside the allow list and the refs is unset for the child; a ref is passed; PATH and HOME stay
76+
* 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]
77+
* 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
78+
* 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]
79+
* test creating a disabled row starts no client; enabling starts one; deleting stops it; names are unique
80+
* test creating a disabled row starts no client; enabling starts one; deleting stops it; names are unique [L#65]
81+
* test a spec claiming :catalog is refused; an ill-formed spec is refused; a core tool may not carry one
82+
* test a spec claiming :catalog is refused; an ill-formed spec is refused; a core tool may not carry one [L#81]
83+
* test the entry's name, description, schema, risk, effect, timeout and digest are the spec's; two specs share one module
84+
* test the entry's name, description, schema, risk, effect, timeout and digest are the spec's; two specs share one module [L#37]
85+
* test the runner validates against the spec's schema and hands execute/2 the tool name in the context
86+
* test the runner validates against the spec's schema and hands execute/2 the tool name in the context [L#64]
87+
* test a call over stdio at each era answers; ping is only the legacy era's
88+
* test a call over stdio at each era answers; ping is only the legacy era's [L#67]
89+
* 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
90+
* 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]
91+
* test a server that lists no revision the driver speaks is refused, with the list in the error, and nothing is registered
92+
* test a server that lists no revision the driver speaks is refused, with the list in the error, and nothing is registered [L#95]
93+
* test AC6: the server dies: the tools are unregistered; it is restarted with backoff; the tools are registered again
94+
* test AC6: the server dies: the tools are unregistered; it is restarted with backoff; the tools are registered again [L#80]
95+
* 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
96+
* 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]
97+
* test a tool with an artifact override crosses the membrane as an effect
98+
* test a tool with an artifact override crosses the membrane as an effect [L#68]
99+
* 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
100+
* 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]
101+
* 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
102+
* 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]
103+
* test a failing tool is a result the model reads as the server's error, not a crash
104+
* test a failing tool is a result the model reads as the server's error, not a crash [L#56]
105+
* test AC4: surfaced as an approval carrying the server's request, answered, resumed with the state echoed, completed
106+
* test AC4: surfaced as an approval carrying the server's request, answered, resumed with the state echoed, completed [L#33]
107+
* test the server rejects a retry whose requestState is altered or omitted; the one echoed byte-for-byte completes
108+
* test the server rejects a retry whose requestState is altered or omitted; the one echoed byte-for-byte completes [L#85]
109+
* test an approval carrying a server's input request renders as a form; answering decides once with the typed answer
110+
* test an approval carrying a server's input request renders as a form; answering decides once with the typed answer [L#77]
111+
* test the page lists a server with its status, revision and tools; disable, enable and remove act on the row and the client
112+
* 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]
113+
* test declining a server's request denies the approval
114+
* test declining a server's request denies the approval [L#142]
115+
* test the form adds an http server, which connects; a bad row shows its errors
116+
* test the form adds an http server, which connects; a bad row shows its errors [L#47]
117+
Finished in 3.5 seconds (0.08s async, 3.4s sync)
118+
Result: 30 passed
119+
```
120+
121+
## Acceptance criteria evidence
122+
123+
### AC1 [auto]: Both test servers connect; version chosen per server is logged and asserted (test)
124+
Three servers under test, all beam_mcp's own transports over `Trinity.MCP.TestCatalog` (test/support/mcp):
125+
stdio dual-era (a child VM running `test/support/mcp/stdio_server.exs`), stdio legacy-only (the same with
126+
`legacy`, `supported_versions: ["2025-11-25"]`), and HTTP (`BeamMCP.Transport.HTTP` behind Bandit on a
127+
loopback port). `client_test.exs` "AC1": each connects; `Client.info/1` reports `2026-07-28`, `2025-11-25`,
128+
`2026-07-28`; the log lines `mcp s-modern: connected at 2026-07-28, 3 tools`, `mcp s-legacy: connected at
129+
2025-11-25, 3 tools`, `mcp s-http: connected at 2026-07-28, 3 tools` are captured and asserted; the nine
130+
tools are registered under `mcp:<server>:<tool>` with `kind: :dynamic, effect: :none, risk: :ask` and the
131+
server's schema as the definition the model sees. A fourth server (the double with `future`) advertises a
132+
revision nobody speaks and is refused with the list in `last_error`, nothing registered.
133+
134+
### AC2 [auto]: An MCP tool call flows through `Trinity.Effects` with `effect: :none` and yields a query receipt (test)
135+
`bridge_test.exs` "AC2": `Trinity.Effects.Runner.run/2` on `mcp:b:add` under an allow rule answers the
136+
server's sum as one untrusted part (`taint: :untrusted`, origin `tool:mcp:b:add`, source
137+
`mcp://b/add`), and the session's chain holds `{"decision", "mcp:b:add"}` and `{"query", "mcp:b:add"}`
138+
and nothing else. The neighbour test: an `artifact` override crosses the membrane (`effect admit` and
139+
`effect done` receipts).
140+
141+
### AC3 [auto]: A server-config claiming `effect: :catalog` for an MCP tool is refused at load with a receipt (test)
142+
`bridge_test.exs` "AC3", both halves: `Servers.create/1` with `tool_overrides: %{"echo" => %{"effect" =>
143+
"catalog"}}` is refused by the changeset ("catalog is compile time and cannot be claimed"); a struct built
144+
outside the changeset with the same claim, started through `Servers.start/1`, connects with `add` and
145+
`boom` registered and `echo` absent, and the chain scope `mcp:c` holds one `decision` receipt, subject
146+
`{"server" => "c", "tool" => "mcp:c:echo", "phase" => "load"}`, outcome `deny`, reason
147+
`:catalog_is_compile_time` (read from the signed payload).
148+
149+
### 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)
150+
The server is Trinity's double for the wire (`test/support/mcp/mrtr_server.exs`; NOTES "Read before code":
151+
the core refuses MRTR by design and the 061 wrapper is blocked on the seam). `mrtr_test.exs` "AC4" runs a
152+
real Session with the fake provider calling `mcp:q:ask_name`: the gate's own approval first (the tier of a
153+
namespaced name is `:ask`), then the server's question as a second approval whose `request` carries
154+
`inputRequests.who` (method `elicitation/create`, message "What is your name?", `requestedSchema` requiring
155+
`name`), the Session in `approval_wait`; `decide_request(id, :once, answer: %{"who" => %{"action" =>
156+
"accept", "content" => %{"name" => "Ayla"}}})` resumes it; the tool row reads `Hi, Ayla` with `ok: true`;
157+
the continuation is gone from the client and the approval row carries no `requestState`. The second test
158+
takes the state from a direct call and shows the double refusing an altered state, an omitted state and a
159+
state minted for other arguments (`-32602`), and completing on the exact bytes. The double's state is an
160+
HMAC over the request it belongs to, as the revision asks servers to do.
161+
162+
### AC5 [deferred]: Tasks
163+
Retagged at G1 (SLICE.md, NOTES.md "Read before code"): 059's approved finding 4 says this slice builds no
164+
client for an extension the core neither builds nor refuses; the question goes to beam_mcp's board first
165+
(the Tasks issue on its plan, after 1.0.0). Nothing of Tasks is in the tree.
166+
167+
### AC6 [auto]: Server dies → tools unregistered → reconnect → re-registered (test with short backoff)
168+
`client_test.exs` "AC6": the stdio child's OS pid from `Client.info/1` is killed with `kill -9`; the client
169+
reports `down` and `mcp:r:echo` is gone from the registry; with the suite's backoff (50 ms doubling to 400
170+
ms, `config/test.exs`) it reconnects, a new child pid is reported, `attempts` is back to 0 and `mcp:r:echo`
171+
is registered again.
172+
173+
### AC7 [auto]: The thin-driver rule: a census over `lib/trinity/mcp/client/` (test, with the population command pasted)
174+
```
175+
$ git ls-files lib/trinity/mcp/client lib/trinity/mcp/client.ex
176+
lib/trinity/mcp/client.ex
177+
lib/trinity/mcp/client/auth.ex
178+
lib/trinity/mcp/client/transport.ex
179+
lib/trinity/mcp/client/transport/http.ex
180+
lib/trinity/mcp/client/transport/stdio.ex
181+
lib/trinity/mcp/client/wire.ex
182+
```
183+
`thin_driver_census_test.exs` holds that population to: every `"a/b"` string is a method the driver sends
184+
(`server/discover`, `initialize`, `notifications/initialized`, `tools/list`, `tools/call`, `ping`,
185+
`prompts/get`, `resources/read`) or the one notification it reads, plus the HTTP media type; the two
186+
revision literals are in `wire.ex` and quoted nowhere else; decoding is `BeamMCP.JSON.decode/1` and
187+
validation `BeamMCP.Schema.validate/2`, both in `wire.ex`, which is the only file naming the core; no
188+
`Jason.decode`, no `JSV`, no `Trinity.Tools.Schema`, no schema literal. Beside it, 059's boundary census now
189+
holds the two referrers of `BeamMCP.` under `lib/` (`mcp.ex`, `mcp/client/wire.ex`) and nothing outside
190+
`lib/trinity/mcp/`.
191+
192+
### AC8 [manual]: One real public 2026-07-28 server used end-to-end (GIF)
193+
Done once here against `https://mcpplaygroundonline.com/mcp-stateless-server` (NOTES finding 1), with
194+
`scripts/dev_chat_mcp_playground.sh` (the chat on its own database, the fake provider scripted to call
195+
`mcp:playground:mrtr_signed_state` with `target: "slice 060"`) and a headless browser:
196+
`proof/ac8-mrtr-playground.gif` and the four frames `proof/ac8-1-mcp-page.png` (the server ready at
197+
2026-07-28 with its three tools), `proof/ac8-2-ask.png` (the gate's approval), `proof/ac8-3-server-asks.png`
198+
(the server's question, "Re-issue this call, echoing requestState verbatim", as the card's form) and
199+
`proof/ac8-4-done.png` (the retry accepted, the label back). The server's own answer to a direct probe:
200+
```
201+
$ 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":{}}}}'
202+
{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true}},"resultType":"complete","ttlMs":300000,"cacheScope":"public",…},"jsonrpc":"2.0","id":1}
203+
```
204+
The owner's queue: rerun the script (`PORT=` is printed), open `/mcp` and a new conversation, send any
205+
message, allow once, tick `proceed`, answer; or read the GIF.
206+
207+
## Manual verification for the reviewer
208+
AC8 above. Everything else is a test.
209+
210+
## Deviations from SLICE.md
211+
NOTES.md: two stated before code (Tasks deferred; AC4's server a double), four found building (no `risk`
212+
override, the child's environment allow list, the stdio log handler, `server/discover` with the modern
213+
`_meta`).
214+
215+
## Versions touched
216+
`VERSIONS.md` updated: no. No new dependency: Req (022), Bandit (Phoenix), beam_mcp (059).
217+
`mix versions.verify`: named in the gate output above.
218+
219+
## Git
220+
```
221+
$ git log --oneline main..HEAD
222+
(named in the closing correction, after the final commit)
223+
```

0 commit comments

Comments
 (0)