scripts/smoke_pid_com.py runs the whole track against the live AutoCAD in a new document. ComBackend.connect() is lazy by design, so the script reads reachability from system_status()["connected"] rather than from connect() as the plan sketched, and closes its document without saving unless --keep is passed. On this machine (AutoCAD 2026, "25.1s (LMS Tech)") it exits 0: 5 nodes, 4 edges, 0 dangling, confidence_min 1.0, 0 critique issues, 2 XDATA chunks — identical to the headless engine on the same spec. The first attempt exited 2 on a stale win32com gen_py cache (a package folder holding only __pycache__); an environment fault, no backend change needed. A/B against v1.5.1 published as ab-v1.5.1-vs-v1.6.0-dev.json: 26/29 -> 29/29, three miss -> pass, zero regressed. check_doc_numbers.py now names the report and the two row labels in one place (AB_REPORT, AB_*_LABEL), and README.md / benchmarks/README.md carry the new rows; the v1.5.0 and v1.4.0 tables stay as history. README: P&ID row, the showcase and catalogue images, the token table re-measured (161 / 44,930 by default, 152 / 41,420 with TOOL_PACKS=core, lean 50 / 14,007), every stale count reconciled (154 -> 166 registered, 149 -> 161 advertised, corpus 158/672 -> 162/731 by the same count, 19 -> 20 groups, 6 -> 8 resources, six-task matrix with pid_roundtrip), a "Known limitations of the P&ID track" subsection, and the snapshot line at this machine's 2423 collected tests (CI has not run on this tree; noted in the changelog with the pre-existing 1369 vs 1363 drift). CLAUDE.md: section 17, sections 5 and 7 counts, TOOL_PACKS, the two 1.6 contracts and the reader rules. Spec status -> implemented. The plan and spec markdown were reformatted by ruff (fenced Python only) because `ruff format --check .` was already red on them at HEAD and the gate requires the whole tree clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
82 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Track A of 1.6: the server becomes a P&ID expert and a P&ID reader. Nine
pid_* tools in a new SECTION 17, two generic contracts every engine
implements, six critique focuses that join drawing_finalize, and a
TOOL_PACKS gate so a mechanical-only client never pays for the P&ID
surface. Spec: docs/superpowers/specs/2026-09-15-v1.6-pid-design.md.
Added
- P&ID — 9 tools, pack
pid.pid_symbol_list/pid_symbol_insert: a 43-symbol ISO 10628-2 / ISA-5.1 catalogue authored in code (11 valve bodies × 6 actuators, 6 rotating machines, 4 heat exchangers, 6 parametric vessels with nozzle ports, 7 inline items, the instrument bubble in 4 types × 5 locations, 5 line markers, 3 connectors), placed as real blocks with aTAGATTRIB, named ports, and anACADMCP_PIDXDATA payload. Ports are transformed through the INSERT's real rotation, scale and mirror; a stretched symbol (|x_scale| ≠ |y_scale|) has no circle for a radial port and is refused rather than answered with a wrong point.pid_line_draw: port-to-port orthogonal routing (autopicks from seven candidates by fewest bends, then length;direct; or waypoints), eight ISA-5.1 line classes mapped to layer / linetype / marker blocks, and line numbers filled from a format (default{size}-{service}-{seq}-{spec}-{insulation}; an empty field drops out with its separator). A route that cannot honour thestubon both ports is refused by name (route((0,0), 0°, (3,8), 180°, stub=5)raises) rather than drawn short; a non-axis port direction, a NaN coordinate and an unfillable format token are refused before anything is written. Lines it crosses are counted and reported (crossings), never silently drawn over — and never refused.pid_tag_parse: the ISA-5.1 identification-letter grammar (Table 4.1, pinned bytests/data/isa51_tags.json), with the equipment-prefix map.pid_graph: the reader. Every symbol INSERT becomes a node with asourceand aconfidence— catalogue name 1.0,ACADMCP_PIDpayload 0.95,TAG-style attribute or keyword block name 0.6 with inferred ports, an INSERT a plain line merely touches 0.3. Confidence is the minimum of its inputs, never raised by agreement between heuristics. Each line end resolves to a port, a junction on another line, ordanglingwith the nearest connectable port as a hint, always within its own space (scope="all"never joins two layouts). Edgelengthis the bulge-aware length the engine reports, not a chord walk (12.5 % short on a semicircular jump).pid_instrument_index/pid_line_list/pid_equipment_list: derived from the graph only, optionally written as CSV. An instrument tapping a pipe reports the pipe's line number, never its own signal wire.pid_from_spec: a whole sheet from one JSON spec in one transaction — rolled back entirely on the first refusal — returning the handles, the graph and the critique;dry_run=Trueroutes in memory and touches nothing.
block_define(name, entities, attdefs)on both engines: a block from typed primitive specs (line, circle, arc, polyline, text, solid) and ATTDEFs, no source entities needed;overwrite=Truereplaces the contents so existing INSERTs keep the name. Validation runs before any write: a DXF-illegal layer name, a duplicate or trailing-newline ATTDEF tag, a multi-line or trailing-caret string areTypeError/ValueErrornamingentities[i]/attdefs[i], and nothing is created.entity_get_xdata/entity_set_xdataon both engines, typed by DXF group code. The two limits AutoCAD enforces silently are enforced loudly and before the write: a string over 255 characters and an entity budget of 16 KB across every application (three 15 KB apps on one entity used to pass headlessly and would have failedSetXDatalive). LF/CR in a string is refused — it split a DXF tag, the saved file failed to reopen and, because every undo snapshot is a DXF save,transaction_rollbackbroke after one such write.nan/infare refused rather than written as literal text that reads back asnull.- Six critique focuses —
pid_dangling_line,pid_duplicate_tag,pid_incompatible_connection,pid_untagged_instrument,pid_illegal_tag,pid_unconnected_equipment— run underfocus=Noneand insidedrawing_finalize, over one cached graph. They score evidence only: a drawing without a recognised P&ID node returns[], a 0.3 touch guess can neither create nor suppress a finding, and a line ending on a sheet frame (an INSERT the drawing does not vouch for) is dangling.drawing_refinehints name only portspid_line_drawaccepts. - Two resources:
autocad://pid/symbols(the catalogue with ports and parameter schemas) andautocad://standards/isa51(the letter tables and the grammar's disambiguation rules). - Benchmark evidence.
benchmarks/tasks_v4.pyaddspid_roundtrip(the example sheet drawn throughpid_from_spec, read back by a graph builder that never sees the spec); the correctness suite grows to 29 with three P&ID checks. A/B againstv1.5.1: 26 / 29 → 29 / 29, threemiss → pass, zero regressed (ab-v1.5.1-vs-v1.6.0-dev.json). scripts/smoke_pid_com.pyruns the track once against the live AutoCAD on the machine, in a new document;scripts/render_pid_catalog.pyandscripts/render_readme_pid.pyrebuild the two README images.
Fixed
- Headless
block_insert(attributes=…)lost its ATTRIBs. The ezdxf path usedadd_auto_blockref, which wraps the INSERT in an anonymous*Ublock: the handle it returned reportedblock_name*U1, andblock_get_attributeson it returned{}. Reproduce on v1.5.1 withblock_create_from_entitiesof a block with one ATTDEF, thenblock_insert(name, attributes={"TAG": "P-101"})andblock_get_attributes(handle). It now inserts the named block withadd_auto_attribs, so the INSERT carries the name and the ATTRIBs, on both engines alike.block_insertof an undefined name is refused before writing (it used to return a handle whose dangling INSERTdrawing_auditlater deleted). - Live LWPOLYLINEs report as
POLYLINEthrough ActiveX (AcDbPolyline), which the P&ID line reader's type filter excluded: on AutoCAD every auto-built line number restarted at seq 1 andcrossingswas always 0. Both spellings are accepted; the COM_entity_infopolyline branch now returnspoints(and per-vertexbulges) for a live LWPOLYLINE. - Polyline
bulgesnow leave both engines in WCS: a mirrored polyline's arcs were handed out in the entity frame next to WCS points, so a reader tested them on the wrong side of the chord. - INSERT properties gain
geometry_bbox(drawn geometry only, ATTRIBs excluded) andmirroredon both engines. The COM box is exact at right angles and measured off them; a member ActiveX cannot measure is carried by its corners and the box saysapproximate: true.
Changed
TOOL_PACKS=all|core,pid(defaultall) advertises only the vertical packs a client uses;coreis always on and unknown names are ignored with a warning. It composes withTOOL_PROFILE=lean(which now carriespid_symbol_insert,pid_line_drawandpid_graph: 50 tools, 47 withTOOL_PACKS=core) andENABLE_3D;system_aboutreportstool_packs. Measured idle cost: 161 tools / 44,930 tokens by default, 152 / 41,420 withTOOL_PACKS=core.PID_LAYERS(drawing_apply_iso_layers("pid")) gainsPROCESS-LINE-TEXT;drawing_plan/drawing_preflightreportLAYER_SET_INTENT_MISMATCHwhen a P&ID intent is planned onto a non-pidlayer set.- The
prompt_pid_diagramprompt template is rewritten onto the tools (pid_from_spec,pid_symbol_insert,pid_line_draw,pid_graph) instead of describing primitives. CritiqueFocusgrows by the sixpid_*values; benchmark matrix v4 (--matrix v3/v2reproduce the earlier sets); correctness suite 29.- README release snapshot: 166 tools · 8 resources · 5 prompts · 2423 collected tests. The collected-test figure is this machine's; CI has not run on this tree yet, and the pre-existing 1369-vs-1363 drift between the README and Linux collection predates the track and is superseded by the new figure.
Live COM smoke
scripts/smoke_pid_com.py, AutoCAD 2026 (25.1s (LMS Tech)), Windows 11,
2026-09-16, in a new document (Drawing2.dwg) — block_define →
pid_symbol_insert → entity_set_xdata → pid_line_draw → pid_graph →
six focuses → entity_get_xdata, all through the COM engine, exit 0:
{
"autocad": "25.1s (LMS Tech)",
"active_document": "Drawing2.dwg",
"handles": {"P-101": "98", "V-201": "A8", "FCV-101": "B8", "FIC-101": "C5", "OP-1": "CF"},
"graph_stats": {
"nodes_by_kind": {"connector": 1, "equipment": 2, "instrument": 1, "valve": 1},
"edges_by_class": {"electric": 1, "process_major": 3},
"dangling": 0,
"unclassified": 0,
"confidence_min": 1.0,
"entities_scanned": 16
},
"crossings_total": 0,
"critique_issues": 0,
"critique": [],
"xdata_chunks": 2
}
The headless engine reports the identical graph_stats, crossings_total,
critique_issues and xdata_chunks on the same spec. The first attempt
exited 2 on this machine because of a stale win32com gen_py cache for the
AutoCAD type library (a package directory holding only a __pycache__) — an
environment fault, not a backend one; deleting %TEMP%\gen_py\3.11 cleared
it and no backend change was needed.
[1.5.1] — 2026-08-06
A patch for three defects found after 1.5.0 went to PyPI, two of them by the adversarial pass over the rewritten README. 1.5.0 is yanked: its diameter and radius callouts put wrong numbers on drawings at default settings, and that is not something to leave installable.
Fixed
- The HTTP bind guard failed open on the launch path it was written for.
_guarded_run_asyncread the bind address from thehostkwarg and treated an absent one as loopback. It is not:fastmcp runforwards that kwarg only when--hostwas passed, and otherwise fastmcp resolves the host fromfastmcp.settings.host, which is environment-backed. SoFASTMCP_HOST=0.0.0.0 fastmcp run server.py:mcp --transport httpbound every interface — 149 tools including file open/save and AutoCAD command execution, with no token — while the guard inspected the literal string"127.0.0.1"and waved it through. v1.4.0's changelog claimed the guard "now fires on every launch path"; it fired, and then looked at the wrong host. It now resolves the effective host, and says so in a warning if it cannot. [com]and[full]could not be installed off Windows. Both extras listedpywin32>=306with no environment marker, and pywin32 publishes Windows wheels and no sdist — sopip install autocad-mcp-pro[full]died at resolution on Linux and macOS, and[full]is the extra this project's own README recommended to contributors. CI never caught it because every Linux lane installs[pdf]or syncs fromuv.lock, whose resolution markers hide the gap. Both entries now carry; sys_platform == "win32", and a release-consistency test fails if a Windows-only distribution loses its marker again.drawing_settings({"dimscale": ...})reported success and changed nothing headlessly. It writes the$DIMSCALEheader variable, which is what AutoCAD consults when it draws a dimension — so the call worked on the live backend. ezdxf renders from the dimstyle table entry and never reads the header, so the same call produced two different drawings on the two engines while both saidok. The header value is now folded into the per-dimension override on all five dimension tools, includingdimension_alignedanddimension_angular, which do not share the tolerance-override path and were missed by the obvious fix. DIMSCALE 1.0 writes no override.
Changed
- README: the "What you get" table qualified every engine-conditional family (boundary tracing, WIPEOUT/REVCLOUD/MTEXT masks, CHSPACE, typed hatch edge paths, headless undo) instead of listing them as if both engines had them, and the checkout install line no longer recommends an extra that cannot resolve on the reader's platform.
[1.5.0] — 2026-08-06
Added
-
Selection filters — 3 tools.
selection_window(AutoCAD's ssget window/crossing),selection_polygonandselection_filter(QSELECT). Window returns only entities wholly inside; crossing also returns the ones straddling the edge — the modes are reported back andwindowis 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_filtertakes named parameters rather than a query string deliberately: ezdxf's query language answers an unknown attribute with an empty result, soLINE[nosuchattr=='x']is indistinguishable from "no matches". Here a typo is aTypeErrorat the boundary, andfiltered_byreports which filters actually ran.min_areaexcludes shapes that have no area at all rather than treating them as zero, and a negativemin_areais refused — no area is negative. -
Boundary tracing — 2 tools, and five deliberately not built.
boundary_traceis AutoCAD's BOUNDARY/BPOLY: a seed point in, the nearest enclosing closed polyline out. It never callsedgeminer.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_chainemits n+1 points with the last duplicating the first andclosedleft False — a polyline that draws like a square and behaves like an open path to hatch association, area queries andentity_offset; andedgesmith.loop_areaworks 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 theanalysis_measure_areabug this release fixed, and shipping it would have re-introduced the class. -
Hatch depth — 3 tools.
hatch_set_gradient,hatch_editandhatch_add_boundary.hatch_editleaves omitted parameters alone — a partial edit that resets the rest is data loss — andchangedreports 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_boundarytakes 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 withcapability: "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, whichentity_getdeliberately does not carry. It cannot be handed over as-is for two reasons, both fixed here:dxfattribs()holdsVec3values 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) andtext_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 theRevcloudPropsxdata and takes the bulge sign from the winding direction, so the result satisfiesis_revcloud()— and asegment_lengthlonger 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_backgroundrefuses 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_replacesearches 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 returnssearched_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_revcloudrefuses withcapability: "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_deleteandentity_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, andlayouts.rename()skips the name validationnew()performs, soBad/Nameand 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_listsaid one thing,$TILEMODEanother, and_msp()quietly wrote to model space; all three now agree. A handle from a deleted sheet refuses instead of dying withAttributeError(entitydb.getis documented not to filter destroyed entities, and_get_entitybelieved it).layout_copyis a construction — ezdxf has no layout-copy API andxref.load_paperspaceis 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;skippedandassociativity_droppedreport what could not be carried over rather than letting a bareok: trueimply a complete copy.viewport_listreports the layout's own main viewport withis_main: truerather than hiding it — it is what remains after every drafting viewport is deleted — and refuses to report a scale on R12, whereview_heightandflagsare dropped on export andget_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, andviewport_lockread-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'smove_to_layoutapplies 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 usesviewport.get_transformation_matrix()rather than a hand-rolled one: the hand-rolled version ignoresview_target_pointand misplaced geometry by 25 mm in testing whileis_top_viewwas 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 unlessfreeze_dimensions=truebakes the measurement in first (transformhalvesget_measurement()while the baked block text keeps the old value, so one of the two is always lying), ACIS bodies (transformis accepted and moves nothing), tables and proxies (transformraises, andhasattr(e, "transform")isTruefor 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 withcapability: "chspace": ActiveX has no change-space member, so the alternatives areCopyObjects+ 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:Dispatchis not a passive probe — it COM-launches the application and setsVisible = True— so honouring the setting onGetActiveObjectand 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_statusnow reportscad_progid, without which a non-AutoCAD operator reads anautocad_versionkey naming a product they do not own. Caveat, stated everywhere it appears:CAD_PROGIDonly 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.exampleare 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_areashoelaced points the caller typed in, and the live COM backend did the same despite having the document, aHandleToObjectlookup it already used fourteen times, and an ActiveXAreaproperty. The only available workflow was also silently wrong —entity_getread 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. Andprops["length"] = ent.length()was dead code —LWPolylinehas nolength(), so the key never appeared at all. Bulge geometry is always circular, so the newengineering/measure.pyis exact rather than approximate: shoelace plus each arc's signed circular segment, and arc length for perimeter.entity_getnow reportsareaandlengthfrom 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), andself_intersecting, because the shoelace cancels crossed lobes and a bowtie measures 0.0. REGION/3DSOLID refuse withcapability: "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/MEASUREGEOMnow 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 withcapability: "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_entitiesworks on the live backend. It refused outright and pointed atsystem_run_commandwith_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 reportskippedhandles 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_stackwas declared, read and cleaned up, but nothing ever appended to it, sodrawing_undocould only answer "Nothing to undo" — while the tool summary said "Step back one operation" and the alias corpus routedUNDOand "take that back" straight at it. The old test assertedisinstance(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 withcapability: "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, sotitleblock_apply_iso_a3gains alayoutparameter — 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"andfit="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.rreproduces as the geometric mean ofpandson 24/24 steps — withppinned in this module beforerexisted — ands/t/utrack 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. Shafttis 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, defaultoff): replaces the advertised catalog withsearch_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.) Ariskceiling 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 onlist_tools, which needs its own migration note. -
@cad_tool— a discovery-metadata channel next to the tool contract. A decorator written above anmcp.toolregistration attaches acadkey to that tool's MCPmeta: a one-linesummaryfor a search hit's preview, acostfrom 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 fromdiscovery/aliases.py, never restated, so the corpus stays the one edit site.tags=andannotations=deliberately stay on themcp.toolcall, 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 threetransaction_*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(default120s,0disables): per-call timeout for the headless backend, mirroringCOM_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 overrequires-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 withuv sync --locked, which fails rather than re-resolving when the lock andpyproject.tomldisagree — 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. Thepackagejob stays unlocked on purpose: it is the only job that proves the published dependency ranges still resolve for a plainpip install autocad-mcp-pro. Theuv.lockline 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.pykeeps the v2 ten byte-for-byte (their ids and weights key the published v1.4 reports) and addstool_discovery,token_budget,hatch_islands,selection_filterandmeasure_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_bilinguallost 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_masspropsbecamemeasure_from_handle: no centroid or moment-of-inertia tool ships, since F2 was cut toentity_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 anot runlegend entry rather than zero, because an invented zero is indistinguishable from a measured one.run_competitorsgrows--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 thatresults/published/holds — that sanitisation used to be a manual edit. -
A capability refusal in the benchmark runner is now recorded
unsupportedinstead offail. The result enum has carriedunsupportedsince 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. -
Five checks added to
correctness_suite.py(26, was 21), and the two kinds read differently in the A/B table. Three measurement checks are new capability — v1.4.0 misses them by not having the method (miss → pass) — and are there because all three broke during this release. Two dimension checks are repaired defects: v1.4.0 has the methods and gets them wrong (fail → pass). Result against v1.4.0: 21/26 → 26/26, five fixed, zero regressed.
Changed
-
The backend contract is 19 modules instead of one 1854-line file, and optional methods are declarative.
backends/base.pyheld a single ABC with 112 abstract methods; adding one made both backends fail at instantiation until both were written, and two test modules constructComBackendat import time, so a half-finished contract edit took the entire suite red. The interface now lives inbackends/contracts/, one module per domain, split along the section markers that were already in the file — the seams were not re-decided.AutoCADBackendis the composition of all 19 and stays the import surface, sofrom backends.base import AutoCADBackendand everyisinstancecheck 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. Newbackends/capability.pycarries@capability(key, reason=...), which marks a contract method optional and supplies a default. The default raisesUnsupportedCapabilityErrorrather 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 ortests/test_capability_contract.pyfails; without that gate the decorator would just be a way to ship refusalssystem_capabilitiesnever mentions, which is worse than the build break it replaces. Its first user is COM'sentity_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 andbaseimports the contracts);backends.basere-exports it, so there is still exactly one exception class object in play. -
BREAKING (env): the
coretool profile was removed.TOOL_PROFILEis nowleanorfull; the discovery layer above solves the crowded-surface problemcoreexisted for.TOOL_PROFILE=corestill boots — it falls back tofullwith a warning naming the removal, andsystem_aboutreports the requested profile alongside the applied one. -
BREAKING (install):
ezdxf>=1.4is 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 withpip install -U "ezdxf>=1.4". -
Runtime pins narrowed. A published library cannot pin exactly and stay co-installable, so what a user's
pip installgets 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.fastmcpis 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-levelimport 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 barepip install autocad-mcp-proresolves 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 unpinnedpip install ruff/ruff>=0.8). The version is now written in exactly one place —[dependency-groups] dev— and frozen there byuv.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, sopip install --group devgives 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-windowsand therelease.ymltest job now runuv sync --locked+uv run --no-sync, with uv itself pinned in the workflow env for the same reason ruff is.package,dockerandregistry-validateare unchanged.benchmark.ymlis 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_chartnow skips non-task-matrix reports so multiple lanes can sharebenchmarks/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:
AcadPViewporthas noViewCentermember.viewport_listdied on it outright. Worse,viewport_createhas carried the same line since v1.4 wrapped in a bareexcept: pass, so it raised on every single call and said nothing — every COM viewport has been looking at the model origin instead of theview_center_x/ythe caller asked for. The member isTarget, and the failure is no longer swallowed.ModelSpace.AddWipeoutdoes not exist. ActiveX has no wipeout constructor at all; WIPEOUT is a command. Now a typed refusal (capability: "wipeout") instead of anAttributeError.AcDbMText.BackgroundFillColordoes not exist — it raises on read as well as on write, with an int and with anAcCmColorobject alike.BackgroundFill(the on/off flag) does work, sotext_set_backgroundenables and disables the mask on COM and refuses a colour request withcapability: "mtext_background_color", rather than switching the fill on in the wrong colour and reporting success.analysis_list_propertiescalled 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_renderwas declared unsupported on ezdxf, everydrawing_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,deleteandset_active_layout— butnames()returns the stored spelling, so every guard written asname in doc.layouts.names()disagreed with the code it was guarding, in both directions.layout_create("SHEET")next to an existingSheetwalked past its own duplicate check and died on a rawDXFValueErrorfrom 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 andviewport_liston 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'srenamerefuses that outright, so it goes through a staging name. -
entity_change_spacerefuses an entity that lives on another sheet instead of half-applying.move_to_layoutraisesDXFValueErrorfrom 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
extrusionvector — 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 ownentity_mirroracross the Y axis a circle drawn at (30, 20) reportedcenter [30.0, 20.0]while the same response'sbounding_boxsaidmin [-35, 15] / max [-25, 25]: the payload contradicted itself by 60 mm, silently, with no error anywhere.point_from_snapreturned points on the wrong side of the axis,entity_select_smartfound the entity where it wasn't, andentity_offsetplaced the new circle 60 mm away and grew it when asked to shrink. Reads are now normalised at the single_entity_info_dxffunnel, so all 38 call sites and every consumer ofEntityInfo.propertiesare 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
extrusionattribute too and were always correct (ezdxf makes theirocs()a pass-through);entity_rotatewas never affected — it preserves extrusion(0,0,1). The trigger wasentity_mirrorand foreign DXF files. A dimension'sdefpointis DXF group code 10 and WCS already; onlytext_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_getreports the WCS centre plusplane_normaland omitsradius/start_angle/end_anglerather than reporting a number that is not true in the frame the rest of the dict uses, andentity_edit_geometryrefuses withcapability: "ocs_tilted_plane", pointing atentity_move(which transforms the whole entity and preserves its plane). New capability keysocs_normalizedandocs_tilted_planeon both backends make the boundary pre-checkable viasystem_capabilities. - Named residuals (in the
ocs_normalizedreason rather than left silent): the OCS z component is not reported, HATCH boundaries are not reported at all (silence, not a wrong number), and TEXTrotationstays 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.AcDb2dPolylineis deliberately out of scope: itsCoordinatesmay be 3D triples, and changing the stride on a documentation reading alone would risk a path that works today.
- Not touched, deliberately: LINE, MTEXT, ELLIPSE, SPLINE and POINT carry
an
-
validate_pathhanded out working handles toC:\Windows. Windows names the same file through several anchors that compare unequal. The backslash spellings were rejected, butpathlibnormalises the forward-slash forms into them only after.resolve()— so//?/C:/Windows/win.inislipped past the pattern check and then failed to matchC:/Windowsin the system-directory loop. Proven: it returned a handle thatos.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 theloadpattern 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:Eis 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: blockingINSERT/SAVEASas command strings whileblock_insert/drawing_save_asexist 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 insecurity.pyso it is not re-opened. -
config.settings.backendwas a control that controlled nothing._make_backendreados.environ["AUTOCAD_MCP_BACKEND"]directly, so the setting had no production reader at all andmonkeypatch.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 meantauto, i.e. attaching to the operator's live unsaved drawing and creating entities in it.Settings.backendis now a read-only property that reads the environment live,_make_backendgoes 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 toconfig.settings.backendnow raisesAttributeErrorinstead 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), wherecapabilityis a key of the capability map, sosystem_capabilitiesanswers the same question up-front. None of that reached anyone:FastMCP.call_toolrewraps a non-FastMCPErrorasToolError(f"Error calling tool {name!r}: {e}") from e, and while thefrom ekeeps the cause alive in-process (which is whycad_batchcould 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 returnsis_error: truewith{"ok": false, "kind": "unsupported", "capability", "error", "tool", "backend"}instructured_content, produced by one newCapabilityRefusalMiddleware— no tool signature changed. Not done by rebasing the exception onFastMCPError: 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.UnsupportedCapabilityErrormoved tobackends/base.py(still importable frombackends.ezdxf_backend, and the same class object) so both backends raise one type, and the five headlesssolid_*methods now raise it instead of returning{"ok": false}— same payload, butis_errorstops being quietlyfalseon a call that did nothing. -
BREAKING (behavior):
cad_batchcounted a step that declined by value as a success. A tool that returns{"ok": false, ...}rather than raising was recordedstatus: "ok"and folded intosucceeded, so a batch could reportsucceeded: 12over work that never happened — the same class of lie as the DWG bytes and the unverified rollback. Such a step is nowstatus: "error"with kindunsupported(when it names a capability) orrefused, counts towardfailed, and honourson_errorlike any other failure. Batches that relied on, say, a no-opdrawing_redopassing silently will now stop or roll back; that is the point. Read-only tools are excluded on purpose:validation_check/drawing_critique/drawing_preflightanswer{"ok": len(issues) == 0}, so theirok: falsemeans 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 thereadOnlyHintannotation each tool already publishes. -
BREAKING (behavior):
drawing_auditsaid "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, soauditor.fixesis a log of mutations already applied. The tool discarded that list entirely, never marked the document dirty — sodrawing_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 throughstr()on anErrorEntrythat defines no__str__, i.e. as<ezdxf.audit.ErrorEntry object at 0x...>. The tool now returnsrepaired/fixes/fix_countalongsideerrors/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 followingdrawing_close(save=True)rewrites the file where it previously did not. That is the repair finally persisting; the tool was already annotatedreadOnlyHint: 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, sohandleisnullfor them; the message names the type and handle in prose. Readfixesbefore saving. On the COM backend_AUDIT Ylikewise repairs, but AutoCAD returns nothing machine-readable overSendCommand, so the counts come back asnullwithdetail: "unavailable"andcapability: "audit_detail"— never0, which would claim nothing was wrong — plus the path of the.adtlog. That backend also no longer leaves the user'sAUDITCTLpermanently set to 1. -
BREAKING (behavior): the ezdxf backend no longer writes DXF bytes into a
.dwgname.drawing_save("part.dwg")previously calleddoc.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, sodrawing_save_as("x.dwg", fmt="dxf")is refused too. Refusals carry a machine-readable{"ok": false, "capability": "dwg"}, anddwgis now a declared capability in both backends (ezdxf: unsupported; COM: native). Save as.dxf, or use the COM backend for DWG. -
drawing_openno 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 rawOSErrorblaming the file rather than the backend. The tool now refuses.dwgunless the active backend declares the capability, and its parameter text no longer advertises DWG unconditionally. Workflows holding files produced by the olddrawing_savebug must rename them to.dxf; the error message says so. -
TOOL_PROFILEwas 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 andsystem_aboutreported a collapsed inventory. It now reads the underlying registry. -
ruff format --check .failed on unmodifiedmain. Ruff 0.16 began formatting Python code blocks inside Markdown, so the committeddocs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.mdstarted 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.ymlnow runsruff format --check .alongsideruff check ., matching the CI lint job — the release gate previously skipped formatting. -
Every headless diameter and radius dimension reported the wrong number, at default settings.
add_diameter_dim/add_radius_dimmeasure to the point they are given;leader_lengthis a text placement. Passingcentre + (radius + leader)as that point made the leader part of the measurement, so a true ⌀40 bore was dimensioned 60 with the defaultleader_length=10, and 108 at 34. Radius callouts wereleader_lengthtoo large the same way. This is the worst class of defect for this project — a wrong number, silently, on the one part of a drawing that exists to carry numbers — and it shipped in v1.4.0 and earlier. Now the size comes fromradiusandlocationonly places the text. The live COM backend was never affected: ActiveX'sAddDimDiametrictakes the two chord points and the leader length as separate arguments, so the two engines had been disagreeing by2 × leader_lengthon the same call. Found while building the sheet in the README's hero image, whose own bore callout was wrong. Two checks added tocorrectness_suite.py(26, was 24); both reportfail → passagainst v1.4.0 rather thanmiss → pass, which is what distinguishes a repaired defect from new capability in that lane. -
A HATCH could not be measured, and the refusal listed HATCH as measurable.
entity_measurehad 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_stylecomes back with the number so it can be read against the drawing's own setting. On a curved edgeflatten_toleranceis 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 whyexactgoes False and stays False. -
boundary_traceandentity_measuredisagreed about the same shape. The boundary tools reported an area taken from a copy of the loop flattened at a fixed 0.01 sag whileentity_measureread 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_entitiesbuilds from any two-arc loop.polygon_area_perimetertreated 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=0returns v1.5.0 to 244.8 ms and 1,679 ms, v1.4.0's numbers exactly. The whole cost is the per-callasyncio.wait_forthis release wrapped around every headless call so that one hung ezdxf call can no longer wedge a server whose document lock is a singleasyncio.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 MCPserver.jsonschema validation. - Release pipeline (
.github/workflows/release.yml): tagv*→ test gate → tag/version match check → build → PyPI trusted publishing (OIDC) → GitHub Release with artifacts. - MCP registry manifest
server.json(schema 2025-12-11, nameio.github.u-c4n/autocad-mcp, PyPI package) plus themcp-nameownership marker in the README and anautocad-mcp-proconsole alias souvx autocad-mcp-proworks 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 todocs/assets/autocad-mcp-livebench.svg. Weekly/dispatch CI workflow. - Tool profiles (
TOOL_PROFILE=lean|core|full): capability-aware discovery applied in the server lifespan —leanis a curated ~46-tool drafting core,corehides raw escape hatches and long-tail tools,full(default) exposes everything. Reported bysystem_about. - Paper space / layouts:
layout_list,layout_create,layout_set_current,viewport_create(scaled model viewports) on both backends, anddrawing_export_pdf(layout=...)for plotting a layout. Headless limitation is explicit: viewport model-content projection is COM-only (viewport_rendercapability). - 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) withfit_lookup("H7", 20.0);dimension_linear/radius/diametergained afitparameter 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 honestsolid_3dcapability 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 wheelonly-include, and the READMEmcp-namemarker.
Changed
- pyproject metadata completed for PyPI: SPDX license + license file, authors, keywords, classifiers, project URLs.
build_dim_override:deviation/limitmodes now pass DIMTM signed (-lower) instead ofabs()— 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_spaceis now native on both backends; newviewport_renderfeature (COM native / ezdxf unsupported);solid_3dreflects the ENABLE_3D gate on COM. - README rebuilt around the live-run benchmark lane; CI/PyPI badges added;
ruff formatapplied repo-wide and enforced in CI.
Fixed
- Dockerfile shipped a broken image:
engineering/andversion.pywere 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.pysection-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_preflightnormalizes production requirements, reports missing or conflicting facts before geometry starts, and emits a deterministic SHA-256 spec hash thatdrawing_plancan enforce.drawing_refineruns a boundedcritique → repair → re-critiqueloop. 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_tableandleader_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_capabilitiesand shared typed backend capability maps distinguish native, rendered, composite, snapshot, shared, and unsupported features.drawing_delivercreates DXF/PDF/PNG bundles, runs validator + critique, re-opens the canonical DXF for entity/type/layer/bounds parity, hashes every artifact, and writesmanifest.jsonplusvalidation.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_aboutand delivery manifests. benchmarks/correctness_suite.pynow shows usage for argless/--helpcalls 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.tomlnow declares the wheel file selection explicitly (server.py,config.py,security.py,backends/,engineering/). (#2) dimension_linearon the COM backend — always crashed with<unknown>.AddDimLinear: the AutoCAD ActiveX API has noAddDimLinearmethod. Linear dimensions are now created viaAddDimRotated(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 oversystem_get_variable/system_set_variable; cross-backend.- In-place editing —
entity_edit_textre-labels or resizes an existing TEXT/MTEXT entity, andentity_edit_geometryre-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_framedraws a feature control frame (all 14 geometric characteristics, ⌀ zone prefix, Ⓜ/Ⓛ/Ⓢ material modifiers, multi-datum references) anddatum_featureplaces 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
gdtcritique focus failsdrawing_finalizewhen 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_diametergaintol_upper/tol_lower/tol_mode(symmetric±,deviation+a/-b,limitstacked,basicboxed) and atext_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) — thedrawing_finalizepayload now carries a 0-100score, aninvalidity_ratio, and an A-Fgradeover 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_typescapability (it authors 2D entities only, no 3D solids) — corrected toentities_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 — the layer-juggling workaround is no longer needed. Surfaces thePICKFIRSTsysvar state so an empty selection is self-explanatory. The ezdxf headless backend has no viewport, so it returnsok=Falsewith 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) andpoint_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_finalizevalidator. - HTTP bearer-token auth:
StaticTokenVerifierwired into FastMCP whenMCP_AUTH_TOKENis 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_mtextrotation parameter;dimension_autolayer override;bounding_box/ ARClength/ TEXT-MTEXTrotation+char_heighton 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_finalizenow 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=Truefails on warnings too.drawing_save_asderives the on-disk format from the file extension —part.dxfwrites DXF, not DWG. ezdxf refuses to mislabel a.dwgfile.- Dimension and construction layers resolve from the active layer set (iso13567 →
M-DIMEN-T-N/M-CONST-E-N, not the hardcodedDIM/CONSTRUCTION). - Premium meta-tools lifted into the shared
AutoCADBackendbase class (single source of truth). - Dead code removed (
section.py,generate_tooth_profile,CommandResult,set_layer_active, …).
Fixed
- ezdxf
dimension_aligned/dimension_angularraisedTypeError(wrong ezdxf 1.4 args) — fixed. - Full-circle polar array placed a duplicate copy over the original — fixed (divisor = count).
entity_offsetignoredside_x/side_yon both backends — now honored (and COM no longer leaks extras).- COM
entity_create_hatchbuilt 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_pdfcould SIGSEGV (matplotlib GUI backend in a worker thread) — now render headless viaAgg(Figure+FigureCanvasAgg).entity_select_smartlength_rangesilently rejected all ARCs (nolength) — 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_lispalways reported"nil";system_set_variabledidn't coerce numeric sysvars — fixed.system_abouttool groups /_registered_tool_countno longer drift or surface-1. drawing_newbootstrap failures now surface asdegradedinstead 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 (
CoUninitializeon teardown); transaction commit/rollback andsystem_run_lispnow 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