Files
autocad-mcp-pro/tests/test_discovery_transform.py
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:45:20 +03:00

669 lines
28 KiB
Python

"""The discovery search transform must find tools a drafter can actually name.
Stock ``BM25SearchTransform`` indexes tool name + description + parameter names
+ parameter descriptions and nothing else. Measured against this server's live
catalog, that means ``PEDIT``, ``WBLOCK``, ``OVERKILL``,
``LAYTRANS`` and ``MATCHPROP`` all score df=0 and return *nothing*,
and "how many entities are on the GEOMETRY layer" returns ``entity_delete_many``
as its top hit. Both are proved here against the real catalog before anything is
asserted about the replacement.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from fastmcp import Client
from fastmcp.server.transforms.search import BM25SearchTransform
from fastmcp.server.transforms.search.base import serialize_tools_for_output_json
import config
import server
from discovery.serialize import serialize_search_results
from discovery.transform import CadSearchTransform, tool_risk
from tests.data.discovery_golden import ALL_CASES, HOLDOUT_CASES, TUNING_CASES
pytestmark = pytest.mark.asyncio
@pytest.fixture(scope="module")
def catalog():
"""The real, untransformed 131-tool catalog.
Synchronous on purpose: ``_registered_tools`` only reads the in-process
component registry, and a module-scoped *async* fixture would need its own
event-loop scope under ``asyncio_mode = strict``.
"""
return asyncio.run(server._registered_tools())
@pytest.fixture
def transform():
return CadSearchTransform()
@pytest.fixture
def stock():
return BM25SearchTransform()
def _names(tools) -> list[str]:
return [t.name for t in tools]
# ── the df=0 vocabulary gap ─────────────────────────────────────────────────
# Verified against the live catalog: stock BM25 returns *zero* hits for every
# one of these. FILLET is deliberately absent — it is already indexed (df=2,
# the tool is literally named entity_fillet), so citing it would overstate the
# gap.
#
# BPOLY and QSELECT left this list in v1.5.0 (M8): `boundary_trace` and
# `selection_filter` name those commands in their own docstrings, so stock BM25
# now finds them and the gap is genuinely closed for those two. Keeping them
# here would have made the corpus look more load-bearing than it is. The
# remaining five are still unreachable without it.
DF_ZERO_COMMANDS = [
("PEDIT", "entity_edit_geometry"),
("WBLOCK", "block_create_from_entities"),
("OVERKILL", "drawing_refine"),
("LAYTRANS", "template_apply_layers"),
("MATCHPROP", "entity_set_properties"),
]
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
async def test_stock_bm25_cannot_find_these_commands_at_all(stock, catalog, command, expected):
"""Guard the guard: the gap is real, not an artefact of a weak assertion."""
assert await stock._search(catalog, command) == []
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
async def test_autocad_command_names_resolve(transform, catalog, command, expected):
assert _names(transform.rank(catalog, command).hits)[0] == expected
@pytest.mark.parametrize(
("command", "expected"),
[
("DLI", "dimension_linear"),
("REC", "entity_create_rectangle"),
("CHA", "entity_chamfer"),
("TR", "entity_trim"),
("MI", "entity_mirror"),
("XL", "construction_xline"),
],
)
async def test_short_command_aliases_resolve(transform, catalog, command, expected):
"""Two- and three-letter aliases are what drafters actually type."""
assert _names(transform.rank(catalog, command).hits)[0] == expected
async def test_searchable_text_extends_the_stock_document(transform, catalog):
"""The hook adds alias vocabulary; it never drops the stock text."""
from fastmcp.server.transforms.search.base import _extract_searchable_text
tool = next(t for t in catalog if t.name == "entity_create_polyline")
text = transform._extract_searchable_text(tool)
assert _extract_searchable_text(tool) in text
assert "PLINE" in text
assert "closed profile" in text
async def test_a_tool_with_no_alias_record_is_still_indexed(transform, catalog):
"""An un-aliased tool must stay findable, not vanish from the index."""
tool = next(t for t in catalog if t.name == "entity_create_line")
stripped = tool.model_copy(update={"name": "zzz_unaliased_probe"})
hits = transform.rank([*catalog, stripped], "zzz_unaliased_probe").hits
assert _names(hits)[0] == "zzz_unaliased_probe"
# ── the two boosts ──────────────────────────────────────────────────────────
async def test_an_exact_command_outranks_tools_that_share_a_description_word(transform, catalog):
""" "CIRCLE" is the command; a dozen tools merely mention circles."""
hits = _names(transform.rank(catalog, "CIRCLE", limit=5).hits)
assert hits[0] == "entity_create_circle"
async def test_a_command_name_that_is_also_an_english_word_stays_english(transform, catalog):
"""LAYER is a command *and* a noun.
Boosting on a bare lowercase "layer" made layer_modify / layer_list /
layer_create the top three for this query and pushed layer_delete out of
the answer entirely — exactly backwards. Measured regression, not a
hypothetical.
"""
hits = _names(transform.rank(catalog, "delete the layer called GEOMETRY", limit=8).hits)
assert "layer_delete" in hits
assert not {"layer_create", "layer_modify", "layer_list"} & set(hits[:3])
async def test_the_same_command_in_capitals_is_treated_as_a_command(transform, catalog):
"""The other half of the rule: LAYER typed as a command routes to LAYER tools."""
hits = set(_names(transform.rank(catalog, "LAYER", limit=5).hits))
assert {"layer_create", "layer_list", "layer_modify"} <= hits
async def test_a_lone_lowercase_command_still_counts_as_a_command(transform, catalog):
"""Nobody shouts when the command is the whole message."""
assert _names(transform.rank(catalog, "bpoly").hits)[0] == "boundary_trace"
async def test_articles_do_not_break_synonym_phrase_matching(transform, catalog):
"""The corpus says "delete a layer"; users say "delete the layer"."""
assert "layer_delete" in _names(transform.rank(catalog, "delete the layer", limit=5).hits)
# ── the risk filter ─────────────────────────────────────────────────────────
COUNTING_QUERY = "how many entities are on the GEOMETRY layer"
async def test_stock_bm25_answers_a_counting_question_with_destructive_tools(stock, catalog):
"""Guard the guard: the destructive-hit problem is measured, not assumed.
This asserted the top hit was ``entity_delete_many`` until
``analysis_measure_entity`` was added and displaced it to second. The
ranking order is incidental; what the risk ceiling exists to prevent is a
read-only question being answered with tools that destroy geometry, and that
is still exactly what stock BM25 does here.
"""
hits = await stock._search(catalog, COUNTING_QUERY)
risky = [t.name for t in hits if tool_risk(t) == "destructive"]
assert risky, f"expected destructive hits among {_names(hits)}"
assert "entity_delete_many" in risky
@pytest.mark.parametrize(
("name", "expected"),
[
("drawing_info", "read"),
("analysis_entity_stats", "read"),
("entity_create_line", "write"),
("entity_delete_many", "destructive"),
("layer_delete", "destructive"),
("transaction_rollback", "destructive"),
],
)
async def test_risk_is_derived_from_the_mcp_annotations(catalog, name, expected):
tool = next(t for t in catalog if t.name == name)
assert tool_risk(tool) == expected
async def test_read_ceiling_keeps_destructive_tools_out_of_a_counting_answer(transform, catalog):
outcome = transform.rank(catalog, COUNTING_QUERY, risk="read")
assert [tool_risk(t) for t in outcome.hits] == ["read"] * len(outcome.hits)
assert "analysis_entity_stats" in _names(outcome.hits)
async def test_write_ceiling_allows_mutation_but_not_destruction(transform, catalog):
outcome = transform.rank(catalog, "delete the entities on the GEOMETRY layer", risk="write")
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
async def test_the_filter_frees_slots_rather_than_shrinking_the_answer(transform, catalog):
"""Filtering must promote lower-ranked safe tools, not just delete rows."""
unfiltered = transform.rank(catalog, COUNTING_QUERY, limit=5)
filtered = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
assert len(unfiltered.hits) == 5
assert len(filtered.hits) == 5
assert set(_names(filtered.hits)) - set(_names(unfiltered.hits))
async def test_the_filter_records_exactly_what_it_hid(transform, catalog):
outcome = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
assert "entity_delete_many" in _names(outcome.hidden)
# Only tools the caller would otherwise have seen — not the whole catalog.
assert len(outcome.hidden) <= 5
async def test_nothing_is_recorded_as_hidden_when_the_filter_is_off(transform, catalog):
assert transform.rank(catalog, COUNTING_QUERY, limit=5).hidden == ()
# ── the risk filter must be visible in the payload ──────────────────────────
class _FakeServer:
def __init__(self, tools):
self._tools = list(tools)
async def list_tools(self, run_middleware: bool = True):
return list(self._tools)
class _FakeContext:
"""Just enough Context for ``_get_visible_tools`` to reach the catalog."""
def __init__(self, tools):
self.fastmcp = _FakeServer(tools)
async def _search_payload(transform, catalog, query: str, **kwargs):
tool = transform._make_search_tool()
return await tool.fn(query=query, ctx=_FakeContext(catalog), **kwargs)
async def test_the_payload_says_when_the_risk_filter_removed_hits(transform, catalog):
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="read")
notice = [line for line in payload.splitlines() if line.startswith("Risk filter:")]
assert len(notice) == 1
assert "entity_delete_many" in notice[0]
assert 'risk="any"' in notice[0]
async def test_a_mixed_intent_is_never_silently_halved(transform, catalog):
""" "list the layers then delete GEOMETRY" is half a read and half a delete."""
payload = await _search_payload(
transform, catalog, "list the layers then delete GEOMETRY", risk="read"
)
assert "layer_list" in payload
notice = next(line for line in payload.splitlines() if line.startswith("Risk filter:"))
assert "entity_delete" in notice
async def test_no_notice_when_the_filter_removed_nothing(transform, catalog):
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="any")
assert "Risk filter:" not in payload
async def test_a_structured_serializer_gets_the_notice_as_a_sibling_key(catalog):
"""There is no line to append to when the serializer returns JSON."""
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="read")
assert isinstance(payload["results"], list)
assert "entity_delete_many" in payload["notice"]
async def test_a_structured_serializer_returns_bare_results_when_nothing_was_hidden(catalog):
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="any")
assert isinstance(payload, list)
# ── the search tool's own schema ────────────────────────────────────────────
async def test_search_tool_advertises_limit_and_risk(transform):
properties = transform._make_search_tool().parameters["properties"]
assert set(properties) == {"query", "limit", "risk"}
assert properties["risk"]["enum"] == ["any", "write", "read"]
assert properties["limit"]["default"] == transform._max_results
@pytest.mark.parametrize(("asked", "served"), [(999, 25), (0, 1), (-4, 1), (3, 3)])
async def test_limit_is_clamped_to_a_usable_range(transform, catalog, asked, served):
outcome = transform.rank(catalog, "line", limit=asked)
assert len(outcome.hits) == served
async def test_an_unknown_risk_value_is_rejected_rather_than_ignored(transform, catalog):
with pytest.raises(ValueError, match="risk"):
transform.rank(catalog, "line", risk="mostly-harmless")
# ── the compact serializer ──────────────────────────────────────────────────
def _tools(catalog, *names):
by_name = {t.name: t for t in catalog}
return [by_name[n] for n in names]
async def test_one_card_per_tool_on_one_line_each(catalog):
tools = _tools(catalog, "entity_create_circle", "entity_delete", "drawing_info")
lines = serialize_search_results(tools).splitlines()
assert len(lines) == 3
assert lines[0].startswith("entity_create_circle")
async def test_a_card_carries_name_summary_and_required_params(catalog):
(line,) = serialize_search_results(_tools(catalog, "entity_create_circle")).splitlines()
assert "entity_create_circle" in line
assert "req: cx, cy, radius" in line
async def test_a_card_marks_the_risk_level(catalog):
text = serialize_search_results(
_tools(catalog, "drawing_info", "entity_create_circle", "entity_delete")
)
assert "drawing_info (read)" in text
assert "entity_create_circle (write)" in text
assert "entity_delete (destructive)" in text
async def test_a_card_carries_the_autocad_command(catalog):
(line,) = serialize_search_results(_tools(catalog, "entity_chamfer")).splitlines()
assert "acad: CHAMFER" in line
async def test_the_cad_meta_summary_wins_over_the_docstring(catalog):
"""@cad_tool exists to give a hit a hand-written one-liner."""
tool = next(t for t in catalog if t.name == "transaction_begin")
summary = (tool.meta or {})["cad"]["summary"]
assert summary in serialize_search_results([tool])
async def test_the_first_docstring_line_is_the_fallback_summary(catalog):
"""A card still reads well for a tool that carries no @cad_tool summary.
The fallback is exercised by stripping the meta channel off a real tool
rather than by naming one that happens to lack it: the @cad_tool rollout
is filling the catalog in, so "a tool without cad meta" is not a stable
fixture, while the serializer's fallback branch has to keep working for
any tool a transform hands it (a mounted server's, say).
"""
tool = next(t for t in catalog if t.name == "entity_create_circle")
bare = tool.model_copy(update={"meta": None})
first_line = bare.description.strip().splitlines()[0]
assert first_line in serialize_search_results([bare])
async def test_a_long_summary_is_truncated_on_a_word_boundary(catalog):
tool = next(t for t in catalog if t.name == "entity_create_circle")
verbose = tool.model_copy(update={"description": "word " * 200, "meta": None})
(line,) = serialize_search_results([verbose]).splitlines()
assert len(line) < 250
assert line.count("...") == 1
async def test_a_zero_argument_tool_says_so(catalog):
(line,) = serialize_search_results(_tools(catalog, "drawing_info")).splitlines()
assert "req: (none)" in line
async def test_an_empty_result_set_is_explicit():
assert serialize_search_results([]) == "No tools matched the query."
# Budgets read off the measured output, not chosen first and enforced onto the
# renderer. Over the 15 REALISTIC_QUERIES below at limit=5 the worst rendered
# payload measured 798 characters and the worst single card in the whole
# 131-tool catalog measured 191 (p50 123, p90 160). The thresholds sit ~30%
# above those so an added tool with a long docstring does not turn into a
# spurious CI failure — a real regression would be a multiple, not a nudge.
MAX_CARD_CHARS = 256
MAX_PAYLOAD_CHARS = 1024
REALISTIC_QUERIES = [
"FILLET",
"BPOLY",
"round the corner",
"bill of materials",
"how many entities are on the GEOMETRY layer",
"draw a hole in the plate",
"put a leader note on the chamfer",
"dimension everything with a chain",
"make a title block",
"save the drawing as a pdf",
"select every circle on the GEOMETRY layer",
"gear",
"keyway in the hub bore",
"screenshot",
"check the drawing before finalising",
]
async def test_no_single_card_exceeds_the_measured_budget(catalog):
"""Checked over every registered tool, not just the ones a query happens to hit."""
worst = max(catalog, key=lambda tool: len(serialize_search_results([tool])))
assert len(serialize_search_results([worst])) <= MAX_CARD_CHARS, worst.name
@pytest.mark.parametrize("query", REALISTIC_QUERIES)
async def test_a_five_hit_payload_stays_inside_the_measured_budget(transform, catalog, query):
hits = transform.rank(catalog, query, limit=5).hits
assert len(serialize_search_results(hits)) <= MAX_PAYLOAD_CHARS
async def test_the_serializer_is_far_smaller_than_stock_json(transform, catalog):
"""Payload size is a real gain, but it is *not* the differentiator.
Stock BM25 with this same serializer would close most of the gap; what it
cannot do is find BPOLY at all. Asserted as a ratio so it stays honest.
"""
hits = transform.rank(catalog, "round the corner", limit=5).hits
compact = len(serialize_search_results(hits))
stock_json = len(json.dumps(serialize_tools_for_output_json(hits)))
assert compact < stock_json / 5
# ── the golden set ──────────────────────────────────────────────────────────
TOP_K = 3
# Measured on the tuning set with the scorer frozen: top-1 47/51 (92.2%),
# top-3 50/51 (98.0%). The bars sit below those, not on them.
TUNING_TOP1_BAR = 0.85
TUNING_TOP3_BAR = 0.94
# Declared *before* the holdout set was measured for the first time, and not
# revised afterwards. Lower than the tuning bar on purpose: a ranker that
# scores the same on data it never saw is the exception, not the expectation.
HOLDOUT_TOP3_BAR = 0.80
# A tuning case that fails for a reason this module cannot fix. "smaller" and
# "by half" appear nowhere in discovery/aliases.py (which has "shrink" but not
# "smaller"), so no scoring change reaches entity_scale — only a corpus edit
# does, and the corpus is a separate deliverable. Left in the set rather than
# quietly deleted: it is the honest ceiling of an alias-driven approach.
KNOWN_CORPUS_GAPS = {"make the whole shape smaller by half"}
def _tuning_params():
return [
pytest.param(
case,
marks=(
[pytest.mark.xfail(reason="vocabulary missing from discovery/aliases.py")]
if case.query in KNOWN_CORPUS_GAPS
else []
),
id=str(case),
)
for case in TUNING_CASES
]
async def _accuracy(transform, stock, catalog, cases):
"""(mine_top1, mine_topk, stock_top1, stock_topk, misses) over ``cases``."""
mine1 = mine_k = stock1 = stock_k = 0
misses = []
for case in cases:
# risk="any" for both, so the A/B never credits the risk filter.
ours = _names(transform.rank(catalog, case.query, limit=5).hits)
theirs = _names(await stock._search(catalog, case.query))
mine1 += ours[:1] == [case.expect]
stock1 += theirs[:1] == [case.expect]
if case.expect in ours[:TOP_K]:
mine_k += 1
else:
misses.append(f"{case.query!r} want={case.expect} got={ours[:TOP_K]}")
stock_k += case.expect in theirs[:TOP_K]
return mine1, mine_k, stock1, stock_k, misses
@pytest.mark.parametrize("case", _tuning_params())
async def test_tuning_case_finds_its_tool(transform, catalog, case):
hits = _names(transform.rank(catalog, case.query, limit=5).hits)
assert case.expect in hits[:TOP_K], hits
async def test_tuning_accuracy_holds_its_measured_bar(transform, stock, catalog):
mine1, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, TUNING_CASES)
n = len(TUNING_CASES)
assert mine1 / n >= TUNING_TOP1_BAR, f"top-1 {mine1}/{n}"
assert mine_k / n >= TUNING_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
async def test_holdout_accuracy_clears_the_bar_declared_before_it_was_measured(
transform, stock, catalog
):
_, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, HOLDOUT_CASES)
n = len(HOLDOUT_CASES)
assert mine_k / n >= HOLDOUT_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
async def test_it_beats_stock_bm25_on_the_whole_golden_set(transform, stock, catalog):
"""The A/B. Both sides run unfiltered, so this measures vocabulary and
ranking only — none of the credit goes to the risk filter.
Scored on top-1, not a top-`TOP_K` ratio. A ratio between two scores over a
shared corpus is not a property of this transform: improving an unrelated
tool's docstring adds vocabulary to *both* indexes, so a `mine_k >= 2 *
stock_k` assertion moves when stock gets **better**, which is a good outcome
being reported as a regression. It fired exactly that way when the undo/redo
docstrings were written. Top-1 is also the number that matters — a search
tool returns one answer to act on.
"""
mine1, mine_k, stock1, stock_k, _ = await _accuracy(transform, stock, catalog, ALL_CASES)
n = len(ALL_CASES)
report = (
f"top-1 mine {mine1}/{n} vs stock {stock1}/{n}; "
f"top-{TOP_K} mine {mine_k}/{n} vs stock {stock_k}/{n}"
)
assert mine1 >= 2 * stock1, report
assert mine_k > stock_k, report
async def test_stock_bm25_cannot_answer_a_fifth_of_the_set_at_all(transform, stock, catalog):
"""The thesis, stated as the thing no amount of tuning can change.
The corpus gap is *data*, not parameters: AutoCAD command names appear in
none of the tool descriptions, so those queries score df=0 and stock returns
an empty list. No serializer and no BM25 constant invents vocabulary that is
not in the index — which is why the alias corpus, not the ranking, is what
this transform actually contributes.
"""
empty_stock = [c.query for c in ALL_CASES if not await stock._search(catalog, c.query)]
empty_mine = [c.query for c in ALL_CASES if not transform.rank(catalog, c.query, limit=5).hits]
assert len(empty_stock) >= 15, f"only {len(empty_stock)} empty — has the corpus changed?"
assert empty_mine == [], f"the alias corpus must cover every case; empty: {empty_mine}"
COUNTING_CASES = [case for case in ALL_CASES if case.kind == "counting"]
@pytest.mark.parametrize("case", COUNTING_CASES, ids=str)
async def test_a_counting_question_never_answers_with_a_destructive_tool(transform, catalog, case):
outcome = transform.rank(catalog, case.query, limit=5, risk=case.risk)
assert case.risk == "read"
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
assert case.expect in _names(outcome.hits)[:TOP_K]
async def test_the_risk_ceiling_is_what_removes_the_delete_tool(transform, catalog):
"""Not the ranking — the ranking still surfaces it, and says that it did."""
query = "how many entities are on the GEOMETRY layer"
assert "entity_delete_many" in _names(transform.rank(catalog, query, limit=5).hits)
filtered = transform.rank(catalog, query, limit=5, risk="read")
assert "entity_delete_many" not in _names(filtered.hits)
assert "entity_delete_many" in filtered.notice()
# ── DISCOVERY_MODE ──────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _restore_discovery_mode():
"""Attaching a transform mutates the shared server; always put it back."""
yield
server._apply_discovery_mode("off")
async def test_discovery_is_off_by_default(monkeypatch):
"""v1.5.0 ships the flag, not the switch: turning it on is a wire-level
breaking change for every connected client and needs its own migration."""
monkeypatch.delenv("DISCOVERY_MODE", raising=False)
assert config.Settings().discovery_mode == "off"
async def test_the_env_var_selects_search_mode(monkeypatch):
monkeypatch.setenv("DISCOVERY_MODE", "SEARCH")
assert config.Settings().discovery_mode == "search"
async def test_off_leaves_the_whole_catalog_on_the_wire():
server._apply_discovery_mode("off")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
assert "search_tools" not in names
async def test_search_replaces_the_catalog_with_the_two_synthetic_tools():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert names == {"search_tools", "call_tool"}
async def test_search_mode_is_reversible_and_attaches_only_once():
server._apply_discovery_mode("search")
server._apply_discovery_mode("search")
server._apply_discovery_mode("off")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
async def test_an_unknown_discovery_mode_falls_back_to_off():
assert server._apply_discovery_mode("semantic-vector-magic") == "off"
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
async def test_the_search_tool_answers_on_the_wire():
"""End to end: a real client, a real AutoCAD command, a real payload."""
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "BPOLY"})
assert "boundary_trace" in result.content[0].text
async def test_the_risk_notice_survives_to_the_wire():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool(
"search_tools",
{"query": "how many entities are on the GEOMETRY layer", "risk": "read"},
)
text = result.content[0].text
assert "analysis_entity_stats" in text
assert "Risk filter:" in text
assert "entity_delete_many" in text
async def test_search_cannot_surface_a_tool_the_profile_disabled():
"""Capability-aware discovery has to survive the transform.
The 3D solid tools are registered but disabled unless ENABLE_3D=true, so
searching for one must come back empty rather than advertising a call that
would only be rejected. This works because the search reads the *filtered*
catalog through _get_visible_tools, not the raw registry.
"""
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "BOX", "limit": 10})
assert "solid_box" not in result.content[0].text
async def test_limit_reaches_the_wire():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "layer", "limit": 2})
assert len(result.content[0].text.splitlines()) == 2
async def test_search_mode_does_not_shrink_what_the_server_reports_about_itself():
"""system_about reads the untransformed registry; prove it under the transform."""
server._apply_discovery_mode("search")
assert await server._registered_tool_count() > 100
assert len(await server._tool_groups()) > 5