"""server.py must never report something it does not have. Three families of that rule live here. **The registry must be read untransformed.** ``_registered_tools()`` backs three things that all need the *real* inventory: * ``system_status`` / ``system_about`` tool counts, * ``_tool_groups()`` (the system_about per-group breakdown), * ``_apply_tool_profile()`` (which tools get ``mcp.disable()``d). FastMCP applies :class:`~fastmcp.server.transforms.Transform` chains at two levels, and only one of them reaches ``mcp._list_tools()``. Shipped search mode (``_apply_discovery_mode("search")``) adds a *server-level* transform: it swaps the catalog for ``search_tools``/``call_tool`` at the wire, while ``mcp._list_tools()`` upstream of it still lists everything — so search mode on its own moves none of the three. A *provider-level* transform is the one that rewrites ``mcp._list_tools()`` itself, and that is what ``_TruncatingTransform`` below installs. So the property pinned here is the stronger, layer-agnostic one: registry-truth is read below every transform, and no catalog rewrite — today's, or a provider-level one someone mounts tomorrow — can make the server report a surface it does not have or silently turn TOOL_PROFILE into a no-op. **A tool must not report a document it did not open** (T0.2) — the read-side twin of the DWG write refusal, and the reason a ``.dwg`` path is refused on a backend whose ``dwg`` capability is false. **Discovery metadata must not cost anything.** ``@cad_tool`` writes summary / cost / alias metadata into the MCP ``meta`` channel; ``tags`` are load-bearing (``_tool_groups()`` buckets every tool by them for system_about), so the grouping is pinned to the tags literally written in server.py's source. The channel is also gated for *coverage*: every registered tool must carry a card, and no card may contradict the tool's own annotations. """ from __future__ import annotations import ast import shutil from pathlib import Path import pytest import pytest_asyncio from fastmcp import Client from fastmcp.exceptions import ToolError from fastmcp.server.transforms import Transform import server from backends.base import CapabilityMap, FeatureCapability from discovery.aliases import aliases_for from discovery.serialize import MAX_SUMMARY_CHARS pytestmark = pytest.mark.asyncio class _TruncatingTransform(Transform): """Stand-in for a catalog-replacing transform: collapses the list to two tools. Mirrors the shape of the problem rather than any shipped transform — a transform that replaces the tool list wholesale instead of decorating it, mounted (see the fixture) at the level that reaches ``mcp._list_tools()``. """ def __init__(self, keep: int = 2): self.keep = keep async def list_tools(self, tools): return list(tools)[: self.keep] @pytest.fixture def truncating_transform(): """Install the transform where FastMCP actually applies it, then remove it. ``mcp._list_tools()`` aggregates ``provider.list_tools()``, and ``Provider.list_tools`` is what runs the provider's transforms — so a provider-level transform is exactly what rewrites that call's output. """ transform = _TruncatingTransform() server.mcp.local_provider.add_transform(transform) try: yield transform finally: server.mcp.local_provider._transforms.remove(transform) @pytest_asyncio.fixture(autouse=True) async def _restore_full_profile(): """These tests call _apply_tool_profile, which mutates global enablement.""" yield await server._apply_tool_profile("full") async def test_transform_really_rewrites_the_public_listing(truncating_transform): """Guard the guard: prove the fixture actually breaks the public accessor. Without this the other tests would pass even if the transform silently did nothing. """ assert len(await server.mcp._list_tools()) == 2 async def test_registered_tools_unaffected_by_a_transform(truncating_transform): names = {t.name for t in await server._registered_tools()} assert len(names) > 100 assert "system_about" in names assert "gear_draw_spur_front_view" in names async def test_registered_tool_count_unaffected_by_a_transform(truncating_transform): count = await server._registered_tool_count() assert count is not None assert count > 100 async def test_tool_groups_unaffected_by_a_transform(truncating_transform): groups = await server._tool_groups() # The transform collapses the catalog to two drawing tools, which would # leave a single "drawing" bucket. assert len(groups) > 5 assert groups["transactions"] == [ "transaction_begin", "transaction_commit", "transaction_rollback", ] assert "gear_draw_spur_front_view" in groups["engineering"] async def test_tool_profile_still_disables_under_a_transform(truncating_transform): """TOOL_PROFILE must keep working when a catalog-rewriting transform is mounted. Reading the transformed catalog would make ``registered`` tiny, so ``registered - enabled`` collapses and ``mcp.disable()`` never fires. """ info = await server._apply_tool_profile("lean") assert info["registered_count"] > 100 assert info["enabled_count"] == len(server.LEAN_TOOL_NAMES) assert info["disabled_count"] > 50 # The escape hatches really are hidden, not merely counted as hidden. assert "system_run_command" in info["disabled_tools"] async def test_registered_tools_returns_empty_when_registry_unknown(monkeypatch): """Contract: never raises, and reports [] / None rather than a bogus count.""" class _Broken: @property def _components(self): raise RuntimeError("registry layout changed") async def _list_tools(self): raise RuntimeError("registry layout changed") async def _boom(): raise RuntimeError("public accessor gone") monkeypatch.setattr(server.mcp, "_local_provider", _Broken(), raising=False) monkeypatch.setattr(server.mcp, "_list_tools", _boom, raising=False) assert await server._registered_tools() == [] assert await server._registered_tool_count() is None # ── T0.2 — drawing_open must not report a document it did not open ────────── class _Ctx: """Minimal async context for calling a tool function directly.""" def __init__(self, backend): self.lifespan_context = {"backend": backend} async def info(self, message): pass async def warning(self, message): pass async def report_progress(self, progress, total): pass async def _saved_dxf(backend, tmp_path): dxf = tmp_path / "part.dxf" await backend.drawing_save_as(str(dxf), "dxf") return dxf async def test_drawing_open_opens_a_dxf(backend, tmp_path): """Baseline: the honest path keeps working.""" dxf = await _saved_dxf(backend, tmp_path) result = await server.drawing_open(str(dxf), ctx=_Ctx(backend)) assert result["ok"] is True assert result["name"] == "part.dxf" async def test_drawing_open_refuses_a_dwg_the_backend_cannot_read(backend, tmp_path): """DXF bytes wearing a .dwg name must not be reported as an opened DWG. ezdxf sniffs content, not extensions, so it happily parses a mislabelled file (exactly what pre-1.5.0 drawing_save produced) and the tool then answers ``{"ok": True, "name": "part.dwg"}`` — a document that does not exist in that format. """ dxf = await _saved_dxf(backend, tmp_path) disguised = tmp_path / "part.dwg" shutil.copy(dxf, disguised) with pytest.raises(ToolError) as excinfo: await server.drawing_open(str(disguised), ctx=_Ctx(backend)) message = str(excinfo.value).lower() assert "dwg" in message # Blames the backend's missing capability and names the escape hatch, # rather than ezdxf's bare "is not a DXF file". assert "ezdxf" in message assert "com" in message async def test_drawing_open_refuses_a_real_dwg_without_leaking_a_parser_error(backend, tmp_path): real_dwg = tmp_path / "real.dwg" real_dwg.write_bytes(b"AC1032" + b"\x00" * 256) with pytest.raises(ToolError) as excinfo: await server.drawing_open(str(real_dwg), ctx=_Ctx(backend)) assert "is not a DXF file" not in str(excinfo.value) async def test_drawing_open_refusal_leaves_the_current_document_untouched(backend, tmp_path): dxf = await _saved_dxf(backend, tmp_path) ctx = _Ctx(backend) await server.drawing_open(str(dxf), ctx=ctx) disguised = tmp_path / "other.dwg" shutil.copy(dxf, disguised) with pytest.raises(ToolError): await server.drawing_open(str(disguised), ctx=ctx) info = await backend.drawing_info() assert info.name == "part.dxf" class _DwgCapableBackend: """Stand-in for the live COM backend, which reads DWG natively.""" name = "com" def __init__(self): self.opened: list[str] = [] def capabilities(self) -> CapabilityMap: return CapabilityMap( backend="com", features={"dwg": FeatureCapability(True, "native")}, ) async def drawing_open(self, path: str) -> dict: self.opened.append(path) return {"ok": True, "name": "part.dwg", "path": path} async def test_drawing_open_allows_dwg_when_the_backend_supports_it(tmp_path): """The refusal is capability-driven, not an ezdxf-shaped hardcode.""" fake = _DwgCapableBackend() result = await server.drawing_open(str(tmp_path / "part.dwg"), ctx=_Ctx(fake)) assert result["ok"] is True assert len(fake.opened) == 1 async def test_drawing_open_does_not_advertise_unconditional_dwg(): """The lie was also in the docs the model reads before calling.""" tool = next(t for t in await server._registered_tools() if t.name == "drawing_open") text = (tool.description or "").lower() for param in (tool.parameters or {}).get("properties", {}).values(): text += " " + str(param.get("description", "")).lower() assert "dwg" in text, "DWG support should still be discoverable" assert "com" in text, "but qualified by the backend that actually has it" # ── @cad_tool — the discovery metadata channel ────────────────────────────── CAD_TOOL_SECTION = ("transaction_begin", "transaction_commit", "transaction_rollback") def _component(name: str): for tool in server._local_tool_components(): if tool.name == name: return tool raise AssertionError(f"{name} is not registered") @pytest.mark.parametrize("name", CAD_TOOL_SECTION) async def test_cad_tool_publishes_discovery_meta(name): cad = (_component(name).meta or {})["cad"] assert cad["summary"] and "\n" not in cad["summary"] assert cad["cost"] in server.CAD_TOOL_COSTS @pytest.mark.parametrize("name", CAD_TOOL_SECTION) async def test_cad_tool_pulls_aliases_from_the_discovery_corpus(name): """The corpus is the single source of truth; server.py must not restate it.""" record = aliases_for(name) cad = (_component(name).meta or {})["cad"] assert cad["acad"] == list(record.acad) assert cad["synonyms"] == list(record.synonyms) assert cad["synonyms"], "the transaction tools do carry synonyms" async def test_cad_tool_meta_reaches_the_wire(monkeypatch): monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") async with Client(server.mcp) as client: tools = {tool.name: tool for tool in await client.list_tools()} cad = tools["transaction_rollback"].meta["cad"] assert cad["cost"] == "destructive" assert "rollback" in cad["synonyms"] async def test_cad_tool_rejects_an_unknown_cost(): with pytest.raises(ValueError, match="cost"): server.cad_tool(summary="x", cost="somewhat-dangerous") async def test_cad_tool_requires_the_registration_below_it(): """A misordered decorator must fail loudly at import, not silently no-op.""" decorator = server.cad_tool(summary="x", cost="read") async def never_registered(ctx=None) -> dict: return {} with pytest.raises(RuntimeError, match="not registered"): decorator(never_registered) # ── @cad_tool coverage: the whole surface, not a sample ───────────────────── # # The twin of the alias-corpus gate in tests/test_discovery_aliases.py. That one # proves every registered tool has an alias record; this one proves every # registered tool has a discovery card, and that the card agrees with the # annotations the same tool already publishes. Both derive their subject from # the live registry, so a tool added without the metadata fails here instead of # shipping with a summary silently scraped off its docstring. def _cad(tool) -> dict | None: cad = (tool.meta or {}).get("cad") return cad if isinstance(cad, dict) else None async def test_every_registered_tool_carries_cad_meta(): """Registering a tool without @cad_tool must fail this gate.""" missing = sorted(t.name for t in await server._registered_tools() if _cad(t) is None) assert not missing, f"tools with no @cad_tool metadata: {missing}" async def test_every_cad_summary_is_a_single_non_empty_line(): """A card is one line; a summary with a newline in it would break the format.""" bad: list[str] = [] for tool in await server._registered_tools(): summary = (_cad(tool) or {}).get("summary") if not isinstance(summary, str) or not summary.strip() or "\n" in summary: bad.append(f"{tool.name}:{summary!r}") assert not bad, f"summaries that are not one usable line: {bad}" async def test_no_authored_summary_is_long_enough_to_be_truncated(): """A hand-written summary that overruns the budget would ship rendered as "...". The serializer's truncation exists for the docstring fallback, not as a licence to write a paragraph into @cad_tool. """ overlong = sorted( (t.name, len((_cad(t) or {}).get("summary") or "")) for t in await server._registered_tools() if len((_cad(t) or {}).get("summary") or "") > MAX_SUMMARY_CHARS ) assert not overlong, f"summaries the serializer would truncate: {overlong}" async def test_every_cad_cost_is_a_known_value(): bad = sorted( f"{t.name}:{(_cad(t) or {}).get('cost')!r}" for t in await server._registered_tools() if (_cad(t) or {}).get("cost") not in server.CAD_TOOL_COSTS ) assert not bad, f"costs outside CAD_TOOL_COSTS: {bad}" async def test_a_read_only_tool_is_costed_read(): """``readOnlyHint`` is what a client gates on; the cost must not contradict it.""" bad: list[str] = [] for tool in await server._registered_tools(): annotations = tool.annotations if annotations is not None and annotations.readOnlyHint: cost = (_cad(tool) or {}).get("cost") if cost != "read": bad.append(f"{tool.name}:{cost!r}") assert not bad, f"readOnlyHint tools not costed 'read': {bad}" async def test_a_destructive_tool_is_never_costed_read_or_safe(): """The other half of the agreement: an erase must not look harmless.""" bad: list[str] = [] for tool in await server._registered_tools(): annotations = tool.annotations if annotations is not None and annotations.destructiveHint: cost = (_cad(tool) or {}).get("cost") if cost in ("read", "safe"): bad.append(f"{tool.name}:{cost!r}") assert not bad, f"destructiveHint tools costed as harmless: {bad}" async def test_only_the_raw_passthroughs_are_costed_escape(): """'escape' means 'this hands your string to AutoCAD', and nothing else.""" escapes = sorted( t.name for t in await server._registered_tools() if (_cad(t) or {}).get("cost") == "escape" ) assert escapes == ["system_run_command", "system_run_lisp"] async def test_no_tool_restates_its_aliases_in_server_py(): """@cad_tool copies the corpus; server.py must never hand-write acad/synonyms.""" bad: list[str] = [] for tool in await server._registered_tools(): cad = _cad(tool) or {} record = aliases_for(tool.name) expected_acad = list(record.acad) if record else [] expected_synonyms = list(record.synonyms) if record else [] if cad.get("acad") != expected_acad or cad.get("synonyms") != expected_synonyms: bad.append(tool.name) assert not bad, f"cad metadata out of step with discovery.aliases: {bad}" # ── tags are load-bearing: _tool_groups() feeds system_about ──────────────── def _tags_declared_in_source() -> dict[str, set[str]]: """Every tool's tags as literally written in the @mcp.tool(...) call. An independent derivation of the input to _tool_groups(): if @cad_tool (or anything else) rewrote a registered tool's tags, the live grouping would stop matching what server.py declares. """ tree = ast.parse(Path(server.__file__).read_text(encoding="utf-8")) declared: dict[str, set[str]] = {} for node in ast.walk(tree): if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)): continue for decorator in node.decorator_list: if not isinstance(decorator, ast.Call): continue func = decorator.func if not ( isinstance(func, ast.Attribute) and func.attr == "tool" and isinstance(func.value, ast.Name) and func.value.id == "mcp" ): continue name = node.name tags: set[str] = set() for keyword in decorator.keywords: if keyword.arg == "name" and isinstance(keyword.value, ast.Constant): name = keyword.value.value if keyword.arg == "tags" and isinstance(keyword.value, ast.Set): tags = { element.value for element in keyword.value.elts if isinstance(element, ast.Constant) } declared[name] = tags return declared async def test_registered_tags_are_exactly_the_ones_written_in_source(): declared = _tags_declared_in_source() live = {tool.name: set(tool.tags) for tool in await server._registered_tools()} assert live == declared async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): """_tool_groups() must be reproducible from server.py's own `tags={...}`.""" expected: dict[str, list[str]] = {} for name, tags in _tags_declared_in_source().items(): label = "other" for tag in server._GROUP_TAG_PRIORITY: if tag in tags: label = server._GROUP_TAG_LABELS[tag] break expected.setdefault(label, []).append(name) for names in expected.values(): names.sort() assert await server._tool_groups() == dict(sorted(expected.items())) async def test_tool_group_sizes_are_unchanged(): """Frozen snapshot of the surface (241 tools, 26 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and `layouts` moved 4 -> 12 with M6's layout/viewport lifecycle. That group gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and `_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts rather than splitting a ninth tool off into entity_modification. `blocks` moved 8 -> 9 when v1.6's `block_define` (typed primitives + ATTDEFs on both engines) joined SECTION 7. `entity_query` moved 8 -> 9 and `entity_modification` 15 -> 16 when v1.6's `entity_get_xdata` / `entity_set_xdata` joined SECTION 5 (the getter is tagged `query`, the setter `modify`, so the pair splits across two groups). `pid` appeared (0 -> 2) when v1.6's SECTION 17 opened with `pid_symbol_list` / `pid_symbol_insert`, then 2 -> 3 when `pid_line_draw` joined, 3 -> 4 when the reader `pid_graph` joined and 4 -> 7 when the three graph deliverables (`pid_instrument_index` / `pid_line_list` / `pid_equipment_list`) joined, 7 -> 8 when `pid_from_spec` (a whole sheet from one declarative spec, in one transaction) joined and 8 -> 9 when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool) joined; the `pid` tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under their secondary `query` / `create` tags. Three groups appeared with v1.6's track E — `styles` (10, SECTION 18), `page_setup` (6, SECTION 19) and `environment` (22, SECTION 20) — each tagged with its section name, ranked directly after `pid` in `_GROUP_TAG_PRIORITY` so a style tool that also carries `dimension` or an environment tool that also carries `layer` files under its section; the `environment` group is exactly `PACK_TOOL_NAMES["settings"]`. (Until the merge task tagged them, the four groups had filed those 28 tools under `layouts` / `templates` / `drawing` / `layers` / `view` / `system` by their secondary tags; those counts returned to their pre-track-E values when the section tags landed.) `engineering` moved 10 -> 12 when v1.6's SECTION 24 opened with `sheet_frame` and `titleblock_apply` (the ISO 5457 frame and the ISO 7200 title block for every size A4-A0). Both are tagged `engineering` and `sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under `engineering` beside `titleblock_apply_iso_a3` rather than opening a group of their own. It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and `balloon_add` joined the same section; `bom_extract` also carries `query`, but `engineering` outranks it in `_GROUP_TAG_PRIORITY`. The section's last five tools file elsewhere on purpose: they are whole-drawing and entity operations, not sheet furniture, and `_GROUP_TAG_PRIORITY` files them where a caller would look. `xref_attach` and `xref_manage` carry `drawing` *and* `block`, and `block` outranks `drawing`, so they file under `blocks` (9 -> 11) beside `block_insert` -- which is where an xref belongs: it is a block definition that lives in another file. `image_attach` carries `create` (entity_creation 18 -> 19), `data_extract` carries `engineering` (16 -> 17) and `drawing_export_dwg` carries `drawing` alone (11 -> 12). Every other number here has been unchanged since the snapshot was taken. `mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with the part model and its view engine (`mech_part_draw` / `mech_view_add` / `mech_dimension_part` / `mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the `mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under their secondary `create` / `dimension` / `query` tags. SECTION 22 added the standard-parts catalogue (`std_part_list` / `std_part_insert` / `std_feature_draw`) to the same group, filed under `mech` rather than under their secondary `query` / `create` tags. SECTION 23 added the mechanical annotation symbols (`surface_texture` / `weld_symbol` / `centre_marks` / `section_line` / `hatch_material`). They are tagged `engineering` and `mech`; with `mech` ranked ahead of `engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all, exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched. Two groups appeared with v1.6's tracks B and G — `mechanical` (14, SECTIONS 21-23: the part drawer, the standard-parts catalogue and the ISO annotation symbols) and `sheet` (11, SECTION 24: frames, title blocks, revisions, the parts list, balloons, xrefs, images and DWG). Both tags are ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so a part tool that also carries `create` and an annotation tool that also carries `dimension` file under their own section. `mechanical` is exactly `PACK_TOOL_NAMES["mech"]` (the `mech` label above was renamed when the integration task gave the section its full name); `sheet` is core-pack, because a frame, a title block, a parts list and an xref are universal drafting. The integration also tagged SECTION 24's last four tools (`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`) with `sheet`, so pack, group and SECTION are one list again: `engineering` returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value. `architecture` appeared with v1.6's track F (12, SECTION 25: the wall network, openings, stairs, rooms and their reader, schedules, grid, symbols, the catalogue, the exterior chains and the whole plan from one spec). The `arch` tag is ranked directly after `mech` in `_GROUP_TAG_PRIORITY`, so a SECTION 25 tool that also carries `create`, `query` or `dimension` files under its own section; `architecture` is exactly `PACK_TOOL_NAMES["arch"]`, and every group the track-F branches had grown while the tag was unranked on them returned to its pre-track-F value. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { "analysis": 12, "architecture": 12, "batch": 3, "blocks": 9, "corner_ops": 4, "dimensions": 5, "drawing": 11, "engineering": 10, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, "environment": 22, "layers": 14, "layouts": 12, "mechanical": 14, "page_setup": 6, "pid": 9, "premium": 12, "sheet": 11, "solids": 5, "styles": 10, "system": 7, "templates": 2, "transactions": 3, "validation": 1, "view": 4, } assert sum(sizes.values()) == 241