Skip to content

Commit 49b4114

Browse files
bitwize-musicclaude
andcommitted
test: guard tool descriptions structurally instead of locking them (#537)
The schema golden deliberately excludes tool descriptions, which left the one description failure mode that matters uncovered. Descriptions are the highest-leverage field the server publishes: 81 of the 91 carry their handler's full Args: block, ~50k characters, and that prose decides which of 91 tools the model picks. A bad input schema fails loudly at call time; a degraded description causes quiet wrong-tool selection. But the two ways one can change are not symmetric: * A human edits a docstring — already visible in that file's own diff. Locking it in the golden adds no information and costs a regeneration roughly every five days (104 commits touched handlers/ in six months, ~32 of them docstring prose). A golden that moves weekly stops being read, which would cost the schema half its whole point. * The SDK changes how it *derives* descriptions — publishes only the summary line, strips Args:, caps length. That silently rewrites all 91 with no diff anywhere in the repo, and nothing else in the suite sees it. Only the second is worth a test, and it does not need the prose locked — just its shape asserted. Three guards, all mutation-tested against simulated SDK regressions (first-line-only, empty, capped at 200 chars): - every tool publishes a non-empty description - every parameterised tool still publishes an Args: block, with a one-entry allowlist for master_album, whose parameters are genuinely undocumented - no description is truncated The dump script now emits descriptions and takes --golden to narrow output to the golden file's fields, so one dump serves both consumers. The golden itself regenerates byte-identical. Verified on both SDK lines: 20 passed on 1.28.1 and 2.0.0, and the full dump including descriptions is byte-identical between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent be44f25 commit 49b4114

3 files changed

Lines changed: 128 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ This project uses [Conventional Commits](https://conventionalcommits.org/) and [
1414
- **The MCP server runs on `mcp` 2.x, and still runs on 1.x** ([#537](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/537)) — `mcp` 2.0.0 moved `mcp.server.fastmcp`'s `FastMCP` to `mcp.server.mcpserver`'s `MCPServer` with no compat shim. `server.py` now imports whichever is present, preferring 2.x, and `requirements.txt` pins `mcp[cli]==2.0.0`. Supporting both is the point rather than a hedge: the plugin updates through the marketplace but the venv is updated by hand, so a hard cutover would have killed the server on every existing install the moment the plugin updated, with no action by the user. `check_venv_health` reports the pin drift and people upgrade on their own schedule. The install advice and all four readiness probes accept either line — a probe naming only one module calls a perfectly working install broken, which is the same false verdict in the opposite direction from the one #542 fixed.
1515
- **The migration is smaller than it looked, and that was measured rather than assumed.** The concern on the issue was that `_shared.install_error_boundary` monkey-patches `mcp.tool` across all 91 tools and could keep working while silently changing every generated schema. It does not: both SDKs build schemas from the handler signature via `inspect.signature(fn, eval_str=True)`, every registration site is a bare `@mcp.tool()` with no keyword arguments, and `MCPServer` takes the same constructor name, `.tool()` decorator and `.run(transport="stdio")`. Dumped side by side, all 91 tools produce byte-identical `tools/list` wire schemas on 1.28.1 and 2.0.0, and a real stdio handshake returns the same protocol version and capabilities on both.
1616
- **`serverInfo.version` now reports the plugin version on 2.x.** 1.x has no `version` parameter and hardcodes the SDK's own version — telling a client `1.28.1`, which describes the SDK rather than this server — while 2.x would otherwise default it to the empty string. It is passed only where it is accepted, so 1.x behaviour is unchanged.
17-
- **A golden file locks the 91 generated schemas** (`tests/fixtures/tool_schemas.json`, checked by `tests/unit/state/test_tool_schema_parity.py`). This is the schema-parity check the issue asked for, and it keeps earning its place after the migration: drop the `@functools.wraps` out of the error boundary and every tool collapses to a single `async_wrapper` taking `(*args, **kwargs)` — handlers keep working, the rest of the suite stays green, and clients lose every parameter. That is [#443](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/443)'s failure mode, and nothing else in the suite sees it. Verified by mutation: removing the decorator turns the parity test red. Tool *descriptions* are deliberately outside the golden so ordinary docstring edits do not force a regeneration.
17+
- **A golden file locks the 91 generated schemas** (`tests/fixtures/tool_schemas.json`, checked by `tests/unit/state/test_tool_schema_parity.py`). This is the schema-parity check the issue asked for, and it keeps earning its place after the migration: drop the `@functools.wraps` out of the error boundary and every tool collapses to a single `async_wrapper` taking `(*args, **kwargs)` — handlers keep working, the rest of the suite stays green, and clients lose every parameter. That is [#443](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/443)'s failure mode, and nothing else in the suite sees it. Verified by mutation: removing the decorator turns the parity test red.
18+
- **Tool descriptions are guarded structurally rather than locked in the golden.** They are the highest-leverage field the server publishes — 81 of the 91 carry their handler's full `Args:` block, ~50k characters in total, and that prose is what decides which of 91 tools gets picked; a bad input schema fails loudly at call time, a degraded description just causes quiet wrong-tool selection. But the two ways one can change are not symmetric. A human editing a docstring is already visible in that file's own diff, and locking it would force a regeneration roughly every five days at this repo's rate of handler churn — a golden that moves weekly stops being read, which would cost the *schema* half its whole point. An SDK changing how it derives descriptions — publishing only the summary line, stripping `Args:`, capping length — silently rewrites all 91 with no diff anywhere. Only the second needs a test, and it does not need the prose locked, just its shape asserted: every tool has a description, every parameterised tool still publishes an `Args:` block (with a one-entry allowlist for `master_album`, whose parameters are undocumented), and nothing is truncated. All three were mutation-tested against simulated SDK regressions. Descriptions are byte-identical on 1.28.1 and 2.0.0 today; this is what keeps that true.
1819
- **A `MCP Server Boot (mcp 1.x fallback)` CI job covers the fallback branch.** Every other job installs `requirements.txt` and therefore only ever exercises 2.x, which would leave the 1.x path uncovered from the day it was written — the way compat shims rot. The job installs the pinned set, downgrades mcp alone to the 1.28.1 floor, fails loudly if the downgrade did not take, boots the server through the same `mcp-launch` launcher `.mcp.json` uses, and re-checks the schema golden. That last step is what holds the cross-SDK guarantee: the golden is generated on one line and must reproduce byte-for-byte on the other. Linux-only, since the variable under test is the SDK and not the OS.
1920
- **Dependency bumps that the grouped PR could not deliver: `ruff` 0.16.0 → 0.16.2, `boto3` 1.43.56 → 1.43.69, `playwright` 1.61.0 → 1.62.0** — these three were safe the whole time, but rode in a `pip-all` group PR alongside two upgrades that cannot land, so all three sat unmerged. Split out and verified on their own. `ruff` matters most of the three: it is one of the exactly-pinned gate tools from [#532](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/532), so its verdict changes on unchanged code — 0.16.2 was run against the full tree (`tools/`, `servers/`, `hooks/`, plus the scoped `PLW1514` preview pass) and reports no new findings.
2021
- **`librosa` 1.0+ is blocked in `.github/dependabot.yml`, on the same Python-floor grounds as `scipy` and `numpy`** — `librosa` 1.0.0 declares `Requires-Python >=3.12` and the plugin supports 3.11, so pip cannot resolve it at all: `No matching distribution found for librosa==1.0.0`. That is worse than a failing test. Every job that installs `requirements.txt` dies at the install step, `pip-audit` included, which is why the group PR carrying it failed 11 checks rather than the 6 that mcp alone accounts for. It joins the existing ignore block, whose comment now also records *why* this class of upgrade is blocked rather than merely which packages are affected. ([#532](https://github.com/bitwize-music-studio/claude-ai-music-skills/issues/532)) — `requirements.txt` pinned all 16 runtime deps with `==`, but every entry in `requirements-test.txt` used `>=`, so `ruff`, `mypy` and `bandit` resolved to whatever was newest on PyPI at the moment CI ran. Those three decide the Lint and Security Scan verdicts, and unlike a test runner they change their answer on unchanged code — a new rule or a widened check reddens a commit nobody touched, and re-running an old green build no longer reproduces it. The drift was already visible: the file read `ruff>=0.15.21` while CI had been installing `0.16.0`, which is why Dependabot closed #522 as redundant. `ruff`, `mypy` and `bandit` are now `==` pins (`cache: 'pip'` never mitigated this — it caches wheels, but pip still resolves to newest). The `pytest` stack stays on `>=`: it changes what runs, not what counts as a violation. A parametrized test in `tests/unit/shared/test_pinned_dependencies.py` keeps the three from silently loosening again.

tests/unit/state/_dump_tool_schemas.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
diffs the output against the committed golden file;
88
* by a maintainer regenerating that golden after an intentional tool change::
99
10-
python3 tests/unit/state/_dump_tool_schemas.py > tests/fixtures/tool_schemas.json
10+
python3 tests/unit/state/_dump_tool_schemas.py --golden > tests/fixtures/tool_schemas.json
1111
1212
Why a subprocess rather than an in-process import: the ~24 test modules that
1313
exercise ``server.py`` install a fake ``mcp.server.fastmcp`` into ``sys.modules``
@@ -51,22 +51,32 @@ def _isolate_state_cache(tmp: Path) -> None:
5151
indexer.LOCK_FILE = tmp / "state.lock"
5252

5353

54+
# The fields the committed golden file locks. `description` is deliberately NOT
55+
# among them: it comes from the handler docstring, so locking it would turn every
56+
# prose edit into a regeneration — roughly one every five days at this repo's rate
57+
# of handler churn. A golden that moves weekly stops being read, which would cost
58+
# the schema half of this file its whole point. Descriptions are still *dumped*,
59+
# and guarded structurally instead — see test_tool_schema_parity.py.
60+
GOLDEN_FIELDS = ("name", "inputSchema", "outputSchema")
61+
62+
63+
def golden_projection(tool: dict[str, object]) -> dict[str, object]:
64+
"""Narrow a dumped tool to the fields the golden file locks."""
65+
return {field: tool.get(field) for field in GOLDEN_FIELDS}
66+
67+
5468
def collect() -> list[dict[str, object]]:
55-
"""Return each tool's name and generated schemas, sorted by name."""
69+
"""Return each tool's description and generated schemas, sorted by name."""
5670
import server
5771

5872
tools = asyncio.run(server.mcp.list_tools())
5973
dumped = [t.model_dump(mode="json", by_alias=True, exclude_none=True) for t in tools]
6074

61-
# `description` is deliberately excluded. It comes from the handler docstring,
62-
# so including it would turn every prose edit into a golden-file regeneration —
63-
# real friction for contributors, and not what this file guards. The generated
64-
# schemas are the SDK-dependent part (#537) and the part the error boundary
65-
# could silently change (#443).
6675
return sorted(
6776
(
6877
{
6978
"name": t["name"],
79+
"description": t.get("description") or "",
7080
"inputSchema": t.get("inputSchema"),
7181
"outputSchema": t.get("outputSchema"),
7282
}
@@ -77,9 +87,14 @@ def collect() -> list[dict[str, object]]:
7787

7888

7989
def main() -> None:
90+
"""Dump all tools; ``--golden`` narrows output to the golden file's fields."""
91+
golden_only = "--golden" in sys.argv[1:]
8092
with tempfile.TemporaryDirectory() as tmp:
8193
_isolate_state_cache(Path(tmp))
82-
json.dump(collect(), sys.stdout, indent=2, sort_keys=True)
94+
tools = collect()
95+
if golden_only:
96+
tools = [golden_projection(t) for t in tools]
97+
json.dump(tools, sys.stdout, indent=2, sort_keys=True)
8398
sys.stdout.write("\n")
8499

85100

tests/unit/state/test_tool_schema_parity.py

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@
1919
passing, and clients lose every parameter. That failure mode is exactly what a
2020
golden file catches and nothing else does.
2121
22+
Tool *descriptions* are guarded differently — see the description tests at the
23+
bottom of this file for why they are not in the golden.
24+
2225
Regenerate after an intentional tool change (new tool, renamed or retyped
2326
parameter) and review the diff as part of the change::
2427
25-
python3 tests/unit/state/_dump_tool_schemas.py > tests/fixtures/tool_schemas.json
28+
python3 tests/unit/state/_dump_tool_schemas.py --golden > tests/fixtures/tool_schemas.json
2629
"""
2730

2831
import json
@@ -36,7 +39,13 @@
3639
DUMP_SCRIPT = PROJECT_ROOT / "tests" / "unit" / "state" / "_dump_tool_schemas.py"
3740
GOLDEN = PROJECT_ROOT / "tests" / "fixtures" / "tool_schemas.json"
3841

39-
REGENERATE = f"python3 {DUMP_SCRIPT.relative_to(PROJECT_ROOT)} > {GOLDEN.relative_to(PROJECT_ROOT)}"
42+
REGENERATE = (
43+
f"python3 {DUMP_SCRIPT.relative_to(PROJECT_ROOT)} --golden "
44+
f"> {GOLDEN.relative_to(PROJECT_ROOT)}"
45+
)
46+
47+
# Fields the golden locks — kept in sync with _dump_tool_schemas.GOLDEN_FIELDS.
48+
GOLDEN_FIELDS = ("name", "inputSchema", "outputSchema")
4049

4150

4251
def _dump_live_schemas() -> list[dict]:
@@ -66,7 +75,10 @@ def _dump_live_schemas() -> list[dict]:
6675
@pytest.mark.unit
6776
def test_tool_schemas_match_golden() -> None:
6877
"""Every registered tool's generated schemas match the committed golden."""
69-
live = _dump_live_schemas()
78+
live = [
79+
{field: tool.get(field) for field in GOLDEN_FIELDS}
80+
for tool in _dump_live_schemas()
81+
]
7082
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
7183

7284
live_names = [t["name"] for t in live]
@@ -129,3 +141,91 @@ def test_golden_covers_every_tool_with_a_schema() -> None:
129141
f"{len(no_params)} of {len(golden)} tools take no parameters. That is the "
130142
f"shape of collapsed (*args, **kwargs) introspection, not a real API."
131143
)
144+
145+
146+
# --- tool descriptions --------------------------------------------------------
147+
#
148+
# Descriptions are the highest-leverage field the server publishes: 81 of the 91
149+
# carry their handler's full `Args:` block, ~50k characters in total, and that
150+
# prose is what the model reads when choosing between 91 tools. A bad input schema
151+
# fails loudly at call time; a degraded description just causes quiet wrong-tool
152+
# selection that reads as the model being dim.
153+
#
154+
# They are still kept OUT of the golden file, because the two ways a description
155+
# can change are not symmetric:
156+
#
157+
# * A human edits a docstring — already visible in that file's own diff. Locking
158+
# it in the golden adds no information and costs a regeneration every few days.
159+
# * The SDK changes how it *derives* descriptions — starts at the summary line,
160+
# strips `Args:`, truncates, dedents differently. That silently rewrites all 91
161+
# with no diff anywhere in the repo, and nothing else in the suite would see it.
162+
#
163+
# Only the second is worth a guard, and it does not need the prose locked — just
164+
# its shape asserted. That is what these tests do: SDK-drift coverage with zero
165+
# churn on ordinary docstring edits.
166+
167+
# Parameterised tools whose docstring legitimately has no `Args:` block. Keep this
168+
# list short — an entry is a tool whose parameters are undocumented to the model.
169+
TOOLS_WITHOUT_ARGS_BLOCK = {"master_album"}
170+
171+
172+
@pytest.mark.unit
173+
def test_every_tool_has_a_description() -> None:
174+
"""An empty description leaves the model picking a tool by name alone."""
175+
live = _dump_live_schemas()
176+
177+
missing = [t["name"] for t in live if not (t.get("description") or "").strip()]
178+
assert not missing, (
179+
f"{len(missing)} tool(s) publish no description: {missing[:10]}. Either the "
180+
f"handler lost its docstring, or the SDK stopped deriving descriptions from "
181+
f"one — check `mcp.server.*`'s tool registration before assuming the former."
182+
)
183+
184+
185+
@pytest.mark.unit
186+
def test_parameterised_tools_document_their_arguments() -> None:
187+
"""A tool's parameters must still reach the model, not just its summary line.
188+
189+
This is the SDK-drift canary. If a future mcp version publishes only the
190+
docstring's first line — a plausible and entirely silent change — every
191+
parameterised tool keeps working while the model loses the text telling it what
192+
the parameters mean. Nothing else in the suite notices.
193+
"""
194+
live = _dump_live_schemas()
195+
196+
undocumented = sorted(
197+
t["name"]
198+
for t in live
199+
if (t.get("inputSchema") or {}).get("properties")
200+
and t["name"] not in TOOLS_WITHOUT_ARGS_BLOCK
201+
and "Args:" not in (t.get("description") or "")
202+
)
203+
assert not undocumented, (
204+
f"{len(undocumented)} parameterised tool(s) publish no `Args:` block: "
205+
f"{undocumented[:10]}.\n\nIf many tools regressed at once, suspect the SDK's "
206+
f"docstring handling rather than the handlers — that is the failure this "
207+
f"test exists for. If a single new tool is listed, give it an `Args:` block "
208+
f"or add it to TOOLS_WITHOUT_ARGS_BLOCK with a reason."
209+
)
210+
211+
212+
@pytest.mark.unit
213+
def test_descriptions_are_not_truncated() -> None:
214+
"""Catch an SDK that starts capping description length.
215+
216+
A cap would not empty any description, so the two tests above would both pass
217+
while the model silently lost the tail of every long one.
218+
"""
219+
live = _dump_live_schemas()
220+
descriptions = {t["name"]: (t.get("description") or "") for t in live}
221+
222+
elided = sorted(n for n, d in descriptions.items() if d.rstrip().endswith(("...", "…")))
223+
assert not elided, f"description(s) end in an ellipsis, i.e. truncated: {elided[:10]}"
224+
225+
# The longest description is ~2.3k characters. If the maximum collapses to a
226+
# round-ish number, something upstream is capping it.
227+
longest = max(descriptions.values(), key=len)
228+
assert len(longest) > 1000, (
229+
f"the longest tool description is only {len(longest)} characters. These run "
230+
f"to ~2.3k, so a ceiling this low means something upstream is truncating them."
231+
)

0 commit comments

Comments
 (0)