release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5a0ff5109
commit
b03a7615b1
@@ -5,12 +5,48 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
#: ProgID the live backend attaches to when ``CAD_PROGID`` is unset. The only
|
||||
#: CAD this project is developed and tested against.
|
||||
DEFAULT_CAD_PROGID = "AutoCAD.Application"
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Server configuration loaded from environment variables."""
|
||||
|
||||
@property
|
||||
def backend(self) -> str:
|
||||
"""Which engine to run: ``ezdxf``, ``com`` or ``auto``.
|
||||
|
||||
Read live rather than captured in ``__init__``, and deliberately
|
||||
read-only. ``_make_backend`` used to read ``os.environ`` itself while
|
||||
this attribute had no production reader at all, so
|
||||
``monkeypatch.setattr(settings, "backend", "ezdxf")`` looked like a
|
||||
guard and was a no-op — on a machine with AutoCAD open that meant a test
|
||||
attaching to the operator's live drawing. Late binding is what keeps
|
||||
``monkeypatch.setenv`` working for the call sites that pin the headless
|
||||
backend correctly; the missing setter is what makes the broken spelling
|
||||
fail loudly instead of silently.
|
||||
"""
|
||||
return os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
|
||||
|
||||
@property
|
||||
def cad_progid(self) -> str:
|
||||
"""COM ProgID the live backend attaches to.
|
||||
|
||||
Read live and deliberately read-only for the same reasons as
|
||||
``backend``: late binding keeps ``monkeypatch.setenv`` working, and the
|
||||
missing setter makes a wrong spelling fail loudly rather than look like
|
||||
a guard that does nothing.
|
||||
|
||||
Stripped but **not** lowercased — the value is echoed back in error
|
||||
messages and ``system_status``, where ``gstarcad.application`` reads
|
||||
like a typo we introduced. Never validated against what is actually
|
||||
registered on the machine: an unrecognised ProgID is not "invalid", it
|
||||
is unverifiable by us, and only AutoCAD is tested.
|
||||
"""
|
||||
return os.environ.get("CAD_PROGID", "").strip() or DEFAULT_CAD_PROGID
|
||||
|
||||
def __init__(self):
|
||||
self.backend: str = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
|
||||
self.log_level: str = os.environ.get("LOG_LEVEL", "INFO").upper().strip()
|
||||
|
||||
raw_paths = os.environ.get("ALLOWED_PATHS", "").strip()
|
||||
@@ -37,18 +73,46 @@ class Settings:
|
||||
# otherwise block the single-thread STA executor forever.
|
||||
self.com_call_timeout: float = float(os.environ.get("COM_CALL_TIMEOUT", "60"))
|
||||
|
||||
# Per-call timeout for the headless ezdxf backend. Every ezdxf method
|
||||
# runs under one document lock, so a single hung call (pathological DXF,
|
||||
# a render that never returns) would otherwise hold that lock forever
|
||||
# and deadlock every later tool call. Higher than com_call_timeout
|
||||
# because matplotlib PDF/PNG rendering of a large drawing is legitimately
|
||||
# slow. Set to 0 to disable the check.
|
||||
self.ezdxf_call_timeout: float = float(os.environ.get("EZDXF_CALL_TIMEOUT", "120"))
|
||||
|
||||
# How many undo steps the headless backend keeps. 0 = off, which is the
|
||||
# default because it is not free: ezdxf has no journal, so a step is a
|
||||
# full DXF snapshot. Measured — 10 entities 3.1 ms / 19 KB, 1000
|
||||
# entities 28 ms / 146 KB, 5000 entities 130 ms / 658 KB — and the cost
|
||||
# is paid on every mutating call, so switching it on for a large drawing
|
||||
# is a real trade the user should make deliberately rather than inherit.
|
||||
self.ezdxf_undo_depth: int = max(0, int(os.environ.get("EZDXF_UNDO_DEPTH", "0")))
|
||||
|
||||
# HTTP transport: refuse non-loopback bind unless explicitly opted in.
|
||||
self.allow_remote_http: bool = (
|
||||
os.environ.get("ALLOW_REMOTE_HTTP", "false").lower().strip() == "true"
|
||||
)
|
||||
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.
|
||||
# Exposed tool profile: "full" (default, everything) or "lean" (a
|
||||
# curated ~47-tool drafting/inspection core for clients with tight tool
|
||||
# caps). Invalid values fall back to "full" with a warning at startup;
|
||||
# so does the "core" profile removed in v1.5.0 (see
|
||||
# server.REMOVED_TOOL_PROFILES).
|
||||
self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip()
|
||||
|
||||
# Tool discovery: "off" (default) advertises the full catalog; "search"
|
||||
# replaces it with a search tool plus a call_tool proxy (see
|
||||
# discovery/transform.py and server._apply_discovery_mode).
|
||||
#
|
||||
# Default "off" deliberately. "search" is the intended v1.x default,
|
||||
# but flipping it changes what every connected client sees on
|
||||
# list_tools — a breaking wire-level change that needs its own
|
||||
# migration note and README pass, neither of which ships in v1.5.0.
|
||||
# Invalid values fall back to "off" with a warning at startup.
|
||||
self.discovery_mode: str = os.environ.get("DISCOVERY_MODE", "off").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.
|
||||
|
||||
Reference in New Issue
Block a user