Add /mcps, which reports the reachable tool surface #777

Merged
coilysiren merged 4 commits from eng/list-the-mcps into main 2026-08-14 22:41:12 +00:00
Member

Adds /mcps, which lists the MCP servers this deployment reaches and the tools each advertises.

closes #776

eco (7): get_market, get_stores, get_world, ...
forgejo (12): close_issue, comment_issue, create_issue, ...
steam: did not answer this turn

It reads what the process reached, not what the file declares

The roster file names servers; whether one answered is a different fact, and they come apart exactly when someone needs this. So the reply is built from the discovered tool set with the configured roster supplying names, keeping three states distinct: answered with tools, answered with none, and configured but silent.

No new network calls. The supervisor already discovers and caches tools per server, and ToolDefinition already carries Server attribution alongside Unavailable(). This reads what a turn would have read.

Three constraints, each enforced rather than noted

No addresses. A roster entry holds a URL, transport, and environment map. None is rendered — the reasoning that keeps them out of the boot capability log ("a path or a channel id here would grow into an identifier") applies. TestRenderMCPRosterNeverRendersAnAddress asserts it.

Ephemeral, declared on the command. The flag lives in CommandDefinition, so the refusal paths — not permitted, rate limited, bad arguments — inherit it. A denied /mcps would otherwise become the public event the flag was avoiding. Echo's channel carries members who did not ask.

No job dependency. runCommand returned jobs are not enabled before its switch, so every command assumed the job system. /mcps is answered above that guard.

Gates are unchanged

Per docs/sirens-echo-commands.md, a command is a summon path: access policy, then admission, in that order. /mcps passes both like every other command and reaches nothing a message from the same caller could not.

Registration still requires SIRENS_ECHO_DISCORD_COMMANDS, so no deployment gains this without opting in.

Verification

go test ./..., go vet, policy-check, and the full pre-commit suite pass. Six new tests cover per-server grouping, the unavailable-vs-empty distinction, address absence, truncation, the empty roster, and the declaration itself.

I have not exercised it against live Discord — that needs a deployment with commands registered, which is an operator step.

One judgement worth a reviewer

sirens-echo-community/SKILL.md forbids the model from describing its toolset. This does not contradict that: the reply is deterministic runtime output, not the model describing itself. But it is a deliberate decision that members may now see the tool surface, and ephemeral is what keeps it from becoming a channel artifact. If that read is wrong, the command should be gated harder rather than reworded.

🤖 Generated with Claude Code

Adds `/mcps`, which lists the MCP servers this deployment reaches and the tools each advertises. closes #776 ```text eco (7): get_market, get_stores, get_world, ... forgejo (12): close_issue, comment_issue, create_issue, ... steam: did not answer this turn ``` ## It reads what the process reached, not what the file declares The roster file names servers; whether one answered is a different fact, and they come apart exactly when someone needs this. So the reply is built from the discovered tool set with the configured roster supplying names, keeping three states distinct: answered with tools, answered with none, and configured but silent. **No new network calls.** The supervisor already discovers and caches tools per server, and `ToolDefinition` already carries `Server` attribution alongside `Unavailable()`. This reads what a turn would have read. ## Three constraints, each enforced rather than noted **No addresses.** A roster entry holds a URL, transport, and environment map. None is rendered — the reasoning that keeps them out of the boot capability log ("a path or a channel id here would grow into an identifier") applies. `TestRenderMCPRosterNeverRendersAnAddress` asserts it. **Ephemeral, declared on the command.** The flag lives in `CommandDefinition`, so the refusal paths — not permitted, rate limited, bad arguments — inherit it. A denied `/mcps` would otherwise become the public event the flag was avoiding. Echo's channel carries members who did not ask. **No job dependency.** `runCommand` returned `jobs are not enabled` before its switch, so every command assumed the job system. `/mcps` is answered above that guard. ## Gates are unchanged Per `docs/sirens-echo-commands.md`, a command is a summon path: access policy, then admission, in that order. `/mcps` passes both like every other command and reaches nothing a message from the same caller could not. Registration still requires `SIRENS_ECHO_DISCORD_COMMANDS`, so no deployment gains this without opting in. ## Verification `go test ./...`, `go vet`, `policy-check`, and the full pre-commit suite pass. Six new tests cover per-server grouping, the unavailable-vs-empty distinction, address absence, truncation, the empty roster, and the declaration itself. I have not exercised it against live Discord — that needs a deployment with commands registered, which is an operator step. ## One judgement worth a reviewer `sirens-echo-community/SKILL.md` forbids the model from describing its toolset. This does not contradict that: the reply is deterministic runtime output, not the model describing itself. But it is a deliberate decision that members may now see the tool surface, and ephemeral is what keeps it from becoming a channel artifact. If that read is wrong, the command should be gated harder rather than reworded. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(commands): add /mcps, which reports the reachable tool surface
Some checks failed
ci / test (pull_request) Failing after 32s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Failing after 29s
a9f48cadce
Which MCP servers a lane carries is a deploy fact, set through
SIRENS_ECHO_MCP_ROSTER, and nothing could be asked about it from outside the
process. logCapabilities emits a count at boot: how many, not which, and only
to whoever reads logs.

The reply is built from the discovered tool set rather than the roster file,
because the file names servers and answering is a different fact. Three states
stay distinct: answered with tools, answered with none, and configured but
silent. Collapsing them would hide the one worth asking about.

No new network calls. The supervisor already discovers and caches tools per
server, and ToolDefinition already carries its Server.

No addresses. A roster entry holds a URL, a transport, and an environment map,
and none of it is rendered. The reasoning that keeps them out of the boot
capability log holds here: a name is a fact about the deployment, an address is
an identifier. A test asserts the absence rather than leaving it to review.

Ephemeral, declared on the command rather than in the handler, so the refusal
paths inherit it. A denied /mcps would otherwise become the public event the
flag was avoiding. Echo's channel carries members who did not ask.

Answered above the jobs guard, since reporting a tool surface is not job work
and a deployment with jobs off still has one.

The reply is cut to fit one interaction and says when it cut something, because
a silently short list is indistinguishable from a short roster.

closes #776

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Merge branch 'main' into eng/list-the-mcps
Some checks failed
ci / image-build (pull_request) Successful in 24s
ci / test (pull_request) Failing after 29s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
72d6f58db1
fix(gate): read the declared lane where it now lives
Some checks failed
ci / test (pull_request) Failing after 3m21s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 27s
26656c7208
7f37680 moved ward.workflow out of .ward/ward.yaml into AGENTS.md
frontmatter but left all three readers pointing at the old file. The
gate's sed found nothing, so `declared` was empty and the main-branch
refusal never fired: the lane guard from issue 329 has been dead since.

TestTheGateReadsTheDeclaredWorkflow skipped rather than failed, because
it gated itself on the same missing string. That skip is what the
reviewed-skip-set check has been failing on, on main and on every branch
cut from it.

The behaviour test's fixture wrote ward.yaml too, so it passed against a
guard that could not fire in the real tree. It now writes the frontmatter
the gate reads, which makes it fail when the reader and the declaration
come apart again.

AGENTS.md still told readers the lane lived in ward.yaml. Its wording now
matches agentic-os, which is the only other PR-lane repo migrated so far.

closes #329

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
fix(jobs): bind a thread without racing the runner
All checks were successful
ci / test (pull_request) Successful in 34s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 19s
9cc3042cbd
BindJobToThread read the job, then asked the store to move it to the
state it had just read. Between those two calls the runner starts the
job, and queued is not reachable from running, so the store refused the
write. bindJobThread swallows the error by design, so the job ran with
no thread binding and the id-less follow-up the commands doc promises
resolved to nothing.

Binding is not a state change and should never have named a state. The
store now offers Update, which applies a mutation under the same lock
without consulting the state machine, and the bind uses it.

This is what TestASecondJobInAThreadNeitherBindsNorFails has been
failing on intermittently, on main and everywhere cut from it. It caught
a real defect rather than a flake in itself, but only when it lost the
race, so the new test forces the interleaving instead of waiting for it:
a store that starts the job the moment the bind reads it fails against
the old code every run.

closes #620

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
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!777
No description provided.