feat(mcp): re-export the roster over /mcp, off by default and token-gated #1040

Merged
coilysiren merged 5 commits from aos/claude/gh1025-roster-reexport into main 2026-08-19 03:53:18 +00:00
Member

Closes the gap #1025 names: a lane could not re-export its rostered tools, so a fleet client paid a full agent turn for work that is one tool call.

SIRENS_ECHO_MCP_REEXPORT now offers each rostered tool over /mcp beside turn.

The decision #1025 says matters, answered conservatively

Re-export moves a security boundary rather than adding an interface, and it probably wants real authentication landing first.

I took that seriously rather than around. Three things keep the boundary where it is unless someone deliberately moves it:

  1. Off by default, per lane, through the existing feature-flag table.
  2. Every re-exported call requires SIRENS_ECHO_HTTP_TOKEN as an Authorization: Bearer header, compared in constant time. turn is untouched and still requires none.
  3. An empty configured token trusts nobody. Turning this on without a token offers tools that refuse every caller, not tools that admit every caller. A half-configured deployment fails closed, which is the property that matters on a NodePort that by design refuses nobody.

So this PR does not, by itself, hand any tailnet peer the Dowel lane's Discord write surface. Setting both the flag and the token does, deliberately.

The other four decisions

  • Admission reuses the transportMCP budget, so a tool client cannot outspend the guilds it shares a deployment with.
  • Namespacing needed no new rule. proxyToolName already yields server__tool, so the collision check that guards the model's tool list guards this surface too.
  • Roster instability refreshes on a knob, and a failed refresh keeps the previous list rather than emptying it. #943 recorded the surface collapsing 86 to 0 and back; a client whose tool list vanishes underneath it is the worse failure.
  • Which lanes stays a deployment decision, since the flag is per lane and no values file sets it.

Tests

Ten, and the two that matter if this ever regresses are a call with no token and a call with a wrong token, both refused.

One of them initially passed for the wrong reason. The helper presented a token whenever one was configured, so my "untrusted" case was authenticated. configured and presented are now separate parameters. Worth knowing that the gate's own test was briefly not testing the gate.

The turn-only default is asserted rather than assumed: flag off lists turn alone, byte for byte what shipped before this existed.

Cost, stated rather than hidden

A call opens a roster session, calls, and closes it. That is honest rather than efficient and is the first thing to improve if this carries real traffic.

Docs

docs/sirens-echo-http.md said "Nothing is bypassed there either", which #1025 names as one of three places stating the opposite intent. Corrected. The third is deploy's README and lives in the other repository, so it wants a matching edit there if any lane turns this on.

The docs band is full, and this is worth flagging beyond this PR: 40 of 40 pages, with the three relevant pages at 7866, 7998, and 8000 characters against an 8000-character cap. I tried a dedicated page and the hook correctly refused, since splitting trades one violation for another. So a substantial feature got three sentences where house style wants a walkthrough. That is a real constraint rather than a choice I made.

Not done

Nothing is deployed and no lane sets the flag. This is also a feature rather than an operational improvement, so under the August 19 freeze it sits behind August 20 unless Kai decides otherwise.

Refs: #1025, #943, #310

🤖 Generated with Claude Code

Closes the gap #1025 names: a lane could not re-export its rostered tools, so a fleet client paid a full agent turn for work that is one tool call. `SIRENS_ECHO_MCP_REEXPORT` now offers each rostered tool over `/mcp` beside `turn`. ## The decision #1025 says matters, answered conservatively > **Re-export moves a security boundary rather than adding an interface**, and it probably wants real authentication landing first. I took that seriously rather than around. Three things keep the boundary where it is unless someone deliberately moves it: 1. **Off by default**, per lane, through the existing feature-flag table. 2. **Every re-exported call requires `SIRENS_ECHO_HTTP_TOKEN`** as an `Authorization: Bearer` header, compared in constant time. `turn` is untouched and still requires none. 3. **An empty configured token trusts nobody.** Turning this on without a token offers tools that refuse every caller, not tools that admit every caller. A half-configured deployment fails closed, which is the property that matters on a NodePort that by design refuses nobody. So this PR does not, by itself, hand any tailnet peer the Dowel lane's Discord write surface. Setting both the flag and the token does, deliberately. ## The other four decisions * **Admission** reuses the `transportMCP` budget, so a tool client cannot outspend the guilds it shares a deployment with. * **Namespacing** needed no new rule. `proxyToolName` already yields `server__tool`, so the collision check that guards the model's tool list guards this surface too. * **Roster instability** refreshes on a knob, and **a failed refresh keeps the previous list rather than emptying it**. #943 recorded the surface collapsing 86 to 0 and back; a client whose tool list vanishes underneath it is the worse failure. * **Which lanes** stays a deployment decision, since the flag is per lane and no values file sets it. ## Tests Ten, and the two that matter if this ever regresses are a call with **no token** and a call with a **wrong token**, both refused. **One of them initially passed for the wrong reason.** The helper presented a token whenever one was configured, so my "untrusted" case was authenticated. `configured` and `presented` are now separate parameters. Worth knowing that the gate's own test was briefly not testing the gate. The turn-only default is asserted rather than assumed: flag off lists `turn` alone, byte for byte what shipped before this existed. ## Cost, stated rather than hidden A call opens a roster session, calls, and closes it. That is honest rather than efficient and is the first thing to improve if this carries real traffic. ## Docs `docs/sirens-echo-http.md` said **"Nothing is bypassed there either"**, which #1025 names as one of three places stating the opposite intent. Corrected. The third is deploy's README and lives in the other repository, so it wants a matching edit there if any lane turns this on. **The docs band is full**, and this is worth flagging beyond this PR: 40 of 40 pages, with the three relevant pages at 7866, 7998, and 8000 characters against an 8000-character cap. I tried a dedicated page and the hook correctly refused, since splitting trades one violation for another. **So a substantial feature got three sentences where house style wants a walkthrough.** That is a real constraint rather than a choice I made. ## Not done **Nothing is deployed and no lane sets the flag.** This is also a feature rather than an operational improvement, so under the August 19 freeze it sits behind August 20 unless Kai decides otherwise. Refs: #1025, #943, #310 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(mcp): re-export the roster over /mcp, off by default and token-gated
Some checks failed
ci / test (pull_request) Failing after 57s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 40s
a78e925c97
Kai's expectation on #1025 is that a lane can re-export the MCP servers on
its roster to an outside client. It could not: mcpserver.go registered one
tool, `turn`, and that was the only non-test AddTool in the repository.

SIRENS_ECHO_MCP_REEXPORT now offers each rostered tool beside `turn`, so a
fleet client makes one call instead of paying a whole agent turn, currently
a 39.7s median on the Dowel lane, for work that is one tool call.

WHAT #1025 CALLS THE DECISION THAT MATTERS, AND HOW IT IS ANSWERED HERE.
Re-export moves a security boundary rather than adding an interface: the
lane's guards are in the turn pipeline, so a caller reaching a tool directly
is past runReplyChecks, response validation, and the IdentifierGuard #310
depends on. Three things keep that boundary where it is unless somebody
deliberately moves it.

  - Off by default, per lane, through the feature-flag table.
  - Every re-exported call requires SIRENS_ECHO_HTTP_TOKEN as an
    Authorization Bearer header, compared in constant time. `turn` is
    unchanged and still requires none.
  - An empty configured token trusts nobody, so turning this on without a
    token offers tools that refuse every caller rather than tools that admit
    every caller. A half-configured deployment fails closed, which matters
    on a NodePort that by design refuses nobody at the network layer.

The other decisions #1025 lists. Admission reuses the transportMCP budget, so
a tool client cannot outspend the guilds it shares a deployment with.
Namespacing needed no new rule: proxyToolName already yields server__tool, so
the collision check guarding the model's tool list guards this surface too.
Roster instability is answered by refreshing on a knob and, when a refresh
fails, keeping the previous list rather than emptying it. #943 recorded the
surface collapsing 86 to 0 and back, and a client whose tool list vanishes
underneath it is the worse failure.

Ten tests, including the two that would matter if this regressed: a call
with no token and a call with a wrong token are both refused. The first
draft of the untrusted test passed for the wrong reason, because the helper
presented a token whenever one was configured, so configured and presented
are now separate parameters.

The turn-only default is asserted rather than assumed: with the flag off the
surface lists `turn` alone, byte for byte what shipped before this existed.

Cost, stated rather than hidden: a call opens a roster session, calls, and
closes it. That is the part to improve first if this ever carries traffic.

docs/sirens-echo-http.md said "Nothing is bypassed there either", which
#1025 names as one of three places stating the opposite intent. Corrected.
The third is deploy's README and lives in the other repository.

NOT DONE. Nothing is deployed and no lane sets the flag. The docs band is
full at 40 pages with the three relevant pages at 7866, 7998, and 8000
characters against an 8000 cap, so this feature has three sentences where it
wanted a page. That is a real constraint rather than a choice.

Refs: #1025, #943, #310
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Merge branch 'main' into aos/claude/gh1025-roster-reexport
Some checks failed
ci / image-build (pull_request) Successful in 46s
ci / test (pull_request) Failing after 1m2s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
5d9e684f86
The new file ran 16% comment against 10% in mcpserver.go and 9% in
toolset.go beside it. AGENTS.md asks for the surrounding density rather
than a raised one, and cutting volume is the point rather than a side
effect. Now 7%, and the test file 4% against 6% in mcpserver_test.go.

Most of what went was restatement the code already said: what a helper
prefixes, what a cache holds, that a description is a description. The
six-line header shrank to two, since the security argument it carried is
in docs/sirens-echo-http.md, the pull request, and #1025, and going stale
in a fourth place helps nobody.

One deletion is a correction rather than a trim. addReexportedTool
carried "Taking it by value is what keeps each handler bound to its own
tool rather than to the loop's last one", which stopped being true when
Go 1.22 made loop variables per-iteration. This module is on 1.25.4, so
the comment was teaching a hazard that no longer exists.

What survives is what the code cannot say: the nil-interface trap in
rosterProvider, why a failed refresh keeps its list rather than emptying
it, why headerTrusted exists beside callerTrusted, and that the harness
owns the turn name.

No behaviour change. Full suite green.

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
docs(mcp): give roster re-export the page it wanted
All checks were successful
ci / test (pull_request) Successful in 58s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 32s
2916e18d94
#1039 deleted two doc pages, taking the band from 40 of 40 to 38, so the
constraint recorded on #1040 lifted. The feature had three sentences in
sirens-echo-http.md because a page could not be added, not because three
sentences were the right size for something that reverses a documented
posture.

The page carries what the three sentences could not: that a re-exported
call skips runReplyChecks, response validation, and the IdentifierGuard;
that an empty token refuses everyone rather than admitting them; why a
failed roster refresh keeps its list; and that this is the existing token
enforced on a new path rather than the authentication #1025 says probably
wants landing first.

sirens-echo-http.md keeps a pointer, which also returns the characters
the inline version was spending.

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!1040
No description provided.