The harness serves only turn to MCP clients, so a lane cannot re-export its rostered tools #1025

Open
opened 2026-08-19 02:23:02 +00:00 by coilyco-ops · 2 comments
Member

Filed by Saiya (exec seat), 2026-08-19, at Kai's direction. Read-only inspection of main plus live probes. Nothing changed.

Kai's expectation is that a harness can re-export the MCP servers on its roster to an outside MCP client. It cannot. This is unbuilt rather than regressed or config-gated, and building it reverses a documented posture, which is the part worth deciding before anyone writes code.

Current state, from source

internal/community/http.go:64 mounts the MCP path:

mux.Handle(mcpServerPath, a.mcpHandler())

internal/community/mcpserver.go builds one server and registers one tool:

server := mcp.NewServer(...)
mcp.AddTool(
    server,
    &mcp.Tool{Name: "turn", ...},
    a.handleMCPTurn,
)

That is the only non-test mcp.AddTool in the repository. Every other occurrence is a _test.go fixture standing up a fake upstream. Nothing enumerates the roster and re-offers it, and there is no second route: the harness serves /healthz, /readyz, /v1/turn, /v1/jobs, /v1/jobs/, and /mcp.

Confirmed against the running lanes

Probed each NodePort directly with an MCP initialize plus tools/list:

  • sirens-echo on 30120 - 1 tool, turn
  • sirens-deep on 30121 - 1 tool, turn
  • sirens-dowel on 30122 - 1 tool, turn

The generated inventory in agentic-os-kai agrees: "turn through turn."

An authenticated probe would see the same list. deploy/services/sirens-echo/README.md records that SIRENS_ECHO_HTTP_TOKEN "records whether a caller authenticated but does not yet refuse anyone [...] and it is not authentication."

What building this would reverse

Three places currently state the opposite intent, so this is not a missing loop in an otherwise-agreed design:

  • mcpserver.go - the handler exists to serve "Echo's own turn as an MCP tool, so a fleet client reaches it natively instead of learning the bespoke JSON contract."
  • serverInstructions(), sent to every client - "It is an agent rather than a data source, so it answers in prose rather than records, it may decline, and it is not a passthrough to the underlying model."
  • The deploy README - "Every MCP is ClusterIP-only, with no public or tailnet route, and no lane reaches across a namespace boundary for a surface."

If re-export lands, those three want updating in the same change rather than being left contradicting the behaviour.

What it unblocks, concretely

Tonight, exporting a week of owl.glass Discord history from outside the cluster was impossible. The Discord MCP is ClusterIP-only, the lane's only external surface is turn, and a turn answers in prose rather than records. #932 records two earlier proxied read attempts through a lane's own turn tool failing for the same reason. The fallback was to hand a human the raw bot token and a standalone script.

More generally, a fleet client currently pays a full agent turn, currently a 39.7s median on the Dowel lane, for work that is one tool call against a server the lane already holds.

Decisions this needs, none of them made here

  1. Authority, and this is the one that matters. The lane's guards live in the turn pipeline rather than in the tools: runReplyChecks, response validation, and the IdentifierGuard that #310 depends on. Re-exporting tools puts a caller past all of them. Combined with a NodePort that by design refuses nobody, that hands any tailnet peer the Dowel lane's Discord write surface directly. Re-export moves a security boundary rather than adding an interface, and it probably wants real authentication landing first.
  2. Admission. handleMCPTurn calls a.limiter.Admit with transportMCP specifically so an MCP client "cannot outspend the guilds it shares a deployment with." A re-export path needs its own answer, since tool calls are cheaper than turns but not free and some of them write.
  3. Namespacing. 93 tools across 12 servers on the Dowel lane. A flat re-export invites collisions, so tool names likely want a server prefix.
  4. Roster instability becomes externally visible. #943 records the tool surface collapsing from 86 to 0 mid-turn and back. Internally that is one bad turn. Externally it is a client whose tool list vanishes underneath it.
  5. Which lanes, and opt-in or not. Echo and Deep serve real community guilds. Dowel is the demo lane. Per-lane opt-in is probably safer than a harness-wide default.

Scope note

Re-export is the general answer, and it is not the only way to solve the case that surfaced it. Getting Discord history out could also be done with a NodePort on that one wrapper, or by an operator running a script with the token. Worth saying so the immediate need does not justify the general feature by itself.

Boundaries

  • Frozen. This changes what the agent surface can do, so it is a feature under #929 and sits behind 2026-08-20. Explicitly not for the August 19 stream.
  • Owner: engineer. Filed from the exec seat, which records the gap and the decisions rather than making them. Priority left for triage.
  • #929 - the scope freeze and the features-versus-operational-improvements test.
  • #943 - the tool surface collapsing mid-turn.
  • #310 - the identifier guard that sits in the reply path rather than in the tools.
  • coilyco-bridge/deploy#655 - where the ClusterIP-only posture is recorded.
**Filed by Saiya (exec seat), 2026-08-19, at Kai's direction. Read-only inspection of `main` plus live probes. Nothing changed.** Kai's expectation is that a harness can re-export the MCP servers on its roster to an outside MCP client. It cannot. **This is unbuilt rather than regressed or config-gated**, and building it reverses a documented posture, which is the part worth deciding before anyone writes code. ## Current state, from source `internal/community/http.go:64` mounts the MCP path: ```go mux.Handle(mcpServerPath, a.mcpHandler()) ``` `internal/community/mcpserver.go` builds one server and registers one tool: ```go server := mcp.NewServer(...) mcp.AddTool( server, &mcp.Tool{Name: "turn", ...}, a.handleMCPTurn, ) ``` That is the only non-test `mcp.AddTool` in the repository. Every other occurrence is a `_test.go` fixture standing up a fake upstream. Nothing enumerates the roster and re-offers it, and there is no second route: the harness serves `/healthz`, `/readyz`, `/v1/turn`, `/v1/jobs`, `/v1/jobs/`, and `/mcp`. ## Confirmed against the running lanes Probed each NodePort directly with an MCP `initialize` plus `tools/list`: * `sirens-echo` on 30120 - 1 tool, `turn` * `sirens-deep` on 30121 - 1 tool, `turn` * `sirens-dowel` on 30122 - 1 tool, `turn` The generated inventory in `agentic-os-kai` agrees: "`turn` through `turn`." An authenticated probe would see the same list. `deploy/services/sirens-echo/README.md` records that `SIRENS_ECHO_HTTP_TOKEN` "records whether a caller authenticated but does not yet refuse anyone [...] and it is not authentication." ## What building this would reverse Three places currently state the opposite intent, so this is not a missing loop in an otherwise-agreed design: * `mcpserver.go` - the handler exists to serve "Echo's own turn as an MCP tool, so a fleet client reaches it natively instead of learning the bespoke JSON contract." * `serverInstructions()`, sent to every client - "It is an agent rather than a data source, so it answers in prose rather than records, it may decline, and it is not a passthrough to the underlying model." * The deploy README - "Every MCP is ClusterIP-only, with no public or tailnet route, and no lane reaches across a namespace boundary for a surface." If re-export lands, those three want updating in the same change rather than being left contradicting the behaviour. ## What it unblocks, concretely Tonight, exporting a week of owl.glass Discord history from outside the cluster was impossible. The Discord MCP is ClusterIP-only, the lane's only external surface is `turn`, and a turn answers in prose rather than records. `#932` records two earlier proxied read attempts through a lane's own turn tool failing for the same reason. The fallback was to hand a human the raw bot token and a standalone script. More generally, a fleet client currently pays a full agent turn, currently a 39.7s median on the Dowel lane, for work that is one tool call against a server the lane already holds. ## Decisions this needs, none of them made here 1. **Authority, and this is the one that matters.** The lane's guards live in the turn pipeline rather than in the tools: `runReplyChecks`, response validation, and the `IdentifierGuard` that `#310` depends on. Re-exporting tools puts a caller *past* all of them. Combined with a NodePort that by design refuses nobody, that hands any tailnet peer the Dowel lane's Discord write surface directly. **Re-export moves a security boundary rather than adding an interface**, and it probably wants real authentication landing first. 2. **Admission.** `handleMCPTurn` calls `a.limiter.Admit` with `transportMCP` specifically so an MCP client "cannot outspend the guilds it shares a deployment with." A re-export path needs its own answer, since tool calls are cheaper than turns but not free and some of them write. 3. **Namespacing.** 93 tools across 12 servers on the Dowel lane. A flat re-export invites collisions, so tool names likely want a server prefix. 4. **Roster instability becomes externally visible.** `#943` records the tool surface collapsing from 86 to 0 mid-turn and back. Internally that is one bad turn. Externally it is a client whose tool list vanishes underneath it. 5. **Which lanes, and opt-in or not.** Echo and Deep serve real community guilds. Dowel is the demo lane. Per-lane opt-in is probably safer than a harness-wide default. ## Scope note Re-export is the general answer, and it is not the only way to solve the case that surfaced it. Getting Discord history out could also be done with a NodePort on that one wrapper, or by an operator running a script with the token. Worth saying so the immediate need does not justify the general feature by itself. ## Boundaries * **Frozen.** This changes what the agent surface can do, so it is a feature under `#929` and sits behind 2026-08-20. Explicitly not for the August 19 stream. * **Owner: engineer.** Filed from the exec seat, which records the gap and the decisions rather than making them. Priority left for triage. ## Related * `#929` - the scope freeze and the features-versus-operational-improvements test. * `#943` - the tool surface collapsing mid-turn. * `#310` - the identifier guard that sits in the reply path rather than in the tools. * `coilyco-bridge/deploy#655` - where the ClusterIP-only posture is recorded.
Author
Member

Evidence from a full tool sweep of the Dowel lane, 2026-08-19 ~03:00-03:30Z

I smoke-tested all nine of Dowel's tool families before the stream. Because turn is the only exported tool, every probe had to go through prose, which turned the exercise into an unintentional measurement of what this issue describes. Posting it as evidence rather than as an argument for building re-export, since the authority decision above is the one that matters and this does not touch it.

The cost this issue names, measured

a fleet client currently pays a full agent turn, currently a 39.7s median on the Dowel lane, for work that is one tool call against a server the lane already holds

Nine tool families took seven turns, because a turn answers in prose and batching more than two tools per turn destroys attribution when one fails.

The sharper cost is not latency. A prose answer is not a result, so nothing is verifiable from the exported surface alone. For each tool I had to hold independent ground truth and check the reply against it:

  • calculate - 8447 × 293 = 2,474,971, matched
  • forgejo - returned #1026's title verbatim, matched
  • fetch - Luma page title, matched my own fetch exactly
  • exa - go1.26.6, matched go.dev's release JSON
  • signoz - self-reported p95 community.turn at 137,517ms against my own SigNoz query at 135,376ms, a 1.5% gap on a sliding window
  • discord, scratchpad - exercised, worked

Seven of nine confirmed working, and every one of those confirmations came from a source outside the lane. A tools/list plus seven direct calls would have taken seconds and returned records rather than sentences.

Decision 4, observed live rather than recalled

Roster instability becomes externally visible. #943 records the tool surface collapsing from 86 to 0 mid-turn and back.

Both of tonight's failures are that shape, and one of them adds a mechanism.

playwright. Dowel's calls failed 3 for 3 across two turns, at 03:13:16Z and 03:22:51Z, every one of them:

refresh upstream tools: calling "tools/list": Bad Request
(reconnect also failed: reconnect upstream MCP "http://127.0.0.1:8931/mcp":
 sending "notifications/initialized": Bad Request)

In the same window the wrapper served 23 calls successfully. Splitting the pod log by trace id: the three failures all carry a trace id and are Dowel's, and the 23 successes carry none, so they came from a client that is not this lane. At 03:22:51 a navigate failed and at 03:22:57 one succeeded.

So the browser is healthy and the lane's path into it is not. Marked as inference: the wrapper appears to hold one upstream session to 127.0.0.1:8931, another active client claims or resets it, and Dowel's next tools/list lands on a session closed underneath it. That matches the documented posture that callers of this surface share tabs, cookies, and page state. What would settle it is one Dowel browser call while nothing else drives the browser.

moxn. Same failure shape from a different cause, Unauthorized at initialize on a dead hand-minted credential. Tracked at #1026.

Why that sharpens decision 4

#943 frames the instability as internally one bad turn. Tonight shows a second property worth carrying into the decision: the collapse can be per-caller rather than global. The playwright roster was intact and serving throughout, and only this lane's view of it vanished. An external client would not merely see its tool list disappear, it would see one that other clients are using successfully at the same moment, which is harder to diagnose than an outage.

And a note on how this was diagnosed

None of the above was visible through turn. Dowel reported its failures accurately and honestly every time, named the exact error, and invented nothing. But separating "playwright is down" from "playwright works and Dowel cannot reach it" needed pod logs, container status, and a trace-id split, all from outside the lane.

That asymmetry is worth weighing against the authority argument rather than only alongside it. The current surface is the safer one, and it is also the one where a caller cannot tell a broken tool from a broken path to it.

## Evidence from a full tool sweep of the Dowel lane, 2026-08-19 ~03:00-03:30Z I smoke-tested all nine of Dowel's tool families before the stream. Because `turn` is the only exported tool, **every probe had to go through prose**, which turned the exercise into an unintentional measurement of what this issue describes. Posting it as evidence rather than as an argument for building re-export, since the authority decision above is the one that matters and this does not touch it. ### The cost this issue names, measured > a fleet client currently pays a full agent turn, currently a 39.7s median on the Dowel lane, for work that is one tool call against a server the lane already holds Nine tool families took **seven turns**, because a turn answers in prose and batching more than two tools per turn destroys attribution when one fails. The sharper cost is not latency. **A prose answer is not a result, so nothing is verifiable from the exported surface alone.** For each tool I had to hold independent ground truth and check the reply against it: * `calculate` - `8447 × 293 = 2,474,971`, matched * `forgejo` - returned #1026's title verbatim, matched * `fetch` - Luma page title, matched my own fetch exactly * `exa` - `go1.26.6`, matched go.dev's release JSON * `signoz` - self-reported p95 `community.turn` at 137,517ms against my own SigNoz query at 135,376ms, a 1.5% gap on a sliding window * `discord`, `scratchpad` - exercised, worked Seven of nine confirmed working, and **every one of those confirmations came from a source outside the lane**. A `tools/list` plus seven direct calls would have taken seconds and returned records rather than sentences. ### Decision 4, observed live rather than recalled > **Roster instability becomes externally visible.** `#943` records the tool surface collapsing from 86 to 0 mid-turn and back. Both of tonight's failures are that shape, and one of them adds a mechanism. **playwright.** Dowel's calls failed 3 for 3 across two turns, at 03:13:16Z and 03:22:51Z, every one of them: ``` refresh upstream tools: calling "tools/list": Bad Request (reconnect also failed: reconnect upstream MCP "http://127.0.0.1:8931/mcp": sending "notifications/initialized": Bad Request) ``` In the same window the wrapper **served 23 calls successfully**. Splitting the pod log by trace id: the three failures all carry a trace id and are Dowel's, and the 23 successes carry none, so they came from a client that is not this lane. At 03:22:51 a navigate failed and at 03:22:57 one succeeded. **So the browser is healthy and the lane's path into it is not.** Marked as inference: the wrapper appears to hold one upstream session to `127.0.0.1:8931`, another active client claims or resets it, and Dowel's next `tools/list` lands on a session closed underneath it. That matches the documented posture that callers of this surface share tabs, cookies, and page state. What would settle it is one Dowel browser call while nothing else drives the browser. **moxn.** Same failure shape from a different cause, `Unauthorized` at `initialize` on a dead hand-minted credential. Tracked at #1026. ### Why that sharpens decision 4 #943 frames the instability as internally one bad turn. Tonight shows a second property worth carrying into the decision: **the collapse can be per-caller rather than global.** The playwright roster was intact and serving throughout, and only this lane's view of it vanished. An external client would not merely see its tool list disappear, it would see one that other clients are using successfully at the same moment, which is harder to diagnose than an outage. ### And a note on how this was diagnosed None of the above was visible through `turn`. Dowel reported its failures accurately and honestly every time, named the exact error, and invented nothing. But separating "playwright is down" from "playwright works and Dowel cannot reach it" needed pod logs, container status, and a trace-id split, all from outside the lane. **That asymmetry is worth weighing against the authority argument rather than only alongside it.** The current surface is the safer one, and it is also the one where a caller cannot tell a broken tool from a broken path to it.
Author
Member

Implemented in #1040, with decision 1 answered conservatively rather than deferred

The re-export exists. SIRENS_ECHO_MCP_REEXPORT offers each rostered tool over /mcp beside turn.

This issue's decision 1 was the gate, so it is the design. Three properties keep the boundary where it stands unless somebody deliberately moves it:

  • off by default, per lane
  • every re-exported call requires SIRENS_ECHO_HTTP_TOKEN, constant-time compared, while turn is untouched and still requires none
  • an empty configured token trusts nobody, so enabling this without a token yields tools that refuse everyone rather than tools that admit everyone

That last one is the answer to this issue's sharpest sentence, that a NodePort refusing nobody plus an ungated re-export hands any tailnet peer the Dowel lane's Discord write surface. A half-configured deployment now fails closed.

It does not claim to be the real authentication this issue says probably wants landing first. It is the existing token, actually enforced on this path. If that is judged insufficient, the flag staying off is the safe state and nothing regresses.

Decisions 2 through 5

  • Admission reuses the transportMCP budget.
  • Namespacing needed no new rule. proxyToolName already yields server__tool, so the existing collision check covers it.
  • Roster instability refreshes on a knob, and a failed refresh keeps the previous list rather than emptying it, because #943's collapse is worse seen from outside.
  • Which lanes stays a deployment decision. No values file sets the flag.

Two things this surfaced that belong on the record

The gate's own test briefly did not test the gate. The first draft passed because the helper presented a token whenever one was configured, so the "untrusted" case was authenticated. configured and presented are separate parameters now. Naming it because a security test that passes for the wrong reason is the failure mode this whole issue is about.

The docs band is full, and it bit this change. 40 of 40 pages, with the three pages that could host this at 7866, 7998, and 8000 characters against an 8000 cap. A dedicated page was correctly refused by the hook, since splitting trades one violation for another. So a feature that reverses a documented posture landed with three sentences of documentation. That is not a choice I made, and it is worth its own issue rather than being absorbed here.

Standing

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

## Implemented in #1040, with decision 1 answered conservatively rather than deferred The re-export exists. `SIRENS_ECHO_MCP_REEXPORT` offers each rostered tool over `/mcp` beside `turn`. **This issue's decision 1 was the gate, so it is the design.** Three properties keep the boundary where it stands unless somebody deliberately moves it: * off by default, per lane * every re-exported call requires `SIRENS_ECHO_HTTP_TOKEN`, constant-time compared, while `turn` is untouched and still requires none * an empty configured token trusts nobody, so enabling this without a token yields tools that refuse everyone rather than tools that admit everyone That last one is the answer to this issue's sharpest sentence, that a NodePort refusing nobody plus an ungated re-export hands any tailnet peer the Dowel lane's Discord write surface. A half-configured deployment now fails closed. **It does not claim to be the real authentication this issue says probably wants landing first.** It is the existing token, actually enforced on this path. If that is judged insufficient, the flag staying off is the safe state and nothing regresses. ### Decisions 2 through 5 * **Admission** reuses the `transportMCP` budget. * **Namespacing** needed no new rule. `proxyToolName` already yields `server__tool`, so the existing collision check covers it. * **Roster instability** refreshes on a knob, and a failed refresh keeps the previous list rather than emptying it, because #943's collapse is worse seen from outside. * **Which lanes** stays a deployment decision. No values file sets the flag. ### Two things this surfaced that belong on the record **The gate's own test briefly did not test the gate.** The first draft passed because the helper presented a token whenever one was configured, so the "untrusted" case was authenticated. `configured` and `presented` are separate parameters now. Naming it because a security test that passes for the wrong reason is the failure mode this whole issue is about. **The docs band is full**, and it bit this change. 40 of 40 pages, with the three pages that could host this at 7866, 7998, and 8000 characters against an 8000 cap. A dedicated page was correctly refused by the hook, since splitting trades one violation for another. **So a feature that reverses a documented posture landed with three sentences of documentation.** That is not a choice I made, and it is worth its own issue rather than being absorbed here. ### Standing Nothing is deployed and no lane sets the flag. It is a feature rather than an operational improvement, so under the August 19 freeze it sits behind August 20 unless Kai decides otherwise.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#1025
No description provided.