From 70afd578313d42acb63b03e9a5b4317228db5b2e Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 11:35:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(server):=20settings=20pack=20=E2=80=94=202?= =?UTF-8?q?2=20environment=20tools=20behind=20TOOL=5FPACKS,=20lean=20grows?= =?UTF-8?q?=20to=2055,=20three=20groups,=20golden=20set=20+10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 24 (merge sanity) on main after the five track E groups merged. - TOOL_PACK_NAMES = ("core", "pid", "settings"); PACK_TOOL_NAMES["settings"] is exactly the 22 SECTION 20 tools. Styles and page setup stay in core. - LEAN_TOOL_NAMES += page_setup_apply, batch_plot (53 -> 55). - _GROUP_TAG_PRIORITY / _GROUP_TAG_LABELS: "style" moved up next to "pid", "pagesetup" -> page_setup and "environment" -> environment added; the 28 SECTION 19/20 decorators gain their section tag, so _tool_groups() reports styles 10 / page_setup 6 / environment 22 (23 groups, 204 tools) and pack == group == section for the settings pack. - SECTION headers, backends/base.py composition, contracts/__init__.py exports and the layer_states feature entry were already correct after the merge (read and confirmed, nothing edited). - Pack text: config.py, .env.example, README config row; README line 204 tools in 23 groups; release snapshot 3259 collected tests (measured). - docs/tool-inventory.json regenerated; registry snapshot re-derived. - tests: test_tool_packs.py replaced (11 tests), test_capability_contract.py +3 (composition, the agreed capability flags, layer_states honesty), golden set +10 tuning cases (81 = 66 tuning + 15 holdout); all ten route in the top 3 without alias edits. Deviation from the plan text: TRACK_E_KEYS carries five keys, not six. The plan's "mleaderstyle" (ezdxf True / com False) contradicts spec §8.1, which retracted that key because the ActiveX route exists (§4.2); measured on the tree: capability_keys() == {boundary_trace, chspace, interactive_prompt, live_application, preferences, revcloud, wipeout}, and ComBackend.mleaderstyle_create is a native implementation (backends/com_backend.py:6752, is_capability_default -> False). The test now also asserts the key is absent. No new ActiveX call was written in this task, so no live probe was needed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- .env.example | 2 +- README.md | 6 +- config.py | 2 +- docs/tool-inventory.json | 106 ++++++++++++++--------- server.py | 102 +++++++++++++++------- tests/data/discovery_golden.py | 21 ++++- tests/test_capability_contract.py | 50 +++++++++++ tests/test_tool_packs.py | 136 +++++++++++++++++++++++++++--- tests/test_tool_registry.py | 57 +++++-------- 9 files changed, 356 insertions(+), 126 deletions(-) diff --git a/.env.example b/.env.example index 022938c..8e52a43 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,7 @@ DANGEROUS_COMMANDS_ENABLED=false # still boots, falling back to full with a warning. TOOL_PROFILE=full -# Tool packs: all (default) | comma list of core,pid — advertise only the +# Tool packs: all (default) | comma list of core,pid,settings — advertise only the # vertical packs this client uses. core is always on. TOOL_PACKS=all diff --git a/README.md b/README.md index 621f20e..41e3f2d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3259 collected tests. > 204 is the **registered** count; a default install advertises 199 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. @@ -115,7 +115,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -172 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. @@ -217,7 +217,7 @@ Nothing loads a `.env` file — export these, or set them in your MCP client's ` | `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` | | `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to | | `TOOL_PROFILE` | `full` | `lean` (50 curated tools) or `full` | -| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid` (`core` always on) | +| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid,settings` (`core` always on) | | `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` | | `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM) | | `LOG_LEVEL` | `INFO` | Python logging level | diff --git a/config.py b/config.py index 9ef9bbb..be7a235 100644 --- a/config.py +++ b/config.py @@ -103,7 +103,7 @@ class Settings: self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip() # Tool packs: "all" (default) advertises every vertical pack; a comma - # list ("core,pid") advertises only those. core cannot be disabled. + # list ("core,pid,settings") advertises only those. core cannot be disabled. self.tool_packs: str = os.environ.get("TOOL_PACKS", "all").lower().strip() # Tool discovery: "off" (default) advertises the full catalog; "search" diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 5706bcc..c5c16f1 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -6,7 +6,7 @@ "tools": 204, "resources": 8, "prompts": 5, - "groups": 21 + "groups": 23 }, "groups": { "analysis": 12, @@ -14,22 +14,24 @@ "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 16, + "drawing": 11, "engineering": 10, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, - "layers": 18, - "layouts": 16, + "environment": 22, + "layers": 14, + "layouts": 12, + "page_setup": 6, "pid": 9, "premium": 12, "solids": 5, "styles": 10, - "system": 14, - "templates": 4, + "system": 7, + "templates": 2, "transactions": 3, "validation": 1, - "view": 10 + "view": 4 }, "tools": [ { @@ -178,12 +180,13 @@ }, { "name": "batch_plot", - "group": "layouts", + "group": "page_setup", "cost": "mutate", "summary": "Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.", "tags": [ "export", "layout", + "pagesetup", "plot" ], "annotations": { @@ -524,11 +527,12 @@ }, { "name": "document_activate", - "group": "drawing", + "group": "environment", "cost": "safe", "summary": "Switch which open document every later tool call targets.", "tags": [ - "drawing" + "drawing", + "environment" ], "annotations": { "title": "Activate Document", @@ -538,11 +542,12 @@ }, { "name": "document_close", - "group": "drawing", + "group": "environment", "cost": "destructive", "summary": "Close one open document by name; never drops unsaved work unless told to.", "tags": [ - "drawing" + "drawing", + "environment" ], "annotations": { "title": "Close Document", @@ -551,11 +556,12 @@ }, { "name": "document_list", - "group": "drawing", + "group": "environment", "cost": "read", "summary": "List the open documents: which is active, which have unsaved changes.", "tags": [ - "drawing" + "drawing", + "environment" ], "annotations": { "title": "List Documents", @@ -759,11 +765,12 @@ }, { "name": "drawing_properties_get", - "group": "drawing", + "group": "environment", "cost": "read", "summary": "Read the drawing's title, subject, author, keywords, comments and custom properties.", "tags": [ "drawing", + "environment", "settings" ], "annotations": { @@ -773,11 +780,12 @@ }, { "name": "drawing_properties_set", - "group": "drawing", + "group": "environment", "cost": "safe", "summary": "Set the drawing's summary fields and add, change or delete custom properties.", "tags": [ "drawing", + "environment", "settings" ], "annotations": { @@ -867,11 +875,12 @@ }, { "name": "drawing_template_list", - "group": "templates", + "group": "page_setup", "cost": "read", "summary": "List the five bundled drawing templates: standard, sheet, layers, styles, page setup.", "tags": [ "drawing", + "pagesetup", "query", "template" ], @@ -882,11 +891,12 @@ }, { "name": "drawing_template_save", - "group": "templates", + "group": "page_setup", "cost": "mutate", "summary": "Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.", "tags": [ "drawing", + "pagesetup", "template" ], "annotations": { @@ -1756,10 +1766,11 @@ }, { "name": "layer_state_delete", - "group": "layers", + "group": "environment", "cost": "destructive", "summary": "Delete one saved layer state from the drawing.", "tags": [ + "environment", "layer" ], "annotations": { @@ -1769,10 +1780,11 @@ }, { "name": "layer_state_list", - "group": "layers", + "group": "environment", "cost": "read", "summary": "List the layer states saved in this drawing.", "tags": [ + "environment", "layer" ], "annotations": { @@ -1782,10 +1794,11 @@ }, { "name": "layer_state_restore", - "group": "layers", + "group": "environment", "cost": "safe", "summary": "Restore a saved layer state, all properties or a chosen subset.", "tags": [ + "environment", "layer" ], "annotations": { @@ -1796,10 +1809,11 @@ }, { "name": "layer_state_save", - "group": "layers", + "group": "environment", "cost": "safe", "summary": "Save the layer table (on/frozen/locked/colour/linetype/lineweight/plot + current) under a name, in the file.", "tags": [ + "environment", "layer" ], "annotations": { @@ -1984,11 +1998,12 @@ }, { "name": "page_setup_apply", - "group": "layouts", + "group": "page_setup", "cost": "safe", "summary": "Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).", "tags": [ "layout", + "pagesetup", "plot" ], "annotations": { @@ -1998,11 +2013,12 @@ }, { "name": "page_setup_list", - "group": "layouts", + "group": "page_setup", "cost": "read", "summary": "Read each sheet's page setup: paper, orientation, ctb, scale, device.", "tags": [ "layout", + "pagesetup", "plot", "query" ], @@ -2140,11 +2156,12 @@ }, { "name": "plot_style_list", - "group": "layouts", + "group": "page_setup", "cost": "read", "summary": "List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.", "tags": [ "layout", + "pagesetup", "plot", "query" ], @@ -2358,10 +2375,11 @@ }, { "name": "system_launch", - "group": "system", + "group": "environment", "cost": "safe", "summary": "Attach to the running AutoCAD or start it, optionally opening a file (live only).", "tags": [ + "environment", "system" ], "annotations": { @@ -2372,10 +2390,11 @@ }, { "name": "system_preferences_get", - "group": "system", + "group": "environment", "cost": "read", "summary": "Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).", "tags": [ + "environment", "system" ], "annotations": { @@ -2385,10 +2404,11 @@ }, { "name": "system_preferences_set", - "group": "system", + "group": "environment", "cost": "safe", "summary": "Change one whitelisted AutoCAD preference; reports old and new (live only).", "tags": [ + "environment", "system" ], "annotations": { @@ -2399,10 +2419,11 @@ }, { "name": "system_prompt_message", - "group": "system", + "group": "environment", "cost": "safe", "summary": "Print a message on AutoCAD's command line (live only).", "tags": [ + "environment", "system" ], "annotations": { @@ -2465,10 +2486,11 @@ }, { "name": "system_variable_describe", - "group": "system", + "group": "environment", "cost": "read", "summary": "Explain a system variable: type, range, default, where it is saved, which engine honours it.", "tags": [ + "environment", "settings", "system" ], @@ -2633,10 +2655,11 @@ }, { "name": "ucs_list", - "group": "view", + "group": "environment", "cost": "read", "summary": "List the user coordinate systems, with the implicit world one.", "tags": [ + "environment", "view" ], "annotations": { @@ -2646,10 +2669,11 @@ }, { "name": "ucs_restore", - "group": "view", + "group": "environment", "cost": "safe", "summary": "Make a saved UCS current, or `world` to reset to WCS.", "tags": [ + "environment", "view" ], "annotations": { @@ -2660,10 +2684,11 @@ }, { "name": "ucs_set", - "group": "view", + "group": "environment", "cost": "safe", "summary": "Define (or replace) a named UCS from an origin and two perpendicular axes, and make it current.", "tags": [ + "environment", "view" ], "annotations": { @@ -2674,10 +2699,11 @@ }, { "name": "user_pick_point", - "group": "system", + "group": "environment", "cost": "read", "summary": "Ask the operator to pick a point on screen (live only; blocks until they do).", "tags": [ + "environment", "system" ], "annotations": { @@ -2687,10 +2713,11 @@ }, { "name": "user_select", - "group": "system", + "group": "environment", "cost": "read", "summary": "Ask the operator to select one entity or a set on screen (live only).", "tags": [ + "environment", "system" ], "annotations": { @@ -2714,10 +2741,11 @@ }, { "name": "view_named_list", - "group": "view", + "group": "environment", "cost": "read", "summary": "List the named views saved in this drawing.", "tags": [ + "environment", "view" ], "annotations": { @@ -2727,10 +2755,11 @@ }, { "name": "view_named_restore", - "group": "view", + "group": "environment", "cost": "safe", "summary": "Restore a named view.", "tags": [ + "environment", "view" ], "annotations": { @@ -2741,10 +2770,11 @@ }, { "name": "view_named_save", - "group": "view", + "group": "environment", "cost": "safe", "summary": "Save a named view: a centre and height (and width) to come back to.", "tags": [ + "environment", "view" ], "annotations": { diff --git a/server.py b/server.py index 181576b..3635868 100644 --- a/server.py +++ b/server.py @@ -690,6 +690,9 @@ async def _registered_tool_count() -> int | None: # entity/drawing tags. Tools whose tags match none fall into "other". _GROUP_TAG_PRIORITY = ( "pid", + "style", + "pagesetup", + "environment", "engineering", "premium", "corner", @@ -703,7 +706,6 @@ _GROUP_TAG_PRIORITY = ( "validation", "analysis", "block", - "style", "layer", "linetype", "create", @@ -716,6 +718,9 @@ _GROUP_TAG_PRIORITY = ( # Map the winning tag to a human-readable group label for the breakdown. _GROUP_TAG_LABELS = { "pid": "pid", + "style": "styles", + "pagesetup": "page_setup", + "environment": "environment", "engineering": "engineering", "premium": "premium", "corner": "corner_ops", @@ -729,7 +734,6 @@ _GROUP_TAG_LABELS = { "analysis": "analysis", "validation": "validation", "block": "blocks", - "style": "styles", "layer": "layers", "linetype": "layers", "create": "entity_creation", @@ -938,6 +942,11 @@ LEAN_TOOL_NAMES = frozenset( "dimstyle_set_current", "textstyle_set_current", "drawing_apply_standard", + # Page setup (track E) — the two a lean client needs to set the + # sheet and plot it. Core-pack tools, so TOOL_PACKS=core leaves + # them on the lean surface; with S's three above, lean is 55. + "page_setup_apply", + "batch_plot", } ) @@ -948,8 +957,10 @@ SOLID_TOOL_NAMES = frozenset( # Tool packs: vertical domains a client can opt out of advertising. `core` is # everything not claimed by another pack and is always on. ~300 tools would # push the full catalog to ~80k idle tokens; a client that only drafts -# mechanically should not pay for the P&ID surface. -TOOL_PACK_NAMES = ("core", "pid") +# mechanically should not pay for the P&ID surface, and a client that never +# talks to a live seat should not pay for the environment surface. Styles and +# page setup (SECTION 18/19) are drafting essentials and stay in core. +TOOL_PACK_NAMES = ("core", "pid", "settings") PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "pid": frozenset( { @@ -964,6 +975,33 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "pid_from_spec", } ), + # SECTION 20 — the 22 environment tools (spec §7, §8.2). + "settings": frozenset( + { + "document_list", + "document_activate", + "document_close", + "layer_state_save", + "layer_state_restore", + "layer_state_list", + "layer_state_delete", + "view_named_save", + "view_named_restore", + "view_named_list", + "ucs_list", + "ucs_set", + "ucs_restore", + "system_launch", + "system_preferences_get", + "system_preferences_set", + "drawing_properties_get", + "drawing_properties_set", + "system_variable_describe", + "user_pick_point", + "user_select", + "system_prompt_message", + } + ), } _active_tool_profile: dict | None = None @@ -7283,7 +7321,7 @@ async def drawing_apply_standard( ) @mcp.tool( annotations={"title": "List Page Setups", "readOnlyHint": True}, - tags={"layout", "plot", "query"}, + tags={"pagesetup", "layout", "plot", "query"}, ) async def page_setup_list( layout: Annotated[ @@ -7314,7 +7352,7 @@ async def page_setup_list( ) @mcp.tool( annotations={"title": "Apply Page Setup", "destructiveHint": False}, - tags={"layout", "plot"}, + tags={"pagesetup", "layout", "plot"}, ) async def page_setup_apply( layout: Annotated[str, "Paper-space layout to set up (never 'Model')."], @@ -7369,7 +7407,7 @@ async def page_setup_apply( ) @mcp.tool( annotations={"title": "List Plot Styles", "readOnlyHint": True}, - tags={"layout", "plot", "query"}, + tags={"pagesetup", "layout", "plot", "query"}, ) async def plot_style_list(ctx: Context = None) -> dict: """The ctb files AutoCAD ships (`monochrome.ctb`, `acad.ctb`, `Grayscale.ctb`, @@ -7390,7 +7428,7 @@ async def plot_style_list(ctx: Context = None) -> dict: ) @mcp.tool( annotations={"title": "Batch Plot", "destructiveHint": False}, - tags={"layout", "plot", "export"}, + tags={"pagesetup", "layout", "plot", "export"}, ) async def batch_plot( output_dir: Annotated[str, "Folder for the PDFs (created if missing)."], @@ -7437,7 +7475,7 @@ async def batch_plot( ) @mcp.tool( annotations={"title": "List Drawing Templates", "readOnlyHint": True}, - tags={"template", "drawing", "query"}, + tags={"pagesetup", "template", "drawing", "query"}, ) async def drawing_template_list(ctx: Context = None) -> dict: """The templates `drawing_new(template=)` can start from. @@ -7468,7 +7506,7 @@ async def drawing_template_list(ctx: Context = None) -> dict: ) @mcp.tool( annotations={"title": "Save As Template", "destructiveHint": False}, - tags={"template", "drawing"}, + tags={"pagesetup", "template", "drawing"}, ) async def drawing_template_save( path: Annotated[str, "Destination .dwt (live AutoCAD) or .dxf (either engine)."], @@ -7511,7 +7549,7 @@ async def drawing_template_save( ) @mcp.tool( annotations={"title": "List Documents", "readOnlyHint": True}, - tags={"drawing"}, + tags={"environment", "drawing"}, ) async def document_list(ctx: Context = None) -> dict: """Every open document with `name`, `path`, `active`, `saved` and @@ -7534,7 +7572,7 @@ async def document_list(ctx: Context = None) -> dict: @cad_tool(summary="Switch which open document every later tool call targets.", cost="safe") @mcp.tool( annotations={"title": "Activate Document", "readOnlyHint": False, "destructiveHint": False}, - tags={"drawing"}, + tags={"environment", "drawing"}, ) async def document_activate( name_or_path: Annotated[ @@ -7560,7 +7598,7 @@ async def document_activate( ) @mcp.tool( annotations={"title": "Close Document", "destructiveHint": True}, - tags={"drawing"}, + tags={"environment", "drawing"}, ) async def document_close( name_or_path: Annotated[ @@ -7592,7 +7630,7 @@ async def document_close( ) @mcp.tool( annotations={"title": "Save Layer State", "readOnlyHint": False, "destructiveHint": False}, - tags={"layer"}, + tags={"environment", "layer"}, ) async def layer_state_save( name: Annotated[str, "State name, e.g. PLOT-SET or DESIGN"], @@ -7616,7 +7654,7 @@ async def layer_state_save( @cad_tool(summary="Restore a saved layer state, all properties or a chosen subset.", cost="safe") @mcp.tool( annotations={"title": "Restore Layer State", "readOnlyHint": False, "destructiveHint": False}, - tags={"layer"}, + tags={"environment", "layer"}, ) async def layer_state_restore( name: Annotated[str, "A name from layer_state_list"], @@ -7646,7 +7684,7 @@ async def layer_state_restore( @cad_tool(summary="List the layer states saved in this drawing.", cost="read") @mcp.tool( annotations={"title": "List Layer States", "readOnlyHint": True}, - tags={"layer"}, + tags={"environment", "layer"}, ) async def layer_state_list(ctx: Context = None) -> dict: """Every state under `ACADMCP_LAYERSTATES` with its description and layer @@ -7659,7 +7697,7 @@ async def layer_state_list(ctx: Context = None) -> dict: @cad_tool(summary="Delete one saved layer state from the drawing.", cost="destructive") @mcp.tool( annotations={"title": "Delete Layer State", "destructiveHint": True}, - tags={"layer"}, + tags={"environment", "layer"}, ) async def layer_state_delete( name: Annotated[str, "A name from layer_state_list"], @@ -7676,7 +7714,7 @@ async def layer_state_delete( ) @mcp.tool( annotations={"title": "Save Named View", "readOnlyHint": False, "destructiveHint": False}, - tags={"view"}, + tags={"environment", "view"}, ) async def view_named_save( name: Annotated[str, "View name, e.g. DETAIL-A"], @@ -7701,7 +7739,7 @@ async def view_named_save( @cad_tool(summary="Restore a named view.", cost="safe") @mcp.tool( annotations={"title": "Restore Named View", "readOnlyHint": False, "destructiveHint": False}, - tags={"view"}, + tags={"environment", "view"}, ) async def view_named_restore( name: Annotated[str, "A name from view_named_list"], @@ -7725,7 +7763,7 @@ async def view_named_restore( @cad_tool(summary="List the named views saved in this drawing.", cost="read") @mcp.tool( annotations={"title": "List Named Views", "readOnlyHint": True}, - tags={"view"}, + tags={"environment", "view"}, ) async def view_named_list(ctx: Context = None) -> dict: """Every VIEW table entry with centre, height and width. No refusals. @@ -7737,7 +7775,7 @@ async def view_named_list(ctx: Context = None) -> dict: @cad_tool(summary="List the user coordinate systems, with the implicit world one.", cost="read") @mcp.tool( annotations={"title": "List UCS", "readOnlyHint": True}, - tags={"view"}, + tags={"environment", "view"}, ) async def ucs_list(ctx: Context = None) -> dict: """The `world` row first, then every UCS table entry with origin and unit @@ -7763,7 +7801,7 @@ async def ucs_list(ctx: Context = None) -> dict: ) @mcp.tool( annotations={"title": "Set UCS", "readOnlyHint": False, "destructiveHint": False}, - tags={"view"}, + tags={"environment", "view"}, ) async def ucs_set( name: Annotated[str, "UCS name"], @@ -7788,7 +7826,7 @@ async def ucs_set( @cad_tool(summary="Make a saved UCS current, or `world` to reset to WCS.", cost="safe") @mcp.tool( annotations={"title": "Restore UCS", "readOnlyHint": False, "destructiveHint": False}, - tags={"view"}, + tags={"environment", "view"}, ) async def ucs_restore( name: Annotated[str, "A name from ucs_list, or `world`"], @@ -7812,7 +7850,7 @@ async def ucs_restore( "readOnlyHint": False, "destructiveHint": False, }, - tags={"system"}, + tags={"environment", "system"}, ) async def system_launch( visible: Annotated[bool, "Show the application window"] = True, @@ -7837,7 +7875,7 @@ async def system_launch( ) @mcp.tool( annotations={"title": "Get Preferences", "readOnlyHint": True}, - tags={"system"}, + tags={"environment", "system"}, ) async def system_preferences_get( keys: Annotated[ @@ -7865,7 +7903,7 @@ async def system_preferences_get( ) @mcp.tool( annotations={"title": "Set Preference", "readOnlyHint": False, "destructiveHint": False}, - tags={"system"}, + tags={"environment", "system"}, ) async def system_preferences_set( key: Annotated[str, "A writable key from system_preferences_get"], @@ -7890,7 +7928,7 @@ async def system_preferences_set( ) @mcp.tool( annotations={"title": "Ask Operator: Pick Point", "readOnlyHint": True}, - tags={"system"}, + tags={"environment", "system"}, ) async def user_pick_point( prompt: Annotated[str, "Shown on the command line, e.g. 'Pick the base point'"], @@ -7914,7 +7952,7 @@ async def user_pick_point( ) @mcp.tool( annotations={"title": "Ask Operator: Select", "readOnlyHint": True}, - tags={"system"}, + tags={"environment", "system"}, ) async def user_select( prompt: Annotated[str, "Shown on the command line"], @@ -7937,7 +7975,7 @@ async def user_select( @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"}, + tags={"environment", "system"}, ) async def system_prompt_message( text: Annotated[str, "The message; one line is best"], @@ -7955,7 +7993,7 @@ async def system_prompt_message( ) @mcp.tool( annotations={"title": "Describe System Variable", "readOnlyHint": True}, - tags={"system", "settings"}, + tags={"environment", "system", "settings"}, ) async def system_variable_describe( name: Annotated[ @@ -8006,7 +8044,7 @@ async def system_variable_describe( ) @mcp.tool( annotations={"title": "Drawing Properties (read)", "readOnlyHint": True}, - tags={"drawing", "settings"}, + tags={"environment", "drawing", "settings"}, ) async def drawing_properties_get(ctx: Context = None) -> dict: """The DWGPROPS dialog as data: `summary` (title, subject, author, keywords, @@ -8027,7 +8065,7 @@ async def drawing_properties_get(ctx: Context = None) -> dict: ) @mcp.tool( annotations={"title": "Drawing Properties (write)", "readOnlyHint": False}, - tags={"drawing", "settings"}, + tags={"environment", "drawing", "settings"}, ) async def drawing_properties_set( title: Annotated[str | None, "SummaryInfo Title"] = None, diff --git a/tests/data/discovery_golden.py b/tests/data/discovery_golden.py index fb20aaa..3556bd1 100644 --- a/tests/data/discovery_golden.py +++ b/tests/data/discovery_golden.py @@ -1,6 +1,6 @@ """Golden query set for tool discovery: English in, one right tool out. -Seventy-one cases (56 tuning + 15 holdout) split into a **tuning** set and a +Eighty-one cases (66 tuning + 15 holdout) split into a **tuning** set and a **holdout** set. The split is the point of the file. Ranking work is measured against :data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time, @@ -155,6 +155,25 @@ TUNING_CASES: tuple[GoldenCase, ...] = ( ), GoldenCase("create a text style that uses the isocp font", "textstyle_create", "paraphrase"), GoldenCase("which dimension styles are defined", "dimstyle_list", "counting", risk="read"), + # v1.6 track E — styles, page setup, templates, environment. Written + # before the alias records were measured against them; a miss here is + # fixed by adding vocabulary to the tool's record in discovery/aliases.py, + # never by moving the expectation. + GoldenCase("PAGESETUP", "page_setup_apply", "command"), + GoldenCase("PUBLISH", "batch_plot", "command"), + GoldenCase("MLEADERSTYLE", "mleaderstyle_create", "command"), + GoldenCase("STYLE", "textstyle_create", "command"), + GoldenCase("iso 25 dimension style", "dimstyle_create", "synonym"), + GoldenCase("layer snapshot", "layer_state_save", "synonym"), + GoldenCase("save this drawing as a template", "drawing_template_save", "paraphrase"), + GoldenCase("switch to the other open drawing", "document_activate", "paraphrase"), + GoldenCase("ask the operator to click a point on screen", "user_pick_point", "paraphrase"), + GoldenCase( + "what does the LTSCALE variable mean", + "system_variable_describe", + "counting", + risk="read", + ), ) diff --git a/tests/test_capability_contract.py b/tests/test_capability_contract.py index 8d9b584..0fe8d7e 100644 --- a/tests/test_capability_contract.py +++ b/tests/test_capability_contract.py @@ -32,6 +32,9 @@ import pytest from backends.base import AutoCADBackend, UnsupportedCapabilityError from backends.capability import capability_keys, is_capability_default from backends.com_backend import ComBackend +from backends.contracts.environment import EnvironmentContract +from backends.contracts.pagesetup import PageSetupContract +from backends.contracts.styles import StylesContract from backends.ezdxf_backend import EzdxfBackend # Marked per-test rather than module-wide: half of these are plain registry @@ -116,3 +119,50 @@ async def test_an_implementing_backend_is_unaffected(backend): result = await backend.entity_change_space([], "nope") assert result["ok"] is False assert "error" in result, "the real implementation still does its own validation" + + +# ── track E: composition and the five agreed keys ─────────────────────────── + +#: spec §8.1 — the flag each engine must report. `mleaderstyle` is deliberately +#: absent: the spec retracted it (the ActiveX route through the +#: ``ACAD_MLEADERSTYLE`` dictionary exists, §4.2), and both engines implement +#: ``mleaderstyle_create`` natively — `capability_keys()` never registers it. +TRACK_E_KEYS = { + "dwgprops": {"ezdxf": False, "com": True}, + "dwt_write": {"ezdxf": False, "com": True}, + "live_application": {"ezdxf": False, "com": True}, + "preferences": {"ezdxf": False, "com": True}, + "interactive_prompt": {"ezdxf": False, "com": True}, +} + + +def _feature_keys_full(backend) -> dict: + return backend.capabilities().to_dict()["features"] + + +def test_track_e_contracts_are_composed_into_the_backend(): + for contract in (StylesContract, PageSetupContract, EnvironmentContract): + assert issubclass(AutoCADBackend, contract), f"{contract.__name__} not in AutoCADBackend" + assert issubclass(EzdxfBackend, contract) and issubclass(ComBackend, contract) + + +def test_track_e_capability_keys_carry_the_agreed_flags(): + assert "mleaderstyle" not in capability_keys(), "spec §8.1: no such engine boundary" + ezdxf_features = _feature_keys_full(EzdxfBackend()) + com_features = _feature_keys_full(ComBackend()) + for key, flags in TRACK_E_KEYS.items(): + assert key in ezdxf_features, f"{key} undeclared on ezdxf" + assert key in com_features, f"{key} undeclared on com" + assert ezdxf_features[key]["supported"] is flags["ezdxf"], key + assert com_features[key]["supported"] is flags["com"], key + + +def test_layer_states_are_declared_as_ours_not_autocads(): + """Spec §7.2 / §8.3: the states live in the file and travel with it, and do + not appear in AutoCAD's Layer States Manager. Said in the map, not only in + a docstring.""" + for backend in (EzdxfBackend(), ComBackend()): + feature = _feature_keys_full(backend)["layer_states"] + assert feature["supported"] is True + assert feature["mode"] == "xrecord" + assert "layer_states_manager" in (feature["reason"] or ""), backend.name diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py index d4edf19..fc370c2 100644 --- a/tests/test_tool_packs.py +++ b/tests/test_tool_packs.py @@ -1,4 +1,11 @@ -"""TOOL_PACKS: advertise only the vertical packs a client uses; core is always on.""" +# tests/test_tool_packs.py +"""TOOL_PACKS: advertise only the vertical packs a client uses; core is always on. + +v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools. +Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting +essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment +surface and nothing else. +""" from __future__ import annotations @@ -24,6 +31,63 @@ PID_TOOLS = { "pid_from_spec", } +STYLE_TOOLS = { + "dimstyle_list", + "dimstyle_create", + "dimstyle_modify", + "dimstyle_set_current", + "textstyle_list", + "textstyle_create", + "textstyle_set_current", + "mleaderstyle_list", + "mleaderstyle_create", + "drawing_apply_standard", +} + +PAGE_SETUP_TOOLS = { + "page_setup_list", + "page_setup_apply", + "plot_style_list", + "batch_plot", + "drawing_template_list", + "drawing_template_save", +} + +SETTINGS_TOOLS = { + "document_list", + "document_activate", + "document_close", + "layer_state_save", + "layer_state_restore", + "layer_state_list", + "layer_state_delete", + "view_named_save", + "view_named_restore", + "view_named_list", + "ucs_list", + "ucs_set", + "ucs_restore", + "system_launch", + "system_preferences_get", + "system_preferences_set", + "drawing_properties_get", + "drawing_properties_set", + "system_variable_describe", + "user_pick_point", + "user_select", + "system_prompt_message", +} + +LEAN_SETTINGS_ESSENTIALS = { + "drawing_apply_standard", + "page_setup_apply", + "batch_plot", + "dimstyle_set_current", + "textstyle_set_current", +} + +ALL_PACKS = ["core", "pid", "settings"] + @pytest_asyncio.fixture(autouse=True) async def _restore(monkeypatch): @@ -32,29 +96,48 @@ async def _restore(monkeypatch): await server._apply_tool_profile("full") -def test_pack_registry_names_real_tools_and_only_pid_ones(): - assert server.TOOL_PACK_NAMES == ("core", "pid") +def test_pack_registry_names_real_tools_and_only_them(): + assert server.TOOL_PACK_NAMES == ("core", "pid", "settings") assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS) + assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS) + assert len(SETTINGS_TOOLS) == 22 + assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"]) -async def test_core_only_hides_every_pid_tool_but_keeps_block_define(monkeypatch): +async def test_every_pack_member_is_a_registered_tool(): + registered = {tool.name for tool in await server._registered_tools()} + for pack, names in server.PACK_TOOL_NAMES.items(): + missing = names - registered + assert not missing, f"pack {pack!r} names unregistered tools: {sorted(missing)}" + + +async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(monkeypatch): monkeypatch.setattr(config.settings, "tool_packs", "core") info = await server._apply_tool_profile("full") disabled = set(info["disabled_tools"]) assert PID_TOOLS <= disabled + assert SETTINGS_TOOLS <= disabled + assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials" + assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core" assert "block_define" not in disabled and "entity_set_xdata" not in disabled - assert info["tool_packs"] == { - "enabled": ["core"], - "available": ["core", "pid"], - "ignored": [], - } + assert info["tool_packs"] == {"enabled": ["core"], "available": ALL_PACKS, "ignored": []} -async def test_all_is_the_default_and_enables_pid(monkeypatch): +async def test_settings_pack_alone_keeps_pid_hidden(monkeypatch): + monkeypatch.setattr(config.settings, "tool_packs", "settings") + info = await server._apply_tool_profile("full") + disabled = set(info["disabled_tools"]) + assert PID_TOOLS <= disabled + assert not (SETTINGS_TOOLS & disabled) + assert info["tool_packs"]["enabled"] == ["core", "settings"] + + +async def test_all_is_the_default_and_enables_every_pack(monkeypatch): monkeypatch.setattr(config.settings, "tool_packs", "all") info = await server._apply_tool_profile("full") - assert not (PID_TOOLS & set(info["disabled_tools"])) - assert info["tool_packs"]["enabled"] == ["core", "pid"] + disabled = set(info["disabled_tools"]) + assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled) + assert info["tool_packs"]["enabled"] == ALL_PACKS async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( @@ -65,7 +148,7 @@ async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped info = await server._apply_tool_profile("full") assert info["tool_packs"] == { "enabled": ["core", "pid"], - "available": ["core", "pid"], + "available": ALL_PACKS, "ignored": ["mech3000"], } assert "mech3000" in caplog.text @@ -79,9 +162,34 @@ async def test_lean_intersects_with_packs(monkeypatch): assert "entity_create_line" not in info["disabled_tools"] +async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack( + monkeypatch, +): + """Spec §8.2: lean = 55. The five are core-pack tools, so `TOOL_PACKS=core` + leaves them on a lean surface; no environment tool is lean.""" + assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES + assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES) + assert len(server.LEAN_TOOL_NAMES) == 55 + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("lean") + disabled = set(info["disabled_tools"]) + assert not (LEAN_SETTINGS_ESSENTIALS & disabled) + assert info["enabled_count"] == 55 - 3, "lean minus the three pid tools" + + +async def test_the_three_track_e_sections_file_under_their_own_groups(): + """Pack, group and SECTION are the same list, so `system_about` and + `docs/tool-inventory.json` describe the surface the packs gate.""" + groups = await server._tool_groups() + assert set(groups["styles"]) == STYLE_TOOLS + assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS + assert set(groups["environment"]) == SETTINGS_TOOLS + assert len(groups) == 23 + + async def test_system_about_reports_packs(): from fastmcp import Client async with Client(server.mcp) as client: about = (await client.call_tool("system_about", {})).structured_content - assert about["tool_packs"]["available"] == ["core", "pid"] + assert about["tool_packs"]["available"] == ALL_PACKS diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index e6c5243..4b55428 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (172 tools, 20 groups). + """Frozen snapshot of the surface (204 tools, 23 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -517,35 +517,18 @@ async def test_tool_group_sizes_are_unchanged(): when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool) joined; the `pid` tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under - `pid` rather than under their secondary `query` / `create` tags. `styles` - appeared (0 -> 9) when track E's SECTION 18 opened with the dimension, - text and multileader style tools, then 9 -> 10 when - `drawing_apply_standard` (ISO or ANSI styles, units and layers in one - call) joined; the `style` tag sits ahead of `layer` and `drawing` in - `_GROUP_TAG_PRIORITY` so they never file under their secondary - `query` / `create` / `modify` / `drawing` tags. `system` - moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue - as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when - v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on - both engines) joined SECTION 20 — tagged `drawing` and `settings`, and - `settings` is not a group tag, so they file under drawing. - `layouts` moved 12 -> 16 when v1.6's SECTION 19 opened with - `page_setup_list` / `page_setup_apply` / `plot_style_list` / `batch_plot` - — all tagged `layout`, which `_GROUP_TAG_PRIORITY` ranks above - `query`/`export`, so they file with the sheets they set up. `templates` - moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19 - (tagged `template`, which ranks above `drawing`), then 3 -> 4 when - `drawing_template_save` joined. `drawing` - moved 13 -> 16 when v1.6's `document_list` / `document_activate` / - `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. `system` - moved 8 -> 14 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. + `pid` rather than under their secondary `query` / `create` tags. + Three groups appeared with v1.6's track E — `styles` (10, SECTION 18), + `page_setup` (6, SECTION 19) and `environment` (22, SECTION 20) — each + tagged with its section name, ranked directly after `pid` in + `_GROUP_TAG_PRIORITY` so a style tool that also carries `dimension` or an + environment tool that also carries `layer` files under its section; the + `environment` group is exactly `PACK_TOOL_NAMES["settings"]`. (Until the + merge task tagged them, the four groups had filed those 28 tools under + `layouts` / `templates` / `drawing` / `layers` / `view` / `system` by + their secondary tags; those counts returned to their pre-track-E values + when the section tags landed.) 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 == { @@ -554,21 +537,23 @@ async def test_tool_group_sizes_are_unchanged(): "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 16, + "drawing": 11, "engineering": 10, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, - "layers": 18, - "layouts": 16, + "environment": 22, + "layers": 14, + "layouts": 12, + "page_setup": 6, "pid": 9, "premium": 12, "solids": 5, "styles": 10, - "system": 14, - "templates": 4, + "system": 7, + "templates": 2, "transactions": 3, "validation": 1, - "view": 10, + "view": 4, } assert sum(sizes.values()) == 204