release: v1.5.1 — bind guard failed open, [full] would not install, DIMSCALE did nothing

Three defects found after 1.5.0 reached PyPI, two of them by the adversarial
pass over the rewritten README.

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 fastmcp otherwise 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, no token -- while the guard inspected the literal "127.0.0.1" and
waved it through. v1.4.0's changelog claimed this guard "fires on every launch
path"; it fired, and looked at the wrong host. It now resolves the effective
host, and warns rather than staying silent if it cannot.

`[com]` and `[full]` could not be installed off Windows. Both 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 the 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 carry
`; sys_platform == "win32"` now, with a release-consistency test to keep them.

`drawing_settings({"dimscale": ...})` reported success and changed nothing
headlessly. It writes `$DIMSCALE`, which is what AutoCAD reads when it draws a
dimension, so the call worked on COM; ezdxf renders from the dimstyle table
entry and never consults the header. One call, one reported success, two
different drawings. The header value now folds 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 the
obvious fix would have missed. DIMSCALE 1.0 writes nothing.

README: every engine-conditional family in the feature table now says which
engine (boundary tracing, WIPEOUT/REVCLOUD/MTEXT masks, CHSPACE, typed hatch
edge paths, headless undo), and the checkout install line stopped recommending
an extra that could not resolve on the reader's platform.

1257 passed, 1 xfailed. 1.5.0 is to be yanked: its diameter and radius callouts
put wrong numbers on drawings at default settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Umut CAN
2026-08-06 14:28:35 +03:00
co-authored by Claude Opus 5
parent 776fe5bcf1
commit 25f8737132
11 changed files with 291 additions and 19 deletions
+59
View File
@@ -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])
+67
View File
@@ -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")
+23
View File
@@ -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}"
)