A tool list that advertised what the build could not run
One feature combination shipped an MCP server listing two tools, with full schemas, that refused every call. The report the refusal told operators to consult had never named the feature it was about.
On 2026-09-01 CI went red on exactly one feature combination. A build of
native,hep,api,mcp,mcp-http — no vcon — listed export_vcon and
validate_vcon in tools/list, complete with input and output schemas, and
refused every call to them.
tools/list is the only contract MCP gives an agent. Everything the agent
knows about what a server can do arrives there. A tool in that list that can
never run is worse than an absent one: the agent plans around it, calls it, and
gets an error naming nothing it could have chosen differently.
Two facts, hundreds of lines apart
The router composed unconditionally in src/mcp/server.rs. Only the inner
helpers carried the feature split, in src/mcp/tools/vcon.rs:
#[cfg(not(feature = "vcon"))]
fn export_containers(
&self,
_selection: &Selection,
_limit: usize,
) -> Result<ExportVconResponse, rmcp::ErrorData> {
Err(no_exporter())
}
Read on its own that arm looks careful. Its doc comment even argued the case: the tools stay registered rather than disappearing, so an agent asking for a container learns which build it is talking to instead of getting “no such tool”, which reads as sipnab not supporting vCon at all.
The argument is coherent. It is also the whole defect: a registration and a refusal that live in different files leave nobody a file to read that shows both.
Nothing local could see it. The build under test here is full, and full
carries vcon, so every tool that could ever run did run. The combination that
failed is one only CI builds.
The advice the refusal gave
The refusal ended by pointing somewhere:
server_capabilitieslists what this binary carries
That report named ten features — native, tui, tls, hep, api, mcp,
mcp-http, metrics, audio, plugins — and had never named vcon, bpf
or wasm. So an operator who followed sipnab’s own error message, to the
surface sipnab’s own error message nominated, learned nothing about the feature
the error was about.
The report’s comment explained why a reader should trust it. It reads from
cfg!, so it “cannot claim a feature the binary does not have”. That is true
and it is half the rule. Nothing stopped it from omitting a feature the binary
does have, which is the direction that failed.
Both halves went in together. Removing the advertisement is only safe once the report can answer the question that removal raises — a client that notices two tools missing needs somewhere to learn why.
The gate is the agreement, not the tool
tests/mcp_capability_agreement_test.rs states the rule in a form that holds
in every build rather than only under full:
assert!(
present == 0 || present == tools.len(),
"{module} declares {} tool(s) and {present} of them are \
registered. A feature-gated module is all or nothing ...",
tools.len()
);
That test deliberately does not consult cfg!. A module behind one feature
advertises all of its tools or none of them, whichever features happen to be
on, so the assertion cannot rot into something that only means anything on one
build.
Half a module is exactly what shipped — the tools listed, the exporter absent — and no test that checks one tool name at a time can see it.
Two of the mutations written against these tests turned out to be compile
errors rather than failing tests, which is a stronger result and worth
reporting as one. Gating a single method out of a #[tool_router] impl does
not build at all. Forcing the half-registered state took removing a route after
composition, and that does kill the test.
What the matrix run found next
The lesson taken from all of this was to stop building one feature set. Running CI’s whole matrix locally before the next push turned up a second defect immediately.
scripts/check-feature-deps.py read all(a, b) the way it reads any(a, b).
For any, treating each alternative as independent is correct and strict:
any(api, mcp, vcon) means a build enabling only vcon compiles that file, so
vcon alone has to declare what the file imports. Reading those as a union is
the reasoning that let a --features vcon build break at 0.5.130.
For all, the same reading is wrong. No build compiles src/mcp/tools/vcon.rs
with half of all(mcp, vcon), so the pair supplies the imports together.
Demanding that vcon declare rmcp satisfies a build that cannot exist.
The fix splits the two readings and keeps the strict one strict:
Anything mixing the two, or shaped in a way this does not recognize, falls back to the STRICT reading — one alternative per feature. A parser that guesses permissively when it is confused is a gate that opens when it stops understanding what it is looking at.
The any strictness that caught 0.5.130 survives untouched, and it now has a
test that fails if anybody relaxes it. Loosening one half of a gate is the
moment to prove the other half still catches things.
Worth stealing
Two rules came out of this.
A conditional advertisement and the condition it advertises belong in one place. If they cannot be, something has to compare them, because a reviewer reading either file alone finds a defensible design in front of them.
And an error message that tells the reader where to look has made a promise about that destination. This one sent operators to a report that could not answer, and the report had a comment explaining why it was trustworthy.