Files
autocad-mcp-pro/CHANGELOG.md
T
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

1009 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.0] — 2026-08-06
### Added
- **Selection filters — 3 tools.** `selection_window` (AutoCAD's ssget
window/crossing), `selection_polygon` and `selection_filter` (QSELECT).
Window returns only entities wholly inside; crossing also returns the ones
straddling the edge — the modes are reported back and `window` is the
default, because the over-selecting mode must never be the implicit one. A
zero-area box is refused rather than answered with an empty list, which would
read as "nothing there".
`selection_filter` takes named parameters rather than a query string
deliberately: ezdxf's query language answers an unknown attribute with an
empty result, so `LINE[nosuchattr=='x']` is indistinguishable from "no
matches". Here a typo is a `TypeError` at the boundary, and `filtered_by`
reports which filters actually ran. `min_area` excludes shapes that have no
area at all rather than treating them as zero, and a negative `min_area` is
refused — no area is negative.
- **Boundary tracing — 2 tools, and five deliberately not built.**
`boundary_trace` is AutoCAD's BOUNDARY/BPOLY: a seed point in, the nearest
enclosing closed polyline out. It never calls `edgeminer.find_all_loops` —
measured at **63.9 s for 40 edges and a TimeoutError at 60**, because the
docstring's O(n!) is real — and walks one loop per seed-adjacent edge
instead, which is 0.0057 s at the same size. Straight edges are split where
they cross, so a construction line drawn across a shape actually divides it;
without that the line shares no endpoint with anything and is invisible to
the edge graph. Curved edges are not split, and the capability says so.
Two things the obvious implementation gets silently wrong, both now tested:
`edgesmith.lwpolyline_from_chain` emits n+1 points with the last duplicating
the first and `closed` left False — a polyline that draws like a square and
behaves like an open path to hatch association, area queries and
`entity_offset`; and `edgesmith.loop_area` works off edge endpoints, so two
semicircular arcs closing into a circle measure **0.0** against a true
314.159. The area now comes from the flattened curve and each curved edge
keeps its bulge.
**Not built: every REGION and 2D-boolean tool the plan listed.** ezdxf cannot
author a REGION — `add_region()` returns one with zero ACIS bytes — and the
substitute, `greiner_hormann`, takes flat vertex lists. Measured on a square
with one edge bulged into a semicircle, routing it through that boolean loses
**28.2% of the area and 12.5% of the perimeter**. That is the same magnitude
as the `analysis_measure_area` bug this release fixed, and shipping it would
have re-introduced the class.
- **Hatch depth — 3 tools.** `hatch_set_gradient`, `hatch_edit` and
`hatch_add_boundary`. `hatch_edit` leaves omitted parameters alone — a
partial edit that resets the rest is data loss — and `changed` reports the
attributes that actually *moved*, so re-setting a value to what it already
was comes back empty rather than as a false positive. `hatch_add_boundary`
takes typed edges (line/arc/ellipse) because a boundary API that only accepts
vertex lists silently straightens every curve it is given; every edge is
validated before any is written, so a malformed list refuses instead of
leaving a half-built path. On COM it refuses with
`capability: "hatch_edge_paths"`: ActiveX appends boundary loops as existing
objects, not as typed edges, so the fidelity this tool exists for cannot be
expressed there.
- **`analysis_list_properties` — AutoCAD's LIST.** The raw DXF attribute set
for one handle, which `entity_get` deliberately does not carry. It cannot be
handed over as-is for two reasons, both fixed here: `dxfattribs()` holds
`Vec3` values and is not JSON-serialisable, and its coordinates are in the
entity's own frame, so for anything with an extrusion they would contradict
every other coordinate this server reports.
- **Annotation objects — 4 tools.** `entity_create_wipeout` (mask what is
behind a closed polygon), `entity_create_revcloud`, `text_set_background`
(MTEXT background mask) and `text_find_replace`. Each carries a refusal that
was measured rather than assumed. A wipeout of fewer than three points
encloses no area, so it is refused instead of returning a handle to a mask
that masks nothing. A revision cloud *is* its arcs — `ezdxf.revcloud`'s own
helper stamps the `RevcloudProps` xdata and takes the bulge sign from the
winding direction, so the result satisfies `is_revcloud()` — and a
`segment_length` longer than the shortest edge produces no arcs at all, which
would be a plain rectangle reported as a cloud; that case is refused.
`text_set_background` refuses TEXT (only MTEXT has a background-fill
attribute, so setting one on TEXT would report success and change nothing)
and refuses a scale below 1.0 (a box smaller than its text is a stripe
through the text).
`text_find_replace` searches TEXT, MTEXT, ATTRIB **and** ATTDEF, including
inside block definitions — leaving the ATTDEF behind would fix the drawing
and reintroduce the old text on the next insert. It returns
`searched_types`, because "no matches" and "that type was never searched" are
different answers and a caller cannot otherwise tell them apart. DIMENSION
text is deliberately out of scope: its text field holds the `<>` override
placeholder rather than the measurement, so editing it would either do
nothing or detach the dimension from what it measures — the response says so.
On COM, `entity_create_revcloud` refuses with `capability: "revcloud"`:
ActiveX has no revision-cloud member and driving the command blind against a
live drawing is unverified.
- **The rest of the paper-space lifecycle — 8 tools.** v1.4 could make a sheet
and never unmake it. Added `layout_delete`, `layout_rename`, `layout_copy`,
`viewport_list`, `viewport_set_scale`, `viewport_lock`, `viewport_delete` and
`entity_change_space` (CHSPACE). Verified headless (ezdxf); the COM paths for
the first seven are implemented against the ActiveX reference but **never
executed against a live AutoCAD**, and each is marked so in the source.
Most of the work went into refusals, because ezdxf's own guards stop short of
where a tool boundary has to: `layouts.delete("")` deletes the *first* sheet
rather than refusing, `layouts.get("")` resolves to whichever is active, and
`layouts.rename()` skips the name validation `new()` performs, so `Bad/Name`
and even `""` land in the table and the file still audits clean. Deleting or
renaming the current tab used to leave three different answers to "where does
my next line go?" — `layout_list` said one thing, `$TILEMODE` another, and
`_msp()` quietly wrote to model space; all three now agree. A handle from a
deleted sheet refuses instead of dying with `AttributeError` (`entitydb.get`
is documented not to filter destroyed entities, and `_get_entity` believed
it).
`layout_copy` is a construction — ezdxf has no layout-copy API and
`xref.load_paperspace` is cross-document only — and every way it can go wrong
is invisible to the Auditor, which reports zero errors on a duplicated tab
order, two main viewports, and a hatch whose boundary still points into the
layout it was copied from and dangles the moment that layout is deleted. All
three are prevented structurally; `skipped` and `associativity_dropped` report
what could not be carried over rather than letting a bare `ok: true` imply a
complete copy.
`viewport_list` reports the layout's own main viewport with `is_main: true`
rather than hiding it — it is what remains after every drafting viewport is
deleted — and refuses to report a scale on R12, where `view_height` and
`flags` are dropped on export and `get_scale()` then returns a fabricated
number (150.0 measured for a viewport storing no scale at all). The two
writers refuse on R12 outright rather than reporting a success the file
cannot keep, and `viewport_lock` read-modify-writes the flags bitfield, which
also carries the UCS-icon and grid bits a bare assignment would silently drop.
- **`entity_change_space` (CHSPACE), headless only.** AutoCAD moves objects
*through* a viewport, rescaling them so they look identical on screen.
ezdxf's `move_to_layout` applies no geometric change whatsoever, so the
obvious implementation reports success, turns a 100 mm feature into 100 mm of
paper inside a 1:2 viewport, and writes a perfectly clean file. The transform
is therefore the tool, and it uses `viewport.get_transformation_matrix()`
rather than a hand-rolled one: the hand-rolled version ignores
`view_target_point` and misplaced geometry by 25 mm in testing while
`is_top_view` was still true and the twist angle still zero — past every guard
one would naturally write. A regression test pins the result against what
ezdxf's own rendering pipeline projects.
It refuses rather than guessing: non-plan and twisted viewports (ezdxf returns
a meaningless matrix for the first and rotates about the paper origin for the
second), dimensions unless `freeze_dimensions=true` bakes the measurement in
first (`transform` halves `get_measurement()` while the baked block text keeps
the old value, so one of the two is always lying), ACIS bodies (`transform`
is accepted and moves nothing), tables and proxies (`transform` raises, and
`hasattr(e, "transform")` is `True` for all of them), viewports themselves,
and entities already in the target space. Geometry that lands outside the
viewport or off the sheet is moved and **flagged** (`inside_viewport`,
`on_sheet`), not refused — AutoCAD allows it too.
On the COM backend this refuses with `capability: "chspace"`: ActiveX has no
change-space member, so the alternatives are `CopyObjects` + delete or driving
the command blind, and neither could be verified against a live seat.
- **`CAD_PROGID` — the COM ProgID is configurable.** `"AutoCAD.Application"`
was hardcoded at both connection sites, so AutoCAD-compatible applications
(BricsCAD, ZWCAD, GstarCAD) were unreachable even though they expose the same
ActiveX object model. The setting is honoured on *both* paths deliberately:
`Dispatch` is not a passive probe — it COM-launches the application and sets
`Visible = True` — so honouring the setting on `GetActiveObject` and then
falling back would start the very AutoCAD the operator said they were not
using. An unrecognised ProgID fails loudly for the same reason, and the error
names both the ProgID it tried and the variable to change. `system_status`
now reports `cad_progid`, without which a non-AutoCAD operator reads an
`autocad_version` key naming a product they do not own.
**Caveat, stated everywhere it appears:** `CAD_PROGID` only changes which COM
application the backend attaches to — every tool beyond the connection itself
is developed and tested against AutoCAD alone, and clone compatibility is
unverified, so treat a non-default ProgID as experimental. The candidate
ProgIDs in `.env.example` are commented out and marked UNVERIFIED: none has
been tested against a running seat.
- **`analysis_measure_entity` — measure the drawing, not your memory of it.**
Neither backend could answer "what is the area of the polyline I just drew":
`analysis_measure_area` shoelaced points the *caller* typed in, and the live
COM backend did the same despite having the document, a `HandleToObject`
lookup it already used fourteen times, and an ActiveX `Area` property. The
only available workflow was also silently wrong — `entity_get` read LWPOLYLINE
vertices through the default format and kept x and y, dropping the **bulge**
that makes an edge an arc. Measured on a 100×100 square with one edge bulged
into a semicircle: 10000 reported against a true 13926.9908, **28.2% low**,
perimeter **12.5% low**, nothing in the response saying so. And
`props["length"] = ent.length()` was dead code — `LWPolyline` has no
`length()`, so the key never appeared at all.
Bulge geometry is always circular, so the new `engineering/measure.py` is
exact rather than approximate: shoelace plus each arc's signed circular
segment, and arc length for perimeter. `entity_get` now reports `area` and
`length` from the same maths. The payload states its own accuracy — `exact`,
`flatten_tolerance`, `assumed_closed` (an open boundary is closed the way
AutoCAD's AREA does), and `self_intersecting`, because the shoelace *cancels*
crossed lobes and a bowtie measures 0.0. REGION/3DSOLID refuse with
`capability: "measure_area_acis"` naming the live backend; LINE/TEXT/INSERT
raise a plain error, since no engine can give them an area and a capability
tag would falsely suggest switching helps. `AREA`/`MEASUREGEOM` now route here
rather than at the points-based tool — a drafter typing AREA picks objects,
they do not retype coordinates.
- **Handle grounding: `view_screenshot(overlay_handles=true)`.** A screenshot
showed the model what the drawing looks like and nothing it could act on —
every modify tool takes a handle, and nothing connected "the circle at the
top-left" to a hex string. Each entity is now labelled with its handle at its
own bounding-box centre (taken from the renderer's extents, so the label lands
where the entity is *drawn* even in a rotated frame). Off by default, because
labels are ink on the drawing; past 40 entities the render is capped and the
image itself says "handles shown for N of M" rather than letting a labelled
subset read as the whole sheet. The live backend captures the AutoCAD window
rather than rendering, so there is nothing to label there — it refuses with
`capability: "handle_overlay"` instead of quietly returning an unlabelled
image, since producing the overlay would mean creating and deleting entities
in the user's open drawing.
- **`block_create_from_entities` works on the live backend.** It refused
outright and pointed at `system_run_command` with `_BLOCK`, pushing callers
through the free-text escape hatch for something ActiveX does directly
(`Blocks.Add` + `Document.CopyObjects`). The tool's own docstring said the
opposite — "works by using AutoCAD's BLOCK command (COM backend only)" — so
the one engine that could not do it was advertised as the one that could.
Both engines now also report `skipped` handles instead of swallowing them, and
a call where nothing resolves fails rather than leaving an empty definition
behind. COM path unit-tested against a fake ActiveX object, not
hardware-verified.
- **Undo and redo on the headless backend (`EZDXF_UNDO_DEPTH`, default 0).**
The plan called this "add redo". The larger half was that **undo never worked
either**: `_undo_stack` was declared, read and cleaned up, but nothing ever
appended to it, so `drawing_undo` could only answer "Nothing to undo" — while
the tool summary said "Step back one operation" and the alias corpus routed
`UNDO` and "take that back" straight at it. The old test asserted
`isinstance(result, dict)`, which a permanent failure satisfies.
It is opt-in because it is not free. ezdxf keeps no journal, so a history step
is a whole DXF snapshot: measured 3.1 ms / 19 KB at 10 entities but 130 ms /
658 KB at 5000, and switching it on costs **37×** on entity creation (0.18 →
6.65 ms per call) even on a small drawing. Off, it costs nothing; on, you get
real multi-step undo/redo. Both tools refuse with
`capability: "undo_history"` naming the setting rather than pretending. The
live COM backend uses AutoCAD's own undo and ignores it. Drawing after an undo
discards the redo branch, as in AutoCAD — otherwise redo would restore a state
that never existed, with geometry you had removed reappearing beside geometry
you drew afterwards.
- **BREAKING (behavior): geometry now goes into the layout you selected.**
`layout_set_current("A3-Sheet")` answered `{"ok": true, "current":
"A3-Sheet"}` and then every entity created afterwards landed in **model
space** anyway, on both engines — `_msp()` returned model space
unconditionally, so the tool reported a state change that changed nothing.
Proven: a line drawn straight after selecting a layout was in modelspace and
not in the layout. That was also why a title block could not go on a sheet, so
`titleblock_apply_iso_a3` gains a `layout` parameter — where a title block
belongs, since the border frames the printed sheet rather than the model — and
restores your current space afterwards rather than leaving you on the sheet.
Existing model-space workflows are unaffected: a fresh drawing starts in model
space, and a refused layout switch leaves the current space where it was.
- **ISO 286 transition and interference holes (K, M, N, P).** `fit="P7"` used to
raise by name. They are now derived by the standard's delta rule,
`ES = -ei + Δ` with `Δ = IT(n) − IT(n−1)` — a *rule* over the shaft and IT
tables already in the module, not new table data, which is why it can be
pinned against published values (K7/M7/N7/P7 and K6/N6/P6 cross-checked by
hand) instead of merely trusted. Unlocks the hole-basis pairs K7/h6, M7/h6,
N7/h6, P7/h6.
- **ISO 286 interference shafts (r, s, t, u).** `fit="s6"` and `fit="u6"` — the
standard press and drive fits — used to raise. Unlike K/M/N/P these *are* new
table data, sub-stepped more finely than the main size steps (ISO 286 splits
18–30 into 18–24/24–30, 30–50 into 30–40/40–50, and every step above 50 mm in
two or three; flattening that is a silent error of tens of microns at exactly
the sizes press fits are used at). Because it is data rather than a rule, its
**provenance is tested**: every value is cross-checked against ISO 286-1's own
derivation formulae, an independent source. `r` reproduces as the geometric
mean of `p` and `s` on **24/24** steps — with `p` pinned in this module before
`r` existed — and `s`/`t`/`u` track their IT7-based formulae within 1 µm on
every step from 18 mm up. The five places where the standard's rounding puts a
published value 2–3 µm off its own formula are listed by name in the tests, so
a reader holding ISO 286 checks five numbers rather than ninety-five. Shaft `t`
is undefined below 24 mm in the standard and refuses there rather than being
extrapolated. Unlocks H7/r6, H7/s6, H7/t6 and H7/u6.
- **Headless performance lane** (`benchmarks/perf_suite.py` +
`render_perf_chart.py`): fixed wall-time workloads (2k individual creates,
10k build/export/reopen roundtrip, 10k region query, full premium quality
pass) measured through the same backend methods the MCP tools call;
machine fingerprint recorded, self-measurement only.
- **Task-matrix chart** (`benchmarks/render_matrix_chart.py`): tasks × servers
status heatmap rendered from the published live-run reports, so coverage
gaps are visible per capability instead of one folded score.
- **Tool discovery** (`DISCOVERY_MODE=search`, default `off`): replaces the
advertised catalog with `search_tools` + `call_tool`, so a client sees two
tools instead of 127. Ranking layers an AutoCAD command / synonym corpus
(`discovery/aliases.py`, 132 tools · 148 command tokens · 583 synonyms) over
fastmcp's BM25 index, and hits are returned as one compact line per tool
rather than full JSON schemas. Measured on a 66-query golden set with a
15-query holdout: **top-1 62/66 vs 23/66** for stock BM25, top-3 64/66 vs
33/66 (holdout top-3 14/15 vs 9/15); stock returns *nothing at all* for 21 of
the 66, and this transform for none of them — that gap is the corpus, not the
ranking, and no BM25 parameter closes it. (Both sides moved slightly during
the release as tool docstrings were rewritten: better prose feeds *both*
indexes, which is why the headline is the top-1 gap rather than a ratio
between two scores over a shared corpus.) A `risk` ceiling
keeps destructive tools out of read-only answers and says when it hid one.
Off by default — flipping it changes what every connected client sees on
`list_tools`, which needs its own migration note.
- **`@cad_tool` — a discovery-metadata channel next to the tool contract.**
A decorator written *above* an `mcp.tool` registration attaches a `cad` key
to that tool's MCP `meta`: a one-line `summary` for a search hit's preview,
a `cost` from a closed set (`read`, `safe`, `mutate`, `destructive`,
`escape`) for clients that want to gate or colour-code the surface before
calling, and the tool's AutoCAD command names and synonyms — imported from
`discovery/aliases.py`, never restated, so the corpus stays the one edit
site. `tags=` and `annotations=` deliberately stay on the `mcp.tool` call, so
the channel cannot perturb `_tool_groups()` or the registration-counting
release gate. Misuse fails at import rather than silently no-opping: an
unknown cost raises, and a decorator written below the registration (or on a
bare function) raises with the ordering rule in the message. Applied to the
three `transaction_*` tools so far; every other tool still falls back to the
first line of its docstring in search results, which is why the rollout can
be section-by-section instead of a 131-tool patch.
- **`EZDXF_CALL_TIMEOUT`** (default `120`s, `0` disables): per-call timeout for
the headless backend, mirroring `COM_CALL_TIMEOUT`. Every ezdxf method runs
under one document lock, so a single hung call previously deadlocked every
later tool call.
- **`uv.lock` — the dependency graph CI actually installs.** 95 packages,
resolved universally over `requires-python >=3.11`, so one file serves the
Linux 3.11/3.12 matrix and the Windows leg (platform-only packages like
pywin32 carry markers). Every lint and test lane now installs with
`uv sync --locked`, which *fails* rather than re-resolving when the lock and
`pyproject.toml` disagree — a fresh CI run can no longer pick up an upstream
release nobody chose, and the locked set is the set the suite is green on.
The `package` job stays unlocked on purpose: it is the only job that proves
the published dependency *ranges* still resolve for a plain
`pip install autocad-mcp-pro`. The `uv.lock` line in `.gitignore` (added when
the committed lockfile was stale) is gone with it — a lockfile git ignores is
not a lockfile.
- **Scheduled floating-dependency job** (`.github/workflows/deps-floating.yml`):
weekly and on demand, it ignores the lock, resolves the declared ranges fresh
on 3.11/3.12 and re-runs the suite. Locked lanes cannot be broken by an
upstream release, which also means they cannot *notice* one; this is where a
range ceiling that needs moving shows up. It never gates a pull request.
- **Benchmark matrix v3 — five tasks that can fail.** The v2 matrix scored this
server 10/10, which was not evidence of anything: every task in it exercised
something the server was built around, so full marks were the only available
outcome. `benchmarks/tasks_v3.py` keeps the v2 ten byte-for-byte (their ids
and weights key the published v1.4 reports) and adds `tool_discovery`,
`token_budget`, `hatch_islands`, `selection_filter` and
`measure_from_handle`. Each verifies against a value derived independently of
the code under test — a closed-form area, entities placed on purpose, a token
ceiling fixed before the measurement. **Three of the five failed while being
written**, and the defects they found are in *Fixed* below.
Two are renamed from the release plan, and the renames are the point rather
than bookkeeping. `tool_discovery_bilingual` lost its "bilingual": the
Turkish query normalizer was written and then removed, because this
repository is English throughout and the Turkish was inferred from the
author's chat language, not required by the product — a task named
"bilingual" would benchmark a capability that does not exist.
`measurement_massprops` became `measure_from_handle`: no centroid or
moment-of-inertia tool ships, since F2 was cut to `entity_measure`.
The competitor reports are **not** re-scored. Those servers were pinned and
run against v2 at v1.4 and have never been asked these five questions; the
matrix chart renders the five rows blank with a `not run` legend entry rather
than zero, because an invented zero is indistinguishable from a measured one.
`run_competitors` grows `--matrix v2|v3` (v2 stays addressable so a v1.4
report can be reproduced rather than only described), records which matrix
ran in the report, and grows `--publish`, which writes the artifact-path-free
file that `results/published/` holds — that sanitisation used to be a manual
edit.
- A capability refusal in the benchmark runner is now recorded `unsupported`
instead of `fail`. The result enum has carried `unsupported` since v2 for
exactly this, but a refusal is still an exception and was being caught by the
generic handler — "your engine cannot reach this" and "your server got this
wrong" are different findings about a competitor.
- Three measurement checks added to `correctness_suite.py` (24, was 21). They
are new capability rather than repaired regressions — v1.4.0 misses them by
not having the method — and they are there because all three broke during
this release and nothing outside their own unit tests would have noticed.
### Changed
- **The backend contract is 19 modules instead of one 1854-line file, and
optional methods are declarative.** `backends/base.py` held a single ABC with
112 abstract methods; adding one made *both* backends fail at instantiation
until both were written, and two test modules construct `ComBackend` at
import time, so a half-finished contract edit took the entire suite red. The
interface now lives in `backends/contracts/`, one module per domain, split
along the section markers that were already in the file — the seams were not
re-decided. `AutoCADBackend` is the composition of all 19 and stays the
import surface, so `from backends.base import AutoCADBackend` and every
`isinstance` check are unchanged, and the abstract-method count is identical
before and after.
The split surfaced something the file's name hid: roughly 740 of those lines
are shared *concrete* implementation (premium meta-tools, GD&T, the settings
facade), not interface. Those slices are mixins and are labelled as such
rather than presented as pure contracts.
New `backends/capability.py` carries `@capability(key, reason=...)`, which
marks a contract method optional and supplies a default. The default
**raises** `UnsupportedCapabilityError` rather than returning a quiet
`{"ok": False}` — a method nobody implemented has to be indistinguishable
from a deliberate refusal, not from a soft failure, which is the distinction
this release spent a milestone restoring. A key used by the decorator must be
declared in *both* capability maps or `tests/test_capability_contract.py`
fails; without that gate the decorator would just be a way to ship refusals
`system_capabilities` never mentions, which is worse than the build break it
replaces. Its first user is COM's `entity_change_space`, which now inherits
the refusal instead of hand-rolling it. The refusal vocabulary moved into
this module as well (the contracts need it and `base` imports the contracts);
`backends.base` re-exports it, so there is still exactly one exception class
object in play.
- **BREAKING (env): the `core` tool profile was removed.** `TOOL_PROFILE` is
now `lean` or `full`; the discovery layer above solves the crowded-surface
problem `core` existed for. `TOOL_PROFILE=core` still boots — it falls back
to `full` with a warning naming the removal, and `system_about` reports the
requested profile alongside the applied one.
- **BREAKING (install): `ezdxf>=1.4` is now the floor** (was `>=1.3`). The 1.4
line is the only one this release is developed and tested against; 1.3 is no
longer exercised by CI and is unsupported. Upgrade with
`pip install -U "ezdxf>=1.4"`.
- **Runtime pins narrowed.** A published library cannot pin exactly and stay
co-installable, so what a user's `pip install` gets is still a range — and a
range is the only guard that install has. They are therefore deliberately
narrow. (`uv.lock`, above, is the exact guard on *our* side; the two are not
substitutes for each other.)
- `fastmcp>=3.0` → `fastmcp>=3.4.5,<3.5`. `fastmcp` is now a thin
meta-package (`Requires-Dist: fastmcp-slim[client,server]`) and 3.x moves
fast enough that a new minor can reshape the API the server is built on.
Tested against 3.4.5; the ceiling moves only after the suite is re-run.
- `ezdxf>=1.3` → `ezdxf>=1.4,<2` (see the BREAKING note above).
- `pydantic>=2.0` → `pydantic>=2.0,<3`, so a future pydantic 3 cannot be
pulled into an install of this release.
- **Rendering requires an extra — documented, not changed.**
`ezdxf.addons.drawing.__init__` imports `.frontend`, which does an
unconditional top-level `import PIL.Image`. Pillow is therefore a hard
requirement of *every* ezdxf render path (PNG screenshot and PDF export
alike), not just of the COM window-capture path it is listed under. A bare
`pip install autocad-mcp-pro` resolves 77 packages with neither Pillow nor
matplotlib, so it cannot render at all; `[pdf]` (or `[full]`) is required,
and satisfies Pillow transitively via matplotlib. Note that `[com]` alone
installs Pillow but not matplotlib, so the ezdxf backend still cannot render
under it. Pillow deliberately stays out of the core dependencies for now.
- **CI lint pinned to `ruff==0.16.1`** (was an unpinned `pip install ruff` /
`ruff>=0.8`). The version is now written in exactly one place —
`[dependency-groups] dev` — and frozen there by `uv.lock`; the workflows
install ruff *from* the lock instead of restating the pin, so the three
copies that had to be bumped together no longer exist.
- **Dev toolchain moved to a PEP 735 dependency group.** `[tool.uv]
dev-dependencies`, which uv now warns is deprecated, became
`[dependency-groups] dev` — same four tools, byte-identical resolution. The
standard spelling is also understood by pip >=25.1, so `pip install --group
dev` gives contributors who do not use uv the same list without a second
copy of it.
- **CI and the release gate install from the lock.** `lint` (dev group only —
ruff without building the project), `test-linux`, `test-windows` and the
`release.yml` test job now run `uv sync --locked` + `uv run --no-sync`, with
uv itself pinned in the workflow env for the same reason ruff is. `package`,
`docker` and `registry-validate` are unchanged. `benchmark.yml` is locked
too: that lane publishes evidence, and an ezdxf release moving our own
numbers between runs would make the diff unattributable.
- README rebuilt: PyPI-first install, a four-lane benchmark section with
Python-generated visuals (scores, task matrix, performance, source review),
v1.4 feature table, updated client/config/roadmap sections.
- `render_live_chart` now skips non-task-matrix reports so multiple lanes can
share `benchmarks/results/published/`.
### Fixed
- **The COM paths were run against a live AutoCAD 2026, and four of them were
broken.** Every COM implementation added in this release was written against
the ActiveX reference and shipped labelled unverified. Executing them against
a real seat — a fresh instance, a throwaway drawing, nothing saved — found
four defects that no amount of reading would have caught, one of them
pre-existing:
* **`AcadPViewport` has no `ViewCenter` member.** `viewport_list` died on it
outright. Worse, `viewport_create` has carried the same line since v1.4
wrapped in a bare `except: pass`, so it raised on every single call and
said nothing — every COM viewport has been looking at the model origin
instead of the `view_center_x/y` the caller asked for. The member is
`Target`, and the failure is no longer swallowed.
* **`ModelSpace.AddWipeout` does not exist.** ActiveX has no wipeout
constructor at all; WIPEOUT is a command. Now a typed refusal
(`capability: "wipeout"`) instead of an `AttributeError`.
* **`AcDbMText.BackgroundFillColor` does not exist** — it raises on read as
well as on write, with an int and with an `AcCmColor` object alike.
`BackgroundFill` (the on/off flag) does work, so `text_set_background`
enables and disables the mask on COM and refuses a *colour* request with
`capability: "mtext_background_color"`, rather than switching the fill on
in the wrong colour and reporting success.
* `analysis_list_properties` called a helper that does not exist on the class.
After the fixes, all 25 verification calls behave correctly: 18 succeed, 1
refuses per-argument (deleting model space), and 6 refuse with the capability
keys they declare. The remaining COM refusals
(`chspace`, `revcloud`, `boundary_trace`, `hatch_edge_paths`) were confirmed
to hold rather than leak a result. The source comments now say VERIFIED with
the date instead of UNVERIFIED.
- **The headless renderer *does* project model content through viewports.
v1.4 said it did not.** `viewport_render` was declared unsupported on ezdxf,
every `drawing_export_pdf(layout=...)` response carried the note "projecting
model content through viewports is COM-only", and the README table agreed —
so a caller with no AutoCAD was told to go get one for something that already
worked. Measured on ezdxf 1.4.4: a sheet holding *nothing but a viewport*
renders the model geometry that viewport looks at, two viewports at 1:1 and
1:2 render the same circle at two sizes in the right places, and geometry
outside a viewport's window is correctly left out. Understating a capability
is the same class of untruth as overstating one, and it is the more expensive
one for the user. What is genuinely missing is the viewport *border*, which
the headless renderer does not draw — the capability now says exactly that
(`reason: viewport_borders_not_drawn`), and the old note's claim that
"viewport frames are rendered" was wrong in the other direction. Two tests
that pinned the false claim were corrected rather than deleted, and the
replacement proves the behaviour by subtraction: the sheet renders less ink
once the model geometry is gone.
- **Layout names are matched case-insensitively, the way the engine underneath
already matched them.** ezdxf resolves layout names case-insensitively in
`get`, `new`, `rename`, `delete` and `set_active_layout` — but `names()`
returns the *stored* spelling, so every guard written as
`name in doc.layouts.names()` disagreed with the code it was guarding, in both
directions. `layout_create("SHEET")` next to an existing `Sheet` walked past
its own duplicate check and died on a raw `DXFValueError` from inside ezdxf;
`layout_set_current("SHEET")` reported "Layout not found" for a tab that was
right there. Both shipped in v1.4. One resolver now backs
create/set_current/delete/rename/copy and `viewport_list` on both engines, and
the current space is stored in the canonical spelling. Re-spelling a layout's
own name (`Sheet` → `SHEET`) is a rename onto itself rather than a collision;
ezdxf's `rename` refuses that outright, so it goes through a staging name.
- **`entity_change_space` refuses an entity that lives on another sheet instead
of half-applying.** `move_to_layout` raises `DXFValueError` from deep inside
ezdxf when the entity is not in the source layout — and it raises *after* the
transform has already been applied, so the failed call left the line rescaled
and translated by a viewport it never moved through, still sitting on the
wrong sheet. Membership is now checked before anything is written, and the
refusal names the space the entity is actually in. One bad handle in a batch
no longer aborts the good ones.
- **BREAKING (behavior): coordinates crossing the boundary are now WCS. They were
not.** DXF stores CIRCLE, ARC, LWPOLYLINE, TEXT, INSERT and a dimension's text
position in a per-entity frame derived from the entity's `extrusion` vector —
and the string "extrusion" appeared *nowhere* in this repository, so every such
read returned a raw attribute rather than a world coordinate. Mirroring flips
extrusion to `(0,0,-1)` and leaves the stored x unnegated, so after the
server's own `entity_mirror` across the Y axis a circle drawn at (30, 20)
reported `center [30.0, 20.0]` while the **same response's** `bounding_box`
said `min [-35, 15] / max [-25, 25]`: the payload contradicted itself by 60 mm,
silently, with no error anywhere. `point_from_snap` returned points on the
wrong side of the axis, `entity_select_smart` found the entity where it wasn't,
and `entity_offset` placed the new circle 60 mm away *and* grew it when asked
to shrink. Reads are now normalised at the single `_entity_info_dxf` funnel, so
all 38 call sites and every consumer of `EntityInfo.properties` are fixed with
them. The write paths (`entity_edit_geometry`, `entity_offset`) got the
matching inverse — mandatory, not cosmetic: read and write were *equally*
wrong, which made a read-then-write round trip an accidental no-op, and fixing
reads alone would have turned that no-op into a real 60 mm move.
- **Not touched, deliberately:** LINE, MTEXT, ELLIPSE, SPLINE and POINT carry
an `extrusion` attribute too and were always correct (ezdxf makes their
`ocs()` a pass-through); `entity_rotate` was never affected — it preserves
extrusion `(0,0,1)`. The trigger was `entity_mirror` and foreign DXF files.
A dimension's `defpoint` is DXF group code 10 and WCS already; only
`text_midpoint` (code 11) is in the entity frame.
- **Tilted planes.** An entity whose plane is not parallel to WCS XY cannot be
described by an xy pair — a tilted circle of radius 5.0 has a 5.0 × 3.99
footprint. `entity_get` reports the WCS centre plus `plane_normal` and
**omits** `radius` / `start_angle` / `end_angle` rather than reporting a
number that is not true in the frame the rest of the dict uses, and
`entity_edit_geometry` refuses with `capability: "ocs_tilted_plane"`,
pointing at `entity_move` (which transforms the whole entity and preserves
its plane). New capability keys `ocs_normalized` and `ocs_tilted_plane` on
both backends make the boundary pre-checkable via `system_capabilities`.
- **Named residuals** (in the `ocs_normalized` reason rather than left silent):
the OCS z component is not reported, HATCH boundaries are not reported at all
(silence, not a wrong number), and TEXT `rotation` stays in the entity frame
because a mirrored TEXT is mirror-imaged and no single scalar angle expresses
that — measured 29.999 reported against a true WCS 150.0.
- **COM:** ActiveX returns WCS natively for every point property *except*
`AcDbLWPolyline.Coordinates`, which is OCS; that one read is now translated.
Verified only against a fake COM object — this machine has a live AutoCAD and
connecting to it during development is forbidden, so the path ships
unit-tested, not hardware-verified. `AcDb2dPolyline` is deliberately out of
scope: its `Coordinates` may be 3D triples, and changing the stride on a
documentation reading alone would risk a path that works today.
- **`validate_path` handed out working handles to `C:\Windows`.** Windows names
the same file through several anchors that compare unequal. The backslash
spellings were rejected, but `pathlib` normalises the *forward-slash* forms
into them only after `.resolve()` — so `//?/C:/Windows/win.ini` slipped past
the pattern check and then failed to match `C:/Windows` in the
system-directory loop. Proven: it returned a handle that `os.path.samefile`'d
the real file. Non-local anchors are now rejected in every spelling. This
removes no capability anyone had: the equivalent backslash paths were already
refused, so it only makes one policy consistent across spellings.
- **The AutoLISP guard read drawing text as code.** The symbol denylist scanned
inside double-quoted literals, so an engineering server refused its own
vocabulary: `(setq desc "Max load 500 kg")` matched the `load` pattern and
`(setq layer "C:CENTER")` matched the custom-command pattern. Literals are now
blanked before scanning. Every known-dangerous vector still blocks, including
`(load "malicious.lsp")` and `(vl-catch-all-apply (quote command) ...)`, whose
dangerous token is outside the quotes.
- **The command denylist matched characters, not commands.** A word-boundary
regex over the whole string refused `MTEXT 0,0 10,10 Do not erase this part`
— annotation text — while missing spellings AutoCAD accepts. SendCommand takes
a *macro*: segments split on newline or `;`, and only a segment's first token
is a verb. Matching is now verb-scoped with decorators (`_`, `.`, `-`, `'`)
stripped, the set gained the load/run channels it was missing (`ARX`,
`VBASTMT`, `VBAIDE`, `CUILOAD`, `SCR`, `SAVE`, `EXPORT`, `ETRANSMIT`, …), and
the refusal now names the offending verb — previously a user whose annotation
was refused had no way to tell why. Single-letter aliases stay out on purpose:
`E` is the ERASE alias but also ZOOM's Extents option, so blocking it would
refuse `_ZOOM\nE\n`, the canonical zoom-extents macro.
- **README no longer claims the denylist is a security boundary.** It said
"blocked by default, with regression tests for known bypass patterns"; the
bypasses above had no tests, and no denylist over a command language that
loadable modules extend can be complete. It now says what the guard is — a
guardrail against an agent accidentally issuing `ERASE ALL` — and names the
actual boundaries: `ALLOWED_PATHS`, not exposing the server over the network,
and the typed tools. The same sentence is in the two tool docstrings, so it
reaches the model that calls them. *Not* changed: blocking `INSERT`/`SAVEAS`
as command strings while `block_insert`/`drawing_save_as` exist was flagged as
incoherent and is not — the policy is scoped to the free-text channel, and the
typed tools take validated arguments where the raw commands take file paths.
The reasoning is recorded in `security.py` so it is not re-opened.
- **`config.settings.backend` was a control that controlled nothing.**
`_make_backend` read `os.environ["AUTOCAD_MCP_BACKEND"]` directly, so the
setting had no production reader at all and
`monkeypatch.setattr(config.settings, "backend", "ezdxf")` — which two tests
used — read like a safety guard while doing nothing. On a developer machine
with AutoCAD open that meant `auto`, i.e. attaching to the operator's live
unsaved drawing and creating entities in it. `Settings.backend` is now a
read-only property that reads the environment live, `_make_backend` goes
through it, and the test suite pins the headless backend for every test via an
autouse fixture (opt out with `@pytest.mark.live_backend`). Assigning to
`config.settings.backend` now raises `AttributeError` instead of silently
protecting nothing. `monkeypatch.setenv` — what the correct call sites already
used — is unaffected.
- **A capability refusal now survives the trip to the client.** The backends
always knew exactly why they could not do a thing — they raise
`UnsupportedCapabilityError(capability, message)`, where `capability` is a key
of the capability map, so `system_capabilities` answers the same question
up-front. None of that reached anyone: `FastMCP.call_tool` rewraps a
non-`FastMCPError` as `ToolError(f"Error calling tool {name!r}: {e}") from e`,
and while the `from e` keeps the cause alive in-process (which is why
`cad_batch` could classify refusals), `__cause__` does not serialise. A remote
client got an English sentence, and telling "this backend cannot" from "your
arguments were wrong" meant substring-matching prose. A refusal now returns
`is_error: true` with
`{"ok": false, "kind": "unsupported", "capability", "error", "tool",
"backend"}` in `structured_content`, produced by one new
`CapabilityRefusalMiddleware` — no tool signature changed. *Not* done by
rebasing the exception on `FastMCPError`: that was measured and changes
nothing a client can observe, since neither the payload nor the error flag
travels with the exception class. Non-capability errors are byte-for-byte
unchanged. `UnsupportedCapabilityError` moved to `backends/base.py` (still
importable from `backends.ezdxf_backend`, and the *same* class object) so both
backends raise one type, and the five headless `solid_*` methods now raise it
instead of returning `{"ok": false}` — same payload, but `is_error` stops
being quietly `false` on a call that did nothing.
- **BREAKING (behavior): `cad_batch` counted a step that declined *by value* as
a success.** A tool that returns `{"ok": false, ...}` rather than raising was
recorded `status: "ok"` and folded into `succeeded`, so a batch could report
`succeeded: 12` over work that never happened — the same class of lie as the
DWG bytes and the unverified rollback. Such a step is now `status: "error"`
with kind `unsupported` (when it names a capability) or `refused`, counts
toward `failed`, and honours `on_error` like any other failure. Batches that
relied on, say, a no-op `drawing_redo` passing silently will now stop or roll
back; that is the point. **Read-only tools are excluded on purpose**:
`validation_check` / `drawing_critique` / `drawing_preflight` answer
`{"ok": len(issues) == 0}`, so their `ok: false` means *I found something*,
not *I could not run* — reading the two the same way would let a read-only
check trigger a rollback that destroys the batch's own geometry. The
discriminator is the `readOnlyHint` annotation each tool already publishes.
- **BREAKING (behavior): `drawing_audit` said "0 errors" about documents it had
just silently rewritten — and then threw the repairs away.** `Drawing.audit()`
is not a read: it fixes every fixable problem in place, so `auditor.fixes` is
a log of mutations already applied. The tool discarded that list entirely,
never marked the document dirty — so `drawing_close(save=True)` saw a "clean"
document, skipped the save, and the repairs it had just made were lost — and
rendered the errors it *did* report through `str()` on an `ErrorEntry` that
defines no `__str__`, i.e. as `<ezdxf.audit.ErrorEntry object at 0x...>`.
The tool now returns `repaired` / `fixes` / `fix_count` alongside
`errors` / `error_count`, both as `{code, name, message, handle}` dicts, and
marks the document dirty when it repaired something. **The behavioral break:
an audit that repairs now leaves unsaved changes, so a following
`drawing_close(save=True)` rewrites the file where it previously did not.**
That is the repair finally persisting; the tool was already annotated
`readOnlyHint: false` / `cost: mutate`. **Audit can delete entities** — an
entity whose owner handle points nowhere is removed, not patched — and with
the dirty flag fixed, that deletion now reaches disk on the next save where
before it was accidentally discarded. ezdxf reports those particular fixes
without the entity attached, so `handle` is `null` for them; the message names
the type and handle in prose. Read `fixes` before saving. On the COM backend `_AUDIT Y` likewise
repairs, but AutoCAD returns nothing machine-readable over `SendCommand`, so
the counts come back as `null` with `detail: "unavailable"` and
`capability: "audit_detail"` — never `0`, which would claim nothing was wrong
— plus the path of the `.adt` log. That backend also no longer leaves the
user's `AUDITCTL` permanently set to 1.
- **BREAKING (behavior): the ezdxf backend no longer writes DXF bytes into a
`.dwg` name.** `drawing_save("part.dwg")` previously called `doc.saveas()`
and produced a file whose contents did not match its extension — AutoCAD
cannot open it, and nothing in the response said so. Both save paths now
refuse, driven by the extension, so `drawing_save_as("x.dwg", fmt="dxf")` is
refused too. Refusals carry a machine-readable
`{"ok": false, "capability": "dwg"}`, and `dwg` is now a declared capability
in both backends (ezdxf: unsupported; COM: native). Save as `.dxf`, or use
the COM backend for DWG.
- **`drawing_open` no longer pretends it can read DWG.** ezdxf detects format
by content, so a DXF renamed `.dwg` (exactly what the bug above produced)
opened "successfully" as a document that does not exist in that format,
while a genuine DWG died with a raw `OSError` blaming the file rather than
the backend. The tool now refuses `.dwg` unless the active backend declares
the capability, and its parameter text no longer advertises DWG
unconditionally. Workflows holding files produced by the old `drawing_save`
bug must rename them to `.dxf`; the error message says so.
- **`TOOL_PROFILE` was a silent no-op whenever a tool transform was attached.**
`_registered_tools()` read the post-transform tool list, so with a transform
in place it saw 2 tools instead of 131 — the lean profile computed 0
disabled tools and `system_about` reported a collapsed inventory. It now
reads the underlying registry.
- **`ruff format --check .` failed on unmodified `main`.** Ruff 0.16 began
formatting Python code blocks inside Markdown, so the committed
`docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md`
started failing the format gate with no source change. Reformatted the file
and pinned the CI ruff version so a future release cannot re-break the gate
the same way.
- `release.yml` now runs `ruff format --check .` alongside `ruff check .`,
matching the CI lint job — the release gate previously skipped formatting.
- **A HATCH could not be measured, and the refusal listed HATCH as
measurable.** `entity_measure` had no HATCH branch, so a hatch fell through
to the generic error — whose own text named HATCH among the measurable types.
Whichever half was right, the pair could not both be. It now walks the
boundary paths and reports the area the hatch actually *fills*: outer loops
minus the islands inside them, 300 rather than 400 on a 20×20 square with a
10×10 island. Nesting depth is computed by containment rather than read off
the boundary flags, because DXF marks a path external or outermost and never
"two levels down" — and depth is exactly where AutoCAD's three island styles
stop agreeing. `hatch_style` comes back with the number so it can be read
against the drawing's own setting. On a curved edge `flatten_tolerance` is
not the accuracy knob it looks like: ezdxf hands boundaries over as cubic
Beziers, so tightening it converges on the Bezier's area, ~0.028% above the
circle it stands for, which is why `exact` goes False and stays False.
- **`boundary_trace` and `entity_measure` disagreed about the same shape.**
The boundary tools reported an area taken from a copy of the loop flattened
at a fixed 0.01 sag while `entity_measure` read the stored bulges
analytically: 139.2177 against 139.2699 for one figure, with nothing in
either payload marking one as the approximation. The polyline the boundary
tools store carries exact bulges, so the exact answer was always available;
both now read it and round to the same six decimals. The flattened figure
survives only where it is harmless — ranking candidate loops, where a 0.01%
error cannot change which is smallest.
- **A circle stored as two bulged vertices measured 0.0.** Two semicircular
arcs joined into a circle store as a *two*-vertex closed LWPOLYLINE with both
bulges 1.0 — what AutoCAD writes for a JOINed pair of arcs, and what
`boundary_from_entities` builds from any two-arc loop. `polygon_area_perimeter`
treated fewer than three vertices as degenerate and returned zero, when the
shoelace over two points is genuinely 0 and the two circular segments are the
entire shape. Only zero or one vertex bounds nothing now.
- **The v1.4 → v1.5 performance comparison was measured, and it is not
flattering.** The published perf report was recorded on CPython 3.14 and the
new one on 3.11.15, which alone accounts for nearly all of an apparent 2–6×
"speedup". Re-running v1.4.0 in a worktree on the same interpreter with the
same suite file gives the real picture: entity creation is **28% slower**
(245 → 315 ms for 2,000 lines) and the 10,000-line roundtrip **22% slower**.
Fully attributed — `EZDXF_CALL_TIMEOUT=0` returns v1.5.0 to 244.8 ms and
1,679 ms, v1.4.0's numbers exactly. The whole cost is the per-call
`asyncio.wait_for` this release wrapped around every headless call so that
one hung ezdxf call can no longer wedge a server whose document lock is a
single `asyncio.Lock`. Deliberate trade, documented knob, and on the 1.6
roadmap to win back.
_The five items carried into this release from 1.4 are all done: screenshot
overlay + handle grounding, ezdxf undo/redo, COM `block_create_from_entities`,
ISO 286 transition/interference holes, and titleblock on paper-space layouts.
ISO 286 shafts r/s/t/u are deliberately **not** done — they need table data
nothing here can derive, and the refusal says so rather than guessing._
## [1.4.0] — 2026-07-23
Release infrastructure + the roadmap features shipped together. **474 tests,
Ruff lint- and format-clean. Tool count: 122 → 131.**
### Added
- **CI restored and extended** (`.github/workflows/ci.yml`): ruff lint/format,
Linux test matrix (3.11/3.12) with coverage, a **Windows leg** running the
mocked-COM suite on the platform the COM backend targets, a package job
(build + twine check + clean-venv wheel smoke incl. `autocad-mcp --help`),
a Docker build/smoke job, and MCP `server.json` schema validation.
- **Release pipeline** (`.github/workflows/release.yml`): tag `v*` → test gate
→ tag/version match check → build → **PyPI trusted publishing** (OIDC) →
GitHub Release with artifacts.
- **MCP registry manifest** `server.json` (schema 2025-12-11, name
`io.github.u-c4n/autocad-mcp`, PyPI package) plus the `mcp-name` ownership
marker in the README and an `autocad-mcp-pro` console alias so
`uvx autocad-mcp-pro` works directly.
- **Live competitor benchmark lane**: a generic black-box MCP-stdio driver
(`benchmarks/adapters/mcp_stdio.py`), reproducible pinned checkouts
(`benchmarks/competitors_env.py`), and adapters for
**puran-water/autocad-mcp** and **beiming183-cloud/AutoCAD-MCP** that run
the same 10-task matrix over stdio with harness-side DXF verification.
Published results (`benchmarks/results/published/`): autocad-mcp-pro
**100.0**, beiming183 **50.0**, puran-water **45.0**; rendered to
`docs/assets/autocad-mcp-livebench.svg`. Weekly/dispatch CI workflow.
- **Tool profiles** (`TOOL_PROFILE=lean|core|full`): capability-aware
discovery applied in the server lifespan — `lean` is a curated ~46-tool
drafting core, `core` hides raw escape hatches and long-tail tools, `full`
(default) exposes everything. Reported by `system_about`.
- **Paper space / layouts**: `layout_list`, `layout_create`,
`layout_set_current`, `viewport_create` (scaled model viewports) on both
backends, and `drawing_export_pdf(layout=...)` for plotting a layout.
Headless limitation is explicit: viewport model-content projection is
COM-only (`viewport_render` capability).
- **ISO 286 limits and fits** (`engineering/fits.py`): authored table data
(IT4–IT11, sizes 1–500 mm; shafts d/e/f/g/h/js/k/m/n/p, holes D/E/F/G/H/JS)
with `fit_lookup("H7", 20.0)`; `dimension_linear/radius/diameter` gained a
`fit` parameter that resolves deviations from the measured nominal and
appends the fit code to the dimension text. Out-of-scope letters raise a
clear error naming the supported set.
- **Opt-in native 3D solids** (`ENABLE_3D=true`, COM only): `solid_box`,
`solid_cylinder`, `solid_extrude`, `solid_revolve`, `solid_boolean`.
Hidden from discovery and rejected while disabled; ezdxf reports the honest
`solid_3d` capability boundary (ACIS cannot be generated headlessly).
- **Release-consistency test suite** (`tests/test_release_consistency.py`):
pyproject ↔ version.py ↔ CHANGELOG ↔ README snapshot ↔ server.json versions,
README tool/resource/prompt counts vs live registrations, per-section header
counts vs decorators, Dockerfile COPY vs wheel `only-include`, and the
README `mcp-name` marker.
### Changed
- **pyproject metadata completed** for PyPI: SPDX license + license file,
authors, keywords, classifiers, project URLs.
- **`build_dim_override`**: `deviation`/`limit` modes now pass DIMTM signed
(`-lower`) instead of `abs()` — double-positive ISO 286 fits (e.g. p6
+0.035/+0.022) render correctly; the legacy "positive magnitude = minus
deviation" contract is unchanged.
- **Capability maps**: `paper_space` is now native on both backends;
new `viewport_render` feature (COM native / ezdxf unsupported);
`solid_3d` reflects the ENABLE_3D gate on COM.
- README rebuilt around the live-run benchmark lane; CI/PyPI badges added;
`ruff format` applied repo-wide and enforced in CI.
### Fixed
- **Dockerfile shipped a broken image**: `engineering/` and `version.py` were
never copied, so engineering tools failed in containers. Both are now
copied (plus README/LICENSE for metadata) and the healthcheck imports the
engineering package.
- `server.py` section-header tool counts and the CLAUDE.md inventory were
stale (six sections drifted); both now match the live surface and are
locked by tests. Removed the reference to the non-existent
`.claude/skills/` directory.
## [1.3.0] — 2026-07-17
Closed-loop quality, production annotation, and auditable delivery. **415 tests,
Ruff lint-clean. Tool count: 116 → 122.**
### Added
- **`drawing_preflight`** normalizes production requirements, reports missing or
conflicting facts before geometry starts, and emits a deterministic SHA-256
spec hash that `drawing_plan` can enforce.
- **`drawing_refine`** runs a bounded `critique → repair → re-critique` loop.
Each repair round is isolated in its own transaction and rolls back if score
regresses or hard-error count increases. Construction, duplicate, layer
color/lineweight, untrimmed endpoint, and dimension-overlap repairs are
supported; undefined GD&T datums remain explicitly manual.
- **TABLE and MLEADER semantics** via `entity_create_table` and
`leader_create_mleader`: native ActiveX entities on COM and deterministic,
portable LINE/LWPOLYLINE/MTEXT composites on ezdxf. Composite creation
returns child handles and a logical ID; after DXF reopen, inspection uses the
persisted standard child entities rather than a native TABLE/MLEADER object.
- **`system_capabilities`** and shared typed backend capability maps distinguish
native, rendered, composite, snapshot, shared, and unsupported features.
- **`drawing_deliver`** creates DXF/PDF/PNG bundles, runs validator + critique,
re-opens the canonical DXF for entity/type/layer/bounds parity, hashes every
artifact, and writes `manifest.json` plus `validation.json`. Failed gates keep
artifacts for diagnosis but never report delivery success.
- **Benchmark v2**: ten fixed vendor-neutral tasks, an adapter interface, an
ezdxf/COM reference adapter, cooperative per-task timeout reporting,
fixed-matrix coverage-aware scoring, machine-readable statuses,
runtime/environment/capability metadata, and artifact SHA-256.
### Changed
- ezdxf transaction snapshots are isolated from user-facing undo history, so a
refiner rollback cannot consume an unrelated undo entry.
- Package metadata is the canonical version source used by `system_about` and
delivery manifests.
- `benchmarks/correctness_suite.py` now shows usage for argless/`--help` calls
instead of raising a traceback.
### Fixed
- **Install from source** — `pip install -e ".[full]"` failed at
"Preparing editable metadata" because the flat layout has no directory
matching the project name, so hatchling could not infer which files to ship.
`pyproject.toml` now declares the wheel file selection explicitly
(`server.py`, `config.py`, `security.py`, `backends/`, `engineering/`). (#2)
- **`dimension_linear` on the COM backend** — always crashed with
`<unknown>.AddDimLinear`: the AutoCAD ActiveX API has no `AddDimLinear`
method. Linear dimensions are now created via `AddDimRotated` (identical
argument order), with a mocked regression test. (#3)
## [1.2.0] — 2026-07-07
Production-ISO parity + a measurable quality moat. **360 tests, ruff-clean.** Tool count: 111 → 116.
### Added
- **`drawing_settings`** — read or change common AutoCAD drawing settings by
friendly name (units mm/cm/m/inch/feet, linear/angular precision, LTSCALE,
DIMSCALE, text size, point mode/size, OSMODE, fillet radius) without
memorising system-variable names. Call with no argument for a full snapshot.
A convenience facade over `system_get_variable` / `system_set_variable`;
cross-backend.
- **In-place editing** — `entity_edit_text` re-labels or resizes an existing
TEXT/MTEXT entity, and `entity_edit_geometry` re-drives a CIRCLE/LINE/ARC
(center, radius, endpoints, arc angles) — both **preserve the entity handle**,
so the user can adjust a drawing without delete-and-recreate. Cross-backend.
- **2D GD&T (ISO 1101 / ASME Y14.5)** — `gd_frame` draws a feature control frame
(all 14 geometric characteristics, ⌀ zone prefix, Ⓜ/Ⓛ/Ⓢ material modifiers,
multi-datum references) and `datum_feature` places a datum symbol. Frames are
composed from LINE + TEXT so they render identically on **both** the COM and
ezdxf backends (ezdxf's native TOLERANCE entity renders blank via matplotlib).
No competing CAD MCP or surveyed text-to-CAD product ships 2D GD&T authoring.
- **GD&T datum-consistency gate** — a new `gdt` critique focus fails
`drawing_finalize` when a feature control frame references a datum with no
matching datum feature (a meaningless FCF per ISO 1101).
- **ISO 129 dimension tolerances** — `dimension_linear` / `dimension_radius` /
`dimension_diameter` gain `tol_upper` / `tol_lower` / `tol_mode`
(`symmetric` ±, `deviation` +a/-b, `limit` stacked, `basic` boxed) and a
`text_override` (e.g. `⌀20 H7`). Toleranced production dimensions are now
possible — previously the single biggest functional gap.
- **Scalar drawing-score + invalidity ratio** (`engineering/scoring.py`) — the
`drawing_finalize` payload now carries a 0-100 `score`, an `invalidity_ratio`,
and an A-F `grade` over the union of the structural validator and the premium
critique, so drawing quality is regression-trackable (MUSE / CadBench grade an
Invalidity Ratio, not shape).
### Fixed
- **Honesty**: the COM backend advertised a false `all_entity_types` capability
(it authors 2D entities only, no 3D solids) — corrected to `entities_2d`.
## [1.1.1] — 2026-06-20
### Added
- **`selection_get`** (Entity Query, COM backend): reads AutoCAD's implied
("pickfirst") viewport selection — the entities the user highlighted with
grips before invoking the AI — and returns their handles + `EntityInfo`.
This lets the AI scope work to exactly what the user picked
(`dimension_auto(selection_get()["handles"])`) instead of acting on the whole
drawing. Resolves [#1](https://github.com/U-C4N/Autocad-MCP/issues/1) — the
layer-juggling workaround is no longer needed. Surfaces the `PICKFIRST` sysvar
state so an empty selection is self-explanatory. The ezdxf headless backend
has no viewport, so it returns `ok=False` with an empty handle list (same
shape, never raises). Tool count: 110 → 111.
## [1.1.0] — 2026-06-19
Correctness, cross-backend parity, and an **enforced** quality gate, landed across four audited
sprints (see `docs/analysis/`). 318 tests, ruff-clean.
### Added
- **Premium drafting workflow** (shared across both backends): `drawing_plan`, `drawing_critique`
(ISO-128 focuses), `point_from_snap`, `drawing_apply_iso_layers`, `dimension_auto`,
`entity_select_smart`, `construction_xline` / `construction_clear`.
- **Deterministic geometry** for exact OSNAP coordinates: `point_intersection`
(line/line, line/circle, circle/circle) and `point_tangent` (external point → circle).
- **Engineering / deterministic CAD layer**: involute gear front view + section A-A,
DIN 6885 keyed bore, ISO A3 title block, and the 8-step `drawing_finalize` validator.
- **HTTP bearer-token auth**: `StaticTokenVerifier` wired into FastMCP when `MCP_AUTH_TOKEN` is set.
- Mocked-COM test harness (`tests/test_com_backend.py` + Sprint-3/4 suites) — COM logic is now
regression-tested on Linux CI without a live AutoCAD.
- `entity_create_mtext` rotation parameter; `dimension_auto` layer override; `bounding_box` /
ARC `length` / TEXT-MTEXT `rotation`+`char_height` on both backends; `BlockInfo.description`.
- Security module (`security.py`), centralized config (`config.py`), `.env.example`, path
validation, command/LISP sanitization, ruff config, pre-commit hooks, pytest-cov.
### Changed
- **`drawing_finalize` now enforces the premium critique** in addition to the structural validator:
leftover construction geometry, non-ISO-128 lineweights, untrimmed corners, duplicate entities,
and dimension overlap block the gate (was advisory-only). `strict_critique=True` fails on warnings too.
- **`drawing_save_as` derives the on-disk format from the file extension** — `part.dxf` writes DXF,
not DWG. ezdxf refuses to mislabel a `.dwg` file.
- Dimension and construction layers resolve from the active layer set (iso13567 → `M-DIMEN-T-N` /
`M-CONST-E-N`, not the hardcoded `DIM` / `CONSTRUCTION`).
- Premium meta-tools lifted into the shared `AutoCADBackend` base class (single source of truth).
- Dead code removed (`section.py`, `generate_tooth_profile`, `CommandResult`, `set_layer_active`, …).
### Fixed
- ezdxf `dimension_aligned` / `dimension_angular` raised `TypeError` (wrong ezdxf 1.4 args) — fixed.
- Full-circle polar array placed a duplicate copy over the original — fixed (divisor = count).
- `entity_offset` ignored `side_x`/`side_y` on both backends — now honored (and COM no longer leaks extras).
- COM `entity_create_hatch` built an associative hatch then deleted its boundary — now non-associative.
- Lineweight mm-vs-hundredths truncation wiped ISO-128 weights — fixed via `normalize_lineweight`.
- **`view_screenshot` / `drawing_export_pdf` could SIGSEGV** (matplotlib GUI backend in a worker thread)
— now render headless via `Agg` (`Figure` + `FigureCanvasAgg`).
- `entity_select_smart` `length_range` silently rejected all ARCs (no `length`) — fixed.
- Gear tooth profile self-overlapped for high tooth counts (z ≥ ~42); section view drew duplicate
bore lines; validator keyway heuristic was a permanent false-negative — all fixed.
- COM `system_run_lisp` always reported `"nil"`; `system_set_variable` didn't coerce numeric sysvars
— fixed. `system_about` tool groups / `_registered_tool_count` no longer drift or surface `-1`.
- `drawing_new` bootstrap failures now surface as `degraded` instead of reporting success.
### Security
- HTTP remote-bind guard now fires on **every** launch path (including `fastmcp run server.py:mcp`),
not only the `__main__` block — closing an anonymous-remote-bind gap.
- AutoLISP allowlist bypass-vector regression tests (newline injection, symbol aliasing, `vla*`/`acet-*`,
`c:` custom commands).
- COM apartment leak bounded (`CoUninitialize` on teardown); transaction commit/rollback and
`system_run_lisp` now respect the CMDACTIVE guard.
## [1.0.0] — 2026-03-01
### Added
- Initial release with 67 tools, 6 resources, 5 prompt templates
- Dual-engine architecture: COM backend (live AutoCAD) + ezdxf backend (headless)
- FastMCP 3.0 server with middleware stack (error handling, audit, timing, logging)
- Drawing management: new, open, save, save-as, export DXF/PDF, purge, audit, undo/redo
- Entity creation: line, circle, arc, polyline, rectangle, text, mtext, hatch, spline, ellipse, point, block reference
- Dimensions: linear, aligned, angular, radius, diameter
- Entity modification: move, copy, rotate, scale, mirror, offset, delete, rectangular/polar array
- Layer management: create, delete, set current, modify, freeze/thaw, lock/unlock, hide/show, isolate
- Block operations: list, insert, explode, attributes, create from entities, find references
- Analysis: entity stats, region search, distance/area measurement, bounding box, select by layer/type
- View control: zoom extents/window, screenshot
- Transaction support: begin, commit, rollback
- System tools: status, variables, command execution, AutoLISP evaluation
- 5 prompt templates: floor plan, P&ID, electrical schematic, mechanical drawing, quick drawing