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>
159 lines
6.6 KiB
Python
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"}
|