diff --git a/CHANGELOG.md b/CHANGELOG.md index 711b642..275f5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.1] — 2026-08-06 + +A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the +adversarial pass over the rewritten README. 1.5.0 is yanked: its diameter and +radius callouts put wrong numbers on drawings at default settings, and that is +not something to leave installable. + +### Fixed + +- **The HTTP bind guard failed open on the launch path it was written for.** + `_guarded_run_async` read the bind address from the `host` kwarg and treated + an absent one as loopback. It is not: `fastmcp run` forwards that kwarg only + when `--host` was passed, and otherwise fastmcp resolves the host from + `fastmcp.settings.host`, which is environment-backed. So + `FASTMCP_HOST=0.0.0.0 fastmcp run server.py:mcp --transport http` bound every + interface — 149 tools including file open/save and AutoCAD command execution, + with no token — while the guard inspected the literal string `"127.0.0.1"` + and waved it through. v1.4.0's changelog claimed the guard "now fires on + every launch path"; it fired, and then looked at the wrong host. It now + resolves the effective host, and says so in a warning if it cannot. +- **`[com]` and `[full]` could not be installed off Windows.** Both extras + listed `pywin32>=306` with no environment marker, and pywin32 publishes + Windows wheels and *no sdist* — so `pip install autocad-mcp-pro[full]` died + at resolution on Linux and macOS, and `[full]` is the extra this project's + own README recommended to contributors. CI never caught it because every + Linux lane installs `[pdf]` or syncs from `uv.lock`, whose resolution markers + hide the gap. Both entries now carry `; sys_platform == "win32"`, and a + release-consistency test fails if a Windows-only distribution loses its + marker again. +- **`drawing_settings({"dimscale": ...})` reported success and changed nothing + headlessly.** It writes the `$DIMSCALE` header variable, which is what + AutoCAD consults when it draws a dimension — so the call worked on the live + backend. ezdxf renders from the dimstyle table entry and never reads the + header, so the same call produced two different drawings on the two engines + while both said `ok`. The header value is now folded into the per-dimension + override on all five dimension tools, including `dimension_aligned` and + `dimension_angular`, which do not share the tolerance-override path and were + missed by the obvious fix. DIMSCALE 1.0 writes no override. + +### Changed + +- README: the "What you get" table qualified every engine-conditional family + (boundary tracing, WIPEOUT/REVCLOUD/MTEXT masks, CHSPACE, typed hatch edge + paths, headless undo) instead of listing them as if both engines had them, + and the checkout install line no longer recommends an extra that cannot + resolve on the reader's platform. + ## [1.5.0] — 2026-08-06 ### Added diff --git a/README.md b/README.md index be0c1b4..5c407b6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Live through COM on Windows, or headless through ezdxf on any platform — one t --- -> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1250 collected tests. +> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1258 collected tests. > 154 is the **registered** count. A default install **advertises 149** over `tools/list`, because > `ENABLE_3D` is unset and the five `solid_*` tools stay hidden. `system_about` is the runtime authority. @@ -74,7 +74,7 @@ AUTOCAD_MCP_BACKEND=com autocad-mcp # live AutoCAD (pip install "autocad-mc > [!NOTE] > The bare install **cannot draw pixels**. `ezdxf.addons.drawing.frontend` does an unconditional top-level `import PIL.Image`, so Pillow is required by *every* ezdxf render path — PNG screenshot and PDF export alike — not just by the COM window capture it is listed under. Add `[pdf]` (or `[full]`) if you want images on Linux or macOS. -From a checkout: `pip install -e ".[full]"` then `python server.py`. +From a checkout: `pip install -e ".[full]"` on Windows, or `pip install -e ".[pdf]"` elsewhere, then `python server.py`. (`[com]` and `[full]` carry a `sys_platform == "win32"` marker on `pywin32`, so they resolve everywhere — they simply install nothing Windows-specific off Windows.) ### Wire it to a client @@ -100,18 +100,18 @@ Works with Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp - + - + - + - - + + diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index ea1817a..dce2216 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -443,6 +443,30 @@ def _measure_hatch(ent, tolerance: float, result: dict) -> dict: return _round_measure(result) +def _with_header_dimscale(doc, override: dict | None) -> dict | None: + """Carry `$DIMSCALE` onto the dimension being created. + + `drawing_settings({"dimscale": ...})` writes the header variable, which is + what AutoCAD consults when it draws a dimension — so the call worked on the + live backend. ezdxf renders from the dimstyle table entry and never reads + the header, so headlessly the same call reported success and changed + nothing. Folding it into the per-dimension override makes one call mean one + thing on both engines. + + 1.0 is the default and is deliberately not written, so an untouched drawing + does not gain an override on every dimension. + """ + try: + scale = float(doc.header.get("$DIMSCALE", 1.0) or 1.0) + except (AttributeError, TypeError, ValueError): + return override + if abs(scale - 1.0) < 1e-9: + return override + merged = dict(override or {}) + merged.setdefault("dimscale", scale) + return merged + + def _round_measure(result: dict) -> dict: """Six decimal places, matching analysis_measure_distance.""" result["area"] = round(float(result["area"]), 6) @@ -2870,6 +2894,7 @@ class EzdxfBackend(AutoCADBackend): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) + override = _with_header_dimscale(self._require_doc(), override) msp = self._msp() dim = msp.add_linear_dim( base=(float(dim_x), float(dim_y)), @@ -2906,6 +2931,7 @@ class EzdxfBackend(AutoCADBackend): distance=math.sqrt( (float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2 ), + override=_with_header_dimscale(self._require_doc(), None), ) dim.render() ent = dim.dimension @@ -2936,6 +2962,7 @@ class EzdxfBackend(AutoCADBackend): line1=((vxf, vyf), (float(x1), float(y1))), line2=((vxf, vyf), (float(x2), float(y2))), location=(float(tx), float(ty)), + override=_with_header_dimscale(self._require_doc(), None), ) dim.render() ent = dim.dimension @@ -2963,6 +2990,7 @@ class EzdxfBackend(AutoCADBackend): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) + override = _with_header_dimscale(self._require_doc(), override) msp = self._msp() cxf, cyf = float(cx), float(cy) radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2) @@ -3006,6 +3034,7 @@ class EzdxfBackend(AutoCADBackend): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) + override = _with_header_dimscale(self._require_doc(), override) msp = self._msp() x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2) cx = (x1f + x2f) / 2 diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index fb8517f..3c5c78e 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -1,7 +1,7 @@ { "_comment": "GENERATED FILE - do not edit by hand. Derived from the live FastMCP component registry; regenerate with `python scripts/generate_tool_inventory.py`.", "schema_version": 1, - "package_version": "1.5.0", + "package_version": "1.5.1", "totals": { "tools": 154, "resources": 6, diff --git a/pyproject.toml b/pyproject.toml index a5b4d4a..09cd8d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "autocad-mcp-pro" -version = "1.5.0" +version = "1.5.1" description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine" readme = "README.md" requires-python = ">=3.11" @@ -74,7 +74,13 @@ Changelog = "https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md" # renders nothing. Install `[pdf]` (or `[full]`) for rendering on any platform: # Pillow comes in transitively as a matplotlib dependency. com = [ - "pywin32>=306", + # pywin32 publishes Windows wheels and no sdist, so without this marker + # the extra cannot be resolved at all on Linux or macOS -- pip dies at + # "no matching distribution" before installing anything. The marker + # makes `pip install autocad-mcp-pro[com]` a no-op there instead of a + # hard failure, which is the honest outcome: the COM backend needs + # Windows, and the server already falls back to ezdxf. + 'pywin32>=306; sys_platform == "win32"', # Win32 window capture for the live-AutoCAD screenshot path. "Pillow>=10.0", ] @@ -83,7 +89,10 @@ pdf = [ "matplotlib>=3.8", ] full = [ - "pywin32>=306", + # Same marker as `com` above, and for the same reason: `[full]` is the + # extra this project recommends to contributors, and it was + # uninstallable on every non-Windows machine. + 'pywin32>=306; sys_platform == "win32"', "Pillow>=10.0", "matplotlib>=3.8", ] diff --git a/server.json b/server.json index 30cd859..0fb614e 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/U-C4N/Autocad-MCP", "source": "github" }, - "version": "1.5.0", + "version": "1.5.1", "packages": [ { "registryType": "pypi", "identifier": "autocad-mcp-pro", - "version": "1.5.0", + "version": "1.5.1", "transport": { "type": "stdio" }, diff --git a/server.py b/server.py index 628cf73..b6bf774 100644 --- a/server.py +++ b/server.py @@ -6599,9 +6599,47 @@ _HTTP_TRANSPORTS = {"http", "sse", "streamable-http"} _orig_run_async = mcp.run_async +def _effective_http_host(kwargs: dict) -> str: + """The address that will actually be bound — not the one we hope for. + + This used to read ``kwargs.get("host", "127.0.0.1")`` and treat an absent + kwarg as loopback. It is not: ``fastmcp run`` forwards ``host`` only when + ``--host`` was given, and otherwise fastmcp resolves it from + ``fastmcp.settings.host``, which is environment-backed. So + ``FASTMCP_HOST=0.0.0.0`` bound every interface while this guard inspected + the literal string ``"127.0.0.1"`` and let it through — the check failed + *open*, without a token, on the exact launch path it was written for. + + If the setting cannot be resolved we fall back to loopback rather than + refusing to start, but we say so: a guard that cannot see the host is not + enforcing anything, and silence is how the original bug survived. + """ + host = kwargs.get("host") + if host: + return str(host) + try: + import fastmcp + + resolved = getattr(fastmcp.settings, "host", None) + if resolved: + return str(resolved) + except Exception as exc: # pragma: no cover - defensive + log.warning( + "Could not resolve the effective HTTP host from fastmcp settings (%s); " + "assuming loopback. Pass --host explicitly to be sure of the bind guard.", + exc, + ) + return "127.0.0.1" + log.warning( + "fastmcp exposes no resolvable host setting; assuming loopback. Pass " + "--host explicitly to be sure of the bind guard." + ) + return "127.0.0.1" + + async def _guarded_run_async(transport=None, *args, **kwargs): if (transport in _HTTP_TRANSPORTS) or (kwargs.get("transport") in _HTTP_TRANSPORTS): - _validate_http_bind(kwargs.get("host", "127.0.0.1")) + _validate_http_bind(_effective_http_host(kwargs)) return await _orig_run_async(transport, *args, **kwargs) diff --git a/tests/test_drawing_settings.py b/tests/test_drawing_settings.py index ea8dae8..d0c5fec 100644 --- a/tests/test_drawing_settings.py +++ b/tests/test_drawing_settings.py @@ -78,3 +78,62 @@ async def test_server_tool_reads_and_writes(backend): assert write["ok"] is True read = await server.drawing_settings(ctx=ctx) assert read["settings"]["units"]["name"] == "mm" + + +# ── DIMSCALE has to reach the dimension, not just the header ──────────────── + + +def _dim_text_heights(backend, handle) -> list[float]: + """The text heights actually rendered into the dimension's geometry block. + + The override lands in the dimension's XDATA, not on `dim.dxf`, so the only + honest check is the drawing it produces. + """ + dim = backend._doc.entitydb.get(handle) + block = backend._doc.blocks.get(dim.dxf.geometry) + heights = [] + for entity in block: + if entity.dxftype() == "MTEXT": + heights.append(round(float(entity.dxf.char_height), 3)) + elif entity.dxftype() == "TEXT": + heights.append(round(float(entity.dxf.height), 3)) + return sorted(set(heights)) + + +async def test_dimscale_changes_the_dimension_it_is_set_before(backend): + """`drawing_settings({"dimscale": ...})` reported success and did nothing. + + It writes the `$DIMSCALE` header variable, which is what AutoCAD reads when + it creates a dimension — so on the live backend the call worked. ezdxf + renders dimensions from the dimstyle table entry and never consults the + header, so headlessly the text stayed the same size however large the + number was: one call, one reported success, two different drawings. + """ + plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30) + await backend.drawing_settings({"dimscale": 4.0}) + scaled = await backend.dimension_linear(0, 0, 100, 0, 50, -60) + + before = _dim_text_heights(backend, plain.handle) + after = _dim_text_heights(backend, scaled.handle) + assert before and after + assert after == pytest.approx([h * 4.0 for h in before]), ( + f"DIMSCALE=4 must scale the dimension text: {before} -> {after}" + ) + + +async def test_the_default_dimscale_adds_no_override(backend): + """DIMSCALE 1.0 is the default; it must not litter every dimension.""" + plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30) + + dim = backend._doc.entitydb.get(plain.handle) + assert not dim.dxf.hasattr("dimscale") or dim.dxf.dimscale == pytest.approx(1.0) + + +async def test_dimscale_reaches_aligned_and_angular_too(backend): + """Those two do not go through `build_dim_override`, so they were missed.""" + await backend.drawing_settings({"dimscale": 3.0}) + aligned = await backend.dimension_aligned(0, 0, 60, 60, 40, 70) + angular = await backend.dimension_angular(0, 0, 50, 0, 0, 50, 30, 30) + + for handle in (aligned.handle, angular.handle): + assert _dim_text_heights(backend, handle) == pytest.approx([3.0]) diff --git a/tests/test_http_auth.py b/tests/test_http_auth.py index 32a34a7..56979e9 100644 --- a/tests/test_http_auth.py +++ b/tests/test_http_auth.py @@ -179,3 +179,70 @@ def test_validate_http_bind_remote_with_token_logs_warning(caplog): finally: cfg.settings.allow_remote_http = old_remote cfg.settings.mcp_auth_token = old_token + + +# ── the guard must read the host that will actually be bound ──────────────── +# +# `_guarded_run_async` exists to cover the `fastmcp run server.py:mcp +# --transport http` path, which the module docstring and CLAUDE.md both +# advertise. It inferred the bind address from the `host` kwarg and treated an +# absent one as loopback -- but fastmcp only forwards that kwarg when `--host` +# was passed, and otherwise resolves the host from `fastmcp.settings.host`, +# which is environment-backed (`FASTMCP_HOST`). So `FASTMCP_HOST=0.0.0.0` +# bound every interface while the guard inspected the string "127.0.0.1" and +# waved it through: the check failed OPEN, with no token required, on the one +# path it was written for. + + +def _guard_probe(monkeypatch, *, fastmcp_host, allow_remote=False, token=""): + """Drive `mcp.run_async` without starting a server.""" + sys.modules.pop("server", None) + import fastmcp + + import config as cfg + import server as srv + + monkeypatch.setattr(fastmcp.settings, "host", fastmcp_host, raising=False) + monkeypatch.setattr(cfg.settings, "allow_remote_http", allow_remote, raising=False) + monkeypatch.setattr(cfg.settings, "mcp_auth_token", token, raising=False) + + async def _never_runs(*args, **kwargs): + raise AssertionError("the server started despite the guard") + + monkeypatch.setattr(srv, "_orig_run_async", _never_runs, raising=False) + return srv + + +@pytest.mark.asyncio +async def test_an_omitted_host_is_resolved_not_assumed_to_be_loopback(monkeypatch): + """FASTMCP_HOST=0.0.0.0 with no --host must still be refused.""" + srv = _guard_probe(monkeypatch, fastmcp_host="0.0.0.0") + + with pytest.raises(SystemExit, match="Refusing"): + await srv.mcp.run_async(transport="http") + + +@pytest.mark.asyncio +async def test_an_omitted_host_on_a_loopback_default_still_starts(monkeypatch): + """The fix must not refuse the ordinary case.""" + srv = _guard_probe(monkeypatch, fastmcp_host="127.0.0.1") + + with pytest.raises(AssertionError, match="the server started"): + await srv.mcp.run_async(transport="http") + + +@pytest.mark.asyncio +async def test_an_explicit_host_still_wins_over_the_setting(monkeypatch): + """An explicit loopback --host must not be overridden by the env default.""" + srv = _guard_probe(monkeypatch, fastmcp_host="0.0.0.0") + + with pytest.raises(AssertionError, match="the server started"): + await srv.mcp.run_async(transport="http", host="127.0.0.1") + + +@pytest.mark.asyncio +async def test_stdio_never_consults_the_http_guard(monkeypatch): + srv = _guard_probe(monkeypatch, fastmcp_host="0.0.0.0") + + with pytest.raises(AssertionError, match="the server started"): + await srv.mcp.run_async(transport="stdio") diff --git a/tests/test_release_consistency.py b/tests/test_release_consistency.py index 10d1a1d..0ba07cf 100644 --- a/tests/test_release_consistency.py +++ b/tests/test_release_consistency.py @@ -245,3 +245,26 @@ def test_dockerfile_copies_every_wheel_include() -> None: 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}" + + +def test_windows_only_dependencies_carry_a_platform_marker() -> None: + """`pip install autocad-mcp-pro[full]` must resolve on Linux and macOS. + + pywin32 publishes Windows wheels and **no sdist**, so an unmarked + `pywin32>=306` in an extra makes that extra uninstallable everywhere else: + pip fails at resolution with "no matching distribution" before it reaches + anything else. `[full]` is the extra this project's own README recommends + to contributors, and CI never caught it because every Linux lane installs + `[pdf]` or uses uv.lock, whose resolution markers paper over the gap. + """ + extras = _pyproject()["project"]["optional-dependencies"] + unmarked: list[str] = [] + for extra, requirements in extras.items(): + for requirement in requirements: + name = re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0].strip().lower() + if name in {"pywin32"} and "sys_platform" not in requirement: + unmarked.append(f"{extra}: {requirement}") + assert not unmarked, ( + 'Windows-only distributions need `; sys_platform == "win32"` or the ' + f"extra cannot be installed off Windows: {unmarked}" + ) diff --git a/uv.lock b/uv.lock index 1f12c53..dc2a74d 100644 --- a/uv.lock +++ b/uv.lock @@ -67,7 +67,7 @@ wheels = [ [[package]] name = "autocad-mcp-pro" -version = "1.5.0" +version = "1.5.1" source = { editable = "." } dependencies = [ { name = "ezdxf" }, @@ -78,12 +78,12 @@ dependencies = [ [package.optional-dependencies] com = [ { name = "pillow" }, - { name = "pywin32" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, ] full = [ { name = "matplotlib" }, { name = "pillow" }, - { name = "pywin32" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, ] pdf = [ { name = "matplotlib" }, @@ -106,8 +106,8 @@ requires-dist = [ { name = "pillow", marker = "extra == 'com'", specifier = ">=10.0" }, { name = "pillow", marker = "extra == 'full'", specifier = ">=10.0" }, { name = "pydantic", specifier = ">=2.0,<3" }, - { name = "pywin32", marker = "extra == 'com'", specifier = ">=306" }, - { name = "pywin32", marker = "extra == 'full'", specifier = ">=306" }, + { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'com'", specifier = ">=306" }, + { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'full'", specifier = ">=306" }, ] provides-extras = ["com", "pdf", "full"]
AreaWhat it does
Drawing lifecyclecreate, open, save, export DXF/PDF, audit (now repairs), purge, undo/redo
Drawing lifecyclecreate, open, save, export DXF/PDF, audit (now repairs), purge; undo/redo live on COM and opt-in headless via EZDXF_UNDO_DEPTH
Geometrylines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits
AnnotationISO 129 toleranced dimensions, ISO 286 fits (fit="H7"), TABLE, MLEADER, GD&T frames and datums (ISO 1101)
Engineering generatorsinvolute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block
Discovery ✨search_tools ranked over an AutoCAD command and synonym corpus — FILLET, BPOLY, QSELECT, WBLOCK, OVERKILL, CHSPACE each rank #1 of the 149-tool advertised catalog
Batching ✨cad_batch runs a whole step list in one round trip; fields= projects the response on 11 result-heavy tools (a closed, gated list)
Paper space ✨tab lifecycle, viewports, entity_change_space (CHSPACE), drawing_export_pdf(layout=...)
Paper space ✨tab lifecycle, viewports and drawing_export_pdf(layout=...) on both engines; entity_change_space (CHSPACE) headless only
Selection ✨window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box
Boundaries ✨boundary_trace (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges
Boundaries ✨boundary_trace (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges — headless engine only
Measurement ✨analysis_measure_entity measures what is in the drawing, by handle
Hatch depth ✨gradients, in-place edits, typed edge boundaries, island styles
Annotation objects ✨WIPEOUT, REVCLOUD, MTEXT background masks, text find/replace
Hatch depth ✨gradients, in-place edits and island styles on both; typed edge boundaries headless only
Annotation objects ✨WIPEOUT, REVCLOUD, MTEXT background masks — headless only, ActiveX exposes no member — plus text find/replace on both
3D solidssolid_box/cylinder/extrude/revolve/boolean on live AutoCAD (ENABLE_3D=true)
Quality loopdrawing_preflight → drawing_plan → drawing_critique → drawing_refine → drawing_finalize (0–100 score)
Deliverydrawing_deliver: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks