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>
936 lines
36 KiB
Python
936 lines
36 KiB
Python
"""``cad_batch`` — the ordered multi-tool executor.
|
|
|
|
One round trip instead of N, with a later step able to reference an earlier
|
|
one's handle so the model never has to echo it back.
|
|
|
|
Where the win actually comes from, measured on a 17-step mounting-plate task
|
|
(ISO layers, 4 outline lines, 2 fillets, 2 snapped midpoints, 5 circles, 2
|
|
dimensions) against the real in-memory server:
|
|
|
|
* **Payload alone: 1.28x** — and *0.74x* with ``verbose=True``. The batched
|
|
request is the LARGER of the two (1,579 vs 1,357 chars: every step carries a
|
|
``{tool, args, bind}`` wrapper the sequential arm gets free from the protocol
|
|
envelope). Batching does not win by making payloads smaller.
|
|
* **Agent loop: 9.07x** — 340,447 -> 37,548 input tokens. This is the real
|
|
mechanism, and it is *turn elimination*: 18 billed turns become 2, and each
|
|
turn re-sends the whole conversation plus the 18,306-token tool prefix.
|
|
|
|
So the honest framing is that batching multiplies with the discovery lane
|
|
rather than beating it — search alone is 18.29x on the same task, and the two
|
|
together are 106.99x. Nothing here should claim batching is the biggest lever.
|
|
|
|
What these tests actually guard is the *dangerous* half of the idea, because
|
|
the happy path is the easy half:
|
|
|
|
* **Privilege.** A single ``cad_batch`` grant must not reach the raw
|
|
command/LISP escape hatches. The deny set is derived from the live
|
|
``@cad_tool(cost="escape")`` cards rather than hand-listed, so a future
|
|
escape hatch is denied the day it is registered, and a denied step refuses
|
|
the *whole* batch before anything executes — otherwise ``on_error="continue"``
|
|
would turn the denylist into a no-op.
|
|
* **Error typing.** The re-entrant call path reformats every non-FastMCP
|
|
exception into ``ToolError(f"Error calling tool {name!r}: {e}")``. The
|
|
classifier must recover the real cause from the ``__cause__`` chain and
|
|
never from the message text.
|
|
* **Atomicity honesty.** ezdxf rolls back by restoring a full DXF snapshot;
|
|
COM ends an undo mark and fires ``_UNDO B`` at AutoCAD without ever
|
|
confirming it landed. The payload must say which one it got, and must not
|
|
claim a rollback it did not verify.
|
|
* **dry_run.** Validates against each tool's real JSON Schema without
|
|
executing a single step.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastmcp import Client
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
import server
|
|
|
|
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
|
|
|
|
|
|
async def _entity_count(client) -> int:
|
|
info = (await client.call_tool("drawing_info", {})).structured_content or {}
|
|
return int(info["entity_count"])
|
|
|
|
|
|
async def _batch(client, steps, **kwargs):
|
|
payload = {"steps": steps, **kwargs}
|
|
return (await client.call_tool("cad_batch", payload)).structured_content
|
|
|
|
|
|
def dumps(obj) -> str:
|
|
"""Compact JSON — how the payload is actually charged for."""
|
|
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
|
|
LINE = "entity_create_line"
|
|
|
|
|
|
def _line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, **extra):
|
|
return {"tool": LINE, "args": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, **extra}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registration and metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_cad_batch_is_registered_with_a_discovery_card():
|
|
tools = {t.name: t for t in await server._registered_tools()}
|
|
assert "cad_batch" in tools, "cad_batch must be a registered tool"
|
|
card = (tools["cad_batch"].meta or {})["cad"]
|
|
assert card["cost"] == "destructive", "it can run any mutating tool, so it costs the worst case"
|
|
assert card["summary"] and "\n" not in card["summary"]
|
|
assert card["synonyms"], "the alias corpus must cover it"
|
|
|
|
|
|
async def test_cad_batch_is_filed_under_the_batch_group():
|
|
groups = await server._tool_groups()
|
|
assert "cad_batch" in groups["batch"]
|
|
assert {"entity_batch_create", "entity_batch_modify"} <= set(groups["batch"]), (
|
|
"the pre-existing batch tools stay: cad_batch supersedes them in scope, not in the registry"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The happy path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_a_three_step_batch_runs_in_one_round_trip(client):
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
_line(),
|
|
_line(y1=10.0, y2=10.0),
|
|
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
|
|
],
|
|
)
|
|
assert result["ok"] is True
|
|
assert (result["steps"], result["executed"], result["succeeded"], result["failed"]) == (
|
|
3,
|
|
3,
|
|
3,
|
|
0,
|
|
)
|
|
assert [row["status"] for row in result["results"]] == ["ok", "ok", "ok"]
|
|
assert await _entity_count(client) == 3
|
|
|
|
|
|
async def test_a_successful_step_reports_only_its_handle_by_default(client):
|
|
result = await _batch(client, [_line()])
|
|
payload = result["results"][0]["result"]
|
|
assert set(payload) == {"handle"}, (
|
|
"an EntityInfo is fully recoverable via entity_get(handle); re-emitting it "
|
|
"in every batch row is exactly the cost cad_batch exists to remove"
|
|
)
|
|
assert payload["handle"]
|
|
|
|
|
|
async def test_verbose_restores_the_full_step_result(client):
|
|
result = await _batch(client, [_line()], verbose=True)
|
|
payload = result["results"][0]["result"]
|
|
assert payload["handle"] and payload["type"] == "LINE"
|
|
assert "properties" in payload
|
|
|
|
|
|
async def test_a_result_without_a_handle_is_never_trimmed(client):
|
|
"""Compaction only drops what ``entity_get(handle)`` can give back."""
|
|
result = await _batch(client, [{"tool": "drawing_info", "args": {}}])
|
|
payload = result["results"][0]["result"]
|
|
assert payload["entity_count"] == 0
|
|
assert payload["name"]
|
|
|
|
|
|
async def test_steps_run_in_the_order_given(client):
|
|
"""Reads interleaved with writes see the document as of their own position."""
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
_line(),
|
|
{"tool": "drawing_info", "args": {}},
|
|
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
|
|
{"tool": "drawing_info", "args": {}},
|
|
],
|
|
)
|
|
assert result["ok"] is True
|
|
assert result["results"][1]["result"]["entity_count"] == 1
|
|
assert result["results"][3]["result"]["entity_count"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Binding — the half that removes the handle echo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_a_bound_handle_feeds_a_later_step(client):
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
_line(bind="edge"),
|
|
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 5.0, "dy": 5.0}},
|
|
],
|
|
)
|
|
assert result["ok"] is True
|
|
assert result["bindings"] == ["edge"]
|
|
handle = result["results"][0]["result"]["handle"]
|
|
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
|
|
assert got["properties"]["start"][0] == pytest.approx(5.0)
|
|
|
|
|
|
async def test_a_dotted_reference_reaches_into_a_step_result(client):
|
|
"""``point_from_snap`` returns {x, y}; without ``$p.x`` that is a round trip."""
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
_line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, bind="edge"),
|
|
{
|
|
"tool": "point_from_snap",
|
|
"args": {"handle": "$edge", "snap": "mid"},
|
|
"bind": "mid",
|
|
},
|
|
{
|
|
"tool": "entity_create_circle",
|
|
"args": {"cx": "$mid.x", "cy": "$mid.y", "radius": 1.0},
|
|
},
|
|
],
|
|
)
|
|
assert result["ok"] is True, result["results"]
|
|
handle = result["results"][2]["result"]["handle"]
|
|
circle = (await client.call_tool("entity_get", {"handle": handle})).structured_content
|
|
assert circle["properties"]["center"][0] == pytest.approx(5.0)
|
|
|
|
|
|
async def test_a_list_index_is_a_valid_reference_segment():
|
|
resolved = server._resolve_batch_ref("$box.handles.1", {"box": {"handles": ["A", "B", "C"]}})
|
|
assert resolved == "B"
|
|
|
|
|
|
async def test_a_bare_reference_resolves_to_the_handle():
|
|
resolved = server._resolve_batch_ref("$edge", {"edge": {"handle": "2F1", "type": "LINE"}})
|
|
assert resolved == "2F1"
|
|
|
|
|
|
async def test_a_bare_reference_to_a_handleless_result_is_a_typed_error():
|
|
with pytest.raises(server.BatchReferenceError) as excinfo:
|
|
server._resolve_batch_ref("$info", {"info": {"entity_count": 3}})
|
|
assert "handle" in str(excinfo.value)
|
|
|
|
|
|
async def test_a_doubled_dollar_escapes_a_literal_dollar(client):
|
|
"""Text really does start with ``$`` sometimes; ``$$`` is the way out."""
|
|
result = await _batch(
|
|
client,
|
|
[{"tool": "entity_create_text", "args": {"text": "$$4.50", "x": 0, "y": 0, "height": 2.5}}],
|
|
)
|
|
handle = result["results"][0]["result"]["handle"]
|
|
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
|
|
assert got["properties"]["text"] == "$4.50"
|
|
|
|
|
|
async def test_only_a_whole_string_is_a_reference():
|
|
"""Partial interpolation is deliberately unsupported — it is ambiguous."""
|
|
assert server._substitute_batch_refs("cost is $edge each", {"edge": {"handle": "1"}}) == (
|
|
"cost is $edge each"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dry_run — the real JSON Schema, nothing executed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_dry_run_validates_without_executing(client):
|
|
result = await _batch(client, [_line(), _line(y1=5.0, y2=5.0)], dry_run=True)
|
|
assert result["dry_run"] is True
|
|
assert result["ok"] is True
|
|
assert result["executed"] == 0
|
|
assert [row["status"] for row in result["results"]] == ["valid", "valid"]
|
|
assert await _entity_count(client) == 0, "a dry run must not draw anything"
|
|
|
|
|
|
async def test_dry_run_rejects_a_missing_required_argument(client):
|
|
result = await _batch(client, [{"tool": LINE, "args": {"x1": 0, "y1": 0}}], dry_run=True)
|
|
assert result["ok"] is False
|
|
row = result["results"][0]
|
|
assert row["status"] == "invalid"
|
|
assert row["error"]["kind"] == "invalid_args"
|
|
assert "x2" in row["error"]["message"]
|
|
|
|
|
|
async def test_dry_run_rejects_an_argument_of_the_wrong_type(client):
|
|
result = await _batch(
|
|
client,
|
|
[{"tool": LINE, "args": {"x1": "over there", "y1": 0, "x2": 1, "y2": 1}}],
|
|
dry_run=True,
|
|
)
|
|
assert result["results"][0]["error"]["kind"] == "invalid_args"
|
|
|
|
|
|
async def test_dry_run_rejects_an_unknown_argument(client):
|
|
result = await _batch(
|
|
client,
|
|
[{"tool": LINE, "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 1, "colour": 3}}],
|
|
dry_run=True,
|
|
)
|
|
assert result["results"][0]["error"]["kind"] == "invalid_args"
|
|
|
|
|
|
async def test_dry_run_names_an_unknown_tool(client):
|
|
result = await _batch(client, [{"tool": "entity_create_dragon", "args": {}}], dry_run=True)
|
|
row = result["results"][0]
|
|
assert row["error"]["kind"] == "unknown_tool"
|
|
assert "entity_create_dragon" in row["error"]["message"]
|
|
|
|
|
|
async def test_dry_run_catches_a_forward_reference(client):
|
|
"""A step may only reference something an *earlier* step bound."""
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 1.0, "dy": 0.0}},
|
|
_line(bind="edge"),
|
|
],
|
|
dry_run=True,
|
|
)
|
|
row = result["results"][0]
|
|
assert row["error"]["kind"] == "unresolved_ref"
|
|
assert "edge" in row["error"]["message"]
|
|
|
|
|
|
async def test_dry_run_catches_a_reference_nobody_binds(client):
|
|
result = await _batch(
|
|
client,
|
|
[{"tool": "entity_move", "args": {"handle": "$typo", "dx": 1.0, "dy": 0.0}}],
|
|
dry_run=True,
|
|
)
|
|
assert result["results"][0]["error"]["kind"] == "unresolved_ref"
|
|
|
|
|
|
async def test_a_reference_stands_in_for_any_type_during_validation(client):
|
|
"""``$mid.x`` is a float at run time and a string at validation time.
|
|
|
|
The validator must not reject it for that — but it must still check
|
|
everything around it, which the following test proves.
|
|
"""
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
_line(bind="edge"),
|
|
{"tool": "point_from_snap", "args": {"handle": "$edge", "snap": "mid"}, "bind": "m"},
|
|
{"tool": "entity_create_circle", "args": {"cx": "$m.x", "cy": "$m.y", "radius": 1.0}},
|
|
],
|
|
dry_run=True,
|
|
)
|
|
assert result["ok"] is True, result["results"]
|
|
|
|
|
|
async def test_a_reference_does_not_excuse_the_rest_of_the_step(client):
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
_line(bind="edge"),
|
|
{"tool": "entity_move", "args": {"handle": "$edge", "dx": "sideways", "dy": 0.0}},
|
|
],
|
|
dry_run=True,
|
|
)
|
|
assert result["results"][1]["error"]["kind"] == "invalid_args"
|
|
|
|
|
|
async def test_an_invalid_step_stops_a_real_batch_before_anything_runs(client):
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await _batch(client, [_line(), {"tool": "entity_create_dragon", "args": {}}])
|
|
assert "entity_create_dragon" in str(excinfo.value)
|
|
assert await _entity_count(client) == 0, "validation must fail closed, before step 1 draws"
|
|
|
|
|
|
async def test_a_malformed_step_is_reported_not_crashed(client):
|
|
result = await _batch(client, [{"args": {"x1": 0}}], dry_run=True)
|
|
assert result["results"][0]["error"]["kind"] == "malformed_step"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Privilege — the deny set
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_the_deny_set_is_derived_from_the_escape_cost_cards():
|
|
"""Hand-listing would go stale the day a new escape hatch is registered."""
|
|
escapes = {
|
|
tool.name
|
|
for tool in await server._registered_tools()
|
|
if ((tool.meta or {}).get("cad") or {}).get("cost") == "escape"
|
|
}
|
|
assert escapes, "the registry must still carry escape-cost tools for this to mean anything"
|
|
assert server._batch_denied_tools() == escapes | {"cad_batch"}
|
|
|
|
|
|
async def test_the_raw_command_and_lisp_hatches_are_denied():
|
|
denied = server._batch_denied_tools()
|
|
assert {"system_run_command", "system_run_lisp"} <= denied
|
|
|
|
|
|
@pytest.mark.parametrize("tool", ["system_run_command", "system_run_lisp"])
|
|
async def test_a_denied_tool_refuses_the_whole_batch(client, tool):
|
|
args = {"command": "_ZOOM E"} if tool.endswith("command") else {"expression": "(+ 1 1)"}
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await _batch(client, [_line(), {"tool": tool, "args": args}])
|
|
message = str(excinfo.value)
|
|
assert tool in message
|
|
assert "denied" in message.lower() or "not callable" in message.lower()
|
|
assert await _entity_count(client) == 0, "a denied step must abort before step 1 draws"
|
|
|
|
|
|
async def test_a_denied_step_is_not_survivable_with_on_error_continue(client):
|
|
"""Otherwise the deny set would just be a step you skip past."""
|
|
with pytest.raises(ToolError):
|
|
await _batch(
|
|
client,
|
|
[{"tool": "system_run_lisp", "args": {"expression": "(+ 1 1)"}}, _line()],
|
|
on_error="continue",
|
|
)
|
|
assert await _entity_count(client) == 0
|
|
|
|
|
|
async def test_cad_batch_cannot_nest_inside_itself(client):
|
|
"""No recursion, and no laundering a denied step through a nested executor."""
|
|
result = await _batch(
|
|
client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}], dry_run=True
|
|
)
|
|
assert result["results"][0]["error"]["kind"] == "denied"
|
|
with pytest.raises(ToolError, match="cad_batch"):
|
|
await _batch(client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}])
|
|
|
|
|
|
async def test_a_tool_with_no_discovery_card_is_not_run_blind(client):
|
|
"""Fail closed on unknown provenance: the deny set is computed from the local
|
|
registry, but a mounted or transformed provider can supply a tool it never saw.
|
|
Every tool this server registers carries a card (there is a coverage gate),
|
|
so this costs the native surface nothing."""
|
|
|
|
@server.mcp.tool(name="_batch_uncarded_probe")
|
|
async def _uncarded(ctx=None) -> dict:
|
|
return {"ok": True}
|
|
|
|
try:
|
|
result = await _batch(client, [{"tool": "_batch_uncarded_probe", "args": {}}], dry_run=True)
|
|
assert result["results"][0]["error"]["kind"] == "denied"
|
|
assert "card" in result["results"][0]["error"]["message"]
|
|
finally:
|
|
server.mcp.local_provider.remove_tool("_batch_uncarded_probe")
|
|
|
|
|
|
async def test_a_dry_run_reports_a_denied_step_rather_than_hiding_it(client):
|
|
result = await _batch(
|
|
client,
|
|
[{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}],
|
|
dry_run=True,
|
|
)
|
|
assert result["ok"] is False
|
|
assert result["results"][0]["error"]["kind"] == "denied"
|
|
|
|
|
|
async def test_the_refusal_points_at_the_direct_tool(client):
|
|
"""The escape hatch stays reachable — separately, where its own gate applies."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await _batch(client, [{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}])
|
|
assert "directly" in str(excinfo.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The production laundering vector: DISCOVERY_MODE=search
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# In search mode the advertised surface is just ``search_tools`` + ``call_tool``,
|
|
# and ``call_tool`` invokes any tool by name. That is a general-purpose proxy
|
|
# sitting inside the same server as a denylist — i.e. exactly the shape of a way
|
|
# around it. The deny set is derived from ``cost="escape"`` cards, and the proxy
|
|
# is not a local component, so nothing in the *denylist* stops it; what stops it
|
|
# is the fail-closed "no @cad_tool card, no execution" rule.
|
|
#
|
|
# These pin that, because the coupling is invisible: giving ``call_tool`` a card
|
|
# — an entirely reasonable thing to want, so it is costed and discoverable —
|
|
# silently reopens the escalation path with no other test failing.
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def search_mode(monkeypatch):
|
|
"""The real search surface, restored to ``off`` afterwards."""
|
|
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
|
server._apply_discovery_mode("search")
|
|
try:
|
|
async with Client(server.mcp) as connected:
|
|
yield connected
|
|
finally:
|
|
server._apply_discovery_mode("off")
|
|
|
|
|
|
async def test_search_mode_advertises_the_proxy_that_makes_this_a_risk(search_mode):
|
|
names = {tool.name for tool in await search_mode.list_tools()}
|
|
assert names == {"search_tools", "call_tool"}, (
|
|
"if the search surface changed, the laundering test below is aiming at the wrong target"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("hatch", ["system_run_command", "system_run_lisp"])
|
|
async def test_the_search_proxy_cannot_launder_a_denied_hatch(search_mode, hatch):
|
|
"""cad_batch -> call_tool -> system_run_* must not execute."""
|
|
result = (
|
|
await search_mode.call_tool(
|
|
"call_tool",
|
|
{
|
|
"name": "cad_batch",
|
|
"arguments": {
|
|
"steps": [{"tool": "call_tool", "args": {"name": hatch, "arguments": {}}}],
|
|
"dry_run": True,
|
|
},
|
|
},
|
|
)
|
|
).structured_content or {}
|
|
assert result["ok"] is False
|
|
assert result["results"][0]["error"]["kind"] == "denied"
|
|
|
|
|
|
async def test_the_laundered_hatch_is_refused_before_anything_executes(search_mode):
|
|
"""Not merely reported — the whole batch is refused, so the proxy never runs."""
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await search_mode.call_tool(
|
|
"call_tool",
|
|
{
|
|
"name": "cad_batch",
|
|
"arguments": {
|
|
"steps": [
|
|
{
|
|
"tool": "call_tool",
|
|
"args": {
|
|
"name": "system_run_command",
|
|
"arguments": {"command": "_ZOOM E"},
|
|
},
|
|
}
|
|
]
|
|
},
|
|
},
|
|
)
|
|
assert "refused all 1 steps - nothing was executed" in str(excinfo.value)
|
|
|
|
|
|
async def test_cad_batch_still_works_through_the_search_proxy(search_mode):
|
|
"""The denial above must be a denial, not search mode breaking batching."""
|
|
result = (
|
|
await search_mode.call_tool(
|
|
"call_tool",
|
|
{
|
|
"name": "cad_batch",
|
|
"arguments": {
|
|
"steps": [{"tool": "drawing_new", "args": {}}, _line()],
|
|
},
|
|
},
|
|
)
|
|
).structured_content or {}
|
|
assert result["ok"] is True
|
|
assert result["succeeded"] == 2
|
|
|
|
|
|
async def test_a_batch_step_may_still_call_the_older_batch_tools(client):
|
|
"""The reconciliation: they compose. cad_batch orders, entity_batch_create bulks."""
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
{
|
|
"tool": "entity_batch_create",
|
|
"args": {
|
|
"entities": [
|
|
{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
|
|
{"type": "circle", "cx": 0, "cy": 0, "radius": 1},
|
|
]
|
|
},
|
|
},
|
|
_line(),
|
|
],
|
|
)
|
|
assert result["ok"] is True
|
|
assert await _entity_count(client) == 3
|
|
|
|
|
|
async def test_a_tool_hidden_by_the_profile_is_not_reachable_through_a_batch(client):
|
|
"""cad_batch must not be a way around TOOL_PROFILE or the ENABLE_3D gate."""
|
|
result = await _batch(client, [{"tool": "solid_box", "args": {}}], dry_run=True)
|
|
assert result["results"][0]["error"]["kind"] == "unknown_tool"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error taxonomy — typed, never string-sniffed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_a_backend_capability_refusal_is_typed_as_unsupported(client, tmp_path):
|
|
result = await _batch(
|
|
client,
|
|
[{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "part.dwg")}}],
|
|
on_error="continue",
|
|
)
|
|
error = result["results"][0]["error"]
|
|
assert error["kind"] == "unsupported"
|
|
assert error["capability"] == "dwg", "the capability key must survive the re-entrant wrapper"
|
|
|
|
|
|
async def test_the_classifier_reads_the_cause_chain_not_the_message():
|
|
"""The proof that no message text is being sniffed: same words, different type."""
|
|
from backends.ezdxf_backend import UnsupportedCapabilityError
|
|
|
|
liar = RuntimeError("the headless ezdxf backend cannot write DWG (capability 'dwg')")
|
|
assert server._classify_batch_error(liar)["kind"] == "failed"
|
|
|
|
honest = ToolError("Error calling tool 'drawing_save_as': nothing useful here")
|
|
honest.__cause__ = UnsupportedCapabilityError("dwg", "no DWG writer")
|
|
classified = server._classify_batch_error(honest)
|
|
assert classified["kind"] == "unsupported"
|
|
assert classified["capability"] == "dwg"
|
|
|
|
|
|
async def test_a_backend_failure_is_typed_as_failed(client):
|
|
result = await _batch(
|
|
client, [{"tool": "entity_get", "args": {"handle": "NOSUCH"}}], on_error="continue"
|
|
)
|
|
assert result["results"][0]["error"]["kind"] == "failed"
|
|
|
|
|
|
async def test_a_deliberate_tool_refusal_is_typed_as_refused(client):
|
|
result = await _batch(
|
|
client,
|
|
[{"tool": "drawing_open", "args": {"path": "../../etc/passwd"}}],
|
|
on_error="continue",
|
|
)
|
|
assert result["results"][0]["error"]["kind"] == "refused"
|
|
|
|
|
|
async def test_a_bound_value_of_the_wrong_type_is_typed_at_run_time(client):
|
|
"""The one thing dry_run cannot check, reported precisely when it happens."""
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
{"tool": "entity_create_text", "args": {"text": "T", "x": 0, "y": 0}, "bind": "t"},
|
|
{"tool": "entity_move", "args": {"handle": "$t", "dx": "$t.type", "dy": 0.0}},
|
|
],
|
|
on_error="continue",
|
|
)
|
|
assert result["results"][1]["error"]["kind"] == "invalid_args"
|
|
|
|
|
|
async def test_every_reported_kind_is_a_declared_kind(client, tmp_path):
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
|
|
{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "p.dwg")}},
|
|
_line(),
|
|
],
|
|
on_error="continue",
|
|
)
|
|
kinds = {row["error"]["kind"] for row in result["results"] if row["status"] == "error"}
|
|
assert kinds <= set(server.BATCH_ERROR_KINDS)
|
|
assert kinds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_error
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_stop_is_the_default_because_both_backends_deliver_it(client):
|
|
result = await _batch(client, [_line()])
|
|
assert result["on_error"] == "stop"
|
|
|
|
|
|
async def test_stop_leaves_earlier_steps_applied_and_says_so(client):
|
|
result = await _batch(
|
|
client,
|
|
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
|
|
on_error="stop",
|
|
)
|
|
assert result["ok"] is False
|
|
statuses = [row["status"] for row in result["results"]]
|
|
assert statuses == ["ok", "error", "skipped"]
|
|
assert result["skipped"] == 1
|
|
assert await _entity_count(client) == 1
|
|
assert result["atomicity"]["rolled_back"] is False
|
|
assert result["atomicity"]["guarantee"] == "none"
|
|
|
|
|
|
async def test_continue_runs_every_step(client):
|
|
result = await _batch(
|
|
client,
|
|
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
|
|
on_error="continue",
|
|
)
|
|
assert [row["status"] for row in result["results"]] == ["ok", "error", "ok"]
|
|
assert result["succeeded"] == 2 and result["failed"] == 1
|
|
assert await _entity_count(client) == 2
|
|
|
|
|
|
async def test_continue_does_not_execute_a_step_whose_binding_never_landed(client):
|
|
"""A dependent step must not silently run on a stale or missing value."""
|
|
result = await _batch(
|
|
client,
|
|
[
|
|
{"tool": "entity_get", "args": {"handle": "NOSUCH"}, "bind": "ghost"},
|
|
{"tool": "entity_move", "args": {"handle": "$ghost", "dx": 1.0, "dy": 0.0}},
|
|
_line(),
|
|
],
|
|
on_error="continue",
|
|
)
|
|
assert result["results"][0]["status"] == "error"
|
|
assert result["results"][1]["status"] == "error"
|
|
assert result["results"][1]["error"]["kind"] == "unresolved_ref"
|
|
assert result["results"][2]["status"] == "ok"
|
|
|
|
|
|
async def test_an_unknown_on_error_mode_is_rejected(client):
|
|
with pytest.raises(ToolError):
|
|
await _batch(client, [_line()], on_error="pray")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Atomicity — honest per backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _Caps:
|
|
def __init__(self, mode, supported=True):
|
|
from backends.base import CapabilityMap, FeatureCapability
|
|
|
|
self.map = CapabilityMap(
|
|
backend="stub", features={"transactions": FeatureCapability(supported, mode)}
|
|
)
|
|
|
|
def capabilities(self):
|
|
return self.map
|
|
|
|
|
|
async def test_a_snapshot_backend_is_reported_as_a_snapshot_guarantee():
|
|
guarantee, note = server._batch_rollback_guarantee(_Caps("snapshot"))
|
|
assert guarantee == "snapshot"
|
|
assert "snapshot" in note.lower()
|
|
|
|
|
|
async def test_an_undo_mark_backend_is_never_called_atomic():
|
|
"""COM ends the undo mark and fires ``_UNDO B`` without confirming it landed."""
|
|
guarantee, note = server._batch_rollback_guarantee(_Caps("undo_mark"))
|
|
assert guarantee == "best_effort_undo"
|
|
lowered = note.lower()
|
|
assert "not atomic" in lowered, "it must say so, not merely omit the word"
|
|
assert "best-effort" in lowered
|
|
assert "does not confirm" in lowered
|
|
|
|
|
|
async def test_a_backend_without_transactions_gets_no_guarantee():
|
|
guarantee, _ = server._batch_rollback_guarantee(_Caps(None, supported=False))
|
|
assert guarantee == "none"
|
|
|
|
|
|
async def test_an_unrecognised_transaction_mode_is_not_assumed_good():
|
|
guarantee, _ = server._batch_rollback_guarantee(_Caps("time_travel"))
|
|
assert guarantee == "unverified"
|
|
|
|
|
|
async def test_rollback_restores_the_document_on_ezdxf(client):
|
|
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
|
|
result = await _batch(
|
|
client,
|
|
[_line(), _line(y1=4.0, y2=4.0), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
|
|
on_error="rollback",
|
|
)
|
|
assert result["ok"] is False
|
|
assert await _entity_count(client) == 1, "the two batch lines must be gone"
|
|
atomicity = result["atomicity"]
|
|
assert atomicity["guarantee"] == "snapshot"
|
|
assert atomicity["rolled_back"] is True
|
|
assert atomicity["verified"] is True
|
|
assert atomicity["before"] == atomicity["after"]
|
|
|
|
|
|
async def test_a_successful_rollback_batch_commits_and_keeps_its_work(client):
|
|
result = await _batch(client, [_line(), _line(y1=4.0, y2=4.0)], on_error="rollback")
|
|
assert result["ok"] is True
|
|
assert result["atomicity"]["rolled_back"] is False
|
|
assert result["atomicity"]["committed"] is True
|
|
assert await _entity_count(client) == 2
|
|
|
|
|
|
async def test_rollback_reports_the_guarantee_it_actually_got(client):
|
|
result = await _batch(client, [_line()], on_error="rollback")
|
|
atomicity = result["atomicity"]
|
|
assert atomicity["mode"] == "rollback"
|
|
assert atomicity["guarantee"] in server.BATCH_ROLLBACK_GUARANTEES
|
|
assert atomicity["note"]
|
|
|
|
|
|
async def test_rollback_refuses_when_the_backend_declines_the_checkpoint(client, monkeypatch):
|
|
"""A checkpoint we did not open is not one we can roll back to.
|
|
|
|
This is the live COM path: ``ComBackend.transaction_begin`` *returns*
|
|
``{"ok": False, "error": "A transaction is already active"}`` rather than
|
|
raising, so a batch that took the return value on faith would run every
|
|
step and then report a rollback it never had. Simulated on the headless
|
|
backend, whose own transaction stack happily nests.
|
|
"""
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
|
|
async def _decline(self):
|
|
return {"ok": False, "error": "A transaction is already active"}
|
|
|
|
monkeypatch.setattr(EzdxfBackend, "transaction_begin", _decline)
|
|
result = await _batch(client, [_line()], on_error="rollback")
|
|
assert result["ok"] is False
|
|
assert result["executed"] == 0
|
|
assert result["atomicity"]["transaction"] is False
|
|
assert "already active" in result["atomicity"]["note"]
|
|
assert await _entity_count(client) == 0, "nothing may run without the checkpoint"
|
|
|
|
|
|
async def test_a_rollback_batch_does_not_disturb_a_transaction_the_caller_opened(client):
|
|
"""The headless backend's checkpoints are a stack; ours must pop only its own."""
|
|
await client.call_tool("transaction_begin", {})
|
|
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
|
|
result = await _batch(
|
|
client,
|
|
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
|
|
on_error="rollback",
|
|
)
|
|
assert result["atomicity"]["rolled_back"] is True
|
|
assert await _entity_count(client) == 1, "the caller's circle survives our rollback"
|
|
await client.call_tool("transaction_rollback", {})
|
|
assert await _entity_count(client) == 0, "the caller's own checkpoint still works"
|
|
|
|
|
|
async def test_non_rollback_modes_make_no_atomicity_claim(client):
|
|
"""And say it in four keys, not in two paragraphs about a rollback that was
|
|
never on the table — that boilerplate measured 61% of a short batch's reply."""
|
|
for mode in ("stop", "continue"):
|
|
result = await _batch(client, [_line()], on_error=mode)
|
|
atomicity = result["atomicity"]
|
|
assert atomicity["transaction"] is False
|
|
assert atomicity["rolled_back"] is False
|
|
assert atomicity["guarantee"] == "none"
|
|
assert set(atomicity) == {"mode", "guarantee", "transaction", "rolled_back", "note"}
|
|
assert "atomic" not in atomicity["note"].lower()
|
|
assert len(dumps(atomicity)) < 200
|
|
|
|
|
|
async def test_a_dry_run_opens_no_transaction(client):
|
|
result = await _batch(client, [_line()], on_error="rollback", dry_run=True)
|
|
assert result["atomicity"]["transaction"] is False
|
|
status = (await client.call_tool("system_status", {})).structured_content or {}
|
|
assert status.get("transaction_depth", 0) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Limits and progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_an_empty_batch_is_rejected(client):
|
|
with pytest.raises(ToolError):
|
|
await _batch(client, [])
|
|
|
|
|
|
async def test_the_step_count_is_capped(client):
|
|
with pytest.raises(ToolError) as excinfo:
|
|
await _batch(client, [_line()] * (server.MAX_BATCH_STEPS + 1))
|
|
assert str(server.MAX_BATCH_STEPS) in str(excinfo.value)
|
|
|
|
|
|
async def test_progress_is_reported_for_every_step(monkeypatch):
|
|
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
|
seen: list[tuple[float, float | None]] = []
|
|
|
|
async def handler(progress, total, message=None):
|
|
seen.append((progress, total))
|
|
|
|
async with Client(server.mcp, progress_handler=handler) as connected:
|
|
await connected.call_tool("drawing_new", {})
|
|
await connected.call_tool(
|
|
"cad_batch", {"steps": [_line(), _line(y1=2.0, y2=2.0), _line(y1=4.0, y2=4.0)]}
|
|
)
|
|
assert seen, "a long batch is the canonical case for ctx.report_progress"
|
|
assert (3.0, 3.0) in [(p, t) for p, t in seen], "the final tick must report completion"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The rollback claim must come from the backend, not from control flow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_rollback_is_not_claimed_when_the_backend_declines_it(client):
|
|
"""A step that commits consumes the checkpoint out from under us.
|
|
|
|
``transaction_rollback()`` then returns ``{"ok": False, "error": "No active
|
|
transaction to rollback"}``. Reporting ``rolled_back: True`` off control
|
|
flow would be the same class of lie as writing DXF bytes into a .dwg.
|
|
"""
|
|
steps = [
|
|
_line(),
|
|
{"tool": "transaction_commit", "args": {}},
|
|
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
|
|
]
|
|
result = await _batch(client, steps, on_error="rollback")
|
|
|
|
assert result["ok"] is False
|
|
assert result["atomicity"]["rolled_back"] is False, (
|
|
"the backend refused the rollback; the payload must not claim it happened"
|
|
)
|
|
assert await _entity_count(client) == 1, "the line really did survive"
|
|
|
|
|
|
async def test_commit_is_not_claimed_when_the_backend_declines_it(client):
|
|
"""Same rule on the success path: a step that already committed leaves
|
|
nothing for the batch's own commit to do."""
|
|
steps = [_line(), {"tool": "transaction_commit", "args": {}}]
|
|
result = await _batch(client, steps, on_error="rollback")
|
|
|
|
assert result["ok"] is True
|
|
assert result["atomicity"]["committed"] is False, (
|
|
"the checkpoint was already gone; do not report a commit that did not happen"
|
|
)
|
|
|
|
|
|
async def test_a_read_only_finding_is_not_a_failed_step(client):
|
|
"""A checker reporting findings must not be mistaken for a step that failed.
|
|
|
|
``validation_check`` is ``readOnlyHint=True`` and answers
|
|
``{"ok": len(issues) == 0}`` — ``ok: False`` there means *it found something*,
|
|
not *it could not run*. Treating that as a refusal made a read-only check
|
|
trigger a verified rollback that destroyed the geometry the batch had just
|
|
drawn. A mutator's ``ok: False`` still means it did not mutate.
|
|
"""
|
|
steps = [_line(), {"tool": "validation_check", "args": {}}]
|
|
result = await _batch(client, steps, on_error="rollback")
|
|
|
|
assert result["succeeded"] == 2
|
|
assert result["failed"] == 0
|
|
assert result["ok"] is True
|
|
assert result["atomicity"]["rolled_back"] is False
|
|
assert await _entity_count(client) == 1, "the read-only check must not undo the line"
|