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:
co-authored by
Claude Opus 5
parent
c7dd72410a
commit
b83a74e969
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
> **v1.5 release snapshot:** 185 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||||
> 179 is the **registered** count; a default install advertises 174 over `tools/list`,
|
> 185 is the **registered** count; a default install advertises 180 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
## Why this exists
|
## 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 |
|
| 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 |
|
| 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=…)` |
|
| 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 |
|
| 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)* |
|
| 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 |
|
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
|
||||||
|
|||||||
@@ -1896,6 +1896,234 @@ class ComBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._run(_sync)
|
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) ──────────────────────────────────────────
|
# ── selection filters (M8 / F1) ──────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# VERIFIED against a live AutoCAD 2026 (2026-08-05).
|
# VERIFIED against a live AutoCAD 2026 (2026-08-05).
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ class ToolAliases:
|
|||||||
# (document_close); a drafter typing it could mean either.
|
# (document_close); a drafter typing it could mean either.
|
||||||
# LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore,
|
# LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore,
|
||||||
# list, delete in one) that this server splits into discrete tools.
|
# 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.
|
# Every other AutoCAD command must map to exactly one tool.
|
||||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||||
@@ -89,6 +92,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
|||||||
"LAYERSTATE",
|
"LAYERSTATE",
|
||||||
"LAYOUT",
|
"LAYOUT",
|
||||||
"MEASUREGEOM",
|
"MEASUREGEOM",
|
||||||
|
"OPTIONS",
|
||||||
"PLINE",
|
"PLINE",
|
||||||
"QSELECT",
|
"QSELECT",
|
||||||
"SETVAR",
|
"SETVAR",
|
||||||
@@ -1503,6 +1507,46 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"save which layers are frozen",
|
"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(
|
"ucs_list": ToolAliases(
|
||||||
acad=("UCS", "UCSMAN"),
|
acad=("UCS", "UCSMAN"),
|
||||||
synonyms=("the current ucs", "list coordinate systems", "named ucs", "ucs manager"),
|
synonyms=("the current ucs", "list coordinate systems", "named ucs", "ucs manager"),
|
||||||
@@ -1527,6 +1571,26 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"ucs origin",
|
"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(
|
"view_named_list": ToolAliases(
|
||||||
acad=("VIEW",),
|
acad=("VIEW",),
|
||||||
synonyms=("saved views", "existing named views", "list the views"),
|
synonyms=("saved views", "existing named views", "list the views"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 179,
|
"tools": 185,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 20
|
"groups": 20
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
"system": 7,
|
"system": 13,
|
||||||
"templates": 2,
|
"templates": 2,
|
||||||
"transactions": 3,
|
"transactions": 3,
|
||||||
"validation": 1,
|
"validation": 1,
|
||||||
@@ -2141,6 +2141,61 @@
|
|||||||
"readOnlyHint": true
|
"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",
|
"name": "system_run_command",
|
||||||
"group": "system",
|
"group": "system",
|
||||||
@@ -2346,6 +2401,32 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "validation_check",
|
||||||
"group": "validation",
|
"group": "validation",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Environment helpers (v1.6 track E): portable layer states, view and UCS
|
"""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
|
from .layer_states import ( # noqa: F401
|
||||||
DICT_NAME,
|
DICT_NAME,
|
||||||
@@ -12,5 +13,11 @@ from .layer_states import ( # noqa: F401
|
|||||||
validate_state_name,
|
validate_state_name,
|
||||||
)
|
)
|
||||||
from .names import validate_name # noqa: F401
|
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 .ucs import WORLD, resolve_ucs_axes # noqa: F401
|
||||||
from .views import resolve_view_args # noqa: F401
|
from .views import resolve_view_args # noqa: F401
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
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 ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -522,8 +522,12 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
`document_close` (multi-document on both engines) opened SECTION 20;
|
`document_close` (multi-document on both engines) opened SECTION 20;
|
||||||
`layers` moved 14 -> 18 with the four `layer_state_*` tools and `view`
|
`layers` moved 14 -> 18 with the four `layer_state_*` tools and `view`
|
||||||
4 -> 10 with `view_named_*` and `ucs_*` (UCS files under `view`, where
|
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
|
AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. `system`
|
||||||
other number here has been unchanged since the snapshot was taken.
|
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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
@@ -542,10 +546,10 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
"system": 7,
|
"system": 13,
|
||||||
"templates": 2,
|
"templates": 2,
|
||||||
"transactions": 3,
|
"transactions": 3,
|
||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 10,
|
"view": 10,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 179
|
assert sum(sizes.values()) == 185
|
||||||
|
|||||||
Reference in New Issue
Block a user