7 Commits
Author SHA1 Message Date
Umutcan EdizsalanandCursor 67f2c3b575 ci(release): publish to PyPI with the PYPI_API_TOKEN secret instead of OIDC trusted publishing
Release / test (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:37:17 +03:00
Umutcan EdizsalanandCursor d15068efc5 fix(tests): inject win32gui mock with raising=False so the hwnd tests run on the Linux CI leg
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:22:49 +03:00
Umutcan EdizsalanandCursor e04c814cb8 docs(release): v1.4.0 - changelog, README live-benchmark section, CLAUDE.md sync, consistency gates
- CHANGELOG 1.4.0 section (infra + features), Unreleased now lists the 1.5 openers
- README: v1.4 snapshot (131 tools / 6 resources / 5 prompts / 474 tests), CI + PyPI badges, live-run benchmark table + chart, mcp-name registry marker
- CLAUDE.md: sections 15 (layouts) + 16 (solids), tool profiles, ISO 286 fits; counts synced to the live surface
- tests/test_release_consistency.py: locks pyproject/version.py/CHANGELOG/README/server.json versions, README counts vs registrations, section-header counts, Dockerfile COPY vs wheel includes, mcp-name marker

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:16:32 +03:00
Umutcan EdizsalanandCursor 3cbe30df54 feat(server): tool profiles, paper-space layouts + viewports, ISO 286 fits, opt-in COM 3D solids
- TOOL_PROFILE=lean|core|full applied in the lifespan via server-level enable/disable; system_about reports the active profile; opt-in solid_* tools are hidden while ENABLE_3D=false
- layouts: layout_list/create/set_current + viewport_create (scaled model viewports) on both backends; drawing_export_pdf(layout=...); ezdxf uses TILEMODE + active-layout binding, COM switches ActiveLayout and restores it
- ISO 286: engineering/fits.py authored IT4-11 + fundamental-deviation tables (1-500 mm; shafts d..p/h/js, holes D..H/JS) with clear out-of-scope errors; dimension_linear/radius/diameter gained fit= resolved from the measured nominal
- tolerances: DIMTM now signed in deviation/limit modes so double-positive fits (p6 +0.035/+0.022) render correctly; legacy positive-magnitude contract preserved
- solids: solid_box/cylinder/extrude/revolve/boolean native on COM (AddBox/AddCylinder/AddRegion+AddExtrudedSolid/AddRevolvedSolid/Boolean); ezdxf reports the honest solid_3d capability boundary
- capability maps: paper_space native, new viewport_render feature, ENABLE_3D-gated solid_3d
- 60 new tests (profiles, layouts, viewports, layout PDF, fits, gating); version pins in tests now read version.__version__

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:45 +03:00
Umutcan EdizsalanandCursor 6d7c39e012 feat(bench): live competitor lane - black-box MCP stdio adapters, pinned checkouts, published results
- adapters/mcp_stdio.py: drives competitors over MCP stdio like a real host; payload-level error detection, fastmcp output-wrapper normalization, cold-start warmup, artifact-based verification (harness re-opens every exported DXF with ezdxf)
- competitors_env.py: reproducible pinned-SHA checkout + isolated venv per competitor
- adapters for puran-water/autocad-mcp (95476a3) and beiming183-cloud/AutoCAD-MCP (11f7c47) mapped to their own documented tool contracts; unmapped tasks report 'unsupported' with reasons
- run_competitors.py: registry-driven --server from competitors.yaml
- published reports (pro 100.0 / beiming 50.0 / puran 45.0) + render_live_chart.py -> docs/assets/autocad-mcp-livebench.svg
- benchmark.yml: weekly + manual dispatch matrix

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:27 +03:00
Umutcan EdizsalanandCursor 2f56a73093 feat(release): CI matrix, PyPI trusted-publishing pipeline, registry manifest, Docker fix
- ci.yml: ruff lint/format, Linux 3.11/3.12 tests + coverage, Windows mocked-COM leg, package build + clean-venv wheel smoke, Docker smoke, server.json schema validation
- release.yml: tag -> test gate -> tag/version check -> build -> PyPI trusted publishing (OIDC) -> GitHub Release
- pyproject: SPDX license, authors, keywords, classifiers, project URLs, autocad-mcp-pro console alias; version 1.4.0
- Dockerfile: ship engineering/ + version.py (image previously missed both -> engineering tools failed in containers); healthcheck imports engineering
- server.json: MCP registry manifest (io.github.u-c4n/autocad-mcp, pypi package)
- docs/RELEASE-DISTRIBUTION.md: one-time owner actions (PyPI trusted publisher, mcp-publisher login) + directory submissions runbook

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:07 +03:00
Umutcan EdizsalanandCursor 9ec6c4a989 style: apply ruff format across the repository
ruff format was never run repo-wide; CI now enforces 'ruff format --check', so normalize every module once. No behavior changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:14:51 +03:00
80 changed files with 8518 additions and 1016 deletions
+8
View File
@@ -17,3 +17,11 @@ LOG_LEVEL=INFO
# Enable dangerous AutoCAD commands (ERASE ALL, QUIT, etc.)
# Set to true only if you trust all MCP clients
DANGEROUS_COMMANDS_ENABLED=false
# Exposed tool profile: full (default) | core | lean
# core hides raw escape hatches and long-tail tools; lean is a curated
# ~46-tool drafting/inspection core for clients with tight tool caps.
TOOL_PROFILE=full
# Opt-in 3D solids (COM backend only; ezdxf reports them as unsupported)
ENABLE_3D=false
+43
View File
@@ -0,0 +1,43 @@
name: Benchmark
# Headless competitor benchmark (v2 fixed-task matrix). Manual or weekly:
# clones the pinned competitor SHAs, runs the black-box MCP stdio lane and
# uploads the machine-readable reports as artifacts. Results committed to
# benchmarks/results/ are produced from these runs.
on:
workflow_dispatch:
schedule:
- cron: "17 5 * * 1"
jobs:
headless-matrix:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
server:
- autocad-mcp-pro
- puran-water-autocad-mcp
- beiming183-autocad-mcp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: pip install -e ".[pdf]"
- name: Run benchmark (ezdxf lane)
run: |
python -m benchmarks.run_competitors \
--server "${{ matrix.server }}" \
--backend ezdxf \
--artifact-dir "benchmarks/results/ci/${{ matrix.server }}" \
> "report-${{ matrix.server }}.json"
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: benchmark-${{ matrix.server }}
path: |
report-${{ matrix.server }}.json
benchmarks/results/ci/${{ matrix.server }}
+110
View File
@@ -0,0 +1,110 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install ruff
- run: ruff check .
- run: ruff format --check .
test-linux:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[pdf]"
pip install pytest pytest-asyncio pytest-cov
- name: Run tests
run: pytest --cov=. --cov-report=xml --cov-report=term-missing -v
- name: Upload coverage
if: matrix.python-version == '3.11'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
test-windows:
# Windows leg exercises the mocked-COM suite on the platform the COM
# backend actually targets (pywin32 installs; no live AutoCAD needed).
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -e ".[full]"
pip install pytest pytest-asyncio pytest-cov
- name: Run tests
run: pytest -v
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build sdist and wheel
run: |
pip install build twine
python -m build
twine check dist/*
- name: Install wheel in a clean venv and smoke test
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/autocad-mcp --help
/tmp/smoke/bin/python -c "import server, config, security, version, backends, engineering; print('wheel ok', version.__version__)"
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t autocad-mcp:ci .
- name: Smoke test image
run: docker run --rm autocad-mcp:ci python -c "import server, engineering.gear, version; print('docker ok', version.__version__)"
registry-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Validate server.json against MCP registry schema
if: ${{ hashFiles('server.json') != '' }}
run: |
pip install check-jsonschema
check-jsonschema --schemafile https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json server.json
+88
View File
@@ -0,0 +1,88 @@
name: Release
# Tag-driven release: run the test gate, build, publish to PyPI, then cut the
# GitHub Release with the built artifacts attached.
#
# Auth: the PYPI_API_TOKEN repository secret (an API token from
# https://pypi.org/manage/account/token/). Once the project exists on PyPI,
# prefer replacing it with a project-scoped token — or switch back to trusted
# publishing (OIDC) by restoring `environment: pypi` + `id-token: write` and
# registering the publisher on PyPI.
on:
push:
tags: ["v*"]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -e ".[pdf]"
pip install pytest pytest-asyncio ruff
- run: ruff check .
- run: pytest -q
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Verify tag matches package version
run: |
pip install tomli-w 2>/dev/null || true
PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "Tag $GITHUB_REF_NAME does not match pyproject version $PKG_VERSION" >&2
exit 1
fi
- name: Build distributions
run: |
pip install build twine
python -m build
twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish-pypi:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
github-release:
needs: publish-pypi
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
--generate-notes
+6
View File
@@ -43,6 +43,12 @@ desktop.ini
out/
benchmarks/results/*
!benchmarks/results/.gitkeep
!benchmarks/results/published
!benchmarks/results/published/**
benchmarks/.competitors/
*.dxf
*.dwg
*.bak
# Local editor/agent state
.cursor/
+80 -1
View File
@@ -7,7 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
_Next: capability-group schema routing, paper-space/layout production depth, and ISO 286 fit-table lookup._
_Next (1.5): screenshot overlay + handle grounding v1, ezdxf redo (forward
snapshots), COM `block_create_from_entities`, ISO 286 transition/interference
hole letters (delta rule), titleblock on paper-space layouts._
## [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
+19 -14
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~110 tools, 5 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~131 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
## Running the Server
@@ -69,24 +69,30 @@ The `mcp` FastMCP instance is configured with:
- **Middleware stack**: `ErrorHandlingMiddleware` → `AuditMiddleware` (custom timing/audit log) → `TimingMiddleware` → `LoggingMiddleware`
- **`_backend(ctx)`** helper: retrieves the backend from lifespan context, raises `ToolError` if not ready
Tools are organized into 12 sections:
1. Drawing Management (11 tools): `drawing_*`
2. Entity Creation (13 tools): `entity_create_*`
Tools are organized into sections (counts are indicative — `system_about` is authoritative):
1. Drawing Management (12 tools): `drawing_*` (includes `drawing_redo`)
2. Entity Creation (14 tools): `entity_create_*` (includes `entity_create_table`, `leader_create_mleader`)
3. Dimensions (5 tools): `dimension_*`
4. Entity Modification (12 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
5. Entity Query (3 tools): `entity_get`, `entity_list`, `entity_delete_many`
6. Layer Management (12 tools + 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
4. Entity Modification (16 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, corner ops (`entity_trim/extend/fillet/chamfer`), plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
5. Entity Query (4 tools): `entity_get`, `entity_list`, `entity_delete_many`, `selection_get`
6. Layer Management (14 tools incl. 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
7. Block Operations (7 tools): `block_*`
8. Analysis & Query (8 tools): `analysis_*`
9. View & Screenshot (5 tools — includes `view_zoom_and_screenshot`): `view_*`
8. Analysis & Query (8 tools): `analysis_*` — plus Batch (2), Templates (2), Validation (1)
9. View & Screenshot (4 tools — includes `view_zoom_and_screenshot`): `view_*`
10. Transactions (3 tools): `transaction_begin/commit/rollback`
11. System (7 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about`, plus `drawing_settings` (friendly units/scale/precision/osnap facade over system variables)
12. Engineering / Deterministic CAD (7 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
13. Premium meta-tools: `drawing_plan`, `drawing_critique`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
11. System (8 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about/capabilities`, plus `drawing_settings` (friendly units/scale/precision/osnap facade over system variables)
12. Engineering / Deterministic CAD (8 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
13. Premium meta-tools (12): `drawing_preflight`, `drawing_plan`, `drawing_critique`, `drawing_refine`, `drawing_deliver`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
14. GD&T (ISO 1101 / ASME Y14.5): `gd_frame` (feature control frames), `datum_feature` — enforced by the `gdt` critique focus
15. Layouts & Paper Space (4 tools): `layout_list/create/set_current`, `viewport_create` (scaled model viewports); `drawing_export_pdf` takes an optional `layout` param. Viewport model-content projection is COM-only (`viewport_render` capability).
16. 3D Solids (5 tools, opt-in via `ENABLE_3D=true`, COM only): `solid_box/cylinder/extrude/revolve/boolean` — hidden from discovery and rejected while disabled; ezdxf reports `solid_3d` as unsupported (no headless ACIS).
**Tool profiles:** `TOOL_PROFILE=lean|core|full` (default `full`) controls the advertised surface — `lean` ≈ 46 curated drafting tools, `core` hides raw escape hatches (`system_run_command/lisp`, low-level variables, long-tail tools). Applied in the lifespan; reported by `system_about`.
**ISO 129 tolerances:** `dimension_linear` / `dimension_radius` / `dimension_diameter` take `tol_upper` / `tol_lower` / `tol_mode` (`symmetric` ± / `deviation` +a/-b / `limit` / `basic`) and `text_override` (e.g. `⌀20 H7`). Prefer these over hand-drawn tolerance text.
**ISO 286 fits:** the same dimension tools take `fit="H7"` (mutually exclusive with `tol_*`) — deviations are resolved from authored ISO 286 tables (`engineering/fits.py`; shafts d/e/f/g/h/js/k/m/n/p, holes D/E/F/G/H/JS, sizes 1–500 mm) against the measured nominal and the fit code is appended to the dimension text.
**Drawing score:** `drawing_finalize` returns `payload["score"]` — a 0-100 scalar + `invalidity_ratio` + A-F `grade` over the validator + critique union. Use it as the objective quality metric.
### Adding a New Tool
@@ -110,8 +116,7 @@ Tools are organized into 12 sections:
### Premium Drawing Rules
These rules are non-negotiable for production engineering output. The full
discipline + workflow templates live in `.claude/skills/autocad-mcp-premium/`.
These rules are non-negotiable for production engineering output.
1. **Plan before draw**: Call `drawing_plan(intent, scale, sheet)` *before* any
`entity_create_*`. The returned PlanSpec is held by the backend and replayed
+4 -3
View File
@@ -4,9 +4,10 @@ WORKDIR /app
RUN useradd --create-home --shell /bin/bash mcp
COPY pyproject.toml ./
COPY server.py config.py security.py ./
COPY pyproject.toml README.md LICENSE ./
COPY server.py config.py security.py version.py ./
COPY backends/ ./backends/
COPY engineering/ ./engineering/
RUN pip install --no-cache-dir -e .
@@ -15,6 +16,6 @@ USER mcp
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import server; print('ok')" || exit 1
CMD python -c "import server, engineering.gear; print('ok')" || exit 1
CMD ["python", "server.py"]
+32 -7
View File
@@ -3,6 +3,8 @@
Production-grade AutoCAD automation for AI agents—live through COM on Windows,
or headless through ezdxf on any platform.
[![CI](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml/badge.svg)](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/autocad-mcp-pro?color=1f6feb)](https://pypi.org/project/autocad-mcp-pro/)
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-1f6feb?logo=python&logoColor=white)](pyproject.toml)
[![FastMCP 3](https://img.shields.io/badge/MCP-FastMCP%203-8957e5)](https://github.com/jlowin/fastmcp)
@@ -12,8 +14,8 @@ One typed MCP contract controls two execution engines. Build and edit drawings,
query exact geometry, apply engineering standards, refine quality issues inside
transactions, and deliver hashed artifacts with validation evidence.
> **v1.3 release snapshot:** 122 tools · 6 resources · 5 prompt templates ·
> 415 collected tests. Runtime discovery through `system_about` is authoritative.
> **v1.4 release snapshot:** 131 tools · 6 resources · 5 prompt templates ·
> 474 collected tests. Runtime discovery through `system_about` is authoritative.
## Start in 60 seconds
@@ -86,12 +88,32 @@ python -m benchmarks.render_chart
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --json
```
The v1.3 ezdxf reference run records **10/10 tasks (100.0)**. It is a
self-check, not a competitor ranking.
### Live-run lane — same harness, pinned competitors
Cross-project runtime scores will be published only after equivalent adapters
execute the same task contract. The complete rubric, caveats, version A/B suite,
and reproduction commands live in [`benchmarks/`](benchmarks/README.md).
![Live benchmark](docs/assets/autocad-mcp-livebench.svg)
| Server | Pinned commit | Score | Pass | Coverage |
|---|---|---:|---:|---:|
| **autocad-mcp-pro** (this repo) | working tree | **100.0** | 10/10 | 100% |
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | `11f7c47` | 50.0 | 5/10 | 50% |
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | `95476a3` | 45.0 | 4/10 | 50% |
Same ten fixed tasks, same runner, headless ezdxf lane. Competitors are driven
**black-box over MCP stdio** using their own documented tool contracts at the
pinned commit, and every geometry claim is verified by re-opening the exported
DXF with ezdxf in the harness — no competitor self-reporting. `unsupported`
scores zero in the fixed matrix; per-task statuses with exact reasons are
committed under [`benchmarks/results/published/`](benchmarks/results/published/).
File-IPC (live AutoCAD) lanes run locally, not in CI.
```bash
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
python -m benchmarks.render_live_chart
```
The complete rubric, caveats, version A/B suite, and reproduction commands live
in [`benchmarks/`](benchmarks/README.md).
## Why this architecture holds up
@@ -346,3 +368,6 @@ Built from production drawing work, then made model-agnostic through MCP.
## License
[MIT](LICENSE)
<!-- MCP registry ownership marker; must equal the "name" in server.json. -->
mcp-name: io.github.u-c4n/autocad-mcp
+1
View File
@@ -1,4 +1,5 @@
"""AutoCAD MCP Pro – backend package."""
from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
from .com_backend import ComBackend
from .ezdxf_backend import EzdxfBackend
+429 -146
View File
File diff suppressed because it is too large Load Diff
+582 -124
View File
File diff suppressed because it is too large Load Diff
+570 -125
View File
File diff suppressed because it is too large Load Diff
+44 -4
View File
@@ -42,10 +42,50 @@ python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --
python -m benchmarks.run_competitors --task table_mleader --task auditable_delivery --json
```
The release-machine v1.3 ezdxf self-check is **10/10 (100.0)**. It is not a
competitor ranking. Cross-project scores become comparable only when another
adapter runs this exact task matrix; repository stars and raw tool counts do not
contribute to the score. Adapter registration lives in `competitors.yaml`.
The release-machine ezdxf self-check is **10/10 (100.0)**. Repository stars and
raw tool counts do not contribute to the score. Adapter registration lives in
`competitors.yaml`.
## Live competitor lane (v1.4)
Two competitor adapters now execute the exact same task matrix, black-box over
MCP stdio, against commits pinned in `competitors.yaml`:
| Server | Pinned | Score | Pass | Coverage |
|---|---|---:|---:|---:|
| autocad-mcp-pro (reference) | working tree | 100.0 | 10/10 | 100% |
| beiming183-cloud/AutoCAD-MCP | `11f7c47e` | 50.0 | 5/10 | 50% |
| puran-water/autocad-mcp | `95476a33` | 45.0 | 4/10 | 50% |
Method and boundaries:
- `benchmarks/competitors_env.py` clones the pinned SHA into
`benchmarks/.competitors/<id>/` (gitignored), builds an isolated venv, and
installs the competitor from its own `pyproject.toml`.
- `benchmarks/adapters/mcp_stdio.py` drives the competitor exactly like an MCP
host (fastmcp `Client` over stdio). No competitor code is imported.
- Task playbooks call each server's **own documented tool contract**, read from
the pinned source (consolidated `operation` + payload tools; beiming's
`doc_id`/`expected_revision` discipline is honored via
`transaction(operation="context")`).
- **Verification never trusts the competitor's response**: every geometry claim
must survive `save`/`save_as_dxf` and re-opening the DXF with ezdxf inside
the harness.
- Tasks with no documented equivalent are reported `unsupported` with the
reason string; they stay in the fixed-matrix denominator at score zero.
- This lane is headless (ezdxf backends only). File-IPC / live-AutoCAD lanes
(including COM-only servers such as best-cad-mcp or daobataotie/CAD-MCP)
require a local AutoCAD session and are out of CI scope by design.
Published machine-readable reports (artifact paths sanitized to filenames)
live under [`results/published/`](results/published/); the README chart is
regenerated from them:
```bash
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
python -m benchmarks.render_live_chart
```
## Correctness A/B suite
+5 -4
View File
@@ -90,10 +90,11 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
table = await self.backend.entity_create_table(
0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT"
)
leader = await self.backend.leader_create_mleader(
[[0, 0], [10, 10]], "NOTE", layer="DIM"
)
representations = [table.properties.get("representation"), leader.properties.get("representation")]
leader = await self.backend.leader_create_mleader([[0, 0], [10, 10]], "NOTE", layer="DIM")
representations = [
table.properties.get("representation"),
leader.properties.get("representation"),
]
return all(representations), {"representations": representations}, []
async def _task_transactions(self):
+301
View File
@@ -0,0 +1,301 @@
"""Black-box adapter for beiming183-cloud/AutoCAD-MCP (headless ezdxf lane).
Contract verified against the pinned checkout's ``server.py`` (v3.10 line):
- document identity comes from ``transaction(operation="context")`` and every
mutation must carry ``doc_id`` + ``expected_revision`` — top-level for the
``entity``/``annotation`` tools, inside ``data`` for ``drawing`` operations;
- ``entity create_line`` takes top-level ``x1..y2``; circle/dimension payloads
sit under ``data`` (same field spellings as the upstream puran-water base);
- ``transaction begin/commit/rollback`` are compatibility operations that the
headless backend may reject with ``E_UNSUPPORTED*`` — that outcome is
reported as ``unsupported`` rather than ``fail``.
Verification is artifact-based: geometry claims are checked by exporting a DXF
(``drawing save_as_dxf``) and re-reading it with ezdxf in the harness.
"""
from __future__ import annotations
import math
from typing import Any
from benchmarks.adapters.base import TaskResult
from benchmarks.adapters.mcp_stdio import (
McpStdioAdapter,
ToolCallFailed,
extract_handle,
)
from benchmarks.competitors_env import CompetitorSpec
PINNED_SHA = "11f7c47e5038796a20451b38b23032e625b5aa26"
def _find_key(payload: Any, *keys: str) -> Any:
if isinstance(payload, dict):
for key in keys:
if key in payload and payload[key] not in (None, ""):
return payload[key]
for value in payload.values():
found = _find_key(value, *keys)
if found is not None:
return found
elif isinstance(payload, (list, tuple)):
for item in payload:
found = _find_key(item, *keys)
if found is not None:
return found
return None
class BeimingAutocadAdapter(McpStdioAdapter):
name = "beiming183-autocad-mcp"
spec = CompetitorSpec(
competitor_id="beiming183-autocad-mcp",
repo_url="https://github.com/beiming183-cloud/AutoCAD-MCP.git",
pinned_sha=PINNED_SHA,
entry_candidates=(
("-m", "autocad_mcp"),
("server.py",),
("src/server.py",),
),
pip_installs=(("-e", "."),),
launch_env={"AUTOCAD_MCP_BACKEND": "ezdxf"},
pythonpath="src",
)
def extra_launch_env(self) -> dict[str, str]:
# The server redirects writes outside its managed output root; point
# the root at the benchmark artifact directory so exports land where
# the harness verifies them.
return {
"AUTOCAD_MCP_OUTPUT_ROOT": str(self.artifact_dir),
"AUTOCAD_MCP_ALLOW_EXTERNAL_OUTPUTS": "true",
}
def warmup_calls(self):
# Absorb lazy backend initialization once during setup.
return (
("system", {"operation": "status"}),
("drawing", {"operation": "create", "data": {}}),
)
async def _ctx(self) -> dict[str, Any]:
"""Read doc_id + revision from ``transaction(operation="context")``."""
try:
payload = await self.call("transaction", {"operation": "context"})
except ToolCallFailed:
return {}
doc_id = _find_key(payload, "doc_id", "document_id")
revision = _find_key(payload, "revision", "expected_revision", "document_revision")
context: dict[str, Any] = {}
if doc_id is not None:
context["doc_id"] = doc_id
if revision is not None:
context["expected_revision"] = revision
return context
async def _new_drawing(self) -> None:
await self.call("drawing", {"operation": "create", "data": {}})
async def _save_dxf(self, filename: str) -> str:
target = self.save_target(filename)
payload: dict[str, Any] = {"path": str(target)}
try:
await self.call("drawing", {"operation": "save_as_dxf", "data": payload})
except ToolCallFailed:
context = await self._ctx()
await self.call("drawing", {"operation": "save_as_dxf", "data": {**payload, **context}})
return str(target)
async def _entity(self, operation: str, **fields: Any) -> Any:
context = await self._ctx()
return await self.call("entity", {"operation": operation, **context, **fields})
async def _create_line(self, x1: float, y1: float, x2: float, y2: float):
return await self._entity("create_line", x1=x1, y1=y1, x2=x2, y2=y2)
# -- tasks ----------------------------------------------------------------
async def _task_core_geometry(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 3, 4)
await self._entity("create_circle", data={"cx": 10, "cy": 10, "radius": 2})
artifact = await self._save_dxf("beiming_core_geometry.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_core_geometry.dxf"))
lines = doc.modelspace().query("LINE")
circles = doc.modelspace().query("CIRCLE")
length = None
if lines:
start, end = lines[0].dxf.start, lines[0].dxf.end
length = math.dist((start.x, start.y), (end.x, end.y))
passed = (
len(lines) == 1
and len(circles) == 1
and length is not None
and abs(length - 5.0) < 1e-6
and abs(circles[0].dxf.radius - 2.0) < 1e-6
)
return TaskResult(
"core_geometry",
"pass" if passed else "fail",
100.0 if passed else 0.0,
metrics={"line_length": length, "lines": len(lines), "circles": len(circles)},
artifacts=[artifact],
)
async def _task_modify_query(self) -> TaskResult:
await self._new_drawing()
created = await self._create_line(0, 0, 10, 0)
handle = extract_handle(created)
if not handle:
return TaskResult(
"modify_query",
"fail",
0.0,
"could not extract created-entity handle from the create_line response",
)
await self._entity("move", entity_id=handle, data={"dx": 5, "dy": 2})
artifact = await self._save_dxf("beiming_modify_query.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_modify_query.dxf"))
lines = doc.modelspace().query("LINE")
moved = (
bool(lines)
and abs(lines[0].dxf.start.x - 5.0) < 1e-6
and (abs(lines[0].dxf.start.y - 2.0) < 1e-6)
)
return TaskResult(
"modify_query",
"pass" if moved else "fail",
100.0 if moved else 0.0,
artifacts=[artifact],
)
async def _task_layers_linetypes(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
await self.call("drawing", {"operation": "setup_mechanical", "data": {**context}})
artifact = await self._save_dxf("beiming_layers.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_layers.dxf"))
layers = {layer.dxf.name.upper() for layer in doc.layers}
linetypes = {lt.dxf.name.upper() for lt in doc.linetypes}
expected_layers = {"OUTLINE", "CENTER", "DIM"}
got_layers = expected_layers.issubset(layers)
got_linetype = "CENTER" in linetypes
if got_layers and got_linetype:
return TaskResult("layers_linetypes", "pass", 100.0, artifacts=[artifact])
if got_layers:
return TaskResult(
"layers_linetypes",
"partial",
50.0,
"GB/T layers created but CENTER linetype missing from the table",
artifacts=[artifact],
)
return TaskResult(
"layers_linetypes",
"fail",
0.0,
f"layers={sorted(layers)}",
artifacts=[artifact],
)
async def _task_dimensions(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
await self.call(
"annotation",
{
"operation": "create_dimension_linear",
**context,
"data": {"x1": 0, "y1": 0, "x2": 50, "y2": 0, "dim_x": 25, "dim_y": 10},
},
)
artifact = await self._save_dxf("beiming_dimensions.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_dimensions.dxf"))
dims = [e for e in doc.modelspace() if e.dxftype() == "DIMENSION"]
return TaskResult(
"dimensions",
"pass" if dims else "fail",
100.0 if dims else 0.0,
metrics={"dimension_count": len(dims)},
artifacts=[artifact],
)
async def _task_transactions(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
try:
begin = await self.call("transaction", {"operation": "begin", **context})
except ToolCallFailed as exc:
if "E_UNSUPPORTED" in str(exc):
return TaskResult(
"transactions",
"unsupported",
0.0,
"transaction begin is rejected as unsupported on the headless backend",
)
raise
transaction_id = _find_key(begin, "transaction_id", "txn_id")
await self._entity("create_circle", data={"cx": 0, "cy": 0, "radius": 1})
context = await self._ctx()
rollback_args: dict[str, Any] = {"operation": "rollback", **context}
if transaction_id is not None:
rollback_args["transaction_id"] = transaction_id
await self.call("transaction", rollback_args)
artifact = await self._save_dxf("beiming_transactions.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_transactions.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"transactions",
"pass" if count == 0 else "fail",
100.0 if count == 0 else 0.0,
metrics={"entity_count_after_rollback": count},
artifacts=[artifact],
)
async def _task_dxf_roundtrip(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 10, 10)
artifact = await self._save_dxf("beiming_roundtrip.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_roundtrip.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"dxf_roundtrip",
"pass" if count == 1 else "fail",
100.0 if count == 1 else 0.0,
metrics={"entity_count": count},
artifacts=[artifact],
)
# -- documented coverage gaps ----------------------------------------------
async def _task_table_mleader(self) -> TaskResult:
return TaskResult(
"table_mleader",
"unsupported",
0.0,
"no TABLE or MLEADER operation in the annotation tool (v3.10)",
)
async def _task_preflight(self) -> TaskResult:
return TaskResult("preflight", "unsupported", 0.0, "no requirements-preflight equivalent")
async def _task_quality_refiner(self) -> TaskResult:
return TaskResult(
"quality_refiner",
"unsupported",
0.0,
"audits detect issues but there is no automated repair loop",
)
async def _task_auditable_delivery(self) -> TaskResult:
return TaskResult(
"auditable_delivery",
"unsupported",
0.0,
"drawing.deliver is documented as File-IPC only (not headless)",
)
__all__ = ["BeimingAutocadAdapter"]
+304
View File
@@ -0,0 +1,304 @@
"""Generic MCP-stdio benchmark adapter.
Drives a *competitor* MCP server as a black box over the standard stdio
transport (fastmcp ``Client``), exactly the way an agent host would. No
competitor code is imported; verification never trusts the competitor's
response payload — every task that claims to have produced geometry must
save a DXF which the harness re-opens with ezdxf and inspects itself.
Subclasses provide the launch runtime (via ``benchmarks.competitors_env``)
and per-task playbooks named ``_task_<task_id>``; anything unmapped is
reported as ``unsupported`` (coverage-aware scoring already accounts for it).
"""
from __future__ import annotations
import dataclasses
import json
import re
from pathlib import Path
from typing import Any
import ezdxf
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.competitors_env import CompetitorRuntime, CompetitorSpec, ensure_competitor
from benchmarks.tasks_v2 import TaskSpec
CALL_TIMEOUT_SECONDS = 15.0
WARMUP_TIMEOUT_SECONDS = 90.0
class ToolCallFailed(RuntimeError):
"""A competitor tool call returned an error result."""
def read_dxf(path: Path):
return ezdxf.readfile(str(path))
def modelspace_types(doc) -> list[str]:
return [entity.dxftype() for entity in doc.modelspace()]
def find_dxf(artifact_dir: Path, preferred: Path | None = None) -> Path | None:
"""Locate the DXF a competitor produced.
Servers with managed output roots may relocate writes, so fall back to
the newest ``*.dxf`` anywhere under the artifact directory.
"""
if preferred is not None and preferred.is_file():
return preferred
candidates = sorted(
artifact_dir.rglob("*.dxf"), key=lambda item: item.stat().st_mtime, reverse=True
)
return candidates[0] if candidates else None
class McpStdioAdapter(BenchmarkAdapter):
"""Black-box adapter base for competitor MCP servers."""
name = "mcp-stdio"
spec: CompetitorSpec # set by subclasses
def __init__(self, *, backend: str = "ezdxf"):
if backend != "ezdxf":
raise ValueError(
f"{type(self).__name__} only benchmarks the headless ezdxf lane; "
f"got backend={backend!r}. COM lanes run locally, not in CI."
)
self.backend_name = backend
self.artifact_dir = Path()
self.runtime: CompetitorRuntime | None = None
self._client: Client | None = None
self.tool_names: set[str] = set()
# -- lifecycle ----------------------------------------------------------
def extra_launch_env(self) -> dict[str, str]:
"""Environment computed at setup time (``self.artifact_dir`` is set)."""
return {}
async def setup(self, artifact_dir: Path) -> None:
self.artifact_dir = Path(artifact_dir)
self.runtime = ensure_competitor(self.spec)
env = {**self.runtime.env, **self.extra_launch_env()}
env.setdefault("PYTHONIOENCODING", "utf-8")
transport = StdioTransport(
command=str(self.runtime.python_exe),
args=list(self.runtime.entry_args),
env=env,
cwd=str(self.runtime.repo_dir),
)
self._client = Client(transport)
await self._client.__aenter__()
self.tool_names = {tool.name for tool in await self._client.list_tools()}
await self._warmup()
def warmup_calls(self) -> tuple[tuple[str, dict[str, Any]], ...]:
"""Calls issued once after connect to absorb the competitor's lazy
backend initialization (which can exceed the per-call timeout)."""
return ()
async def _warmup(self) -> None:
for tool, arguments in self.warmup_calls():
if tool not in self.tool_names:
continue
try:
await self._client.call_tool(
tool, arguments, timeout=WARMUP_TIMEOUT_SECONDS, raise_on_error=False
)
except Exception: # cold-start absorption is best effort
continue
async def cleanup(self) -> None:
if self._client is not None:
await self._client.__aexit__(None, None, None)
self._client = None
def metadata(self) -> dict:
return {
"backend": self.backend_name,
"kind": "mcp-stdio-blackbox",
"repo": self.spec.repo_url,
"pinned_sha": self.spec.pinned_sha,
"tools_discovered": sorted(self.tool_names),
}
# -- call helpers ---------------------------------------------------------
async def call(self, tool: str, arguments: dict[str, Any]) -> Any:
"""Call one tool; raise ``ToolCallFailed`` on an error result.
Competitor servers in this family report failures *inside* the payload
(``CommandResult(ok=False, error=...)`` serialized as text) rather than
via the MCP ``isError`` flag, so the payload is inspected as well.
"""
if tool not in self.tool_names:
raise ToolCallFailed(f"tool {tool!r} is not exposed by {self.name}")
result = await self._client.call_tool(
tool, arguments, timeout=CALL_TIMEOUT_SECONDS, raise_on_error=False
)
if getattr(result, "is_error", False):
raise ToolCallFailed(f"{tool}({arguments}) -> {_result_text(result)}")
data = getattr(result, "data", None)
payload = data if data is not None else _result_text(result)
payload = _normalize_payload(payload)
error = _payload_error(payload)
if error:
raise ToolCallFailed(f"{tool}({arguments}) -> {error}")
return payload
async def call_op(
self,
tool: str,
operation: str,
data: dict[str, Any] | None = None,
*,
shapes: tuple[dict[str, Any], ...] | None = None,
) -> Any:
"""Call a consolidated ``{tool}(operation=..., ...)`` style tool.
Competitors disagree on whether payload fields live under ``data`` or
at the top level, and on field spellings. ``shapes`` supplies explicit
argument dicts to try in order; otherwise both common layouts of
``data`` are attempted. The first non-error result wins.
"""
attempts: list[dict[str, Any]] = []
if shapes:
attempts.extend({"operation": operation, **shape} for shape in shapes)
else:
payload = data or {}
attempts.append({"operation": operation, "data": payload})
if payload:
attempts.append({"operation": operation, **payload})
last_error: Exception | None = None
for arguments in attempts:
try:
return await self.call(tool, arguments)
except ToolCallFailed as exc: # try the next argument shape
last_error = exc
raise ToolCallFailed(str(last_error))
# -- task dispatch --------------------------------------------------------
async def run_task(self, task: TaskSpec) -> TaskResult:
handler = getattr(self, f"_task_{task.task_id}", None)
if handler is None:
return TaskResult(
task.task_id,
"unsupported",
0.0,
f"{self.name} has no documented equivalent for this task",
)
try:
return await handler()
except ToolCallFailed as exc:
return TaskResult(task.task_id, "fail", 0.0, str(exc)[:500])
# -- verification ---------------------------------------------------------
def save_target(self, filename: str) -> Path:
return self.artifact_dir / filename
def verify_dxf(self, preferred: Path | None = None):
located = find_dxf(self.artifact_dir, preferred)
if located is None:
raise ToolCallFailed("no DXF artifact was produced")
return located, read_dxf(located)
def _result_text(result: Any) -> str:
parts: list[str] = []
for item in getattr(result, "content", None) or []:
text = getattr(item, "text", None)
if text:
parts.append(str(text))
return " | ".join(parts)[:1000] if parts else "<no text content>"
def _normalize_payload(payload: Any) -> Any:
"""Unwrap fastmcp's dynamic output wrappers and JSON-in-text payloads.
Legacy servers return plain text; the fastmcp client may deserialize it
into a generated *dataclass* (e.g. ``entityOutput(result='<json>')``) or a
pydantic model. Normalize down to dict / list / str so verification and
handle extraction see real data.
"""
if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
payload = dataclasses.asdict(payload)
elif hasattr(payload, "model_dump"):
payload = payload.model_dump()
elif not isinstance(payload, (dict, list, str)) and hasattr(payload, "result"):
payload = payload.result
if isinstance(payload, dict) and set(payload) == {"result"}:
payload = payload["result"]
if isinstance(payload, str):
try:
parsed = json.loads(payload)
except ValueError:
parsed = None
if isinstance(parsed, (dict, list)):
payload = parsed
return payload
_TEXT_ERROR_MARKERS = ('"ok": false', "'ok': false", '"ok":false', "ok=false")
def _payload_error(payload: Any) -> str | None:
"""Detect failure reported inside a tool payload instead of MCP isError."""
if isinstance(payload, dict):
if payload.get("ok") is False or payload.get("success") is False:
return str(payload.get("error") or payload.get("message") or "ok=false")[:300]
error = payload.get("error")
if isinstance(error, str) and error.strip():
return error[:300]
return None
if isinstance(payload, str):
lowered = payload.lower()
if lowered.startswith("error") or any(m in lowered for m in _TEXT_ERROR_MARKERS):
return payload[:300]
return None
def wrapped_shapes(payload: dict[str, Any], **top_level: Any) -> tuple[dict[str, Any], ...]:
"""Produce the two common consolidated-tool argument layouts for a payload:
nested under ``data`` and flattened at the top level."""
return (
{**top_level, "data": payload},
{**top_level, **payload},
)
_HANDLE_KEYS = ("handle", "entity_id", "entity_handle", "id", "object_id")
_HANDLE_RE = re.compile(
r'"(?:handle|entity_id|entity_handle|object_id)"\s*:\s*"?([0-9A-Fa-f*][\w*-]*)"?'
)
def extract_handle(payload: Any) -> str | None:
"""Best-effort extraction of a created-entity handle from a black-box
tool response (structured dict, list, or raw text)."""
if isinstance(payload, dict):
for key in _HANDLE_KEYS:
value = payload.get(key)
if isinstance(value, (str, int)) and str(value):
return str(value)
for value in payload.values():
found = extract_handle(value)
if found:
return found
return None
if isinstance(payload, (list, tuple)):
for item in payload:
found = extract_handle(item)
if found:
return found
return None
if isinstance(payload, str):
match = _HANDLE_RE.search(payload)
return match.group(1) if match else None
return None
+224
View File
@@ -0,0 +1,224 @@
"""Black-box adapter for puran-water/autocad-mcp (headless ezdxf lane).
The competitor exposes 8 consolidated tools (``drawing``, ``entity``,
``layer``, ``block``, ``annotation``, ``pid``, ``view``, ``system``) whose
operations take an ``operation`` selector plus a payload. Argument spellings
are probed through ``wrapped_shapes`` fallbacks; every geometry claim is
verified by saving a DXF and re-reading it with ezdxf in the harness.
Tasks with no documented equivalent on this server are reported as
``unsupported`` with an explicit reason so the published JSON explains the
coverage gap instead of hiding it.
"""
from __future__ import annotations
import math
from benchmarks.adapters.base import TaskResult
from benchmarks.adapters.mcp_stdio import (
McpStdioAdapter,
ToolCallFailed,
extract_handle,
)
from benchmarks.competitors_env import CompetitorSpec
PINNED_SHA = "95476a33a1c246308326eb4709d6379ef2efdbc1"
class PuranWaterAdapter(McpStdioAdapter):
name = "puran-water-autocad-mcp"
spec = CompetitorSpec(
competitor_id="puran-water-autocad-mcp",
repo_url="https://github.com/puran-water/autocad-mcp.git",
pinned_sha=PINNED_SHA,
entry_candidates=(
("-m", "autocad_mcp"),
("server.py",),
("src/server.py",),
("main.py",),
),
pip_installs=(("-e", "."),),
launch_env={"AUTOCAD_MCP_BACKEND": "ezdxf"},
pythonpath="src",
)
# Exact v3.1 contract (verified against the pinned checkout's server.py):
# entity create_line takes top-level x1..y2; circle/dimension payloads sit
# under ``data``; modify ops take top-level ``entity_id`` + ``data``.
def warmup_calls(self):
# The first backend touch lazily initializes and can exceed the
# per-call timeout; absorb it once during setup.
return (
("system", {"operation": "status"}),
("drawing", {"operation": "create", "data": {}}),
)
async def _new_drawing(self) -> None:
await self.call_op("drawing", "create", {})
async def _save(self, filename: str) -> str:
target = self.save_target(filename)
await self.call_op("drawing", "save", {"path": str(target)})
return str(target)
async def _create_line(self, x1: float, y1: float, x2: float, y2: float):
return await self.call(
"entity",
{"operation": "create_line", "x1": x1, "y1": y1, "x2": x2, "y2": y2},
)
# -- tasks ----------------------------------------------------------------
async def _task_core_geometry(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 3, 4)
await self.call_op("entity", "create_circle", {"cx": 10, "cy": 10, "radius": 2})
preferred = await self._save("puran_core_geometry.dxf")
_, doc = self.verify_dxf(self.save_target("puran_core_geometry.dxf"))
lines = doc.modelspace().query("LINE")
circles = doc.modelspace().query("CIRCLE")
length = None
if lines:
start, end = lines[0].dxf.start, lines[0].dxf.end
length = math.dist((start.x, start.y), (end.x, end.y))
passed = (
len(lines) == 1
and len(circles) == 1
and length is not None
and abs(length - 5.0) < 1e-6
and abs(circles[0].dxf.radius - 2.0) < 1e-6
)
return TaskResult(
"core_geometry",
"pass" if passed else "fail",
100.0 if passed else 0.0,
metrics={"line_length": length, "lines": len(lines), "circles": len(circles)},
artifacts=[preferred],
)
async def _task_modify_query(self) -> TaskResult:
await self._new_drawing()
created = await self._create_line(0, 0, 10, 0)
handle = extract_handle(created)
if not handle:
return TaskResult(
"modify_query",
"fail",
0.0,
"could not extract created-entity handle from the create_line response",
)
await self.call(
"entity",
{"operation": "move", "entity_id": handle, "data": {"dx": 5, "dy": 2}},
)
artifact = await self._save("puran_modify_query.dxf")
_, doc = self.verify_dxf(self.save_target("puran_modify_query.dxf"))
lines = doc.modelspace().query("LINE")
moved = (
bool(lines)
and abs(lines[0].dxf.start.x - 5.0) < 1e-6
and (abs(lines[0].dxf.start.y - 2.0) < 1e-6)
)
return TaskResult(
"modify_query",
"pass" if moved else "fail",
100.0 if moved else 0.0,
artifacts=[artifact],
)
async def _task_layers_linetypes(self) -> TaskResult:
await self._new_drawing()
try:
await self.call_op(
"layer",
"create",
{"name": "GEOMETRY", "color": 3, "linetype": "CENTER"},
)
except ToolCallFailed:
await self.call_op("layer", "create", {"name": "GEOMETRY", "color": 3})
artifact = await self._save("puran_layers.dxf")
_, doc = self.verify_dxf(self.save_target("puran_layers.dxf"))
has_layer = "GEOMETRY" in {layer.dxf.name.upper() for layer in doc.layers}
has_center = "CENTER" in {lt.dxf.name.upper() for lt in doc.linetypes}
if has_layer and has_center:
return TaskResult("layers_linetypes", "pass", 100.0, artifacts=[artifact])
if has_layer:
return TaskResult(
"layers_linetypes",
"partial",
50.0,
"layer created; no linetype-loading operation is exposed",
artifacts=[artifact],
)
return TaskResult("layers_linetypes", "fail", 0.0, artifacts=[artifact])
async def _task_dimensions(self) -> TaskResult:
await self._new_drawing()
await self.call_op(
"annotation",
"create_dimension_linear",
{"x1": 0, "y1": 0, "x2": 50, "y2": 0, "dim_x": 25, "dim_y": 10},
)
artifact = await self._save("puran_dimensions.dxf")
_, doc = self.verify_dxf(self.save_target("puran_dimensions.dxf"))
dims = [e for e in doc.modelspace() if e.dxftype() == "DIMENSION"]
return TaskResult(
"dimensions",
"pass" if dims else "fail",
100.0 if dims else 0.0,
metrics={"dimension_count": len(dims)},
artifacts=[artifact],
)
async def _task_dxf_roundtrip(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 10, 10)
artifact = await self._save("puran_roundtrip.dxf")
_, doc = self.verify_dxf(self.save_target("puran_roundtrip.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"dxf_roundtrip",
"pass" if count == 1 else "fail",
100.0 if count == 1 else 0.0,
metrics={"entity_count": count},
artifacts=[artifact],
)
# -- documented coverage gaps ----------------------------------------------
async def _task_table_mleader(self) -> TaskResult:
return TaskResult(
"table_mleader",
"unsupported",
0.0,
"no TABLE or MLEADER operation in the 8-tool surface (v3.1)",
)
async def _task_transactions(self) -> TaskResult:
return TaskResult(
"transactions",
"unsupported",
0.0,
"no transaction begin/commit/rollback tool (undo/redo are File-IPC only)",
)
async def _task_preflight(self) -> TaskResult:
return TaskResult("preflight", "unsupported", 0.0, "no requirements-preflight equivalent")
async def _task_quality_refiner(self) -> TaskResult:
return TaskResult(
"quality_refiner", "unsupported", 0.0, "no critique/repair loop equivalent"
)
async def _task_auditable_delivery(self) -> TaskResult:
return TaskResult(
"auditable_delivery",
"unsupported",
0.0,
"no hashed delivery-manifest equivalent (plot_pdf is File-IPC only)",
)
__all__ = ["PuranWaterAdapter", "ToolCallFailed"]
+26 -13
View File
@@ -11,6 +11,7 @@ recorded as a miss instead of taking down the whole run.
The older ref is checked out into a throwaway git worktree, benchmarked, and
removed. The "new" side is always the current repository checkout.
"""
from __future__ import annotations
import json
@@ -27,8 +28,7 @@ PY = sys.executable
def _run(cmd, cwd, repo_on_path):
env = {**os.environ, "PYTHONPATH": repo_on_path}
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True,
text=True, timeout=120)
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
def list_checks():
@@ -43,9 +43,14 @@ def list_checks():
def run_one(repo, suite, name):
try:
r = subprocess.run([PY, suite, name], cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True, text=True, timeout=120)
r = subprocess.run(
[PY, suite, name],
cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
return "timeout"
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
@@ -82,22 +87,30 @@ def main():
op = sum(r["old"] == "pass" for r in rows)
npass = sum(r["new"] == "pass" for r in rows)
summary = {
"baseline_ref": ref, "total": total,
"old_pass": op, "new_pass": npass,
"old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1),
"baseline_ref": ref,
"total": total,
"old_pass": op,
"new_pass": npass,
"old_pct": round(100 * op / total, 1),
"new_pct": round(100 * npass / total, 1),
"fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows),
"regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows),
}
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
print("-" * 66)
for r in rows:
mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else (
" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
mark = (
" ->FIXED"
if (r["old"] != "pass" and r["new"] == "pass")
else (" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
)
print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}")
print("-" * 66)
print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}")
print(
f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}"
)
if json_out:
with open(json_out, "w") as fh:
json.dump({"summary": summary, "rows": rows}, fh, indent=2)
+16
View File
@@ -5,6 +5,22 @@
"id": "autocad-mcp-pro",
"adapter": "benchmarks.adapters.autocad_mcp_pro:AutoCADMCPProAdapter",
"backends": ["ezdxf", "com"]
},
{
"id": "puran-water-autocad-mcp",
"adapter": "benchmarks.adapters.puran_water:PuranWaterAdapter",
"backends": ["ezdxf"],
"repo": "https://github.com/puran-water/autocad-mcp",
"pinned_sha": "95476a33a1c246308326eb4709d6379ef2efdbc1",
"notes": "Black-box MCP stdio lane; File-IPC (live AutoCAD) features run in the local COM lane only."
},
{
"id": "beiming183-autocad-mcp",
"adapter": "benchmarks.adapters.beiming_autocad:BeimingAutocadAdapter",
"backends": ["ezdxf"],
"repo": "https://github.com/beiming183-cloud/AutoCAD-MCP",
"pinned_sha": "11f7c47e5038796a20451b38b23032e625b5aa26",
"notes": "Black-box MCP stdio lane; deliver/undo/redo are documented File-IPC only and reported unsupported."
}
]
}
+121
View File
@@ -0,0 +1,121 @@
"""Reproducible checkout + venv management for competitor MCP servers.
Every competitor is pinned to an exact commit so a benchmark run is
reproducible: same tasks, same tree, same interpreter isolation. The cache
lives under ``benchmarks/.competitors/<id>/`` (gitignored) as::
repo/ the pinned git checkout
venv/ an isolated interpreter with the competitor installed
Nothing here talks MCP — it only prepares a runnable ``python`` + entry args
for :class:`benchmarks.adapters.mcp_stdio.McpStdioAdapter`.
"""
from __future__ import annotations
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
CACHE_ROOT = Path(__file__).resolve().parent / ".competitors"
@dataclass(frozen=True)
class CompetitorSpec:
"""Everything needed to fetch and launch one competitor server."""
competitor_id: str
repo_url: str
pinned_sha: str
# Candidate stdio entrypoints, resolved in order against the checkout.
# ("-m", "pkg") entries are accepted when repo/pkg or repo/src/pkg exists.
entry_candidates: tuple[tuple[str, ...], ...]
# pip arguments executed inside the venv, relative to the checkout.
pip_installs: tuple[tuple[str, ...], ...] = (("-e", "."),)
# Extra environment applied when launching the server.
launch_env: dict[str, str] = field(default_factory=dict)
# Set PYTHONPATH to this repo-relative directory (e.g. "src") if not None.
pythonpath: str | None = None
@dataclass(frozen=True)
class CompetitorRuntime:
"""A resolved, launchable competitor installation."""
python_exe: Path
repo_dir: Path
entry_args: tuple[str, ...]
env: dict[str, str]
def _run(cmd: list[str], cwd: Path | None = None, timeout: float = 600.0) -> None:
completed = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
if completed.returncode != 0:
raise RuntimeError(
f"command failed ({' '.join(cmd)}):\n{completed.stdout}\n{completed.stderr}"
)
def _venv_python(venv_dir: Path) -> Path:
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _ensure_checkout(spec: CompetitorSpec, repo_dir: Path) -> None:
if not (repo_dir / ".git").exists():
repo_dir.parent.mkdir(parents=True, exist_ok=True)
_run(["git", "clone", "--quiet", spec.repo_url, str(repo_dir)])
head = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo_dir, capture_output=True, text=True
).stdout.strip()
if head != spec.pinned_sha:
_run(["git", "fetch", "--quiet", "origin"], cwd=repo_dir)
_run(["git", "checkout", "--quiet", spec.pinned_sha], cwd=repo_dir)
def _ensure_venv(spec: CompetitorSpec, venv_dir: Path, repo_dir: Path) -> Path:
python_exe = _venv_python(venv_dir)
marker = venv_dir / ".installed-sha"
if python_exe.exists() and marker.exists() and marker.read_text().strip() == spec.pinned_sha:
return python_exe
_run([sys.executable, "-m", "venv", str(venv_dir)])
_run([str(python_exe), "-m", "pip", "install", "--quiet", "--upgrade", "pip"])
for install in spec.pip_installs:
_run([str(python_exe), "-m", "pip", "install", "--quiet", *install], cwd=repo_dir)
marker.write_text(spec.pinned_sha)
return python_exe
def _resolve_entry(spec: CompetitorSpec, repo_dir: Path) -> tuple[str, ...]:
for candidate in spec.entry_candidates:
if candidate[0] == "-m":
package = candidate[1].replace(".", "/")
roots = [repo_dir, repo_dir / "src"]
if any(
(root / package).is_dir() or (root / f"{package}.py").is_file() for root in roots
):
return candidate
elif (repo_dir / candidate[0]).is_file():
return tuple([str(repo_dir / candidate[0]), *candidate[1:]])
raise RuntimeError(
f"{spec.competitor_id}: no stdio entrypoint found "
f"(tried {[c[0] for c in spec.entry_candidates]})"
)
def ensure_competitor(spec: CompetitorSpec, cache_root: Path | None = None) -> CompetitorRuntime:
"""Clone, pin, install and resolve one competitor. Idempotent."""
root = (cache_root or CACHE_ROOT) / spec.competitor_id
repo_dir = root / "repo"
_ensure_checkout(spec, repo_dir)
python_exe = _ensure_venv(spec, root / "venv", repo_dir)
entry_args = _resolve_entry(spec, repo_dir)
env = dict(spec.launch_env)
if spec.pythonpath is not None:
env["PYTHONPATH"] = str(repo_dir / spec.pythonpath)
return CompetitorRuntime(
python_exe=python_exe, repo_dir=repo_dir, entry_args=entry_args, env=env
)
+25
View File
@@ -12,6 +12,7 @@ The same file is executed against both the baseline worktree and the current
checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a
method the old version lacks simply raises AttributeError -> miss.
"""
from __future__ import annotations
import asyncio
@@ -23,6 +24,7 @@ import tempfile
async def _b():
from backends.ezdxf_backend import EzdxfBackend
be = EzdxfBackend()
await be.connect()
await be.drawing_new()
@@ -31,53 +33,62 @@ async def _b():
# ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ──
async def core_line_length():
b = await _b()
ln = await b.entity_create_line(0, 0, 3, 4)
info = await b.entity_get(ln.handle)
return abs(float(info.properties["length"]) - 5.0) < 1e-6
async def core_circle_radius():
b = await _b()
c = await b.entity_create_circle(0, 0, 10)
info = await b.entity_get(c.handle)
return abs(float(info.properties["radius"]) - 10.0) < 1e-6
async def core_layer_create():
b = await _b()
await b.layer_create("BENCH", color=1)
names = {lyr.name for lyr in await b.layer_list()}
return "BENCH" in names
async def core_polyline_closed():
b = await _b()
p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
info = await b.entity_get(p.handle)
return bool(info.properties.get("closed"))
async def core_linear_dim():
b = await _b()
d = await b.dimension_linear(0, 0, 100, 0, 50, 20)
info = await b.entity_get(d.handle)
return "DIM" in info.type.upper()
async def core_save_dxf_roundtrip():
b = await _b()
await b.entity_create_line(0, 0, 10, 10)
path = os.path.join(tempfile.mkdtemp(), "rt.dxf")
await b.drawing_save_as(path, "dxf")
import ezdxf
ezdxf.readfile(path) # raises if not a valid DXF
return os.path.getsize(path) > 0
# ── Dimensions (I16) ──────────────────────────────────────────────────────────
async def dim_aligned_no_error():
b = await _b()
d = await b.dimension_aligned(0, 0, 50, 50, 30, 70)
return d is not None
async def dim_angular_no_error():
b = await _b()
d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60)
@@ -86,6 +97,7 @@ async def dim_angular_no_error():
# ── Polar array full circle (R9) ──────────────────────────────────────────────
async def array_polar_360_distinct():
b = await _b()
c = await b.entity_create_circle(10, 0, 1)
@@ -101,6 +113,7 @@ async def array_polar_360_distinct():
# ── Deterministic geometry (I9) ────────────────────────────────────────────────
async def point_intersection_line_line():
b = await _b()
l1 = await b.entity_create_line(0, 0, 10, 0)
@@ -108,6 +121,7 @@ async def point_intersection_line_line():
pt = await b.point_intersection(l1.handle, l2.handle)
return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6
async def point_tangent_external():
b = await _b()
c = await b.entity_create_circle(0, 0, 3)
@@ -120,18 +134,21 @@ async def point_tangent_external():
# ── Selection / property parity (N3, N5) ───────────────────────────────────────
async def arc_has_length():
b = await _b()
a = await b.entity_create_arc(0, 0, 10, 0, 90)
info = await b.entity_get(a.handle)
return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3
async def arc_select_by_length():
b = await _b()
await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7
sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]})
return len(sel) == 1
async def ezdxf_bounding_box():
b = await _b()
ln = await b.entity_create_line(0, 0, 30, 40)
@@ -139,6 +156,7 @@ async def ezdxf_bounding_box():
bb = info.properties.get("bounding_box")
return isinstance(bb, dict) and set(bb) == {"min", "max"}
async def mtext_rotation_roundtrip():
b = await _b()
mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0)
@@ -148,6 +166,7 @@ async def mtext_rotation_roundtrip():
# ── Rendering (screenshot Agg / SIGSEGV) ───────────────────────────────────────
async def screenshot_png():
b = await _b()
await b.entity_create_line(0, 0, 100, 100)
@@ -157,6 +176,7 @@ async def screenshot_png():
# ── Quality gate / critique (R4, N4) ───────────────────────────────────────────
async def dim_overlap_critique_fires():
b = await _b()
await b.drawing_apply_iso_layers("mech")
@@ -165,6 +185,7 @@ async def dim_overlap_critique_fires():
issues = await b.drawing_critique(focus=["dim_overlap"])
return len(issues) >= 1
async def iso13567_dim_layer():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
@@ -172,6 +193,7 @@ async def iso13567_dim_layer():
dims = await b.dimension_auto([ln.handle], style="chain")
return dims[0].layer == "M-DIMEN-T-N"
async def construction_left_iso_caught():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
@@ -182,8 +204,10 @@ async def construction_left_iso_caught():
# ── Engineering (gear) ──────────────────────────────────────────────────────────
async def gear_no_self_overlap():
from engineering.gear import generate_full_gear_outline
pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r
root_r = (2 * 50 / 2.0) - 1.25 * 2
return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6
@@ -191,6 +215,7 @@ async def gear_no_self_overlap():
# ── Linetype on demand (R24) ───────────────────────────────────────────────────
async def center_linetype_applied():
b = await _b()
ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER")
+136
View File
@@ -0,0 +1,136 @@
"""Render the live-run benchmark lane (v2 fixed-task matrix) as a deterministic SVG.
Input: the published per-server reports under ``benchmarks/results/published/``
(produced by ``benchmarks.run_competitors``, artifact paths sanitized to
filenames). Output: a GitHub-readable horizontal bar chart showing the
weighted score and task coverage per server.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-livebench.svg"
def load_reports(input_dir: Path) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for path in sorted(input_dir.glob("*.json")):
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != "2.0":
raise ValueError(f"{path.name}: unsupported schema_version")
summary = data.get("summary") or {}
if "score" not in summary or "coverage_percent" not in summary:
raise ValueError(f"{path.name}: summary is missing score/coverage")
reports.append(data)
if not reports:
raise SystemExit(f"no published reports found in {input_dir}")
return reports
def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as exc:
raise SystemExit(
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
) from exc
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-livebench-v2"
reports = sorted(reports, key=lambda item: item["summary"]["score"])
labels = [item["adapter"] for item in reports]
scores = [item["summary"]["score"] for item in reports]
coverage = [item["summary"]["coverage_percent"] for item in reports]
passed = [item["summary"]["passed"] for item in reports]
attempted = [item["summary"]["attempted"] for item in reports]
colors = ["#0969da" if label == "autocad-mcp-pro" else "#6e7781" for label in labels]
fig, ax = plt.subplots(figsize=(12, 4.8), constrained_layout=True)
fig.patch.set_facecolor("#f6f8fa")
ax.set_facecolor("#f6f8fa")
bars = ax.barh(labels, scores, color=colors, height=0.58)
ax.set_xlim(0, 100)
ax.set_xlabel(
"Weighted live-run score / 100 (fixed 10-task matrix)", color="#24292f", labelpad=10
)
ax.set_title(
"Live-run benchmark - headless ezdxf lane",
loc="left",
fontsize=18,
fontweight="bold",
color="#24292f",
pad=28,
)
ax.text(
0,
1.02,
"Same 10 tasks, same harness, pinned competitor commits; artifacts verified by re-opening the DXF",
transform=ax.transAxes,
color="#57606a",
fontsize=10,
)
for bar, score, cov, ok, total in zip(bars, scores, coverage, passed, attempted, strict=True):
ax.text(
min(score + 1.2, 82),
bar.get_y() + bar.get_height() / 2,
f"{score:g} ({ok}/{total} pass, {cov:g}% coverage)",
va="center",
color="#24292f",
fontweight="bold",
)
ax.text(
0,
-0.22,
"Unsupported tasks score 0 in the fixed matrix; per-task statuses and reasons are in benchmarks/results/published/.",
transform=ax.transAxes,
color="#57606a",
fontsize=9,
)
ax.grid(axis="x", color="#d0d7de", linewidth=0.8, alpha=0.8)
ax.set_axisbelow(True)
ax.tick_params(axis="x", colors="#57606a")
ax.tick_params(axis="y", colors="#24292f", length=0, pad=8)
for spine in ax.spines.values():
spine.set_visible(False)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output,
format="svg",
facecolor=fig.get_facecolor(),
metadata={"Creator": "AutoCAD MCP Pro live benchmark renderer", "Date": None},
)
plt.close(fig)
svg = output.read_text(encoding="utf-8")
normalized = "\n".join(line.rstrip() for line in svg.splitlines()) + "\n"
output.write_text(normalized, encoding="utf-8", newline="\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_DIR)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser
def main() -> int:
args = build_parser().parse_args()
render_chart(load_reports(args.input_dir), args.output)
print(f"Rendered live benchmark chart: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,270 @@
{
"adapter": "autocad-mcp-pro",
"adapter_metadata": {
"backend": "ezdxf",
"capabilities": {
"backend": "ezdxf",
"features": {
"delivery": {
"mode": "shared",
"reason": null,
"supported": true
},
"drawing_2d": {
"mode": "native",
"reason": null,
"supported": true
},
"dxf": {
"mode": "native",
"reason": null,
"supported": true
},
"lisp": {
"mode": null,
"reason": "live_com_only",
"supported": false
},
"mleader": {
"mode": "composite",
"reason": null,
"supported": true
},
"paper_space": {
"mode": null,
"reason": "planned_1.4",
"supported": false
},
"pdf": {
"mode": "rendered",
"reason": null,
"supported": true
},
"png": {
"mode": "rendered",
"reason": null,
"supported": true
},
"preflight": {
"mode": "shared",
"reason": null,
"supported": true
},
"refiner": {
"mode": "shared",
"reason": null,
"supported": true
},
"solid_3d": {
"mode": null,
"reason": "planned_1.5",
"supported": false
},
"table": {
"mode": "composite",
"reason": null,
"supported": true
},
"transactions": {
"mode": "snapshot",
"reason": null,
"supported": true
}
}
}
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
},
"generated_at": "2026-07-23T11:39:04.277175+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"results": [
{
"artifacts": [],
"duration_ms": 8.14,
"message": "",
"metrics": {
"line_length": 5.0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [],
"duration_ms": 3.39,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [],
"duration_ms": 8.11,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [],
"duration_ms": 348.74,
"message": "",
"metrics": {
"entity_type": "DIMENSION"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 15.74,
"message": "",
"metrics": {
"representations": [
"composite",
"composite"
]
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 28.95,
"message": "",
"metrics": {
"entity_count": 0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 2.5,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 29.19,
"message": "",
"metrics": {
"final_score": 100.0,
"initial_score": 92.0,
"remaining_issues": [],
"rounds": [
{
"actions": [
{
"detail": {
"deleted_handle": "3E",
"kept_handle": "3D"
},
"fingerprint": "c1b87eac3cfdf11312c9b18ccf4c062e672791f7a9a6dfa3c45b6db7f9e5835e",
"focus": "duplicate_entities",
"handles": [
"3D",
"3E"
],
"status": "applied"
}
],
"error_count_after": 0,
"error_count_before": 0,
"number": 1,
"score_after": 100.0,
"score_before": 92.0,
"transaction": "committed"
}
],
"status": "threshold_met"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "roundtrip.dxf",
"sha256": "6413204a5baec19a5fbf551418eb56e30b1488ca5bb755e6a8709ac8e8ab0b22",
"size_bytes": 18442
}
],
"duration_ms": 16.66,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [
{
"filename": "manifest.json",
"sha256": "38c2ee819b49b9c66b2311b952e856eff9e3a7565194cce2edea60a3211d233b",
"size_bytes": 3581
}
],
"duration_ms": 4380.8,
"message": "",
"metrics": {
"score": {
"errors": 0,
"grade": "A",
"info": 0,
"invalidity_ratio": 0.0,
"score": 100.0,
"warnings": 0
}
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"coverage_percent": 100.0,
"passed": 10,
"score": 100.0,
"supported": 10,
"unsupported": 0
},
"timeout_seconds": 30.0
}
@@ -0,0 +1,191 @@
{
"adapter": "beiming183-autocad-mcp",
"adapter_metadata": {
"backend": "ezdxf",
"kind": "mcp-stdio-blackbox",
"pinned_sha": "11f7c47e5038796a20451b38b23032e625b5aa26",
"repo": "https://github.com/beiming183-cloud/AutoCAD-MCP.git",
"tools_discovered": [
"annotation",
"block",
"drawing",
"entity",
"job",
"layer",
"pid",
"product",
"solid",
"system",
"transaction",
"view"
]
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
},
"generated_at": "2026-07-23T11:38:36.969519+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"results": [
{
"artifacts": [
{
"filename": "beiming_core_geometry.dxf",
"sha256": "d972b29da994b1ea8f3887eef84f2ed892a486409385b56cd444b1fe9794e337",
"size_bytes": 18587
}
],
"duration_ms": 96.24,
"message": "",
"metrics": {
"circles": 1,
"line_length": 5.0,
"lines": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [
{
"filename": "beiming_modify_query.dxf",
"sha256": "298534d8512ce62833551ab6e54c0c2a0baf18bdf7a96bd956cdf06280fb4fb8",
"size_bytes": 18473
}
],
"duration_ms": 108.09,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [
{
"filename": "beiming_layers.dxf",
"sha256": "b401e145e97aca7011d03097190b3d0ece0a89b47d38022a81c6c4c7e250ed3d",
"size_bytes": 19887
}
],
"duration_ms": 69.86,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [
{
"filename": "beiming_dimensions.dxf",
"sha256": "8996d2ba42da66f46e79d1655405a4bf2ba72301861eeea58509cba44c408fb4",
"size_bytes": 20972
}
],
"duration_ms": 75.55,
"message": "",
"metrics": {
"dimension_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 0.12,
"message": "no TABLE or MLEADER operation in the annotation tool (v3.10)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 18.99,
"message": "transaction begin is rejected as unsupported on the headless backend",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 0.04,
"message": "no requirements-preflight equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 0.02,
"message": "audits detect issues but there is no automated repair loop",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "beiming_roundtrip.dxf",
"sha256": "79d7bdc1e66e506dd2ecf66fbf030839242c77870021ccbfe3c819ad394695ce",
"size_bytes": 18474
}
],
"duration_ms": 69.95,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [],
"duration_ms": 0.06,
"message": "drawing.deliver is documented as File-IPC only (not headless)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"coverage_percent": 50.0,
"passed": 5,
"score": 50.0,
"supported": 5,
"unsupported": 5
},
"timeout_seconds": 30.0
}
@@ -0,0 +1,187 @@
{
"adapter": "puran-water-autocad-mcp",
"adapter_metadata": {
"backend": "ezdxf",
"kind": "mcp-stdio-blackbox",
"pinned_sha": "95476a33a1c246308326eb4709d6379ef2efdbc1",
"repo": "https://github.com/puran-water/autocad-mcp.git",
"tools_discovered": [
"annotation",
"block",
"drawing",
"entity",
"layer",
"pid",
"system",
"view"
]
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
},
"generated_at": "2026-07-23T11:32:07.977232+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"results": [
{
"artifacts": [
{
"filename": "puran_core_geometry.dxf",
"sha256": "bc8185404c8344134859293a0e28ebf821cd5968553cdd96b7029e46e1ca0c99",
"size_bytes": 18587
}
],
"duration_ms": 205.92,
"message": "",
"metrics": {
"circles": 1,
"line_length": 5.0,
"lines": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [
{
"filename": "puran_modify_query.dxf",
"sha256": "2113a59d0a9f624b6134e89f992d153bc8e085495e956f4887b8ba7b54f3c005",
"size_bytes": 18473
}
],
"duration_ms": 156.79,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [
{
"filename": "puran_layers.dxf",
"sha256": "7144467e22e2e8c8d18f1fc4f177f078f9ce450bb73f4e9ca8f74c16d67e1b53",
"size_bytes": 18498
}
],
"duration_ms": 189.98,
"message": "layer created; no linetype-loading operation is exposed",
"metrics": {},
"score": 50.0,
"status": "partial",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [
{
"filename": "puran_dimensions.dxf",
"sha256": "dde808c5e504c5e6b7837f5f5c14ef096711812a6239dd284906efed5c9e3614",
"size_bytes": 20972
}
],
"duration_ms": 215.28,
"message": "",
"metrics": {
"dimension_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 0.12,
"message": "no TABLE or MLEADER operation in the 8-tool surface (v3.1)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 0.02,
"message": "no transaction begin/commit/rollback tool (undo/redo are File-IPC only)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 0.01,
"message": "no requirements-preflight equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 0.03,
"message": "no critique/repair loop equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "puran_roundtrip.dxf",
"sha256": "11335953641be32f3f47c23fbc13f79876111c6f84f8874af8f5e04d5e398cf6",
"size_bytes": 18474
}
],
"duration_ms": 143.4,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [],
"duration_ms": 0.06,
"message": "no hashed delivery-manifest equivalent (plot_pdf is File-IPC only)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"coverage_percent": 50.0,
"passed": 4,
"score": 45.0,
"supported": 5,
"unsupported": 5
},
"timeout_seconds": 30.0
}
+32 -12
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import importlib
import json
import platform
import subprocess
@@ -17,6 +18,19 @@ from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec, task_by_id
def _load_registry() -> dict[str, dict]:
"""Read competitors.yaml (JSON content) into an id -> entry registry."""
path = Path(__file__).with_name("competitors.yaml")
data = json.loads(path.read_text(encoding="utf-8"))
return {entry["id"]: entry for entry in data.get("competitors", [])}
def _make_adapter(entry: dict, backend: str) -> BenchmarkAdapter:
module_name, _, attr = entry["adapter"].partition(":")
adapter_cls = getattr(importlib.import_module(module_name), attr)
return adapter_cls(backend=backend)
def _git_sha() -> str | None:
try:
result = subprocess.run(
@@ -91,9 +105,7 @@ async def run_tasks(
total_weight = sum(weight_by_id.values())
supported_weight = sum(weight_by_id[item.task_id] for item in supported)
weighted_score = sum(
weight_by_id[item.task_id] * item.score
for item in results
if item.status != "unsupported"
weight_by_id[item.task_id] * item.score for item in results if item.status != "unsupported"
)
return {
"schema_version": "2.0",
@@ -122,10 +134,15 @@ async def run_tasks(
}
def _parser() -> argparse.ArgumentParser:
def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--list", action="store_true", help="List the fixed benchmark tasks")
parser.add_argument("--server", default="autocad-mcp-pro", choices=["autocad-mcp-pro"])
parser.add_argument(
"--server",
default="autocad-mcp-pro",
choices=sorted(registry),
help="Adapter id from benchmarks/competitors.yaml",
)
parser.add_argument("--backend", default="ezdxf", choices=["ezdxf", "com"])
parser.add_argument("--task", action="append", help="Run only this task id (repeatable)")
parser.add_argument("--artifact-dir", default="benchmarks/results/latest")
@@ -135,19 +152,22 @@ def _parser() -> argparse.ArgumentParser:
def main() -> None:
args = _parser().parse_args()
registry = _load_registry()
args = _parser(registry).parse_args()
if args.list:
for task in TASKS_V2:
print(f"{task.task_id}\t{task.category}\t{task.description}")
return
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
entry = registry[args.server]
supported_backends = entry.get("backends", ["ezdxf"])
if args.backend not in supported_backends:
raise SystemExit(
f"{args.server} supports backends {supported_backends}, not {args.backend!r}"
)
tasks = [task_by_id(task_id) for task_id in args.task] if args.task else list(TASKS_V2)
adapter = AutoCADMCPProAdapter(backend=args.backend)
report = asyncio.run(
run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout)
)
adapter = _make_adapter(entry, args.backend)
report = asyncio.run(run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout))
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
print(rendered)
+11
View File
@@ -43,5 +43,16 @@ class Settings:
)
self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip()
# Exposed tool profile: "full" (default, everything), "core" (hides the
# raw escape hatches and long-tail tools) or "lean" (a curated ~46-tool
# drafting/inspection core for clients with tight tool caps). Invalid
# values fall back to "full" with a warning at startup.
self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip()
# Opt-in 3D solids (COM backend only). Disabled by default because the
# headless backend cannot produce ACIS solids and live 3D modifies real
# documents; enable deliberately.
self.enable_3d: bool = os.environ.get("ENABLE_3D", "false").lower().strip() == "true"
settings = Settings()
+51
View File
@@ -0,0 +1,51 @@
# Release & Distribution Runbook (v1.4.0+)
The code side of a release is fully automated; three account-bound steps need
a human once, plus the directory submissions. Track them here.
## One-time setup (owner action)
- [ ] **PyPI trusted publisher** — on <https://pypi.org/manage/account/publishing/>
add a *pending publisher* for project `autocad-mcp-pro`:
repository `U-C4N/Autocad-MCP`, workflow `release.yml`, environment `pypi`.
(No API token is stored anywhere; `release.yml` uses OIDC.)
- [ ] **GitHub environment** — create the `pypi` environment in the repo
settings (Settings → Environments) so the publish job can bind to it.
- [ ] **MCP registry login** — install `mcp-publisher`, then
`mcp-publisher login github` (authenticates the `io.github.u-c4n` namespace).
## Every release
1. Bump `pyproject.toml` + `server.json` versions, add the CHANGELOG section,
update the README snapshot line — `tests/test_release_consistency.py`
enforces all of it.
2. Push `main`, wait for CI to go green (lint, linux, windows, package,
docker, registry-validate).
3. Tag and push: `git tag v1.4.0 && git push origin v1.4.0`.
`release.yml` runs the test gate, verifies tag == package version, builds,
publishes to PyPI via trusted publishing and cuts the GitHub Release.
4. Verify: `pip install autocad-mcp-pro==<version>` in a clean venv;
`uvx autocad-mcp-pro --help`.
5. Publish the registry manifest: `mcp-publisher publish` (validates
`server.json`, checks the `mcp-name` marker in the PyPI README).
## Directory listings (submit once, then keep fresh)
- [ ] Glama — <https://glama.ai/mcp/servers> (auto-indexes GitHub; claim the
server page and verify metadata).
- [ ] mcpservers.org — submit via their GitHub repo PR flow.
- [ ] awesome-mcp-servers — PR adding the server under CAD/engineering.
- [ ] Smithery — optional; requires a `smithery.yaml` (evaluate demand first).
## Benchmark refresh (per release)
```bash
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
# sanitize + copy reports into benchmarks/results/published/, then:
python -m benchmarks.render_live_chart
```
Bump the competitor pins in `benchmarks/competitors.yaml` deliberately (new
SHA = re-read their tool contracts before trusting the mappings).
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 69 KiB

+95 -72
View File
@@ -29,8 +29,13 @@ _EXPECTED_LAYER_COLOR: dict[str, int] = {
# Dimension entity types that should sit on the DIM layer.
_DIM_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
"DIMENSION",
"DIMLINEAR",
"DIMALIGNED",
"DIMANGULAR",
"DIMRADIUS",
"DIMDIAMETER",
"DIMORDINATE",
}
@@ -57,15 +62,17 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
# (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept
# either the raw mm value or hundredths/100.
if lw not in allowed and (lw / 100.0) not in allowed:
issues.append(Issue(
severity="warning",
focus="iso128",
message=(
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
f"ISO 128 set {sorted(allowed)}."
),
detail={"layer": lyr.name, "lineweight": lw},
))
issues.append(
Issue(
severity="warning",
focus="iso128",
message=(
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
f"ISO 128 set {sorted(allowed)}."
),
detail={"layer": lyr.name, "lineweight": lw},
)
)
return issues
@@ -79,17 +86,19 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
for lyr in layers:
expected = _EXPECTED_LAYER_COLOR.get(lyr.name)
if expected is None:
continue # unknown layer — caller's responsibility
continue # unknown layer — caller's responsibility
if int(lyr.color) != int(expected):
issues.append(Issue(
severity="warning",
focus="layer_color",
message=(
f"Layer '{lyr.name}' has color {lyr.color}; "
f"engineering standard expects {expected}."
),
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
))
issues.append(
Issue(
severity="warning",
focus="layer_color",
message=(
f"Layer '{lyr.name}' has color {lyr.color}; "
f"engineering standard expects {expected}."
),
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
)
)
return issues
@@ -107,17 +116,21 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
if not leftover:
return []
return [Issue(
severity="error",
focus="construction_left",
message=(
f"{len(leftover)} entity/entities still on a construction layer. "
"Call construction_clear() before drawing_finalize."
),
handles=[e.handle for e in leftover[:50]],
detail={"count": len(leftover),
"layers": sorted({e.layer for e in leftover if e.layer})},
)]
return [
Issue(
severity="error",
focus="construction_left",
message=(
f"{len(leftover)} entity/entities still on a construction layer. "
"Call construction_clear() before drawing_finalize."
),
handles=[e.handle for e in leftover[:50]],
detail={
"count": len(leftover),
"layers": sorted({e.layer for e in leftover if e.layer}),
},
)
]
async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
@@ -150,16 +163,18 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
if key in seen:
continue
seen.add(key)
issues.append(Issue(
severity="warning",
focus="untrimmed_corner",
message=(
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
"likely an untrimmed corner (use entity_trim or entity_fillet)."
),
handles=[h1, h2],
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
))
issues.append(
Issue(
severity="warning",
focus="untrimmed_corner",
message=(
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
"likely an untrimmed corner (use entity_trim or entity_fillet)."
),
handles=[h1, h2],
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
)
)
return issues
@@ -180,12 +195,14 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
k2 = (k1[2], k1[3], k1[0], k1[1])
for key in (k1, k2):
if key in seen:
issues.append(Issue(
severity="warning",
focus="duplicate_entities",
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
handles=[seen[key], ln.handle],
))
issues.append(
Issue(
severity="warning",
focus="duplicate_entities",
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
handles=[seen[key], ln.handle],
)
)
break
else:
seen[k1] = ln.handle
@@ -243,12 +260,14 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
if key in seen:
continue
seen.add(key)
issues.append(Issue(
severity="warning",
focus="dim_overlap",
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
handles=[h1, h2],
))
issues.append(
Issue(
severity="warning",
focus="dim_overlap",
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
handles=[h1, h2],
)
)
return issues
@@ -267,19 +286,21 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
missing = sorted(referenced - defined)
if not missing:
return []
return [Issue(
severity="error",
focus="gdt",
message=(
f"Feature control frame(s) reference datum(s) {missing} with no "
"matching datum feature. Add draw_datum_feature for each datum."
),
detail={
"missing_datums": missing,
"defined": sorted(defined),
"referenced": sorted(referenced),
},
)]
return [
Issue(
severity="error",
focus="gdt",
message=(
f"Feature control frame(s) reference datum(s) {missing} with no "
"matching datum feature. Add draw_datum_feature for each datum."
),
detail={
"missing_datums": missing,
"defined": sorted(defined),
"referenced": sorted(referenced),
},
)
]
_FOCUS_DISPATCH = {
@@ -303,11 +324,13 @@ async def run_critique(
for f in foci:
check = _FOCUS_DISPATCH.get(f)
if check is None:
issues.append(Issue(
severity="warning",
focus=f, # type: ignore[arg-type]
message=f"Unknown critique focus '{f}' (closed enum).",
))
issues.append(
Issue(
severity="warning",
focus=f, # type: ignore[arg-type]
message=f"Unknown critique focus '{f}' (closed enum).",
)
)
continue
issues.extend(await check(backend))
return issues
+4 -4
View File
@@ -163,7 +163,9 @@ async def deliver_drawing(
expected: dict | None = None,
) -> DeliveryResult:
"""Export, validate, hash and re-open a release-ready drawing bundle."""
requested = list(dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"])))
requested = list(
dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"]))
)
unknown = [item for item in requested if item not in _FORMAT_FILENAMES]
if unknown:
raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}")
@@ -210,9 +212,7 @@ async def deliver_drawing(
artifact_path.write_bytes(png)
artifacts.append(_artifact_record(fmt, artifact_path, status="created"))
except Exception as exc:
artifacts.append(
_artifact_record(fmt, artifact_path, status="failed", error=str(exc))
)
artifacts.append(_artifact_record(fmt, artifact_path, status="failed", error=str(exc)))
finally:
timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2)
+181
View File
@@ -0,0 +1,181 @@
"""ISO 286-1/2 limits-and-fits lookup (authored table data, not formulas).
`fit_lookup("H7", 20.0)` resolves a fit code into upper/lower deviations in
millimetres, ready to feed `tolerances.build_dim_override` (tol_mode
"deviation"). Values are the published ISO 286 table values — the standard's
rounding rules make naive formula evaluation drift by 1 µm or a full step in
places, so the tables are authored and pinned by tests instead.
Scope (deliberate, documented):
- Nominal sizes over 1 mm up to and including 500 mm.
- IT grades 4-11 (IT4 is carried for the shaft-k rule and future hole mirrors).
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p.
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS).
- Transition/interference HOLE letters (K, M, N, P) require the ISO 286
delta-rule and sub-stepped r/s tables; they are intentionally excluded and
raise a ValueError naming the supported set.
This covers the preferred general-mechanical fits (H7/g6, H7/h6, H7/k6,
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Size steps: (over, up_to_incl) in mm — ISO 286-1 main steps, 1..500 mm.
_SIZE_STEPS: tuple[tuple[float, float], ...] = (
(1, 3),
(3, 6),
(6, 10),
(10, 18),
(18, 30),
(30, 50),
(50, 80),
(80, 120),
(120, 180),
(180, 250),
(250, 315),
(315, 400),
(400, 500),
)
# Standard tolerance values IT4..IT11 in µm per size step (ISO 286-1 Table 1).
_IT_GRADES = (4, 5, 6, 7, 8, 9, 10, 11)
_IT_TABLE: tuple[tuple[int, ...], ...] = (
# IT4 IT5 IT6 IT7 IT8 IT9 IT10 IT11
(3, 4, 6, 10, 14, 25, 40, 60), # >1-3
(4, 5, 8, 12, 18, 30, 48, 75), # >3-6
(4, 6, 9, 15, 22, 36, 58, 90), # >6-10
(5, 8, 11, 18, 27, 43, 70, 110), # >10-18
(6, 9, 13, 21, 33, 52, 84, 130), # >18-30
(7, 11, 16, 25, 39, 62, 100, 160), # >30-50
(8, 13, 19, 30, 46, 74, 120, 190), # >50-80
(10, 15, 22, 35, 54, 87, 140, 220), # >80-120
(12, 18, 25, 40, 63, 100, 160, 250), # >120-180
(14, 20, 29, 46, 72, 115, 185, 290), # >180-250
(16, 23, 32, 52, 81, 130, 210, 320), # >250-315
(18, 25, 36, 57, 89, 140, 230, 360), # >315-400
(20, 27, 40, 63, 97, 155, 250, 400), # >400-500
)
# Fundamental deviations for shafts in µm per size step (ISO 286-1 Table 2/3).
# d..g carry the upper deviation es (negative); k..p carry the lower
# deviation ei (positive). h is es=0; js is symmetric (±IT/2).
_SHAFT_ES: dict[str, tuple[int, ...]] = {
"d": (-20, -30, -40, -50, -65, -80, -100, -120, -145, -170, -190, -210, -230),
"e": (-14, -20, -25, -32, -40, -50, -60, -72, -85, -100, -110, -125, -135),
"f": (-6, -10, -13, -16, -20, -25, -30, -36, -43, -50, -56, -62, -68),
"g": (-2, -4, -5, -6, -7, -9, -10, -12, -14, -15, -17, -18, -20),
}
_SHAFT_EI: dict[str, tuple[int, ...]] = {
# k applies to IT4..IT7 only (other grades have ei = 0 per the standard).
"k": (1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 4, 4, 5),
"m": (2, 4, 6, 7, 8, 9, 11, 13, 15, 17, 20, 21, 23),
"n": (4, 8, 10, 12, 15, 17, 20, 23, 27, 31, 34, 37, 40),
"p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68),
}
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p")
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS")
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$")
@dataclass(frozen=True)
class FitDeviation:
"""Resolved fit: deviations in mm relative to the nominal size."""
code: str
nominal: float
upper_mm: float
lower_mm: float
it_grade: int
it_value_mm: float
def to_dict(self) -> dict:
return {
"code": self.code,
"nominal": self.nominal,
"upper_mm": self.upper_mm,
"lower_mm": self.lower_mm,
"it_grade": self.it_grade,
"it_value_mm": self.it_value_mm,
}
def parse_fit_code(code: str) -> tuple[str, int]:
"""Split e.g. 'H7' -> ('H', 7) or 'js6' -> ('js', 6)."""
match = _FIT_CODE_RE.match(code or "")
if not match:
raise ValueError(
f"Invalid fit code {code!r}. Expected letter + IT grade, e.g. H7, g6, js9."
)
return match.group(1), int(match.group(2))
def _size_index(nominal_mm: float) -> int:
if not 1.0 < nominal_mm <= 500.0:
raise ValueError(
f"Nominal size {nominal_mm} mm is outside the authored ISO 286 range "
"(over 1 mm up to and including 500 mm)."
)
for index, (over, up_to) in enumerate(_SIZE_STEPS):
if over < nominal_mm <= up_to:
return index
raise ValueError(f"No ISO 286 size step covers {nominal_mm} mm") # pragma: no cover
def _it_um(grade: int, size_index: int) -> int:
if grade not in _IT_GRADES:
raise ValueError(f"IT grade {grade} is outside the authored range IT4-IT11.")
return _IT_TABLE[size_index][_IT_GRADES.index(grade)]
def fit_lookup(code: str, nominal_mm: float) -> FitDeviation:
"""Resolve a fit code for a nominal size into mm deviations.
Holes use uppercase letters, shafts lowercase (ISO 286 convention).
Raises ValueError for codes outside the authored scope.
"""
letter, grade = parse_fit_code(code)
index = _size_index(float(nominal_mm))
it_um = _it_um(grade, index)
if letter in ("js", "JS"):
upper_um = it_um / 2.0
lower_um = -it_um / 2.0
elif letter == "h":
upper_um, lower_um = 0.0, float(-it_um)
elif letter == "H":
upper_um, lower_um = float(it_um), 0.0
elif letter in _SHAFT_ES: # d, e, f, g — clearance shafts
es = float(_SHAFT_ES[letter][index])
upper_um, lower_um = es, es - it_um
elif letter in ("D", "E", "F", "G"): # mirrored clearance holes: EI = -es
ei = float(-_SHAFT_ES[letter.lower()][index])
upper_um, lower_um = ei + it_um, ei
elif letter in _SHAFT_EI: # k, m, n, p — transition/interference shafts
if letter == "k" and not 4 <= grade <= 7:
ei = 0.0 # ISO 286: k has ei = 0 outside IT4-IT7
else:
ei = float(_SHAFT_EI[letter][index])
upper_um, lower_um = ei + it_um, ei
else:
raise ValueError(
f"Fit letter {letter!r} is outside the authored scope. Supported: "
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. "
"Transition/interference hole letters (K, M, N, P) need the ISO 286 "
"delta-rule and are not authored yet."
)
return FitDeviation(
code=f"{letter}{grade}",
nominal=float(nominal_mm),
upper_mm=round(upper_um / 1000.0, 6),
lower_mm=round(lower_um / 1000.0, 6),
it_grade=grade,
it_value_mm=round(it_um / 1000.0, 6),
)
+35 -13
View File
@@ -67,16 +67,18 @@ def generate_involute_flank(
return points
def _rotate_points(
pts: list[tuple[float, float]], angle: float
) -> list[tuple[float, float]]:
def _rotate_points(pts: list[tuple[float, float]], angle: float) -> list[tuple[float, float]]:
c, s = math.cos(angle), math.sin(angle)
return [(c * x - s * y, s * x + c * y) for x, y in pts]
def _arc_points(
cx: float, cy: float, radius: float,
start_angle: float, end_angle: float, n: int = 5,
cx: float,
cy: float,
radius: float,
start_angle: float,
end_angle: float,
n: int = 5,
) -> list[tuple[float, float]]:
"""Sample n points along an arc (start_angle → end_angle, radians, ccw)."""
if n < 2:
@@ -232,20 +234,29 @@ async def draw_helical_gear_front_view(
root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY")
pts = generate_full_gear_outline(
module=module, teeth=teeth, pressure_angle=pressure_angle,
helix_angle=helix_angle, hand=hand, center=(cx, cy),
module=module,
teeth=teeth,
pressure_angle=pressure_angle,
helix_angle=helix_angle,
hand=hand,
center=(cx, cy),
)
outline = await backend.entity_create_polyline(
[[float(x), float(y)] for x, y in pts],
closed=True, layer="GEOMETRY",
closed=True,
layer="GEOMETRY",
)
helix_handles: list[str] | None = None
helix_label: str | None = None
if helix_angle and abs(helix_angle) > 1e-9:
helix_handles, helix_label = await _draw_helix_symbol(
backend, cx=cx, cy=cy, outer_r=outer_r,
helix_angle=helix_angle, hand=hand,
backend,
cx=cx,
cy=cy,
outer_r=outer_r,
helix_angle=helix_angle,
hand=hand,
)
bore_handle: str | None = None
@@ -263,7 +274,10 @@ async def draw_helical_gear_front_view(
keyway_dict = keyed
else:
bore_only = await backend.entity_create_circle(
cx, cy, bore_diameter / 2.0, layer="GEOMETRY",
cx,
cy,
bore_diameter / 2.0,
layer="GEOMETRY",
)
bore_handle = bore_only.handle
@@ -385,10 +399,18 @@ async def draw_gear_section_aa(
bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]]
else:
bore_top = await backend.entity_create_line(
x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY",
x_left,
cy + bore_r,
x_right,
cy + bore_r,
layer="GEOMETRY",
)
bore_bot = await backend.entity_create_line(
x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY",
x_left,
cy - bore_r,
x_right,
cy - bore_r,
layer="GEOMETRY",
)
bore_handles = [bore_top.handle, bore_bot.handle]
+43 -9
View File
@@ -45,7 +45,9 @@ def keyway_dimensions(bore_diameter: float) -> dict:
f"({DIN6885_TABLE[-1][1]} mm)."
)
for b_min, b_max, width, height, t1 in DIN6885_TABLE:
if b_min < bore_diameter <= b_max or (bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]):
if b_min < bore_diameter <= b_max or (
bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]
):
return {
"width": float(width),
"height": float(height),
@@ -95,10 +97,18 @@ async def draw_keyed_bore(
overshoot = max(r * 0.2, 5.0)
horiz = await backend.entity_create_line(
cx - r - overshoot, cy, cx + r + overshoot, cy, layer=center_layer,
cx - r - overshoot,
cy,
cx + r + overshoot,
cy,
layer=center_layer,
)
vert = await backend.entity_create_line(
cx, cy - r - overshoot, cx, cy + r + depth + overshoot, layer=center_layer,
cx,
cy - r - overshoot,
cx,
cy + r + depth + overshoot,
layer=center_layer,
)
return {
@@ -142,23 +152,47 @@ async def draw_keyway_section(
notch_right = cx + half_kw
bore_top = await backend.entity_create_line(
x_left, top_y, x_right, top_y, layer=layer,
x_left,
top_y,
x_right,
top_y,
layer=layer,
)
bore_bottom = await backend.entity_create_line(
x_left, bot_y, x_right, bot_y, layer=layer,
x_left,
bot_y,
x_right,
bot_y,
layer=layer,
)
notch_bottom = await backend.entity_create_line(
notch_left, top_y, notch_right, top_y, layer=layer,
notch_left,
top_y,
notch_right,
top_y,
layer=layer,
)
notch_left_wall = await backend.entity_create_line(
notch_left, top_y, notch_left, notch_top_y, layer=layer,
notch_left,
top_y,
notch_left,
notch_top_y,
layer=layer,
)
notch_top = await backend.entity_create_line(
notch_left, notch_top_y, notch_right, notch_top_y, layer=layer,
notch_left,
notch_top_y,
notch_right,
notch_top_y,
layer=layer,
)
notch_right_wall = await backend.entity_create_line(
notch_right, notch_top_y, notch_right, top_y, layer=layer,
notch_right,
notch_top_y,
notch_right,
top_y,
layer=layer,
)
return {
+45 -41
View File
@@ -13,52 +13,52 @@ log = logging.getLogger(__name__)
ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [
# (name, color, linetype, lineweight, description)
("0", 7, "Continuous", 0.25, "default"),
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
("CENTER", 1, "CENTER", 0.18, "centerlines"),
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT", 7, "Continuous", 0.25, "annotations"),
("HATCH", 8, "Continuous", 0.13, "section hatching"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("0", 7, "Continuous", 0.25, "default"),
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
("CENTER", 1, "CENTER", 0.18, "centerlines"),
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT", 7, "Continuous", 0.25, "annotations"),
("HATCH", 8, "Continuous", 0.13, "section hatching"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
]
# P&ID standard layer set (de-facto, distilled from ISO 10628 + common practice).
PID_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"),
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
("0", 7, "Continuous", 0.25, "default"),
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
]
# ISO 13567-style layer naming kit (Agent2-Element6-Presentation2-Status1).
# This is a *starter* set — extend as needed per discipline.
ISO13567_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"),
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
("0", 7, "Continuous", 0.25, "default"),
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
]
@@ -73,9 +73,9 @@ LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = {
# "DIM"/"CONSTRUCTION" (which only exist in the mech/pid sets — iso13567 uses
# M-DIMEN-T-N / M-CONST-E-N).
LAYER_ROLES: dict[str, dict[str, str]] = {
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
}
@@ -85,6 +85,7 @@ def resolve_role_layer(layer_set_id: str | None, role: str) -> str:
roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"])
return roles.get(role, LAYER_ROLES["mech"][role])
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
@@ -115,14 +116,14 @@ async def ensure_engineering_layers(backend: AutoCADBackend) -> dict[str, str]:
async def apply_layer_set(
backend: AutoCADBackend, standard: str = "mech",
backend: AutoCADBackend,
standard: str = "mech",
) -> dict[str, str]:
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
layer_set = LAYER_SET_REGISTRY.get(standard)
if layer_set is None:
raise RuntimeError(
f"Unknown layer set '{standard}'. "
f"Choose one of: {sorted(LAYER_SET_REGISTRY)}"
f"Unknown layer set '{standard}'. Choose one of: {sorted(LAYER_SET_REGISTRY)}"
)
await ensure_standard_linetypes(backend)
@@ -139,7 +140,10 @@ async def apply_layer_set(
continue
try:
await backend.layer_create(
name=name, color=color, linetype=linetype, lineweight=lineweight,
name=name,
color=color,
linetype=linetype,
lineweight=lineweight,
)
results[name] = "created"
except Exception as exc:
+9 -1
View File
@@ -91,5 +91,13 @@ class Issue:
# ISO 128 lineweight set (mm). Any lineweight outside this set is flagged.
ISO_128_LINEWEIGHTS_MM: tuple[float, ...] = (
0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00, 1.40, 2.00,
0.13,
0.18,
0.25,
0.35,
0.50,
0.70,
1.00,
1.40,
2.00,
)
+1 -3
View File
@@ -99,9 +99,7 @@ def preflight_drawing(
assumptions.append({"field": "units", "value": "mm", "reason": "default"})
if allow_assumptions and not req.get("tolerance_policy"):
req["tolerance_policy"] = "general"
assumptions.append(
{"field": "tolerance_policy", "value": "general", "reason": "default"}
)
assumptions.append({"field": "tolerance_policy", "value": "general", "reason": "default"})
if isinstance(req.get("units"), str):
req["units"] = req["units"].lower()
+2 -6
View File
@@ -64,12 +64,8 @@ async def repair_untrimmed_corner(backend: AutoCADBackend, issue: Issue) -> dict
midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2)
first = await backend.entity_get(issue.handles[0])
second = await backend.entity_get(issue.handles[1])
await backend.entity_edit_geometry(
issue.handles[0], **_endpoint_kwargs(first, p1, midpoint)
)
await backend.entity_edit_geometry(
issue.handles[1], **_endpoint_kwargs(second, p2, midpoint)
)
await backend.entity_edit_geometry(issue.handles[0], **_endpoint_kwargs(first, p1, midpoint))
await backend.entity_edit_geometry(issue.handles[1], **_endpoint_kwargs(second, p2, midpoint))
return {"handles": list(issue.handles), "corner": list(midpoint)}
+29 -7
View File
@@ -72,7 +72,9 @@ async def apply_iso_a3_titleblock(
[ox, oy + SHEET_H],
]
outer = await backend.entity_create_polyline(
points=outer_pts, closed=True, layer=LAYER_BORDER,
points=outer_pts,
closed=True,
layer=LAYER_BORDER,
)
inner_x0 = ox + LEFT_MARGIN
@@ -86,7 +88,9 @@ async def apply_iso_a3_titleblock(
[inner_x0, inner_y1],
]
inner = await backend.entity_create_polyline(
points=inner_pts, closed=True, layer=LAYER_BORDER,
points=inner_pts,
closed=True,
layer=LAYER_BORDER,
)
# ---- title block (lower-right corner of inner border) ----------------------
@@ -103,7 +107,9 @@ async def apply_iso_a3_titleblock(
[tb_x0, tb_y1],
]
tb_frame = await backend.entity_create_polyline(
points=tb_frame_pts, closed=True, layer=LAYER_BORDER,
points=tb_frame_pts,
closed=True,
layer=LAYER_BORDER,
)
# Row baselines (from bottom up): row4_top, row3_top, row2_top
@@ -115,13 +121,21 @@ async def apply_iso_a3_titleblock(
async def hline(y: float) -> str:
ent = await backend.entity_create_line(
tb_x0, y, tb_x1, y, layer=LAYER_BORDER,
tb_x0,
y,
tb_x1,
y,
layer=LAYER_BORDER,
)
return ent.handle
async def vline(x: float, y0: float, y1: float) -> str:
ent = await backend.entity_create_line(
x, y0, x, y1, layer=LAYER_BORDER,
x,
y0,
x,
y1,
layer=LAYER_BORDER,
)
return ent.handle
@@ -157,13 +171,21 @@ async def apply_iso_a3_titleblock(
async def label(text: str, x: float, y: float) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=LABEL_HEIGHT, layer=LAYER_TEXT,
text=text,
x=x,
y=y,
height=LABEL_HEIGHT,
layer=LAYER_TEXT,
)
return ent.handle
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=height, layer=LAYER_TEXT,
text=text,
x=x,
y=y,
height=height,
layer=LAYER_TEXT,
)
return ent.handle
+7 -2
View File
@@ -50,12 +50,17 @@ def build_dim_override(
elif mode == "deviation":
if tol_upper is None or tol_lower is None:
raise ValueError("tol_mode='deviation' requires tol_upper and tol_lower.")
# DIMTM convention: the displayed lower deviation is -DIMTM. Callers
# pass tol_lower as the magnitude of the minus deviation (0.01 means
# -0.01, legacy contract); a *negative* tol_lower expresses a plus
# lower deviation, which double-positive ISO 286 fits (e.g. p6
# +0.035/+0.022) require.
override.update(
{
"dimtol": 1,
"dimlim": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
"dimtm": float(tol_lower),
}
)
elif mode == "limit":
@@ -66,7 +71,7 @@ def build_dim_override(
"dimlim": 1,
"dimtol": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
"dimtm": float(tol_lower),
}
)
elif mode == "basic":
+131 -91
View File
@@ -12,8 +12,13 @@ if TYPE_CHECKING:
REAL_DIMENSION_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
"DIMENSION",
"DIMLINEAR",
"DIMALIGNED",
"DIMANGULAR",
"DIMRADIUS",
"DIMDIAMETER",
"DIMORDINATE",
}
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
@@ -35,7 +40,7 @@ _TITLE_LAYERS = {"TITLE", "TITLEBLOCK"}
class ValidationFinding:
"""One issue surfaced by the validator."""
severity: str # "error" | "warning" | "info"
severity: str # "error" | "warning" | "info"
code: str
message: str
detail: dict = field(default_factory=dict)
@@ -84,40 +89,47 @@ class DrawingValidator:
try:
ents = await backend.entity_list(limit=10000)
text_likes = [
e for e in ents
e
for e in ents
if e.type in ("TEXT", "MTEXT")
and DIMENSION_LIKE_TEXT.search(
getattr(e, "properties", {}).get("text", "") or ""
)
and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "")
]
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
if text_likes:
findings.append(ValidationFinding(
"warning",
"fake_dimension_text",
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
"(Ø, ±, R, etc.) — use dimension_* tools instead",
{
"sample_handles": [t.handle for t in text_likes[:5]],
"real_dim_count": len(real_dims),
},
))
findings.append(
ValidationFinding(
"warning",
"fake_dimension_text",
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
"(Ø, ±, R, etc.) — use dimension_* tools instead",
{
"sample_handles": [t.handle for t in text_likes[:5]],
"real_dim_count": len(real_dims),
},
)
)
except Exception as exc:
findings.append(ValidationFinding(
"warning", "dim_check_failed", str(exc),
))
findings.append(
ValidationFinding(
"warning",
"dim_check_failed",
str(exc),
)
)
# 2 — hidden orphans
try:
hidden = [e for e in ents if not e.visible]
if hidden:
findings.append(ValidationFinding(
"error",
"hidden_orphans",
f"{len(hidden)} entities have visible=False — hide-fallback "
"is forbidden, use entity_delete",
{"sample_handles": [h.handle for h in hidden[:5]]},
))
findings.append(
ValidationFinding(
"error",
"hidden_orphans",
f"{len(hidden)} entities have visible=False — hide-fallback "
"is forbidden, use entity_delete",
{"sample_handles": [h.handle for h in hidden[:5]]},
)
)
except Exception:
pass
@@ -126,45 +138,53 @@ class DrawingValidator:
lts = {x.lower() for x in await backend.linetype_list()}
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
if missing:
findings.append(ValidationFinding(
"error",
"linetypes_missing",
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
{"missing": missing},
))
findings.append(
ValidationFinding(
"error",
"linetypes_missing",
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
{"missing": missing},
)
)
except Exception as exc:
findings.append(ValidationFinding(
"warning", "linetype_check_failed", str(exc),
))
findings.append(
ValidationFinding(
"warning",
"linetype_check_failed",
str(exc),
)
)
# 4 — gear views (heuristic, only if expected demands)
if expected.get("must_have_bore") or expected.get("must_have_keyway"):
bore_circles = [
e for e in ents
if e.type == "CIRCLE" and e.layer == "GEOMETRY"
]
bore_circles = [e for e in ents if e.type == "CIRCLE" and e.layer == "GEOMETRY"]
if expected.get("must_have_bore") and len(bore_circles) < 1:
findings.append(ValidationFinding(
"error",
"bore_missing",
"Expected bore but no circle on GEOMETRY layer found",
))
findings.append(
ValidationFinding(
"error",
"bore_missing",
"Expected bore but no circle on GEOMETRY layer found",
)
)
# draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline
# on GEOMETRY — requiring `closed` here made this a permanent
# false-negative. Match a small (3-6 point) polyline, open or closed.
keyway_polys = [
e for e in ents
e
for e in ents
if e.type in ("LWPOLYLINE", "POLYLINE")
and e.layer == "GEOMETRY"
and 3 <= len(e.properties.get("points", [])) <= 6
]
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
findings.append(ValidationFinding(
"warning",
"keyway_section_unverified",
"Could not confirm keyway via heuristic "
"(small 3-6 point polyline on GEOMETRY)",
))
findings.append(
ValidationFinding(
"warning",
"keyway_section_unverified",
"Could not confirm keyway via heuristic "
"(small 3-6 point polyline on GEOMETRY)",
)
)
# 5 — title consistency
helix = expected.get("helix_angle")
@@ -190,19 +210,23 @@ class DrawingValidator:
has_spur = bool(_SPUR_RE.search(joined))
has_helical = bool(_HELICAL_RE.search(joined))
if has_spur and has_helical:
findings.append(ValidationFinding(
"error",
"title_helical_spur",
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
"'HELICAL GEAR' or 'SPUR GEAR'",
))
findings.append(
ValidationFinding(
"error",
"title_helical_spur",
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
"'HELICAL GEAR' or 'SPUR GEAR'",
)
)
elif has_spur:
findings.append(ValidationFinding(
"error",
"title_says_spur_for_helical",
f"Title contains SPUR but part is helical (helix_angle={helix})",
{"sample": joined[:200]},
))
findings.append(
ValidationFinding(
"error",
"title_says_spur_for_helical",
f"Title contains SPUR but part is helical (helix_angle={helix})",
{"sample": joined[:200]},
)
)
# 6 — saved to disk
try:
@@ -210,46 +234,62 @@ class DrawingValidator:
saved_attr = getattr(info, "saved", None)
full_path = getattr(info, "full_path", "") or ""
if not saved_attr:
findings.append(ValidationFinding(
"error",
"not_saved",
"Drawing has unsaved changes — call drawing_save before finalize",
{"full_path": full_path},
))
findings.append(
ValidationFinding(
"error",
"not_saved",
"Drawing has unsaved changes — call drawing_save before finalize",
{"full_path": full_path},
)
)
elif full_path and not Path(full_path).exists():
findings.append(ValidationFinding(
"error",
"file_missing",
f"Drawing reports saved=True but file not on disk: {full_path}",
))
findings.append(
ValidationFinding(
"error",
"file_missing",
f"Drawing reports saved=True but file not on disk: {full_path}",
)
)
except Exception as exc:
findings.append(ValidationFinding(
"warning", "save_check_failed", str(exc),
))
findings.append(
ValidationFinding(
"warning",
"save_check_failed",
str(exc),
)
)
# 7 — screenshot
try:
shot = await backend.view_screenshot()
if not shot:
findings.append(ValidationFinding(
"info",
"screenshot_unavailable",
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
))
findings.append(
ValidationFinding(
"info",
"screenshot_unavailable",
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
)
)
except Exception as exc:
findings.append(ValidationFinding(
"info", "screenshot_failed", str(exc),
))
findings.append(
ValidationFinding(
"info",
"screenshot_failed",
str(exc),
)
)
# 8 — dwg path returned
try:
info = await backend.drawing_info()
if not getattr(info, "full_path", ""):
findings.append(ValidationFinding(
"warning",
"no_path",
"drawing_info.full_path is empty",
))
findings.append(
ValidationFinding(
"warning",
"no_path",
"drawing_info.full_path is empty",
)
)
except Exception:
pass
+37 -1
View File
@@ -1,15 +1,48 @@
[project]
name = "autocad-mcp-pro"
version = "1.3.0"
version = "1.4.0"
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]
authors = [
{ name = "Umutcan Edizsalan" },
]
keywords = [
"autocad",
"mcp",
"model-context-protocol",
"cad",
"dxf",
"ezdxf",
"drafting",
"engineering-drawing",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Manufacturing",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"fastmcp>=3.0",
"ezdxf>=1.3",
"pydantic>=2.0",
]
[project.urls]
Homepage = "https://github.com/U-C4N/Autocad-MCP"
Repository = "https://github.com/U-C4N/Autocad-MCP"
Issues = "https://github.com/U-C4N/Autocad-MCP/issues"
Changelog = "https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md"
[project.optional-dependencies]
com = [
"pywin32>=306",
@@ -26,6 +59,9 @@ full = [
[project.scripts]
autocad-mcp = "server:main"
# Alias matching the PyPI distribution name so `uvx autocad-mcp-pro` works
# without a --from indirection (MCP registry / uvx convention).
autocad-mcp-pro = "server:main"
[tool.uv]
dev-dependencies = [
+6 -4
View File
@@ -61,10 +61,12 @@ async def _run() -> int:
print(f"ok (center=({cx},{cy}), r=5)")
print("[6/9] entity_select_smart ...", end=" ")
geom = await backend.entity_select_smart({
"type": "LINE",
"layer": "GEOMETRY",
})
geom = await backend.entity_select_smart(
{
"type": "LINE",
"layer": "GEOMETRY",
}
)
assert len(geom) >= 2, len(geom)
print(f"ok ({len(geom)} lines selected)")
+44 -24
View File
@@ -106,32 +106,50 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
# ---------------------------------------------------------------------------
_DANGEROUS_COMMANDS = [
"ERASE", "_ERASE",
"DELETE", "_DELETE",
"PURGE", "_PURGE",
"QUIT", "_QUIT",
"CLOSE", "_CLOSE",
"QSAVE", "_QSAVE",
"SAVEAS", "_SAVEAS",
"NEW", "_NEW",
"OPEN", "_OPEN",
"RECOVER", "_RECOVER",
"WBLOCK", "_WBLOCK",
"INSERT", "_INSERT",
"XREF", "_XREF",
"SHELL", "_SHELL",
"SCRIPT", "_SCRIPT",
"APPLOAD", "_APPLOAD",
"NETLOAD", "_NETLOAD",
"VBARUN", "_VBARUN",
"VBALOAD", "_VBALOAD",
"SECURITYOPTIONS", "_SECURITYOPTIONS",
"ERASE",
"_ERASE",
"DELETE",
"_DELETE",
"PURGE",
"_PURGE",
"QUIT",
"_QUIT",
"CLOSE",
"_CLOSE",
"QSAVE",
"_QSAVE",
"SAVEAS",
"_SAVEAS",
"NEW",
"_NEW",
"OPEN",
"_OPEN",
"RECOVER",
"_RECOVER",
"WBLOCK",
"_WBLOCK",
"INSERT",
"_INSERT",
"XREF",
"_XREF",
"SHELL",
"_SHELL",
"SCRIPT",
"_SCRIPT",
"APPLOAD",
"_APPLOAD",
"NETLOAD",
"_NETLOAD",
"VBARUN",
"_VBARUN",
"VBALOAD",
"_VBALOAD",
"SECURITYOPTIONS",
"_SECURITYOPTIONS",
]
_DANGEROUS_COMMAND_PATTERNS = re.compile(
r"\b("
+ "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS)
+ r")\b",
r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b",
re.IGNORECASE,
)
@@ -213,7 +231,9 @@ def sanitize_lisp(expression: str) -> str:
raise ToolError("LISP expression cannot be empty.")
if config.settings.dangerous_commands_enabled:
log.info("Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80])
log.info(
"Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80]
)
return expression
if _DANGEROUS_LISP_PATTERN.search(expression):
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.u-c4n/autocad-mcp",
"title": "AutoCAD MCP Pro",
"description": "AutoCAD automation for AI agents: live COM or headless ezdxf, ISO dims, GD&T, quality gate.",
"repository": {
"url": "https://github.com/U-C4N/Autocad-MCP",
"source": "github"
},
"version": "1.4.0",
"packages": [
{
"registryType": "pypi",
"identifier": "autocad-mcp-pro",
"version": "1.4.0",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"name": "AUTOCAD_MCP_BACKEND",
"description": "Backend selection: auto (default), com (live AutoCAD, Windows) or ezdxf (headless DXF).",
"isRequired": false,
"format": "string",
"isSecret": false
},
{
"name": "ALLOWED_PATHS",
"description": "Comma-separated absolute paths the server may read and write. Empty blocks file access outside temp.",
"isRequired": false,
"format": "string",
"isSecret": false
},
{
"name": "TOOL_PROFILE",
"description": "Exposed tool profile: full (default), core or lean.",
"isRequired": false,
"format": "string",
"isSecret": false
}
]
}
]
}
+887 -192
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -68,7 +68,10 @@ async def test_analysis_entity_stats(backend):
stats = await backend.analysis_stats()
assert isinstance(stats, dict)
by_type = stats.get("by_type", stats)
total = stats.get("total", stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0))
total = stats.get(
"total",
stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0),
)
assert total >= 3
+11 -4
View File
@@ -51,8 +51,12 @@ async def test_batch_modify_delete(backend):
async def test_template_architectural_layers(backend):
"""Architectural template should create standard layers."""
layer_defs = [
("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4),
("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7),
("WALLS", 7),
("DOORS", 3),
("WINDOWS", 4),
("FURNITURE", 8),
("DIMENSIONS", 2),
("TEXT", 7),
]
for name, color in layer_defs:
await backend.layer_create(name, color=color)
@@ -67,8 +71,11 @@ async def test_template_architectural_layers(backend):
async def test_template_mechanical_layers(backend):
"""Mechanical template layers."""
layer_defs = [
("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3),
("DIMENSIONS", 2), ("SECTION", 5),
("VISIBLE", 7),
("HIDDEN", 1),
("CENTER", 3),
("DIMENSIONS", 2),
("SECTION", 5),
]
for name, color in layer_defs:
await backend.layer_create(name, color=color)
+7 -2
View File
@@ -5,6 +5,7 @@ from unittest.mock import patch
import pytest
from backends.base import CapabilityMap, FeatureCapability
from version import __version__
def test_capability_types_serialize_stably():
@@ -32,7 +33,11 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
"reason": None,
}
assert caps["features"]["mleader"]["mode"] == "composite"
assert caps["features"]["paper_space"]["supported"] is False
# v1.4: paper-space layouts/viewports are native on both backends;
# projecting model content through viewports stays COM-only, and 3D
# ACIS solids remain impossible headlessly.
assert caps["features"]["paper_space"]["supported"] is True
assert caps["features"]["viewport_render"]["supported"] is False
assert caps["features"]["solid_3d"]["supported"] is False
@@ -70,5 +75,5 @@ async def test_system_about_uses_package_version_and_capability_summary(backend)
result = await server.system_about(ctx=Ctx())
assert result["version"] == "1.3.0"
assert result["version"] == __version__
assert result["capabilities"]["table"]["mode"] == "composite"
+10 -4
View File
@@ -48,6 +48,7 @@ async def test_active_document_switch_clears_com_document_state(monkeypatch):
assert backend._preflight_result is None
assert backend._gdt_datums_defined == set()
# ---------------------------------------------------------------------------
# normalize_lineweight — parametrized table
# ---------------------------------------------------------------------------
@@ -107,7 +108,12 @@ def test_normalize_lineweight_non_numeric_passthrough():
# _safe_send_command — CMDACTIVE polling / timeout / var-restore
# ---------------------------------------------------------------------------
def _make_mock_doc(cmdactive_sequence: list[int], modelspace_handles_pre: set[str] | None = None, modelspace_handles_post: set[str] | None = None) -> MagicMock:
def _make_mock_doc(
cmdactive_sequence: list[int],
modelspace_handles_pre: set[str] | None = None,
modelspace_handles_post: set[str] | None = None,
) -> MagicMock:
"""Build a minimal mock AutoCAD document for _safe_send_command tests."""
doc = MagicMock()
@@ -270,9 +276,9 @@ async def test_dimension_linear_uses_add_dim_rotated():
mspace.AddDimRotated.assert_called_once()
mspace.AddDimLinear.assert_not_called()
args = mspace.AddDimRotated.call_args[0]
assert args[0] == (0.0, 0.0) # ext line 1 origin
assert args[1] == (100.0, 0.0) # ext line 2 origin
assert args[2] == (50.0, 20.0) # dim line location
assert args[0] == (0.0, 0.0) # ext line 1 origin
assert args[1] == (100.0, 0.0) # ext line 2 origin
assert args[2] == (50.0, 20.0) # dim line location
assert args[3] == pytest.approx(0.0) # rotation in radians
+6 -2
View File
@@ -53,6 +53,7 @@ class _Doc:
# ── NEW-com-set-variable-coercion ───────────────────────────────────────────
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
app = MagicMock()
app.GetVariable.return_value = 4159 # OSMODE current value is an int
@@ -69,6 +70,7 @@ async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
# ── R23 / N6 — run_lisp guard + value capture ───────────────────────────────
async def test_run_lisp_refuses_when_cmdactive(monkeypatch):
app = MagicMock()
app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0
@@ -90,12 +92,13 @@ async def test_run_lisp_captures_value_from_users1(monkeypatch):
b = _backend_no_executor()
out = await b.system_run_lisp("(+ 1 2)")
assert out["result"] == "3" # captured, not the bogus "nil"
assert out["result"] == "3" # captured, not the bogus "nil"
assert any("USERS1" in c for c in doc.sent) # value stashed out-of-band
# ── R16 — transaction flag cleared even on error ────────────────────────────
async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch):
doc = _Doc()
doc.StartUndoMark = MagicMock()
@@ -136,10 +139,11 @@ async def test_transaction_rollback_uses_safe_send_command(monkeypatch):
# ── R13 — offset honors side_x/side_y and deletes the unused copy ───────────
async def test_offset_picks_copy_nearest_side_point(monkeypatch):
# ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10).
pos_copy = MagicMock(name="pos")
pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10)
pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10)
neg_copy = MagicMock(name="neg")
neg_copy.GetBoundingBox.return_value = ((-1, -11), (1, -9)) # centre (0, -10)
+3 -4
View File
@@ -10,6 +10,7 @@ import pytest
import server
from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from version import __version__
async def _clean_drawing(backend) -> None:
@@ -42,7 +43,7 @@ async def test_delivery_writes_hashed_manifest_and_reopen_parity(backend, tmp_pa
assert dxf["status"] == "created"
assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest()
assert manifest["parity"]["ok"] is True
assert manifest["version"] == "1.3.0"
assert manifest["version"] == __version__
assert manifest["capabilities"]["backend"] == "ezdxf"
@@ -107,9 +108,7 @@ async def test_snapshot_parity_rejects_incomplete_entity_inventory(backend, monk
assert source["listed_entity_count"] == 1
assert source["inventory_complete"] is False
assert result["ok"] is False
assert "source_inventory_complete" in {
item["field"] for item in result["differences"]
}
assert "source_inventory_complete" in {item["field"] for item in result["differences"]}
@pytest.mark.asyncio
+1
View File
@@ -33,6 +33,7 @@ async def test_drawing_export_dxf(backend, tmp_path):
result = await backend.drawing_export_dxf(export_path)
assert result.get("ok") is True
import os
assert os.path.exists(export_path)
+8 -3
View File
@@ -33,9 +33,13 @@ async def test_set_units_cm_and_inch_codes(backend):
async def test_set_numeric_settings(backend):
res = await backend.drawing_settings({
"dimscale": 2.0, "linear_precision": 3, "ltscale": 0.5,
})
res = await backend.drawing_settings(
{
"dimscale": 2.0,
"linear_precision": 3,
"ltscale": 0.5,
}
)
assert res["ok"] is True
snap = (await backend.drawing_settings())["settings"]
assert snap["dimscale"] == 2.0
@@ -57,6 +61,7 @@ async def test_bad_unit_value_is_reported(backend):
# ── server tool wiring ──────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
+51 -13
View File
@@ -66,19 +66,41 @@ async def test_helical_gear_front_view_returns_metadata(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, hand="RH",
backend,
module=3,
teeth=24,
helix_angle=20.0,
hand="RH",
center=(0.0, 0.0),
)
expected_keys = {
"outline", "pitch_circle", "base_circle", "outer_circle", "root_circle",
"helix_symbol", "helix_label", "bore", "keyway", "metadata",
"outline",
"pitch_circle",
"base_circle",
"outer_circle",
"root_circle",
"helix_symbol",
"helix_label",
"bore",
"keyway",
"metadata",
}
assert expected_keys.issubset(res.keys())
md = res["metadata"]
for k in (
"module", "teeth", "pressure_angle", "helix_angle", "hand", "center",
"bore_diameter", "keyway_width", "keyway_depth",
"pitch_radius", "outer_radius", "base_radius", "root_radius",
"module",
"teeth",
"pressure_angle",
"helix_angle",
"hand",
"center",
"bore_diameter",
"keyway_width",
"keyway_depth",
"pitch_radius",
"outer_radius",
"base_radius",
"root_radius",
):
assert k in md, f"metadata missing key {k!r}"
@@ -88,8 +110,13 @@ async def test_helical_gear_with_bore_and_keyway_handles_present(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
backend,
module=3,
teeth=24,
helix_angle=20.0,
bore_diameter=25.0,
keyway_width=8.0,
keyway_depth=4.0,
)
assert res["bore"] is not None
assert res["keyway"] is not None
@@ -102,7 +129,11 @@ async def test_helical_gear_metadata_radii_correct(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, pressure_angle=20.0,
backend,
module=3,
teeth=24,
helix_angle=20.0,
pressure_angle=20.0,
)
md = res["metadata"]
assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6)
@@ -116,12 +147,19 @@ async def test_section_aa_does_not_draw_helix_lines(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
front = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
backend,
module=3,
teeth=24,
helix_angle=20.0,
bore_diameter=25.0,
keyway_width=8.0,
keyway_depth=4.0,
)
section = await draw_gear_section_aa(
backend, gear_metadata=front["metadata"],
x_offset=200.0, face_width=40.0,
backend,
gear_metadata=front["metadata"],
x_offset=200.0,
face_width=40.0,
)
assert "helix" not in section
assert "helix_symbol" not in section
+11 -4
View File
@@ -37,8 +37,11 @@ async def test_draw_keyed_bore_returns_expected_handles(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_keyed_bore(
backend, center=(0.0, 0.0), bore_diameter=25.0,
keyway_width=8.0, keyway_depth=4.0,
backend,
center=(0.0, 0.0),
bore_diameter=25.0,
keyway_width=8.0,
keyway_depth=4.0,
)
for k in ("bore", "keyway_polyline", "centerline_h", "centerline_v"):
assert k in res
@@ -50,8 +53,12 @@ async def test_draw_keyway_section_geometry(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_keyway_section(
backend, center=(0.0, 0.0), bore_diameter=25.0, face_width=40.0,
keyway_width=8.0, keyway_depth=4.0,
backend,
center=(0.0, 0.0),
bore_diameter=25.0,
face_width=40.0,
keyway_width=8.0,
keyway_depth=4.0,
)
assert "bore_top" in res
assert "bore_bottom" in res
+15 -5
View File
@@ -39,7 +39,8 @@ async def test_titleblock_outer_border_dimensions(backend):
async def test_title_text_exact_match(backend):
"""Title text in the drawing must equal metadata.title verbatim."""
result = await apply_iso_a3_titleblock(
backend, metadata=_meta(title="HELICAL GEAR"),
backend,
metadata=_meta(title="HELICAL GEAR"),
)
title_ent = await backend.entity_get(result["title_text"])
assert title_ent.type == "TEXT"
@@ -50,9 +51,16 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend):
"""value_texts must contain a handle for every metadata field."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
expected_keys = {
"drawing_no", "revision", "sheet",
"part_no", "material", "scale",
"drawn_by", "checked_by", "date", "company",
"drawing_no",
"revision",
"sheet",
"part_no",
"material",
"scale",
"drawn_by",
"checked_by",
"date",
"company",
}
assert set(result["value_texts"].keys()) == expected_keys
# All handles must resolve to TEXT entities
@@ -64,7 +72,9 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend):
async def test_titleblock_origin_offset(backend):
"""Passing origin=(100,50) must shift the bbox by that amount."""
result = await apply_iso_a3_titleblock(
backend, metadata=_meta(), origin=(100.0, 50.0),
backend,
metadata=_meta(),
origin=(100.0, 50.0),
)
bbox = result["bbox"]
assert bbox["min"][0] == pytest.approx(100.0)
+12 -3
View File
@@ -73,7 +73,11 @@ async def test_validator_catches_helical_spur_title(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_text(
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
text="HELICAL SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 20})
@@ -86,7 +90,11 @@ async def test_validator_catches_fake_dimension_text(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_text(
text="Ø78", x=10, y=10, height=3.5, layer="DIM",
text="Ø78",
x=10,
y=10,
height=3.5,
layer="DIM",
)
await backend.drawing_save_as(str(tmp_path / "fake.dxf"))
result = await DrawingValidator().run(backend)
@@ -124,7 +132,8 @@ async def test_validator_bore_missing_when_expected(backend, tmp_path):
# No circle drawn at all
await backend.drawing_save_as(str(tmp_path / "nobore.dxf"))
result = await DrawingValidator().run(
backend, expected={"must_have_bore": True},
backend,
expected={"must_have_bore": True},
)
assert "bore_missing" in _codes(result)
+4
View File
@@ -15,6 +15,7 @@ def _close(a, b, tol=1e-6):
# ─── TRIM ──────────────────────────────────────────────────────────────────
class TestTrim:
async def test_trim_keep_left(self, backend):
target = await backend.entity_create_line(0, 0, 100, 0)
@@ -55,6 +56,7 @@ class TestTrim:
# ─── EXTEND ────────────────────────────────────────────────────────────────
class TestExtend:
async def test_extend_auto_endpoint(self, backend):
# Target ends at x=40; boundary is vertical at x=80.
@@ -84,6 +86,7 @@ class TestExtend:
# ─── FILLET ────────────────────────────────────────────────────────────────
class TestFillet:
async def test_fillet_90_degree_corner(self, backend):
# L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80).
@@ -139,6 +142,7 @@ class TestFillet:
# ─── CHAMFER ───────────────────────────────────────────────────────────────
class TestChamfer:
async def test_chamfer_symmetric(self, backend):
l1 = await backend.entity_create_line(0, 0, 100, 0)
+3 -1
View File
@@ -14,10 +14,11 @@ pytestmark = pytest.mark.asyncio
# ── entity_edit_text ────────────────────────────────────────────────────────
async def test_edit_text_changes_content_keeps_handle(backend):
t = await backend.entity_create_text("OLD LABEL", 0, 0, height=2.5, layer="TEXT")
updated = await backend.entity_edit_text(t.handle, text="NEW LABEL")
assert updated.handle == t.handle # handle preserved
assert updated.handle == t.handle # handle preserved
assert updated.properties["text"] == "NEW LABEL"
@@ -44,6 +45,7 @@ async def test_edit_text_rejects_non_text(backend):
# ── entity_edit_geometry ────────────────────────────────────────────────────
async def test_edit_circle_radius_and_center(backend):
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
updated = await backend.entity_edit_geometry(c.handle, cx=5, cy=5, radius=20)
-1
View File
@@ -1,6 +1,5 @@
"""Tests for ezdxf backend — no AutoCAD required."""
import pytest
pytestmark = pytest.mark.asyncio
+118
View File
@@ -0,0 +1,118 @@
"""ISO 286 fit-table lookups pinned against published table values."""
from __future__ import annotations
import pytest
from fastmcp.exceptions import ToolError
from engineering.fits import FitDeviation, fit_lookup, parse_fit_code
from engineering.tolerances import build_dim_override
from server import _fit_to_tolerances
def _um(value_mm: float) -> float:
return round(value_mm * 1000.0, 3)
# (code, nominal, upper µm, lower µm) — published ISO 286-2 values.
CANONICAL_FITS = [
("H7", 20.0, 21, 0),
("H7", 40.0, 25, 0),
("H8", 30.0, 33, 0),
("H9", 6.0, 30, 0),
("H7", 200.0, 46, 0),
("g6", 20.0, -7, -20),
("g6", 6.0, -4, -12),
("f7", 40.0, -25, -50),
("e8", 25.0, -40, -73),
("d9", 100.0, -120, -207),
("h6", 30.0, 0, -13), # 30 mm is inside the >18-30 step (inclusive upper bound)
("h7", 50.0, 0, -25),
("k6", 20.0, 15, 2),
("m6", 25.0, 21, 8),
("n6", 20.0, 28, 15),
("p6", 20.0, 35, 22),
("js9", 25.0, 26, -26),
("G7", 6.0, 16, 4),
("F8", 40.0, 64, 25),
("D10", 30.0, 149, 65),
]
@pytest.mark.parametrize("code,nominal,upper_um,lower_um", CANONICAL_FITS)
def test_canonical_fit_values(code, nominal, upper_um, lower_um):
deviation = fit_lookup(code, nominal)
assert isinstance(deviation, FitDeviation)
assert _um(deviation.upper_mm) == pytest.approx(upper_um), f"{code}@{nominal} upper"
assert _um(deviation.lower_mm) == pytest.approx(lower_um), f"{code}@{nominal} lower"
def test_k_outside_it4_to_7_has_zero_lower_deviation():
deviation = fit_lookup("k9", 20.0)
assert deviation.lower_mm == 0.0
assert _um(deviation.upper_mm) == 52 # IT9 @ 18-30
def test_parse_fit_code():
assert parse_fit_code("H7") == ("H", 7)
assert parse_fit_code("js10") == ("js", 10)
with pytest.raises(ValueError):
parse_fit_code("77")
with pytest.raises(ValueError):
parse_fit_code("")
@pytest.mark.parametrize(
"code,nominal",
[
("K7", 20.0), # transition hole letters need the delta-rule — excluded
("P7", 20.0),
("x6", 20.0), # letter outside the authored subset (regex rejects)
("H3", 20.0), # grade below authored range
("H12", 20.0), # grade above authored range
("H7", 0.5), # below 1 mm
("H7", 600.0), # above 500 mm
],
)
def test_out_of_scope_lookups_raise(code, nominal):
with pytest.raises(ValueError):
fit_lookup(code, nominal)
def test_double_positive_fit_renders_correct_dimvars():
"""p6-style fits (+upper/+lower) must produce a negative DIMTM so AutoCAD
displays the plus lower deviation."""
deviation = fit_lookup("p6", 20.0)
override, _ = build_dim_override(
tol_upper=deviation.upper_mm,
tol_lower=-deviation.lower_mm, # server convention: positive = minus
tol_mode="deviation",
)
assert override["dimtp"] == pytest.approx(0.035)
assert override["dimtm"] == pytest.approx(-0.022) # displayed as +0.022
def test_fit_to_tolerances_contract():
tol_upper, tol_lower, tol_mode, text = _fit_to_tolerances("H7", 20.0, None, None, "none", None)
assert tol_upper == pytest.approx(0.021)
assert tol_lower == pytest.approx(0.0)
assert tol_mode == "deviation"
assert text == "<> H7"
def test_fit_to_tolerances_rejects_mixed_usage():
with pytest.raises(ToolError):
_fit_to_tolerances("H7", 20.0, 0.1, None, "none", None)
with pytest.raises(ToolError):
_fit_to_tolerances("H7", 20.0, None, None, "symmetric", None)
with pytest.raises(ToolError):
_fit_to_tolerances("Q9", 20.0, None, None, "none", None)
def test_fit_passthrough_without_code():
assert _fit_to_tolerances(None, 20.0, 0.1, 0.05, "deviation", "x") == (
0.1,
0.05,
"deviation",
"x",
)
+16 -1
View File
@@ -46,15 +46,18 @@ def _reload_server_with_token(token: str | None):
# Auth object construction
# ---------------------------------------------------------------------------
def test_auth_object_built_when_token_set():
"""_auth is a StaticTokenVerifier when MCP_AUTH_TOKEN is non-empty."""
with patch.dict("os.environ", {"MCP_AUTH_TOKEN": "secret-test-token-123"}):
import config as cfg
old_token = cfg.settings.mcp_auth_token
cfg.settings.mcp_auth_token = "secret-test-token-123"
try:
sys.modules.pop("server", None)
import server as srv
assert srv._auth is not None
assert isinstance(srv._auth, StaticTokenVerifier)
finally:
@@ -65,11 +68,13 @@ def test_auth_object_built_when_token_set():
def test_auth_object_none_when_no_token():
"""_auth is None when MCP_AUTH_TOKEN is empty (anonymous loopback mode)."""
import config as cfg
old_token = cfg.settings.mcp_auth_token
cfg.settings.mcp_auth_token = ""
try:
sys.modules.pop("server", None)
import server as srv
assert srv._auth is None
finally:
cfg.settings.mcp_auth_token = old_token
@@ -79,11 +84,13 @@ def test_auth_object_none_when_no_token():
def test_mcp_receives_auth_when_set():
"""The FastMCP instance's auth attribute matches the _auth object."""
import config as cfg
old_token = cfg.settings.mcp_auth_token
cfg.settings.mcp_auth_token = "another-secret"
try:
sys.modules.pop("server", None)
import server as srv
# FastMCP stores auth as _auth_provider or similar; we check it via
# the _auth module-level variable matching what was passed in.
assert srv._auth is not None
@@ -97,11 +104,13 @@ def test_mcp_receives_auth_when_set():
# _validate_http_bind guard
# ---------------------------------------------------------------------------
def test_validate_http_bind_loopback_no_error():
"""Loopback hosts pass validation regardless of token/allow_remote flags."""
sys.modules.pop("server", None)
import config as cfg
import server as srv
old_remote = cfg.settings.allow_remote_http
old_token = cfg.settings.mcp_auth_token
cfg.settings.allow_remote_http = False
@@ -121,6 +130,7 @@ def test_validate_http_bind_remote_without_flag_raises():
sys.modules.pop("server", None)
import config as cfg
import server as srv
old = cfg.settings.allow_remote_http
cfg.settings.allow_remote_http = False
try:
@@ -135,6 +145,7 @@ def test_validate_http_bind_remote_without_token_raises():
sys.modules.pop("server", None)
import config as cfg
import server as srv
old_remote = cfg.settings.allow_remote_http
old_token = cfg.settings.mcp_auth_token
cfg.settings.allow_remote_http = True
@@ -150,9 +161,11 @@ def test_validate_http_bind_remote_without_token_raises():
def test_validate_http_bind_remote_with_token_logs_warning(caplog):
"""Remote bind with both flag and token logs a warning but does not raise."""
import logging
sys.modules.pop("server", None)
import config as cfg
import server as srv
old_remote = cfg.settings.allow_remote_http
old_token = cfg.settings.mcp_auth_token
cfg.settings.allow_remote_http = True
@@ -160,7 +173,9 @@ def test_validate_http_bind_remote_with_token_logs_warning(caplog):
try:
with caplog.at_level(logging.WARNING, logger="server"):
srv._validate_http_bind("0.0.0.0") # should not raise
assert any("non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records)
assert any(
"non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records
)
finally:
cfg.settings.allow_remote_http = old_remote
cfg.settings.mcp_auth_token = old_token
+134
View File
@@ -0,0 +1,134 @@
"""Paper-space layouts, viewports, layout PDF export and opt-in 3D solids."""
from __future__ import annotations
from importlib.util import find_spec
import ezdxf
import pytest
import pytest_asyncio
import config
import server
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def backend():
b = EzdxfBackend()
await b.connect()
await b.drawing_new()
yield b
await b.disconnect()
async def test_layout_list_reports_model_and_current(backend):
result = await backend.layout_list()
assert result["ok"] is True
assert "Model" in result["layouts"]
assert isinstance(result["current"], str)
async def test_layout_create_and_duplicate_rejected(backend):
created = await backend.layout_create("A3-Sheet")
assert created["ok"] is True
listing = await backend.layout_list()
assert "A3-Sheet" in listing["layouts"]
duplicate = await backend.layout_create("A3-Sheet")
assert duplicate["ok"] is False
async def test_layout_set_current_roundtrip(backend):
await backend.layout_create("A3-Sheet")
switched = await backend.layout_set_current("A3-Sheet")
assert switched["ok"] is True
assert (await backend.layout_list())["current"] == "A3-Sheet"
back = await backend.layout_set_current("Model")
assert back["ok"] is True
missing = await backend.layout_set_current("NOPE")
assert missing["ok"] is False
async def test_viewport_create_scaled(backend, tmp_path):
await backend.layout_create("A3-Sheet")
result = await backend.viewport_create(
"A3-Sheet",
center_x=200,
center_y=140,
width=180,
height=120,
view_center_x=50,
view_center_y=25,
scale=0.5,
)
assert result["ok"] is True
assert result["view_height"] == pytest.approx(240.0) # height / scale
exported = tmp_path / "layout_roundtrip.dxf"
save = await backend.drawing_export_dxf(str(exported))
assert save["ok"] is True
doc = ezdxf.readfile(str(exported))
viewports = doc.layouts.get("A3-Sheet").query("VIEWPORT")
assert len(viewports) >= 1
created = [vp for vp in viewports if vp.dxf.handle == result["handle"]]
assert created, "created viewport handle must survive the DXF roundtrip"
assert created[0].dxf.view_height == pytest.approx(240.0)
async def test_viewport_rejects_model_and_bad_scale(backend):
bad_layout = await backend.viewport_create("Model", 0, 0, 10, 10, 0, 0, 1.0)
assert bad_layout["ok"] is False
await backend.layout_create("A3-Sheet")
bad_scale = await backend.viewport_create("A3-Sheet", 0, 0, 10, 10, 0, 0, 0.0)
assert bad_scale["ok"] is False
@pytest.mark.skipif(find_spec("matplotlib") is None, reason="matplotlib not installed")
async def test_export_pdf_layout(backend, tmp_path):
await backend.layout_create("A3-Sheet")
await backend.entity_create_line(0, 0, 100, 0)
target = tmp_path / "layout.pdf"
result = await backend.drawing_export_pdf(str(target), layout="A3-Sheet")
assert result["ok"] is True
assert result["layout"] == "A3-Sheet"
assert "note" in result # honest viewport-projection limitation
assert target.is_file() and target.stat().st_size > 0
missing = await backend.drawing_export_pdf(str(tmp_path / "x.pdf"), layout="NOPE")
assert missing["ok"] is False
async def test_solids_unsupported_headlessly(backend):
box = await backend.solid_box(0, 0, 0, 10, 10, 10)
cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10)
extrude = await backend.solid_extrude("FF", 10)
revolve = await backend.solid_revolve("FF", 0, 0, 0, 1)
boolean = await backend.solid_boolean("FF", "FE", "union")
for result in (box, cylinder, extrude, revolve, boolean):
assert result["ok"] is False
assert result["capability"] == "solid_3d"
async def test_capability_map_reports_paper_space_and_solids(backend):
features = backend.capabilities().to_dict()["features"]
assert features["paper_space"]["supported"] is True
assert features["viewport_render"]["supported"] is False
assert features["solid_3d"]["supported"] is False
class TestSolidToolGating:
@pytest.mark.asyncio
async def test_solid_tools_hidden_without_enable_3d(self, monkeypatch):
monkeypatch.setattr(config.settings, "enable_3d", False)
info = await server._apply_tool_profile("full")
assert server.SOLID_TOOL_NAMES <= set(info["disabled_tools"])
await server._apply_tool_profile("full")
@pytest.mark.asyncio
async def test_solid_tools_visible_with_enable_3d(self, monkeypatch):
monkeypatch.setattr(config.settings, "enable_3d", True)
info = await server._apply_tool_profile("full")
assert not (server.SOLID_TOOL_NAMES & set(info["disabled_tools"]))
monkeypatch.setattr(config.settings, "enable_3d", False)
await server._apply_tool_profile("full")
+15 -4
View File
@@ -16,10 +16,13 @@ def _close(a, b, tol=1e-6):
# ─── drawing_plan ──────────────────────────────────────────────────────────
class TestDrawingPlan:
async def test_plan_returns_spec_with_intent(self, backend):
plan = await backend.drawing_plan(
"L-bracket 50x80", sheet_size="A4", scale=1.0,
"L-bracket 50x80",
sheet_size="A4",
scale=1.0,
)
assert plan.intent == "L-bracket 50x80"
assert plan.sheet_size == "A4"
@@ -36,6 +39,7 @@ class TestDrawingPlan:
# ─── drawing_critique ──────────────────────────────────────────────────────
class TestDrawingCritique:
async def test_critique_clean_drawing(self, backend):
# Bootstrap layer set so layer_color/iso128 don't trigger.
@@ -73,6 +77,7 @@ class TestDrawingCritique:
# ─── point_from_snap ───────────────────────────────────────────────────────
class TestPointFromSnap:
async def test_snap_end_default(self, backend):
line = await backend.entity_create_line(0, 0, 100, 0)
@@ -122,6 +127,7 @@ class TestPointFromSnap:
# ─── construction layer ───────────────────────────────────────────────────
class TestConstructionLayer:
async def test_xline_creates_on_construction_layer(self, backend):
xl = await backend.construction_xline(0, 0, 45)
@@ -140,6 +146,7 @@ class TestConstructionLayer:
# ─── drawing_apply_iso_layers ──────────────────────────────────────────────
class TestApplyISOLayers:
async def test_mech_layer_set(self, backend):
result = await backend.drawing_apply_iso_layers("mech")
@@ -170,13 +177,15 @@ class TestApplyISOLayers:
# ─── dimension_auto ────────────────────────────────────────────────────────
class TestDimensionAuto:
async def test_chain_creates_one_dim_per_segment(self, backend):
l1 = await backend.entity_create_line(0, 0, 30, 0)
l2 = await backend.entity_create_line(30, 0, 70, 0)
l3 = await backend.entity_create_line(70, 0, 100, 0)
dims = await backend.dimension_auto(
[l1.handle, l2.handle, l3.handle], style="chain",
[l1.handle, l2.handle, l3.handle],
style="chain",
)
assert len(dims) == 3
@@ -198,6 +207,7 @@ class TestDimensionAuto:
# ─── entity_select_smart ───────────────────────────────────────────────────
class TestEntitySelectSmart:
async def test_select_by_type(self, backend):
await backend.entity_create_line(0, 0, 50, 0)
@@ -214,13 +224,13 @@ class TestEntitySelectSmart:
assert len(result) == 1
async def test_select_by_near(self, backend):
await backend.entity_create_line(0, 0, 10, 0) # near origin
await backend.entity_create_line(0, 0, 10, 0) # near origin
await backend.entity_create_line(1000, 1000, 1010, 1000) # far
result = await backend.entity_select_smart({"type": "LINE", "near": [0, 0, 50]})
assert len(result) == 1
async def test_select_by_length_range(self, backend):
await backend.entity_create_line(0, 0, 10, 0) # length 10
await backend.entity_create_line(0, 0, 10, 0) # length 10
await backend.entity_create_line(0, 50, 100, 50) # length 100
result = await backend.entity_select_smart({"type": "LINE", "length_range": [50, 200]})
assert len(result) == 1
@@ -228,6 +238,7 @@ class TestEntitySelectSmart:
# ─── Integration: plan → draw → critique pipeline ──────────────────────────
class TestPremiumPipeline:
async def test_full_l_bracket_pipeline_clean(self, backend):
"""plan → apply_layers → draw + fillet → clear construction → critique = []."""
+2
View File
@@ -13,6 +13,7 @@ pytestmark = pytest.mark.asyncio
# point_intersection
# ---------------------------------------------------------------------------
class TestPointIntersection:
async def test_line_line_perpendicular(self, backend):
"""Horizontal and vertical lines cross at (50, 50)."""
@@ -91,6 +92,7 @@ class TestPointIntersection:
# point_tangent
# ---------------------------------------------------------------------------
class TestPointTangent:
async def test_tangent_from_outside(self, backend):
"""From (0, 0) to unit circle at (5, 0): tangent point is at (25/5, ±12/5)."""
+6 -3
View File
@@ -100,7 +100,8 @@ def test_preflight_does_not_invent_values_when_assumptions_disabled():
@pytest.mark.asyncio
async def test_backend_plan_accepts_matching_ready_preflight(backend):
preflight = await backend.drawing_preflight(
"L bracket", requirements=_complete_requirements(),
"L bracket",
requirements=_complete_requirements(),
)
plan = await backend.drawing_plan(
@@ -125,7 +126,8 @@ async def test_backend_plan_rejects_not_ready_preflight_hash(backend):
@pytest.mark.asyncio
async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend):
preflight = await backend.drawing_preflight(
"L bracket", requirements=_complete_requirements(),
"L bracket",
requirements=_complete_requirements(),
)
with pytest.raises(RuntimeError, match="do not match"):
@@ -148,7 +150,8 @@ async def test_legacy_plan_remains_compatible_and_marks_preflight_skipped(backen
@pytest.mark.asyncio
async def test_document_change_clears_plan_preflight_and_gdt_state(backend):
preflight = await backend.drawing_preflight(
"First drawing", requirements=_complete_requirements(),
"First drawing",
requirements=_complete_requirements(),
)
await backend.drawing_plan(
"First drawing",
+1 -3
View File
@@ -66,9 +66,7 @@ async def test_refiner_repairs_construction_and_duplicates(backend):
assert result.status == "threshold_met"
assert result.final_score == 100.0
assert result.rounds[0].score_after > result.rounds[0].score_before
assert await backend.drawing_critique(
["duplicate_entities", "construction_left"]
) == []
assert await backend.drawing_critique(["duplicate_entities", "construction_left"]) == []
async def test_refiner_dry_run_does_not_mutate_drawing(backend):
+160
View File
@@ -0,0 +1,160 @@
"""Release-consistency gates (v1.4.0 Faz 0).
Locks the version string, the README release snapshot, the server.py section
headers, and the Dockerfile file selection to the actual state of the tree so
none of them can drift again:
- pyproject version == CHANGELOG top release == README snapshot major.minor
- version.py fallback parser returns the pyproject version
- README snapshot tool/resource/prompt counts == live @mcp.* decorator counts
- every ``SECTION n: ... (N tools)`` header matches the decorators below it
- every wheel ``only-include`` entry is COPY'd into the Docker image
"""
from __future__ import annotations
import re
import tomllib
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
def _pyproject() -> dict:
with (ROOT / "pyproject.toml").open("rb") as stream:
return tomllib.load(stream)
def _pyproject_version() -> str:
return str(_pyproject()["project"]["version"])
def _server_source() -> str:
return (ROOT / "server.py").read_text(encoding="utf-8")
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
def test_pyproject_version_is_semver() -> None:
assert SEMVER_RE.match(_pyproject_version()), (
f"pyproject version {_pyproject_version()!r} is not MAJOR.MINOR.PATCH"
)
def test_version_py_fallback_matches_pyproject(monkeypatch: pytest.MonkeyPatch) -> None:
"""The pyproject fallback in version.py must resolve the same version.
The installed-metadata short circuit is forced to miss so the test checks
our parser (the code path a source checkout without an install uses).
"""
import version as version_module
def _raise(_name: str) -> str:
raise version_module.PackageNotFoundError
monkeypatch.setattr(version_module, "version", _raise)
assert version_module.package_version() == _pyproject_version()
def test_changelog_top_release_matches_pyproject() -> None:
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
match = re.search(r"^## \[(\d+\.\d+\.\d+)\]", changelog, flags=re.MULTILINE)
assert match, "CHANGELOG.md has no '## [X.Y.Z]' release heading"
assert match.group(1) == _pyproject_version(), (
f"CHANGELOG top release {match.group(1)} != pyproject {_pyproject_version()}"
)
def _readme_snapshot() -> tuple[str, int, int, int]:
readme = (ROOT / "README.md").read_text(encoding="utf-8")
# The snapshot may wrap across blockquote lines; join continuations first.
flat = re.sub(r"\n>\s*", " ", readme)
match = re.search(
r"\*\*v(\d+\.\d+) release snapshot:\*\*\s*(\d+) tools\D+?(\d+) resources"
r"\D+?(\d+) prompt templates",
flat,
)
assert match, "README.md release-snapshot line not found or malformed"
return match.group(1), int(match.group(2)), int(match.group(3)), int(match.group(4))
def test_readme_snapshot_matches_pyproject_minor() -> None:
snapshot_minor, _, _, _ = _readme_snapshot()
expected = ".".join(_pyproject_version().split(".")[:2])
assert snapshot_minor == expected, (
f"README snapshot v{snapshot_minor} != pyproject major.minor v{expected}"
)
def test_readme_snapshot_counts_match_registrations() -> None:
src = _server_source()
tools = len(re.findall(r"@mcp\.tool\(", src))
resources = len(re.findall(r"@mcp\.resource\(", src))
prompts = len(re.findall(r"@mcp\.prompt\(", src))
_, readme_tools, readme_resources, readme_prompts = _readme_snapshot()
assert (readme_tools, readme_resources, readme_prompts) == (tools, resources, prompts), (
f"README snapshot says {readme_tools}/{readme_resources}/{readme_prompts} "
f"(tools/resources/prompts) but server.py registers {tools}/{resources}/{prompts}"
)
def test_section_header_counts_match_decorators() -> None:
src = _server_source()
headers = [
(m.start(), m.group(1), m.group(2))
for m in re.finditer(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", src)
]
assert headers, "no counted SECTION headers found in server.py"
# Segment boundaries: every SECTION header (counted or not) ends a segment.
boundaries = sorted(m.start() for m in re.finditer(r"#[^\n]*SECTION [\w]+:", src))
boundaries.append(len(src))
mismatches: list[str] = []
for start, section_id, declared in headers:
end = min(b for b in boundaries if b > start)
actual = len(re.findall(r"@mcp\.tool\(", src[start:end]))
if actual != int(declared):
mismatches.append(f"SECTION {section_id}: header says {declared}, actual {actual}")
assert not mismatches, "; ".join(mismatches)
def test_server_json_versions_match_pyproject() -> None:
"""MCP registry manifest must ship the same version as the package."""
import json
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
expected = _pyproject_version()
assert manifest["version"] == expected, (
f"server.json version {manifest['version']} != pyproject {expected}"
)
for package in manifest["packages"]:
assert package["version"] == expected, (
f"server.json package {package['identifier']} version "
f"{package['version']} != pyproject {expected}"
)
if package["registryType"] == "pypi":
assert package["identifier"] == _pyproject()["project"]["name"]
def test_readme_contains_mcp_name_marker() -> None:
"""PyPI ownership validation for the MCP registry needs this marker in the
README (which ships to PyPI as the long description)."""
import json
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
readme = (ROOT / "README.md").read_text(encoding="utf-8")
assert f"mcp-name: {manifest['name']}" in readme
def test_dockerfile_copies_every_wheel_include() -> None:
"""The Docker image must contain everything the wheel ships (GH bug: the
image previously missed engineering/ and version.py)."""
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
copy_lines = "\n".join(
line for line in dockerfile.splitlines() if line.strip().upper().startswith("COPY")
)
includes = _pyproject()["tool"]["hatch"]["build"]["targets"]["wheel"]["only-include"]
missing = [name for name in includes if name not in copy_lines]
assert not missing, f"Dockerfile COPY misses wheel-shipped paths: {missing}"
+4
View File
@@ -73,6 +73,7 @@ class TestValidatePath:
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config
config.settings = Settings()
blocked = tmp_path / "blocked" / "file.dxf"
@@ -98,6 +99,7 @@ class TestValidatePath:
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config
config.settings = Settings()
result = validate_path(str(test_file))
@@ -160,6 +162,7 @@ class TestSanitizeCommand:
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config
config.settings = Settings()
result = sanitize_command("ERASE ALL")
@@ -222,6 +225,7 @@ class TestSanitizeLisp:
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config
config.settings = Settings()
result = sanitize_lisp('(startapp "cmd")')
+30 -9
View File
@@ -32,6 +32,7 @@ class _FakeCtx:
# ── NEW-screenshot-1 ────────────────────────────────────────────────────────
async def test_screenshot_renders_png_without_gui_thread_crash(backend):
"""view_screenshot runs in a worker thread; the Agg figure must not crash and
must return valid PNG bytes."""
@@ -43,6 +44,7 @@ async def test_screenshot_renders_png_without_gui_thread_crash(backend):
# ── I15 — critique is part of the finalize gate ─────────────────────────────
async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_path):
from fastmcp.exceptions import ToolError
@@ -68,6 +70,7 @@ async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_pa
# ── R4 — dim_overlap critique fires ─────────────────────────────────────────
async def test_dim_overlap_critique_is_not_a_noop(backend):
await backend.drawing_apply_iso_layers("mech")
# Two dimensions measuring the same segment overlap exactly.
@@ -80,6 +83,7 @@ async def test_dim_overlap_critique_is_not_a_noop(backend):
# ── N4 — dim / construction layers follow the active layer set ──────────────
async def test_iso13567_dim_and_construction_layers_resolve(backend):
await backend.drawing_apply_iso_layers("iso13567")
assert backend._active_layer_set_id() == "iso13567"
@@ -102,6 +106,7 @@ async def test_iso13567_dim_and_construction_layers_resolve(backend):
# ── NEW-AUTH-1 — bind guard on the run_async launch path ────────────────────
async def test_run_async_invokes_bind_guard_for_http(monkeypatch):
import server
@@ -121,8 +126,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch):
import server
seen = {}
monkeypatch.setattr(server, "_validate_http_bind",
lambda h: seen.setdefault("host", h))
monkeypatch.setattr(server, "_validate_http_bind", lambda h: seen.setdefault("host", h))
async def fake_orig(*args, **kwargs):
return "stdio-ran"
@@ -135,6 +139,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch):
# ── NEW-gear-roottooth — outline never dips inside the root circle ──────────
async def test_gear_outline_stays_outside_root_circle():
from engineering.gear import generate_full_gear_outline
@@ -144,19 +149,28 @@ async def test_gear_outline_stays_outside_root_circle():
pts = generate_full_gear_outline(module, teeth, pa)
radii = [math.hypot(x, y) for x, y in pts]
# No point may sit inside the root circle (the self-overlap bug for z>=~42).
assert min(radii) >= root_r - 1e-6, f"m={module} z={teeth}: min_r {min(radii)} < root_r {root_r}"
assert min(radii) >= root_r - 1e-6, (
f"m={module} z={teeth}: min_r {min(radii)} < root_r {root_r}"
)
# ── NEW-section-dupbore / NEW-validator-keyway / NEW-gear-baseconstr ─────────
async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend, tmp_path):
from engineering.gear import draw_helical_gear_front_view
from engineering.validator import DrawingValidator
await backend.drawing_apply_iso_layers("mech")
res = await draw_helical_gear_front_view(
backend, module=2, teeth=20, helix_angle=0, center=(0, 0),
bore_diameter=20, keyway_width=6, keyway_depth=2.8,
backend,
module=2,
teeth=20,
helix_angle=0,
center=(0, 0),
bore_diameter=20,
keyway_width=6,
keyway_depth=2.8,
)
# NEW-gear-baseconstr: base circle is a persistent PHANTOM reference, not CONSTRUCTION.
base = await backend.entity_get(res["base_circle"])
@@ -167,7 +181,8 @@ async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend
# NEW-validator-keyway: the open 4-point keyway polyline is now recognised.
await backend.drawing_save_as(str(tmp_path / "gear.dxf"))
vr = await DrawingValidator().run(
backend, expected={"must_have_bore": True, "must_have_keyway": True},
backend,
expected={"must_have_bore": True, "must_have_keyway": True},
)
assert "keyway_section_unverified" not in [f.code for f in vr.findings]
@@ -178,9 +193,15 @@ async def test_gear_section_does_not_duplicate_bore_lines(backend):
await backend.drawing_apply_iso_layers("mech")
await draw_gear_section_aa(
backend,
gear_metadata={"outer_radius": 22, "center": [0, 0], "bore_diameter": 20,
"keyway_width": 6, "keyway_depth": 2.8},
x_offset=100, face_width=30,
gear_metadata={
"outer_radius": 22,
"center": [0, 0],
"bore_diameter": 20,
"keyway_width": 6,
"keyway_depth": 2.8,
},
x_offset=100,
face_width=30,
)
dups = await backend.drawing_critique(focus=["duplicate_entities"])
assert dups == []
+10 -5
View File
@@ -1,10 +1,10 @@
"""Regression tests for the CI-verifiable (ezdxf / shared) Sprint-3 fixes.
* N2 — drawing_save_as derives the on-disk format from the path extension
* N3 — ARC carries a `length` property; entity_select_smart length_range selects it
* N5 — properties parity: ezdxf sets bounding_box; MTEXT carries char_height + rotation
* NEW-dimauto-baseline-rotation — baseline dims run parallel to a rotated baseline
* NEW-dimauto-ordinate-misnomer — ordinate dims are staggered, not stacked at one offset
* N2 — drawing_save_as derives the on-disk format from the path extension
* N3 — ARC carries a `length` property; entity_select_smart length_range selects it
* N5 — properties parity: ezdxf sets bounding_box; MTEXT carries char_height + rotation
* NEW-dimauto-baseline-rotation — baseline dims run parallel to a rotated baseline
* NEW-dimauto-ordinate-misnomer — ordinate dims are staggered, not stacked at one offset
"""
from __future__ import annotations
@@ -27,6 +27,7 @@ class _Ctx:
# ── N2 — format follows the file extension ──────────────────────────────────
async def test_save_as_derives_format_from_extension(backend):
import server
@@ -42,6 +43,7 @@ async def test_save_as_derives_format_from_extension(backend):
# ── N3 — ARC length + length_range selection ────────────────────────────────
async def test_arc_has_length_and_is_selectable(backend):
arc = await backend.entity_create_arc(0, 0, 10, 0, 90) # r=10, 90deg -> len ~15.708
info = await backend.entity_get(arc.handle)
@@ -55,6 +57,7 @@ async def test_arc_has_length_and_is_selectable(backend):
# ── N5 — cross-backend property parity ──────────────────────────────────────
async def test_ezdxf_populates_bounding_box(backend):
line = await backend.entity_create_line(0, 0, 30, 40)
info = await backend.entity_get(line.handle)
@@ -78,6 +81,7 @@ async def test_mtext_honors_caller_rotation(backend):
# ── NEW-dimauto-baseline-rotation ───────────────────────────────────────────
async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
ln = await backend.entity_create_line(0, 0, 40, 40) # 45-degree baseline
dims = await backend.dimension_auto([ln.handle], style="baseline")
@@ -90,6 +94,7 @@ async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
# ── NEW-dimauto-ordinate-misnomer ───────────────────────────────────────────
async def test_ordinate_dims_are_staggered_not_overlapping(backend):
l1 = await backend.entity_create_line(0, 0, 20, 10)
l2 = await backend.entity_create_line(0, 0, 40, 30)
+6
View File
@@ -25,6 +25,7 @@ pytestmark = pytest.mark.asyncio
# Helpers
# ---------------------------------------------------------------------------
def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
"""True if pt lies on the circle of radius r AND within the [sa, ea] sweep
(degrees, CCW, with wraparound)."""
@@ -41,10 +42,12 @@ def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
# N7 — ARC snap angle-missing raises RuntimeError, not KeyError
# ---------------------------------------------------------------------------
class TestN7ArcAngleMissing:
async def _patch_entity_get(self, backend, info: EntityInfo):
async def _fake(handle):
return info
backend.entity_get = _fake # type: ignore[method-assign]
async def test_end_snap_missing_angles_raises_runtimeerror(self, backend):
@@ -107,6 +110,7 @@ class TestN7ArcAngleMissing:
# NEW-base-1 — point_tangent on/near the perimeter must not raise ValueError
# ---------------------------------------------------------------------------
class TestNewBase1TangentClamp:
async def test_from_point_exactly_on_circle_no_valueerror(self, backend):
"""A from-point exactly on the perimeter (d == r) degenerates to the
@@ -145,6 +149,7 @@ class TestNewBase1TangentClamp:
# NEW-snap-quad-arc / NEW-snap-near-arc — snaps stay on the arc sweep
# ---------------------------------------------------------------------------
class TestNewSnapArcSweep:
async def test_quad_on_quarter_arc_stays_on_sweep(self, backend):
"""Quarter arc 0..90 deg, r=10 at origin. Only the 0-deg and 90-deg
@@ -208,6 +213,7 @@ class TestNewSnapArcSweep:
# N8 — get_plan_spec accessor exposes the stored PlanSpec
# ---------------------------------------------------------------------------
class TestN8GetPlanSpec:
async def test_none_before_plan(self, backend):
assert backend.get_plan_spec() is None
+13 -7
View File
@@ -34,6 +34,7 @@ def _backend_no_executor() -> ComBackend:
# ── R28 — _find_autocad_hwnd prefers Application.HWND over EnumWindows ───────
async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
"""The real main-frame handle from the COM app wins; EnumWindows is not
consulted when the HWND read succeeds."""
@@ -43,7 +44,9 @@ async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", True)
enum = MagicMock(side_effect=AssertionError("EnumWindows must not be called"))
monkeypatch.setattr(cb, "win32gui", MagicMock(EnumWindows=enum))
# raising=False: the win32gui module global only exists on Windows; the
# mock must be injectable on the Linux CI leg as well.
monkeypatch.setattr(cb, "win32gui", MagicMock(EnumWindows=enum), raising=False)
assert cb._find_autocad_hwnd() == 0xABCD
enum.assert_not_called()
@@ -55,7 +58,7 @@ async def test_find_hwnd_coerces_hwnd_to_int(monkeypatch):
app.HWND = "43981" # COM can hand back a stringy handle
monkeypatch.setattr(cb, "_acad_app", lambda: app)
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", True)
monkeypatch.setattr(cb, "win32gui", MagicMock())
monkeypatch.setattr(cb, "win32gui", MagicMock(), raising=False)
result = cb._find_autocad_hwnd()
assert result == 43981
@@ -80,7 +83,7 @@ async def test_find_hwnd_falls_back_to_enumwindows(monkeypatch):
win32gui.EnumWindows.side_effect = _enum_windows
win32gui.GetWindowText.return_value = "AutoCAD 2025 - [Drawing1.dwg]"
win32gui.IsWindowVisible.return_value = True
monkeypatch.setattr(cb, "win32gui", win32gui)
monkeypatch.setattr(cb, "win32gui", win32gui, raising=False)
assert cb._find_autocad_hwnd() == 0x1234
win32gui.EnumWindows.assert_called_once()
@@ -98,6 +101,7 @@ async def test_find_hwnd_returns_none_when_win32_unavailable(monkeypatch):
# ── S3 — block_list populates BlockInfo.description from .Comments ───────────
class _Block:
"""Minimal stand-in for an AutoCAD block definition."""
@@ -167,10 +171,12 @@ async def test_block_list_description_empty_when_comments_none(monkeypatch):
async def test_block_list_skips_anonymous_layout_blocks(monkeypatch):
doc = _doc_with_blocks([
_Block("*Model_Space", comments="ignored"),
_Block("GEAR", comments="spur gear"),
])
doc = _doc_with_blocks(
[
_Block("*Model_Space", comments="ignored"),
_Block("GEAR", comments="spur gear"),
]
)
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor()
+4
View File
@@ -21,6 +21,7 @@ pytestmark = pytest.mark.asyncio
# ── R24 — CENTER/HIDDEN linetype actually loaded + applied ──────────────────
async def test_create_line_loads_center_linetype(backend):
# Fresh doc: CENTER is not loaded yet.
assert "CENTER" not in backend._doc.linetypes
@@ -53,6 +54,7 @@ async def test_create_line_continuous_does_not_error(backend):
# ── S3 — block description surfaced from the definition ─────────────────────
async def test_block_list_surfaces_description(backend):
# Create a block from an entity, then stamp a description on its definition.
seg = await backend.entity_create_line(0, 0, 10, 0)
@@ -76,6 +78,7 @@ async def test_block_list_description_defaults_empty(backend):
# ── R26 / N9 — honest zoom payload shape ────────────────────────────────────
async def test_zoom_extents_reports_not_applied(backend):
res = await backend.view_zoom_extents()
assert res["ok"] is True
@@ -92,6 +95,7 @@ async def test_zoom_window_reports_not_applied(backend):
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
async def test_undo_empty_stack_returns_error(backend):
res = await backend.drawing_undo()
assert res["ok"] is False
+4
View File
@@ -35,6 +35,7 @@ class _Ctx:
# ── R20 — tool count comes from the public accessor and is a real number ─────
async def test_registered_tool_count_is_positive_never_minus_one():
import server
@@ -59,6 +60,7 @@ async def test_registered_tools_returns_objects_with_name_and_tags():
# ── R15 — system_about tool_groups is dynamic + correct ──────────────────────
async def test_system_about_includes_engineering_group(backend):
import server
@@ -122,6 +124,7 @@ async def test_drawing_close_appears_in_groups(backend):
# ── R20 — system_status reports a real tool_count, never -1 ──────────────────
async def test_system_status_reports_real_tool_count(backend):
import server
@@ -134,6 +137,7 @@ async def test_system_status_reports_real_tool_count(backend):
# ── N8 — drawing_plan docstring corrected ────────────────────────────────────
async def test_drawing_plan_docstring_not_misleading():
import server
+40 -8
View File
@@ -37,7 +37,11 @@ async def test_spur_as_substring_does_not_false_match(backend, tmp_path):
"""R27: 'SPURIOUS' contains 'spur' but must NOT trip a SPUR mismatch."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPURIOUS HELICAL GEAR", x=0, y=0, height=5, layer="TEXT",
text="SPURIOUS HELICAL GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "spurious.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -50,7 +54,11 @@ async def test_genuine_spur_title_flags_when_helical_expected(backend, tmp_path)
"""R27: a real 'SPUR GEAR' title still flags the mismatch when helix declared."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
text="SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "spur.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -63,7 +71,11 @@ async def test_spur_word_in_titleblock_layer_flags(backend, tmp_path):
"""R27: title-block-scoped scan — SPUR on TITLEBLOCK layer still flags."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TITLEBLOCK",
text="SPUR GEAR",
x=0,
y=0,
height=5,
layer="TITLEBLOCK",
)
await backend.drawing_save_as(str(tmp_path / "spur_tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -74,7 +86,11 @@ async def test_substring_in_titleblock_layer_does_not_false_match(backend, tmp_p
"""R27: 'SPURIOUS' on TITLEBLOCK layer must not trip a SPUR match either."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPURIOUS NOTE", x=0, y=0, height=5, layer="TITLEBLOCK",
text="SPURIOUS NOTE",
x=0,
y=0,
height=5,
layer="TITLEBLOCK",
)
await backend.drawing_save_as(str(tmp_path / "spurious_tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -87,7 +103,11 @@ async def test_helical_spur_combo_still_flags_invalid(backend, tmp_path):
"""R27: both whole words present -> invalid combo error preserved."""
await _bootstrap(backend)
await backend.entity_create_text(
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
text="HELICAL SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "combo.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -99,7 +119,11 @@ async def test_no_helix_expected_skips_title_check(backend, tmp_path):
"""Gating preserved: without helix_angle, a SPUR title raises no title finding."""
await _bootstrap(backend)
await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
text="SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "nohelix.dxf"))
result = await DrawingValidator().run(backend)
@@ -116,10 +140,18 @@ async def test_titleblock_layer_takes_precedence_over_text_layer(backend, tmp_pa
"""
await _bootstrap(backend)
await backend.entity_create_text(
text="HELICAL GEAR", x=0, y=0, height=5, layer="TITLEBLOCK",
text="HELICAL GEAR",
x=0,
y=0,
height=5,
layer="TITLEBLOCK",
)
await backend.entity_create_text(
text="SPUR TOOTH DETAIL NOTE", x=0, y=-20, height=3.5, layer="TEXT",
text="SPUR TOOTH DETAIL NOTE",
x=0,
y=-20,
height=3.5,
layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "scope.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
+2 -6
View File
@@ -29,9 +29,7 @@ async def test_ezdxf_table_returns_composite_contract(backend):
async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
result = await backend.entity_create_table(
0, 30, rows=[["A", "1"]], headers=["Name", "Qty"]
)
result = await backend.entity_create_table(0, 30, rows=[["A", "1"]], headers=["Name", "Qty"])
path = tmp_path / "table.dxf"
await backend.drawing_save_as(str(path), fmt="dxf")
await backend.drawing_open(str(path))
@@ -39,9 +37,7 @@ async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
entities = await backend.entity_list(limit=100)
handles = {entity.handle for entity in entities}
texts = {
entity.properties.get("text")
for entity in entities
if entity.type in {"TEXT", "MTEXT"}
entity.properties.get("text") for entity in entities if entity.type in {"TEXT", "MTEXT"}
}
assert set(result.properties["child_handles"]).issubset(handles)
assert {"Name", "Qty", "A", "1"}.issubset(texts)
+78
View File
@@ -0,0 +1,78 @@
"""Tool-profile (TOOL_PROFILE=lean/core/full) behavior."""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import config
import server
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture(autouse=True)
async def _restore_full_profile():
"""Profiles mutate global server enablement; always restore full."""
yield
await server._apply_tool_profile("full")
async def _registered_names() -> set[str]:
return {tool.name for tool in await server._registered_tools() if getattr(tool, "name", None)}
async def test_lean_names_all_exist_in_registry():
registered = await _registered_names()
missing = server.LEAN_TOOL_NAMES - registered
assert not missing, f"LEAN_TOOL_NAMES references unknown tools: {sorted(missing)}"
async def test_core_exclusions_all_exist_in_registry():
registered = await _registered_names()
missing = server.CORE_EXCLUDED_TOOL_NAMES - registered
assert not missing, f"CORE_EXCLUDED_TOOL_NAMES references unknown tools: {sorted(missing)}"
async def test_full_profile_hides_only_gated_solids():
"""Full hides nothing except the opt-in 3D tools while ENABLE_3D=false."""
info = await server._apply_tool_profile("full")
assert info["profile"] == "full"
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
assert info["enabled_count"] == info["registered_count"] - len(server.SOLID_TOOL_NAMES)
async def test_invalid_profile_falls_back_to_full():
info = await server._apply_tool_profile("does-not-exist")
assert info["profile"] == "full"
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
async def test_lean_profile_filters_client_view(monkeypatch):
monkeypatch.setattr(config.settings, "backend", "ezdxf")
monkeypatch.setattr(config.settings, "tool_profile", "lean")
async with Client(server.mcp) as client:
visible = {tool.name for tool in await client.list_tools()}
assert visible == set(server.LEAN_TOOL_NAMES)
async def test_core_profile_hides_escape_hatches_keeps_engineering(monkeypatch):
monkeypatch.setattr(config.settings, "backend", "ezdxf")
monkeypatch.setattr(config.settings, "tool_profile", "core")
async with Client(server.mcp) as client:
visible = {tool.name for tool in await client.list_tools()}
assert "system_run_command" not in visible
assert "system_run_lisp" not in visible
assert "gear_draw_spur_front_view" in visible
assert "drawing_refine" in visible
registered = await _registered_names()
assert visible == registered - server.CORE_EXCLUDED_TOOL_NAMES - server.SOLID_TOOL_NAMES
async def test_profile_switch_is_idempotent():
await server._apply_tool_profile("lean")
info = await server._apply_tool_profile("full")
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
lean_again = await server._apply_tool_profile("lean")
assert lean_again["enabled_count"] == len(server.LEAN_TOOL_NAMES)