Files
autocad-mcp-pro/tests/test_capability_refusal.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

159 lines
6.6 KiB
Python

"""T0.7 — a capability refusal has to survive the trip to the client.
The backends know precisely why they cannot do a thing: they raise
``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a
key of ``capabilities().features`` so a client can pre-check the same boundary
through ``system_capabilities``. That type does not reach anybody.
``FastMCP.call_tool`` rewraps any non-``FastMCPError`` as
``ToolError(f"Error calling tool {name!r}: {e}") from e``; the ``from e`` keeps
the cause alive **in process** — which is the only reason ``cad_batch`` can
classify it — but ``__cause__`` does not serialise. Over the wire the client got
an English sentence and nothing else, so "this backend cannot do that" was
indistinguishable from "your arguments were wrong" except by substring matching.
Subclassing ``FastMCPError`` does not fix this; it changes nothing the client can
observe. The one channel that carries a machine-readable payload *and* the error
flag is ``ToolResult(structured_content=..., is_error=True)``, so a single
middleware — the last place the exception type is still knowable — converts the
refusal, and no tool function changes at all.
The other half is ``cad_batch``: a tool may decline *by value* rather than by
raising, and counting that as a succeeded step is the same class of lie as
writing DXF bytes into a ``.dwg`` or reporting a rollback the backend refused.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from backends.base import UnsupportedCapabilityError
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document.
The env var is what ``server._make_backend`` reads; setting
``config.settings.backend`` would leave a Windows box with AutoCAD open
talking to the operator's live drawing.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
# ── the wire contract ───────────────────────────────────────────────────────
async def test_refusal_reaches_the_client_as_a_machine_readable_payload(client, tmp_path):
target = tmp_path / "part.dwg"
result = await client.call_tool("drawing_save_as", {"path": str(target)}, raise_on_error=False)
assert result.is_error is True
payload = result.structured_content
assert isinstance(payload, dict), "a refusal must not arrive as prose only"
assert payload["ok"] is False
assert payload["kind"] == "unsupported"
assert payload["capability"] == "dwg"
assert payload["tool"] == "drawing_save_as"
assert payload["backend"] == "ezdxf"
# The prose channel still works for clients that only read text, and it is
# the FULL sentence — a refusal that names no way forward is not help.
assert payload["error"] in result.content[0].text
assert ".dxf" in payload["error"]
assert not target.exists(), "a refusal must not leave a mislabelled file behind"
async def test_the_capability_is_a_key_a_client_can_pre_check(client, tmp_path):
"""The whole point of the key: ask before calling, get the same answer."""
caps = (await client.call_tool("system_capabilities", {})).structured_content or {}
features = caps["features"]
result = await client.call_tool(
"drawing_save_as", {"path": str(tmp_path / "x.dwg")}, raise_on_error=False
)
capability = result.structured_content["capability"]
assert features[capability]["supported"] is False
async def test_ordinary_errors_are_left_exactly_as_they_were(client):
"""The middleware must not turn every failure into a capability refusal."""
result = await client.call_tool("entity_get", {"handle": "NOSUCH"}, raise_on_error=False)
assert result.is_error is True
payload = result.structured_content
assert not (isinstance(payload, dict) and payload.get("kind") == "unsupported")
async def test_a_successful_call_is_untouched(client):
result = await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 5, "y2": 0})
assert result.is_error is False
assert result.structured_content["handle"]
# ── one refusal mechanism, not two ──────────────────────────────────────────
async def test_solid_tools_raise_the_typed_refusal_instead_of_returning_a_dict():
"""Returning ``{"ok": False}`` puts a refusal outside the middleware's reach.
The payload the client sees is unchanged; what flips is ``is_error``, from a
quiet False to an honest True.
"""
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.solid_box(0, 0, 0, 10, 10, 10)
assert excinfo.value.capability == "solid_3d"
assert excinfo.value.to_dict() == {
"ok": False,
"error": str(excinfo.value),
"capability": "solid_3d",
}
await backend.disconnect()
async def test_the_refusal_type_lives_on_the_shared_base():
"""Both backends must be able to raise the same class, not two look-alikes."""
from backends.ezdxf_backend import UnsupportedCapabilityError as from_ezdxf
assert from_ezdxf is UnsupportedCapabilityError
# ── cad_batch: a declined step is not a succeeded step ──────────────────────
async def _batch(client, steps, **kwargs):
payload = {"steps": steps, **kwargs}
return (await client.call_tool("cad_batch", payload)).structured_content
async def test_batch_does_not_count_a_step_that_declined_by_value_as_succeeded(client):
"""``drawing_redo`` with nothing to redo returns ``{"ok": False}`` and did nothing."""
result = await _batch(
client,
[
{"tool": "entity_create_line", "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 0}},
{"tool": "drawing_redo", "args": {}},
],
on_error="continue",
)
assert result["succeeded"] == 1, "the redo did nothing; it cannot be a success"
assert result["failed"] == 1
redo_row = result["results"][1]
assert redo_row["status"] == "error"
assert redo_row["error"]["kind"] in {"refused", "unsupported"}