From b83a74e96980193bd85f8f869258bdba926b13e3 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 18:34:09 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- backends/com_backend.py | 228 ++++++++++++ discovery/aliases.py | 64 ++++ docs/tool-inventory.json | 85 ++++- engineering/environment/__init__.py | 9 +- engineering/environment/preferences.py | 146 ++++++++ server.py | 147 +++++++- tests/test_environment_live.py | 467 +++++++++++++++++++++++++ tests/test_tool_registry.py | 12 +- 9 files changed, 1153 insertions(+), 11 deletions(-) create mode 100644 engineering/environment/preferences.py create mode 100644 tests/test_environment_live.py diff --git a/README.md b/README.md index cf9052d..bc9a650 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **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 | diff --git a/backends/com_backend.py b/backends/com_backend.py index 006e43d..3a47762 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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). diff --git a/discovery/aliases.py b/discovery/aliases.py index be2dcfa..5973fa3 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -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"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 4f21c59..c286943 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -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", diff --git a/engineering/environment/__init__.py b/engineering/environment/__init__.py index 7d42f1c..d16b22c 100644 --- a/engineering/environment/__init__.py +++ b/engineering/environment/__init__.py @@ -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 diff --git a/engineering/environment/preferences.py b/engineering/environment/preferences.py new file mode 100644 index 0000000..7ad465b --- /dev/null +++ b/engineering/environment/preferences.py @@ -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 ``.`` 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 diff --git a/server.py b/server.py index 49b87a7..3738f31 100644 --- a/server.py +++ b/server.py @@ -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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_environment_live.py b/tests/test_environment_live.py new file mode 100644 index 0000000..c8ddcd1 --- /dev/null +++ b/tests/test_environment_live.py @@ -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") diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 5013664..305aaaf 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -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