Skip to content

Commit 16be4d1

Browse files
authored
Merge pull request #62 from ScriptKittyOS/fix/s062-prod-compile
fix(s062): the MCP plug's authorize hook is a remote capture, so the release compiles. Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents 1ef9160 + dde4484 commit 16be4d1

4 files changed

Lines changed: 91 additions & 4 deletions

File tree

‎lib/trinity/mcp/server/plug.ex‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ defmodule Trinity.MCP.Server.Plug do
3232
catalog: Catalog,
3333
dispatch: nil,
3434
# Slice 062: the profile's authorization runs in `call/2` before the transport, which
35-
# then finds the principal it left; the transport's own hook is that second look.
36-
authorize: fn _conn ->
37-
if Process.get(@principal_key), do: :ok, else: {:error, :unauthorized}
38-
end,
35+
# then finds the principal it left; the transport's own hook is that second look. A
36+
# remote capture and never an anonymous function: `Plug.Builder` calls `init/1` at
37+
# compile time in `:prod` and escapes what it returns into the endpoint, and a closure
38+
# cannot be escaped (`fix(s062)`: the release build was broken and the gate, which runs
39+
# in `:test` where `init/1` is called at runtime, could not see it).
40+
authorize: &__MODULE__.authorized/1,
3941
allowed_origins: Keyword.get(config, :allowed_origins, loopback_origins()),
4042
server_name: Keyword.get(config, :server_name, "trinity"),
4143
tools_ttl_ms: Keyword.get(config, :tools_ttl_ms, 60_000),
@@ -66,6 +68,16 @@ defmodule Trinity.MCP.Server.Plug do
6668

6769
def call(conn, _opts), do: conn
6870

71+
@doc """
72+
The transport's `:authorize` hook: the second look at what `call/2` decided. `call/2`
73+
authorizes the request and leaves the principal in this process before the transport runs, so
74+
a request that reaches the transport without one never passed the profile's check.
75+
"""
76+
@spec authorized(Plug.Conn.t()) :: :ok | {:error, :unauthorized}
77+
def authorized(_conn) do
78+
if Process.get(@principal_key), do: :ok, else: {:error, :unauthorized}
79+
end
80+
6981
@doc "The principal the plug left for the wrapper, in this process."
7082
@spec principal() :: Trinity.MCP.Auth.Principal.t() | nil
7183
def principal, do: Process.get(@principal_key)

‎slices/062-mcp-server-auth/NOTES.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,48 @@ name, so the older could stay the signer (`Keys.rotate!/1` now makes the new key
241241
- The `TrustedHeaders` profile, only if a deployment asks (deviation 2).
242242
- The `[::1]` origin in `Server.Plug.loopback_origins/0` is a browser `Origin` header (bracketed there,
243243
rightly); F2 is about `URI.host`, not that list.
244+
245+
## Correction, 2026-09-22, after the tag: the release build was broken
246+
247+
**F5, found by the `package` workflow at the `slice/062` tag** (run 35786247006, the first run of
248+
that workflow over this slice: its push trigger is tags only). The linux and windows legs failed to
249+
compile:
250+
251+
```
252+
== Compilation error in file lib/trinity_web/endpoint.ex ==
253+
** (ArgumentError) cannot escape #Function<0.132933281/1 in Trinity.MCP.Server.Plug.init/1>.
254+
The supported values are: lists, tuples, maps, atoms, numbers, bitstrings, PIDs and remote
255+
functions in the format &Mod.fun/arity
256+
```
257+
258+
`Plug.Builder` calls a plug's `init/1` **at compile time** when `MIX_ENV=prod` and escapes what it
259+
returns into the compiled endpoint. Slice 062 replaced 061's `authorize: &Auth.Local.authorize/1`
260+
(a remote capture, which escapes) with an anonymous function closing over the process-dictionary
261+
key (which cannot be escaped). `mix gate` runs in `MIX_ENV=test`, where `init/1` is called per
262+
request, so the local gate and all three CI legs were green over a tree whose release could not
263+
compile. Reproduced on `main` at `9859fbe` in one command, before any fix:
264+
265+
```
266+
$ MIX_ENV=prod mix compile
267+
== Compilation error in file lib/trinity_web/endpoint.ex ==
268+
** (ArgumentError) cannot escape #Function<0.132933281/1 in Trinity.MCP.Server.Plug.init/1>. …
269+
exit 1
270+
```
271+
272+
Fixed in `fix(s062)` on `fix/s062-prod-compile`: the hook is `&__MODULE__.authorized/1`, a public
273+
function with the same body, as 061's was a remote capture. The regression test is
274+
`test/trinity/mcp/server_test.exs`, "every option the plug's init returns can be escaped, as a
275+
compile-time init must be": it runs `Macro.escape/1` over `Plug.init([])`, which is the compile-time
276+
step that failed. Demonstrated red against the closure first, with the same message CI printed.
277+
278+
The slice is not rewritten and the tag is not moved: this is a fix commit referencing the original,
279+
as CLAUDE.md section 4 and docs/04 require.
280+
281+
**Follow-up (new, and the reason this was missed): the gate never compiles `:prod`.** Nothing in
282+
`mix gate` or in the push-triggered CI builds a release, and `package.yml` only triggers on tags,
283+
so a compile-time defect in a plug's options, a release-only config error, or anything else that
284+
differs between `:test` and `:prod` is invisible until a tag exists, which is after approval. Two
285+
candidate fixes, for the owner: add a `MIX_ENV=prod mix compile` step to the gate (about a minute
286+
per run, catches this whole class), or give `package.yml` a `branches:` push trigger beside its
287+
tags one (slower, but proves the bundle too). Owner's call; not done here, since the gate is every
288+
slice's contract and this slice does not own it.

‎slices/062-mcp-server-auth/PROOF.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,18 @@ One of them is scoped `s061`, correctly: it amends 061's own test for the `401`
337337
**Not done, and deliberately.** No pull request is open, nothing is merged, and no tag exists: the
338338
slice's status is `done`, which is the agent's half, and `approved` is yours (G4). The `slice/062`
339339
tag and the three-OS `package` run that the tag triggers both come after that.
340+
341+
## Correction, 2026-09-22, after the tag: the release build, and what this proof did not cover
342+
343+
Appended, not rewritten. Everything above stands as measured, and it did not measure one thing: the
344+
`package` workflow, which this file's "Gate" section correctly said would run only at the
345+
`slice/062` tag. It ran (35786247006) and its linux and windows legs failed to compile the release,
346+
because `Plug.Builder` escapes a plug's `init/1` result at compile time under `MIX_ENV=prod` and
347+
this slice had put an anonymous function in those options. NOTES.md records it as **F5** with the
348+
reproduction on `main` at `9859fbe`, the fix (`authorize: &__MODULE__.authorized/1`) and the
349+
regression test, which was demonstrated red against the closure before the fix.
350+
351+
What this says about the proof above: the AC evidence is unaffected (every criterion was measured
352+
through the running server in `:test`, and the fix changes no behaviour, only how the same function
353+
is passed). What it says about the gate is in NOTES.md as a follow-up: `mix gate` never compiles
354+
`:prod`, so this class of defect cannot be caught before a tag exists under the current CI.

‎test/trinity/mcp/server_test.exs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,19 @@ defmodule Trinity.MCP.ServerTest do
313313
assert %{"result" => %{"supportedVersions" => ["2026-07-28"], "resultType" => "complete"}} =
314314
Jason.decode!(answer)
315315
end
316+
317+
# fix(s062). `Plug.Builder` calls a plug's `init/1` at compile time in `:prod` and escapes what
318+
# it returns into the compiled endpoint, so an option that cannot be escaped breaks the release
319+
# build and nothing else: the gate runs in `:test`, where `init/1` is called per request, and
320+
# was green over it for the whole of slice 062. This is that compile-time step, in a test.
321+
test "every option the plug's init returns can be escaped, as a compile-time init must be" do
322+
opts = Trinity.MCP.Server.Plug.init([])
323+
assert is_list(opts) or is_map(opts)
324+
assert Macro.escape(opts)
325+
326+
# The authorize hook is the option that broke it: a remote capture escapes, a closure does not.
327+
assert_raise ArgumentError, ~r/cannot escape/, fn ->
328+
Macro.escape(authorize: fn _conn -> :ok end)
329+
end
330+
end
316331
end

0 commit comments

Comments
 (0)