feat(environment): system_launch, preference whitelist and operator prompts on COM; declared refusals headlessly

user_pick_point / user_select answer {cancelled: true} on ESC (DISP_E_EXCEPTION)
and {timed_out: true} past COM_CALL_TIMEOUT; preferences_set refuses read-only,
unknown and out-of-range keys before any ActiveX call.

The read-only key is Files.PrinterStyleSheetPath, not the spec's
PrintStyleSheetPath: IAcadPreferencesFiles has no member of that name
(measured live on AutoCAD 2026 / R25.1), and a misspelt key would make
preferences_get() raise on every live call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-21 18:34:09 +03:00
co-authored by Claude Opus 5
parent c7dd72410a
commit b83a74e969
9 changed files with 1153 additions and 11 deletions
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 179 is the **registered** count; a default install advertises 174 over `tools/list`,
> **v1.5 release snapshot:** 185 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 185 is the **registered** count; a default install advertises 180 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -104,7 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
| Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)* |
| Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)*; live only: launch/attach, a preference whitelist (`OPTIONS`), and operator prompts — pick a point, select on screen, a command-line message — where ESC is `cancelled`, not an error |
| 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 *(headless)* |
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
+228
View File
@@ -1896,6 +1896,234 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
# ── application, preferences, operator prompts ────────────────────────────
async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict:
"""Attach to the running application, or start it; optionally open a file.
Honours ``CAD_PROGID`` on both paths, like ``_acad_app``: ``Dispatch``
launches the application, so falling back to AutoCAD would start the
very product the operator said they were not using. The path is
validated before any COM call.
"""
path = str(validate_path(open_path, allow_write=False)) if open_path else None
def _sync():
progid = config.settings.cad_progid
app = _COM_STATE.get("app")
launched = False
if app is None:
try:
app = win32com.client.GetActiveObject(progid)
except Exception as exc:
log.debug("GetActiveObject(%r) failed, dispatching: %s", progid, exc)
app = win32com.client.Dispatch(progid)
launched = True
_COM_STATE["app"] = app
try:
app.Visible = bool(visible)
except Exception as exc: # some hosts refuse to hide
log.debug("Visible=%s refused: %s", visible, exc)
document = None
if path:
document = str(app.Documents.Open(path).Name)
elif int(app.Documents.Count):
document = str(app.ActiveDocument.Name)
return {
"launched": launched,
"attached": not launched,
"version": str(app.Version),
"document": document,
"visible": bool(visible),
"progid": progid,
"backend": "com",
}
result = await self._run(_sync)
await self._ensure_document_state()
return result
async def preferences_get(self, keys: list[str] | None = None) -> dict:
from engineering.environment.preferences import (
PREFERENCE_KEYS,
READ_ONLY_KEYS,
decode_value,
known_keys,
split_key,
)
if keys is None:
wanted = known_keys()
else:
if isinstance(keys, str) or not isinstance(keys, (list, tuple)):
raise TypeError("preferences_get: keys must be a list of preference names")
unknown = [k for k in keys if k not in PREFERENCE_KEYS and k not in READ_ONLY_KEYS]
if unknown:
raise ValueError(
f"preferences_get: unknown preference keys {unknown}; known: {known_keys()}"
)
wanted = list(keys)
def _sync():
prefs = _acad_app().Preferences
values = {}
for key in wanted:
section, member = split_key(key)
values[key] = decode_value(key, getattr(getattr(prefs, section), member))
return {
"values": values,
"read_only": [k for k in wanted if k in READ_ONLY_KEYS],
"backend": "com",
}
return await self._run(_sync)
async def preferences_set(self, key: str, value) -> dict:
from engineering.environment.preferences import (
decode_value,
split_key,
validate_preference,
)
coerced = validate_preference(key, value) # read-only / unknown / range: refused here
section, member = split_key(key)
def _sync():
target = getattr(_acad_app().Preferences, section)
old = getattr(target, member)
setattr(target, member, coerced)
new = getattr(target, member)
return {
"ok": True,
"key": key,
"old": decode_value(key, old),
"new": decode_value(key, new),
"changed": old != new,
"backend": "com",
}
return await self._run(_sync)
@staticmethod
def _prompt_text(prompt, where: str, max_len: int = 255) -> str:
from engineering.environment.names import validate_name
return validate_name(prompt, what=f"{where}: prompt", max_len=max_len)
@staticmethod
def _com_cancel_reason(exc) -> str | None:
"""The operator's cancel as ActiveX reports it, or None for any other COM error.
ESC in GetPoint / GetEntity / SelectOnScreen raises DISP_E_EXCEPTION
(-2147352567) with AutoCAD's description ("User input is a keyword",
"Function cancelled"). Anything else is a real failure and propagates.
"""
args = getattr(exc, "args", ())
if not args or args[0] != -2147352567:
return None
detail = args[2] if len(args) > 2 and isinstance(args[2], tuple) else ()
description = detail[2] if len(detail) > 2 and detail[2] else "cancelled"
return str(description)
async def _interactive(self, func):
"""Run an operator prompt; a wall-clock timeout is an answer, not a crash.
``_run`` has already rebuilt the STA executor by the time its
"did not respond" error surfaces (the worker is still blocked inside
the prompt); the next call re-attaches lazily, exactly as after any
other timeout. ``COM_CALL_TIMEOUT`` is the budget the operator has.
"""
try:
return await self._run(func)
except RuntimeError as exc:
if str(exc).startswith("AutoCAD did not respond"):
return {
"timed_out": True,
"timeout_s": config.settings.com_call_timeout,
"error": str(exc),
}
raise
async def user_pick_point(self, prompt: str) -> dict:
text = self._prompt_text(prompt, "user_pick_point")
def _sync():
doc = _acad_doc()
try:
point = doc.Utility.GetPoint(pythoncom.Missing, f"\n{text}")
except _COM_ERROR as exc:
reason = self._com_cancel_reason(exc)
if reason is None:
raise
return {"cancelled": True, "reason": reason, "backend": "com"}
return {
"cancelled": False,
"x": float(point[0]),
"y": float(point[1]),
"z": float(point[2]) if len(point) > 2 else 0.0,
"backend": "com",
}
return await self._interactive(_sync)
async def user_select(self, prompt: str, mode: str = "single") -> dict:
if mode not in ("single", "multiple"):
raise ValueError(f"user_select: mode must be 'single' or 'multiple', got {mode!r}")
text = self._prompt_text(prompt, "user_select")
def _sync():
doc = _acad_doc()
if mode == "single":
try:
obj, picked = doc.Utility.GetEntity(f"\n{text}")
except _COM_ERROR as exc:
reason = self._com_cancel_reason(exc)
if reason is None:
raise
return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"}
return {
"cancelled": False,
"mode": "single",
"handles": [str(obj.Handle)],
"picked": [float(picked[0]), float(picked[1])],
"backend": "com",
}
doc.Utility.Prompt(f"\n{text}\n")
ss = doc.SelectionSets.Add(f"_PICK_{uuid.uuid4().hex[:8]}")
try:
try:
ss.SelectOnScreen()
except _COM_ERROR as exc:
reason = self._com_cancel_reason(exc)
if reason is None:
raise
return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"}
handles = [str(ss.Item(i).Handle) for i in range(int(ss.Count))]
# Enter with nothing selected is an empty answer, not a cancel.
return {
"cancelled": False,
"mode": "multiple",
"handles": handles,
"count": len(handles),
"backend": "com",
}
finally:
try:
ss.Delete()
except Exception as exc:
log.debug("SelectionSet cleanup failed: %s", exc)
return await self._interactive(_sync)
async def system_prompt_message(self, text: str) -> dict:
clean = self._prompt_text(text, "system_prompt_message", max_len=2000)
def _sync():
_acad_doc().Utility.Prompt(f"\n{clean}\n")
return {"ok": True, "text": clean, "backend": "com"}
return await self._run(_sync)
# ── selection filters (M8 / F1) ──────────────────────────────────────────
#
# VERIFIED against a live AutoCAD 2026 (2026-08-05).
+64
View File
@@ -76,6 +76,9 @@ class ToolAliases:
# (document_close); a drafter typing it could mean either.
# LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore,
# list, delete in one) that this server splits into discrete tools.
# OPTIONS -- one dialog both reads and writes a preference; the server
# splits read (system_preferences_get) and write (system_preferences_set),
# the SETVAR precedent.
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -89,6 +92,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"LAYERSTATE",
"LAYOUT",
"MEASUREGEOM",
"OPTIONS",
"PLINE",
"QSELECT",
"SETVAR",
@@ -1503,6 +1507,46 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"save which layers are frozen",
),
),
"system_launch": ToolAliases(
acad=(),
synonyms=(
"start autocad",
"launch autocad",
"attach to the running autocad",
"is autocad running",
"open autocad with this file",
),
),
"system_preferences_get": ToolAliases(
acad=("OPTIONS",),
synonyms=(
"read a preference",
"autosave interval",
"support file search path",
"what is the pickbox size",
"options dialog value",
),
),
"system_preferences_set": ToolAliases(
acad=("OPTIONS",),
synonyms=(
"change a preference",
"set the autosave interval",
"cursor size",
"pickbox size",
"default plot style table",
),
),
"system_prompt_message": ToolAliases(
acad=(),
synonyms=(
"message on the command line",
"tell the operator",
"print to the command line",
"command line note",
"say something in autocad",
),
),
"ucs_list": ToolAliases(
acad=("UCS", "UCSMAN"),
synonyms=("the current ucs", "list coordinate systems", "named ucs", "ucs manager"),
@@ -1527,6 +1571,26 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"ucs origin",
),
),
"user_pick_point": ToolAliases(
acad=("ID",),
synonyms=(
"pick a point",
"click a point on screen",
"ask the operator for a point",
"where should this go",
"let me click",
),
),
"user_select": ToolAliases(
acad=("SELECT",),
synonyms=(
"let me select",
"pick objects on screen",
"ask the operator to select",
"select on screen",
"hand me the selection",
),
),
"view_named_list": ToolAliases(
acad=("VIEW",),
synonyms=("saved views", "existing named views", "list the views"),
+83 -2
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 179,
"tools": 185,
"resources": 8,
"prompts": 5,
"groups": 20
@@ -24,7 +24,7 @@
"pid": 9,
"premium": 12,
"solids": 5,
"system": 7,
"system": 13,
"templates": 2,
"transactions": 3,
"validation": 1,
@@ -2141,6 +2141,61 @@
"readOnlyHint": true
}
},
{
"name": "system_launch",
"group": "system",
"cost": "safe",
"summary": "Attach to the running AutoCAD or start it, optionally opening a file (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Launch / Attach AutoCAD",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "system_preferences_get",
"group": "system",
"cost": "read",
"summary": "Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).",
"tags": [
"system"
],
"annotations": {
"title": "Get Preferences",
"readOnlyHint": true
}
},
{
"name": "system_preferences_set",
"group": "system",
"cost": "safe",
"summary": "Change one whitelisted AutoCAD preference; reports old and new (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Set Preference",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "system_prompt_message",
"group": "system",
"cost": "safe",
"summary": "Print a message on AutoCAD's command line (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Command-Line Message",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "system_run_command",
"group": "system",
@@ -2346,6 +2401,32 @@
"destructiveHint": false
}
},
{
"name": "user_pick_point",
"group": "system",
"cost": "read",
"summary": "Ask the operator to pick a point on screen (live only; blocks until they do).",
"tags": [
"system"
],
"annotations": {
"title": "Ask Operator: Pick Point",
"readOnlyHint": true
}
},
{
"name": "user_select",
"group": "system",
"cost": "read",
"summary": "Ask the operator to select one entity or a set on screen (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Ask Operator: Select",
"readOnlyHint": true
}
},
{
"name": "validation_check",
"group": "validation",
+8 -1
View File
@@ -1,5 +1,6 @@
"""Environment helpers (v1.6 track E): portable layer states, view and UCS
argument resolution. Pure data and validation — no ezdxf, no COM."""
argument resolution, the preference whitelist. Pure data and validation — no
ezdxf, no COM."""
from .layer_states import ( # noqa: F401
DICT_NAME,
@@ -12,5 +13,11 @@ from .layer_states import ( # noqa: F401
validate_state_name,
)
from .names import validate_name # noqa: F401
from .preferences import ( # noqa: F401
PREFERENCE_KEYS,
READ_ONLY_KEYS,
describe_preferences,
validate_preference,
)
from .ucs import WORLD, resolve_ucs_axes # noqa: F401
from .views import resolve_view_args # noqa: F401
+146
View File
@@ -0,0 +1,146 @@
"""The AutoCAD preferences a model may read and write, as data.
``Preferences.*`` lives in the running application (registry-backed), not in
a drawing, so the headless engine refuses with ``capability: "preferences"``.
The whitelist is deliberately short: every key has a type and a range so a
write is refused *by name* before any ActiveX call, and the four ``Files.*``
paths are read-only because rewriting a support path is how an installation
gets broken. ``OpenSave.SaveAsType`` speaks AutoCAD's ``AcSaveAsType`` enum
by name in both directions.
"""
from __future__ import annotations
#: AcSaveAsType: each release adds 12; dwg, dxf (+1), template (+2).
SAVE_AS_TYPES: dict[str, int] = {
"ac2000_dwg": 12,
"ac2000_dxf": 13,
"ac2000_template": 14,
"ac2004_dwg": 24,
"ac2004_dxf": 25,
"ac2004_template": 26,
"ac2007_dwg": 36,
"ac2007_dxf": 37,
"ac2007_template": 38,
"ac2010_dwg": 48,
"ac2010_dxf": 49,
"ac2010_template": 50,
"ac2013_dwg": 60,
"ac2013_dxf": 61,
"ac2013_template": 62,
"ac2018_dwg": 64,
"ac2018_dxf": 65,
"ac2018_template": 66,
}
#: key -> (kind, spec). kind: "bool" | "int" (spec = (lo, hi)) | "str" | "enum" (spec = name -> value)
PREFERENCE_KEYS: dict[str, tuple[str, object]] = {
"OpenSave.SaveAsType": ("enum", SAVE_AS_TYPES),
"OpenSave.AutoSaveInterval": ("int", (0, 600)),
"OpenSave.CreateBackup": ("bool", None),
"OpenSave.IncrementalSavePercent": ("int", (0, 100)),
"Display.CursorSize": ("int", (1, 100)),
"Drafting.AutoSnapMarkerSize": ("int", (1, 20)),
"Drafting.AutoSnapTooltip": ("bool", None),
"Selection.PickBoxSize": ("int", (0, 50)),
"Output.DefaultPlotStyleTable": ("str", None),
"Output.DefaultOutputDevice": ("str", None),
}
#: Every key is ``<Preferences member>.<its member>`` verbatim, so ``split_key``
#: reaches the ActiveX property without a translation table. The spec spelled
#: the third one ``PrintStyleSheetPath``; ``IAcadPreferencesFiles`` has no such
#: member (measured on AutoCAD 2026 / R25.1: ``PrinterStyleSheetPath``), and a
#: misspelling here would make ``preferences_get()`` raise on every live call.
READ_ONLY_KEYS: tuple[str, ...] = (
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath",
)
def split_key(key: str) -> tuple[str, str]:
"""``"OpenSave.AutoSaveInterval"`` → ``("OpenSave", "AutoSaveInterval")``."""
section, _, member = key.partition(".")
return section, member
def known_keys() -> list[str]:
return list(PREFERENCE_KEYS) + list(READ_ONLY_KEYS)
def validate_preference(key: str, value):
"""The value to write, coerced to the key's type — or a refusal naming the key.
Writes nothing; both engines call it before touching anything.
"""
if key in READ_ONLY_KEYS:
raise ValueError(f"preference {key!r} is read-only")
if key not in PREFERENCE_KEYS:
raise ValueError(
f"unknown preference {key!r}; writable keys: {list(PREFERENCE_KEYS)}; "
f"read-only keys: {list(READ_ONLY_KEYS)}"
)
kind, spec = PREFERENCE_KEYS[key]
if kind == "bool":
if not isinstance(value, bool):
raise TypeError(f"preference {key!r} takes a bool, got {value!r}")
return value
if kind == "int":
whole = isinstance(value, int) and not isinstance(value, bool)
whole = whole or (isinstance(value, float) and value.is_integer())
if not whole:
raise TypeError(f"preference {key!r} takes an int, got {value!r}")
number = int(value)
lo, hi = spec
if not lo <= number <= hi:
raise ValueError(f"preference {key!r} must be between {lo} and {hi}, got {number}")
return number
if kind == "str":
if not isinstance(value, str) or not value.strip():
raise TypeError(f"preference {key!r} takes a non-empty string, got {value!r}")
return value.strip()
# enum
if isinstance(value, str):
name = value.strip().lower()
if name in spec:
return spec[name]
raise ValueError(f"preference {key!r} must be one of {sorted(spec)}, got {value!r}")
if isinstance(value, bool) or not isinstance(value, int) or value not in spec.values():
raise ValueError(
f"preference {key!r} must be one of {sorted(spec)} (or their values), got {value!r}"
)
return value
def decode_value(key: str, raw):
"""What the application returned, in the whitelist's vocabulary (enum names, real bools)."""
kind, spec = PREFERENCE_KEYS.get(key, (None, None))
if kind == "enum":
for name, code in spec.items():
if code == raw:
return name
return raw
if kind == "bool":
return bool(raw)
if kind == "int":
return int(raw)
return raw
def describe_preferences() -> list[dict]:
rows = []
for key, (kind, spec) in PREFERENCE_KEYS.items():
rows.append(
{
"key": key,
"kind": kind,
"range": list(spec) if kind == "int" else None,
"enum": sorted(spec) if kind == "enum" else None,
"writable": True,
}
)
for key in READ_ONLY_KEYS:
rows.append({"key": key, "kind": "str", "range": None, "enum": None, "writable": False})
return rows
+146 -1
View File
@@ -6819,7 +6819,7 @@ async def pid_tag_parse(
# ---------------------------------------------------------------------------
# ── SECTION 20: Environment (13 tools) ──────────────────────────────────────
# ── SECTION 20: Environment (19 tools) ──────────────────────────────────────
# ---------------------------------------------------------------------------
@@ -7120,6 +7120,151 @@ async def ucs_restore(
return await _backend(ctx).ucs_restore(name)
@cad_tool(
summary="Attach to the running AutoCAD or start it, optionally opening a file (live only).",
cost="safe",
)
@mcp.tool(
annotations={
"title": "Launch / Attach AutoCAD",
"readOnlyHint": False,
"destructiveHint": False,
},
tags={"system"},
)
async def system_launch(
visible: Annotated[bool, "Show the application window"] = True,
open_path: Annotated[str | None, "A .dwg/.dxf to open after attaching"] = None,
ctx: Context = None,
) -> dict:
"""Connect to the application named by `CAD_PROGID` (attach if running,
launch otherwise) and report `launched`, `attached`, `version` and the
active `document`. Refusals: headless, `capability: "live_application"`
(there is no application to launch); an `open_path` that fails path
validation. Pack: settings · lean: no.
"""
if open_path is not None:
open_path = str(validate_path(open_path, allow_write=False))
await ctx.info(f"Launching or attaching to {config.settings.cad_progid}")
return await _backend(ctx).system_launch(visible, open_path)
@cad_tool(
summary="Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).",
cost="read",
)
@mcp.tool(
annotations={"title": "Get Preferences", "readOnlyHint": True},
tags={"system"},
)
async def system_preferences_get(
keys: Annotated[
list[str] | None,
"Subset of OpenSave.SaveAsType, OpenSave.AutoSaveInterval, OpenSave.CreateBackup, "
"OpenSave.IncrementalSavePercent, Display.CursorSize, Drafting.AutoSnapMarkerSize, "
"Drafting.AutoSnapTooltip, Selection.PickBoxSize, Output.DefaultPlotStyleTable, "
"Output.DefaultOutputDevice, Files.SupportPath, Files.TemplateDwgPath, "
"Files.PrinterStyleSheetPath, Files.PrinterConfigPath; omit for all",
] = None,
ctx: Context = None,
) -> dict:
"""The whitelisted `Preferences.*` values, enum values as names
(`SaveAsType: "ac2018_dwg"`), with `read_only` naming the four `Files.*`
paths. Refusals: headless, `capability: "preferences"` (they live in the
running application, not in a file); a key outside the whitelist.
Pack: settings · lean: no.
"""
return await _backend(ctx).preferences_get(keys)
@cad_tool(
summary="Change one whitelisted AutoCAD preference; reports old and new (live only).",
cost="safe",
)
@mcp.tool(
annotations={"title": "Set Preference", "readOnlyHint": False, "destructiveHint": False},
tags={"system"},
)
async def system_preferences_set(
key: Annotated[str, "A writable key from system_preferences_get"],
value: Annotated[Any, "New value: bool, int within the key's range, string, or an enum name"],
ctx: Context = None,
) -> dict:
"""Write one preference and report `old`, `new` and `changed`.
Refusals, all before any write: a read-only key (`Files.*`), an unknown
key, a value of the wrong type, an int outside the authored range
(`AutoSaveInterval` 0–600 min, `CursorSize` 1–100, `PickBoxSize` 0–50, …),
an unknown `SaveAsType` name; headless, `capability: "preferences"`.
Pack: settings · lean: no.
"""
await ctx.info(f"Setting preference {key} = {value!r}")
return await _backend(ctx).preferences_set(key, value)
@cad_tool(
summary="Ask the operator to pick a point on screen (live only; blocks until they do).",
cost="read",
)
@mcp.tool(
annotations={"title": "Ask Operator: Pick Point", "readOnlyHint": True},
tags={"system"},
)
async def user_pick_point(
prompt: Annotated[str, "Shown on the command line, e.g. 'Pick the base point'"],
ctx: Context = None,
) -> dict:
"""`Utility.GetPoint`: returns the WCS `x`, `y` (`z`) the operator clicks.
ESC is an answer, not an error: `cancelled: true` with AutoCAD's reason.
Waiting longer than `COM_CALL_TIMEOUT` returns `timed_out: true` (the
prompt is abandoned on AutoCAD's side; press ESC there). Refusals: an
empty prompt; headless, `capability: "interactive_prompt"` (no operator).
Pack: settings · lean: no.
"""
await ctx.info(f"Asking the operator to pick a point: {prompt}")
return await _backend(ctx).user_pick_point(prompt)
@cad_tool(
summary="Ask the operator to select one entity or a set on screen (live only).",
cost="read",
)
@mcp.tool(
annotations={"title": "Ask Operator: Select", "readOnlyHint": True},
tags={"system"},
)
async def user_select(
prompt: Annotated[str, "Shown on the command line"],
mode: Annotated[str, "single (GetEntity) | multiple (SelectOnScreen)"] = "single",
ctx: Context = None,
) -> dict:
"""Returns `handles` the operator picked — one with `mode="single"` (plus
the `picked` point), any number with `mode="multiple"` (Enter with nothing
selected is `count: 0`, not a cancel). ESC → `cancelled: true`; a wait
longer than `COM_CALL_TIMEOUT` → `timed_out: true`. Refusals: a mode
outside the two, an empty prompt; headless, `capability:
"interactive_prompt"`. Pack: settings · lean: no.
"""
await ctx.info(f"Asking the operator to select ({mode}): {prompt}")
return await _backend(ctx).user_select(prompt, mode)
@cad_tool(summary="Print a message on AutoCAD's command line (live only).", cost="safe")
@mcp.tool(
annotations={"title": "Command-Line Message", "readOnlyHint": False, "destructiveHint": False},
tags={"system"},
)
async def system_prompt_message(
text: Annotated[str, "The message; one line is best"],
ctx: Context = None,
) -> dict:
"""`Utility.Prompt`: tell the operator something where they are looking.
Nothing in the drawing changes. Refusals: an empty message; headless,
`capability: "interactive_prompt"`. Pack: settings · lean: no."""
return await _backend(ctx).system_prompt_message(text)
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+467
View File
@@ -0,0 +1,467 @@
"""The live-only environment trio: launch, preferences, operator prompts.
Headless there is no application to launch, no registry of preferences and
no operator to prompt, so ``EzdxfBackend`` inherits the ``@capability``
defaults and refuses with a key ``system_capabilities`` declares. Live, the
COM paths are exercised against fake ActiveX objects asserting the call
shapes; ``scripts/smoke_settings_com.py`` runs them once for real.
A cancelled pick is an answer (``cancelled: true``), not an error; a
wall-clock timeout is ``timed_out: true``. Preference writes are refused by
name before any ActiveX call: read-only keys, unknown keys, values outside
the authored range.
"""
from __future__ import annotations
import types
import pytest
from fastmcp import Client
import config
import server
from backends.base import UnsupportedCapabilityError
from backends.capability import is_capability_default
from backends.ezdxf_backend import EzdxfBackend
from engineering.environment.preferences import (
PREFERENCE_KEYS,
READ_ONLY_KEYS,
SAVE_AS_TYPES,
decode_value,
describe_preferences,
split_key,
validate_preference,
)
pytestmark = pytest.mark.asyncio
# ── the whitelist, pure ─────────────────────────────────────────────────────
def test_whitelist_is_the_spec_list():
assert set(PREFERENCE_KEYS) == {
"OpenSave.SaveAsType",
"OpenSave.AutoSaveInterval",
"OpenSave.CreateBackup",
"OpenSave.IncrementalSavePercent",
"Display.CursorSize",
"Drafting.AutoSnapMarkerSize",
"Drafting.AutoSnapTooltip",
"Selection.PickBoxSize",
"Output.DefaultPlotStyleTable",
"Output.DefaultOutputDevice",
}
assert READ_ONLY_KEYS == (
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath",
)
assert split_key("OpenSave.AutoSaveInterval") == ("OpenSave", "AutoSaveInterval")
assert SAVE_AS_TYPES["ac2018_dwg"] == 64 and SAVE_AS_TYPES["ac2000_dwg"] == 12
def test_validate_preference_coerces_and_refuses_by_name():
assert validate_preference("OpenSave.AutoSaveInterval", 10) == 10
assert validate_preference("OpenSave.AutoSaveInterval", 10.0) == 10
assert validate_preference("OpenSave.CreateBackup", False) is False
assert validate_preference("OpenSave.SaveAsType", "AC2018_DWG") == 64
assert validate_preference("OpenSave.SaveAsType", 48) == 48
assert (
validate_preference("Output.DefaultPlotStyleTable", " monochrome.ctb ") == "monochrome.ctb"
)
with pytest.raises(ValueError, match="'Files.SupportPath' is read-only"):
validate_preference("Files.SupportPath", "C:/x")
with pytest.raises(ValueError, match="unknown preference 'Display.Theme'"):
validate_preference("Display.Theme", 1)
with pytest.raises(ValueError, match="between 0 and 600, got 601"):
validate_preference("OpenSave.AutoSaveInterval", 601)
with pytest.raises(TypeError, match="takes an int"):
validate_preference("Selection.PickBoxSize", 2.5)
with pytest.raises(TypeError, match="takes a bool"):
validate_preference("OpenSave.CreateBackup", 1)
with pytest.raises(ValueError, match="must be one of"):
validate_preference("OpenSave.SaveAsType", "ac1999_dwg")
with pytest.raises(TypeError, match="takes a non-empty string"):
validate_preference("Output.DefaultOutputDevice", "")
def test_decode_value_maps_enum_values_back_to_names():
assert decode_value("OpenSave.SaveAsType", 64) == "ac2018_dwg"
assert decode_value("OpenSave.SaveAsType", 999) == 999
assert decode_value("OpenSave.CreateBackup", 1) is True
assert decode_value("Files.SupportPath", "C:/a;C:/b") == "C:/a;C:/b"
def test_describe_preferences_covers_every_key():
rows = {row["key"]: row for row in describe_preferences()}
assert set(rows) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
assert rows["OpenSave.AutoSaveInterval"] == {
"key": "OpenSave.AutoSaveInterval",
"kind": "int",
"range": [0, 600],
"enum": None,
"writable": True,
}
assert rows["Files.SupportPath"]["writable"] is False
# ── headless: declared refusals ─────────────────────────────────────────────
@pytest.mark.parametrize(
"method, args, key",
[
("system_launch", (), "live_application"),
("preferences_get", (), "preferences"),
("preferences_set", ("Display.CursorSize", 5), "preferences"),
("user_pick_point", ("Pick",), "interactive_prompt"),
("user_select", ("Select",), "interactive_prompt"),
("system_prompt_message", ("hi",), "interactive_prompt"),
],
)
async def test_headless_refuses_with_the_declared_key(backend, method, args, key):
assert is_capability_default(EzdxfBackend, method)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await getattr(backend, method)(*args)
assert excinfo.value.capability == key
assert "ezdxf" in str(excinfo.value)
feature = backend.capabilities().features[key]
assert feature.supported is False and feature.reason
async def test_refusal_reaches_the_client_with_the_key(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
result = await client.call_tool(
"user_pick_point", {"prompt": "Pick the base point"}, raise_on_error=False
)
assert result.is_error is True
payload = result.structured_content
assert payload["ok"] is False and payload["capability"] == "interactive_prompt"
caps = (await client.call_tool("system_capabilities", {})).structured_content
assert caps["features"]["interactive_prompt"]["supported"] is False
# ── live engine, against a fake ActiveX surface ──────────────────────────────
def _com_error(description: str):
pywintypes = pytest.importorskip("pywintypes")
return pywintypes.com_error(
-2147352567, "Exception occurred.", (0, None, description, None, 0, -2145320928), None
)
class _FakeUtility:
def __init__(self):
self.calls: list[tuple[str, tuple]] = []
self.point = (12.5, 7.25, 0.0)
self.raise_on_pick = None
self.entity = types.SimpleNamespace(Handle="1F3")
def GetPoint(self, base, prompt):
self.calls.append(("GetPoint", (base, prompt)))
if self.raise_on_pick is not None:
raise self.raise_on_pick
return self.point
def GetEntity(self, prompt):
self.calls.append(("GetEntity", (prompt,)))
if self.raise_on_pick is not None:
raise self.raise_on_pick
return self.entity, (3.0, 4.0, 0.0)
def Prompt(self, text):
self.calls.append(("Prompt", (text,)))
class _FakeSelectionSet:
def __init__(self, name, items):
self.Name = name
self._items = items
self.calls: list[str] = []
@property
def Count(self):
return len(self._items)
def Item(self, index):
return self._items[index]
def SelectOnScreen(self):
self.calls.append("SelectOnScreen")
def Delete(self):
self.calls.append("Delete")
class _FakeSelectionSets:
def __init__(self, items):
self.items = items
self.sets: list[_FakeSelectionSet] = []
def Add(self, name):
ss = _FakeSelectionSet(name, self.items)
self.sets.append(ss)
return ss
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
utility = _FakeUtility()
selection = [types.SimpleNamespace(Handle="A1"), types.SimpleNamespace(Handle="B2")]
document = types.SimpleNamespace(
Name="Part.dwg",
FullName="C:/work/Part.dwg",
Utility=utility,
SelectionSets=_FakeSelectionSets(selection),
)
preferences = types.SimpleNamespace(
OpenSave=types.SimpleNamespace(
SaveAsType=64, AutoSaveInterval=10, CreateBackup=True, IncrementalSavePercent=50
),
Display=types.SimpleNamespace(CursorSize=5),
Drafting=types.SimpleNamespace(AutoSnapMarkerSize=5, AutoSnapTooltip=True),
Selection=types.SimpleNamespace(PickBoxSize=3),
Output=types.SimpleNamespace(
DefaultPlotStyleTable="monochrome.ctb", DefaultOutputDevice="DWG To PDF.pc3"
),
Files=types.SimpleNamespace(
SupportPath="C:/support",
TemplateDwgPath="C:/t",
PrinterStyleSheetPath="C:/p",
PrinterConfigPath="C:/pc3",
),
)
app = types.SimpleNamespace(
Preferences=preferences,
Version="25.0",
Documents=types.SimpleNamespace(Count=1),
ActiveDocument=document,
)
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app, document
async def test_com_no_longer_inherits_the_defaults():
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends.com_backend import ComBackend
for method in (
"system_launch",
"preferences_get",
"preferences_set",
"user_pick_point",
"user_select",
"system_prompt_message",
):
assert not is_capability_default(ComBackend, method), method
async def test_com_pick_point_returns_the_operator_point(com_backend):
backend, app, document = com_backend
import pythoncom
result = await backend.user_pick_point("Pick the base point")
assert result == {"cancelled": False, "x": 12.5, "y": 7.25, "z": 0.0, "backend": "com"}
(call,) = document.Utility.calls
assert call[0] == "GetPoint" and call[1][0] is pythoncom.Missing
assert call[1][1] == "\nPick the base point"
async def test_com_pick_point_cancel_is_an_answer_not_an_error(com_backend):
backend, app, document = com_backend
document.Utility.raise_on_pick = _com_error("User input is a keyword")
result = await backend.user_pick_point("Pick")
assert result == {"cancelled": True, "reason": "User input is a keyword", "backend": "com"}
async def test_com_pick_point_other_com_errors_still_raise(com_backend):
backend, app, document = com_backend
pywintypes = pytest.importorskip("pywintypes")
document.Utility.raise_on_pick = pywintypes.com_error(
-2147418111, "Call was rejected", None, None
)
with pytest.raises(pywintypes.com_error):
await backend.user_pick_point("Pick")
async def test_com_pick_point_timeout_reports_timed_out(com_backend, monkeypatch):
backend, app, document = com_backend
monkeypatch.setattr(config.settings, "com_call_timeout", 7.0)
async def _timed_out(func, *args, **kwargs):
raise RuntimeError(
"AutoCAD did not respond within 7s. The application may be showing a modal dialog"
)
monkeypatch.setattr(backend, "_run", _timed_out)
result = await backend.user_pick_point("Pick")
assert result["timed_out"] is True and result["timeout_s"] == 7.0
assert "did not respond" in result["error"]
result = await backend.user_select("Pick", "multiple")
assert result["timed_out"] is True
async def test_com_prompt_validation_happens_before_activex(com_backend):
backend, app, document = com_backend
with pytest.raises(ValueError, match="prompt must not be empty"):
await backend.user_pick_point(" ")
with pytest.raises(ValueError, match="mode must be 'single' or 'multiple'"):
await backend.user_select("Pick", "many")
assert document.Utility.calls == [] and document.SelectionSets.sets == []
async def test_com_select_single_uses_getentity(com_backend):
backend, app, document = com_backend
result = await backend.user_select("Pick the flange")
assert result == {
"cancelled": False,
"mode": "single",
"handles": ["1F3"],
"picked": [3.0, 4.0],
"backend": "com",
}
assert document.Utility.calls == [("GetEntity", ("\nPick the flange",))]
document.Utility.raise_on_pick = _com_error("Function cancelled")
assert (await backend.user_select("Pick"))["cancelled"] is True
async def test_com_select_multiple_uses_a_selection_set_and_deletes_it(com_backend):
backend, app, document = com_backend
result = await backend.user_select("Select the pipes", mode="multiple")
assert result == {
"cancelled": False,
"mode": "multiple",
"handles": ["A1", "B2"],
"count": 2,
"backend": "com",
}
assert document.Utility.calls == [("Prompt", ("\nSelect the pipes\n",))]
(ss,) = document.SelectionSets.sets
assert ss.Name.startswith("_PICK_") and ss.calls == ["SelectOnScreen", "Delete"]
async def test_com_prompt_message_writes_to_the_command_line(com_backend):
backend, app, document = com_backend
result = await backend.system_prompt_message("Drawing saved by the assistant")
assert result == {"ok": True, "text": "Drawing saved by the assistant", "backend": "com"}
assert document.Utility.calls == [("Prompt", ("\nDrawing saved by the assistant\n",))]
async def test_com_preferences_get_reads_the_whitelist(com_backend):
backend, app, document = com_backend
result = await backend.preferences_get()
assert result["read_only"] == list(READ_ONLY_KEYS)
assert result["values"]["OpenSave.SaveAsType"] == "ac2018_dwg"
assert result["values"]["OpenSave.AutoSaveInterval"] == 10
assert result["values"]["Files.SupportPath"] == "C:/support"
assert set(result["values"]) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
subset = await backend.preferences_get(["Display.CursorSize"])
assert subset == {"values": {"Display.CursorSize": 5}, "read_only": [], "backend": "com"}
with pytest.raises(ValueError, match="unknown preference keys \\['Display.Theme'\\]"):
await backend.preferences_get(["Display.Theme"])
async def test_com_preferences_set_reports_old_and_new(com_backend):
backend, app, document = com_backend
result = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
assert result == {
"ok": True,
"key": "OpenSave.AutoSaveInterval",
"old": 10,
"new": 15,
"changed": True,
"backend": "com",
}
assert app.Preferences.OpenSave.AutoSaveInterval == 15
same = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
assert same["changed"] is False
enum = await backend.preferences_set("OpenSave.SaveAsType", "ac2013_dwg")
assert enum["old"] == "ac2018_dwg" and enum["new"] == "ac2013_dwg"
assert app.Preferences.OpenSave.SaveAsType == 60
async def test_com_preferences_set_refuses_before_any_write(com_backend):
backend, app, document = com_backend
with pytest.raises(ValueError, match="read-only"):
await backend.preferences_set("Files.SupportPath", "C:/evil")
with pytest.raises(ValueError, match="between 1 and 100"):
await backend.preferences_set("Display.CursorSize", 0)
with pytest.raises(ValueError, match="unknown preference"):
await backend.preferences_set("Display.Theme", 1)
assert app.Preferences.Files.SupportPath == "C:/support"
assert app.Preferences.Display.CursorSize == 5
async def test_com_launch_attaches_first_and_dispatches_otherwise(com_backend, monkeypatch):
backend, app, document = com_backend
from backends import com_backend as module
monkeypatch.setattr(module, "_COM_STATE", {})
calls: list[str] = []
def _get_active(progid):
calls.append(f"GetActiveObject:{progid}")
raise RuntimeError("not running")
def _dispatch(progid):
calls.append(f"Dispatch:{progid}")
app.Visible = False
return app
monkeypatch.setattr(module.win32com.client, "GetActiveObject", _get_active)
monkeypatch.setattr(module.win32com.client, "Dispatch", _dispatch)
result = await backend.system_launch(visible=True)
assert result == {
"launched": True,
"attached": False,
"version": "25.0",
"document": "Part.dwg",
"visible": True,
"progid": config.settings.cad_progid,
"backend": "com",
}
assert calls == [
f"GetActiveObject:{config.settings.cad_progid}",
f"Dispatch:{config.settings.cad_progid}",
]
assert app.Visible is True and module._COM_STATE["app"] is app
again = await backend.system_launch()
assert again["launched"] is False and again["attached"] is True
assert len(calls) == 2, "an app already cached is attached, not re-dispatched"
async def test_com_launch_opens_a_validated_path(com_backend, monkeypatch, tmp_path):
backend, app, document = com_backend
from backends import com_backend as module
opened: list[str] = []
app.Documents = types.SimpleNamespace(
Count=1,
Open=lambda path: opened.append(path) or types.SimpleNamespace(Name="gear.dwg"),
)
monkeypatch.setattr(module, "_COM_STATE", {"app": app})
target = tmp_path / "gear.dwg"
target.write_bytes(b"")
result = await backend.system_launch(open_path=str(target))
assert result["document"] == "gear.dwg" and opened == [str(target.resolve())]
from fastmcp.exceptions import ToolError
with pytest.raises(ToolError):
await backend.system_launch(open_path="../../etc/passwd")
+8 -4
View File
@@ -522,8 +522,12 @@ async def test_tool_group_sizes_are_unchanged():
`document_close` (multi-document on both engines) opened SECTION 20;
`layers` moved 14 -> 18 with the four `layer_state_*` tools and `view`
4 -> 10 with `view_named_*` and `ucs_*` (UCS files under `view`, where
AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. Every
other number here has been unchanged since the snapshot was taken.
AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. `system`
moved 7 -> 13 with v1.6's live-only environment tools (`system_launch`,
`system_preferences_get/set`, `user_pick_point`, `user_select`,
`system_prompt_message`), which refuse headlessly with declared
capability keys. Every other number here has been unchanged since the
snapshot was taken.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -542,10 +546,10 @@ async def test_tool_group_sizes_are_unchanged():
"pid": 9,
"premium": 12,
"solids": 5,
"system": 7,
"system": 13,
"templates": 2,
"transactions": 3,
"validation": 1,
"view": 10,
}
assert sum(sizes.values()) == 179
assert sum(sizes.values()) == 185