diff --git a/.env.example b/.env.example index 0eb77fb..022938c 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,10 @@ 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 +# vertical packs this client uses. core is always on. +TOOL_PACKS=all + # Tool discovery: off (default) | search # "search" replaces the advertised catalog with search_tools + call_tool, so a # client sees two tools instead of ~126 and searches by AutoCAD command name diff --git a/README.md b/README.md index 4721fa6..61adaec 100644 --- a/README.md +++ b/README.md @@ -189,13 +189,14 @@ A page that only lists strengths is a page that has not been measured. Nothing loads a `.env` file — export these, or set them in your MCP client's `env` block.
-All 16 environment variables +All 17 environment variables | Variable | Default | Purpose | |---|---|---| | `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` | | `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to | -| `TOOL_PROFILE` | `full` | `lean` (47 curated tools) or `full` | +| `TOOL_PROFILE` | `full` | `lean` (50 curated tools) or `full` | +| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid` (`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 31c9eef..9ef9bbb 100644 --- a/config.py +++ b/config.py @@ -102,6 +102,10 @@ class Settings: # server.REMOVED_TOOL_PROFILES). 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. + self.tool_packs: str = os.environ.get("TOOL_PACKS", "all").lower().strip() + # Tool discovery: "off" (default) advertises the full catalog; "search" # replaces it with a search tool plus a call_tool proxy (see # discovery/transform.py and server._apply_discovery_mode). diff --git a/server.py b/server.py index eaf966e..27a06b2 100644 --- a/server.py +++ b/server.py @@ -923,6 +923,13 @@ LEAN_TOOL_NAMES = frozenset( # lever is turn elimination (9.07x), so a lean surface without cad_batch # withholds the saving from the callers who need it most. "cad_batch", + # P&ID — the three tools a lean client needs to draw and read a P&ID + # (symbol insertion, line drawing, the reader). Still subject to + # TOOL_PACKS: lean intersects with the enabled packs, so + # TOOL_PACKS=core hides them from a lean surface too. + "pid_symbol_insert", + "pid_line_draw", + "pid_graph", } ) @@ -930,14 +937,64 @@ SOLID_TOOL_NAMES = frozenset( {"solid_box", "solid_cylinder", "solid_extrude", "solid_revolve", "solid_boolean"} ) +# 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") +PACK_TOOL_NAMES: dict[str, frozenset[str]] = { + "pid": frozenset( + { + "pid_symbol_list", + "pid_symbol_insert", + "pid_line_draw", + "pid_tag_parse", + "pid_graph", + "pid_instrument_index", + "pid_line_list", + "pid_equipment_list", + "pid_from_spec", + } + ), +} + _active_tool_profile: dict | None = None +def _enabled_packs() -> tuple[set[str], list[str]]: + """Resolve TOOL_PACKS into (enabled packs, ignored unknown entries). + + ``all`` (or an empty value) enables every pack; a comma list enables the + named packs on top of ``core``, which can never be dropped. Unknown names + are ignored with a warning rather than failing startup. + """ + raw = (config.settings.tool_packs or "all").lower() + requested = [p.strip() for p in raw.split(",") if p.strip()] + if not requested or "all" in requested: + return set(TOOL_PACK_NAMES), [] + enabled = {"core"} + ignored: list[str] = [] + for pack in requested: + if pack in TOOL_PACK_NAMES: + enabled.add(pack) + else: + ignored.append(pack) + log.warning( + "Unknown TOOL_PACKS entry %r ignored. Valid packs: %s.", + pack, + ", ".join(TOOL_PACK_NAMES), + ) + return enabled, ignored + + def _profile_enabled_names(profile: str, registered: set[str]) -> set[str]: + packs, _ignored = _enabled_packs() + hidden_by_pack: set[str] = set().union( + *(names for pack, names in PACK_TOOL_NAMES.items() if pack not in packs) + ) + enabled = set(registered) - hidden_by_pack if profile == "lean": - enabled = registered & LEAN_TOOL_NAMES - else: - enabled = set(registered) + enabled &= LEAN_TOOL_NAMES if not config.settings.enable_3d: # Capability-aware discovery: don't advertise opt-in 3D tools that # would only reject the call. @@ -984,6 +1041,12 @@ async def _apply_tool_profile(profile: str | None = None) -> dict: "disabled_count": len(disabled), "disabled_tools": disabled, } + packs, ignored = _enabled_packs() + _active_tool_profile["tool_packs"] = { + "enabled": sorted(packs), + "available": list(TOOL_PACK_NAMES), + "ignored": ignored, + } if requested != selected: # Surface the fallback where an MCP client can actually see it: a # warning on a STDIO server's stderr is routinely invisible. @@ -3454,7 +3517,7 @@ BATCH_ON_ERROR_MODES = ("stop", "continue", "rollback") BATCH_ERROR_KINDS = ( "denied", # on cad_batch's deny set; never executed "malformed_step", # not a {tool, args, bind} object - "unknown_tool", # not registered, or hidden by TOOL_PROFILE / ENABLE_3D + "unknown_tool", # not registered, or hidden by TOOL_PROFILE / TOOL_PACKS / ENABLE_3D "invalid_args", # rejected by the tool's own JSON Schema "unresolved_ref", # a $reference that no earlier step successfully bound "unsupported", # this backend cannot do it (carries `capability`) @@ -4134,7 +4197,7 @@ async def cad_batch( _invalid( "unknown_tool", f"{name!r} is not a callable tool here (unregistered, or hidden by " - "the active TOOL_PROFILE / ENABLE_3D gate).", + "the active TOOL_PROFILE / TOOL_PACKS / ENABLE_3D gate).", ) elif card.get("cost") == "escape": # The deny set is computed from the local registry; this repeats @@ -5083,6 +5146,16 @@ async def system_about(ctx: Context = None) -> dict: # dict omitted all engineering/premium/corner-ops tools + drawing_close and # misfiled entity_delete_many under entity_creation). tool_count = await _registered_tool_count() + tool_packs = (_active_tool_profile or {}).get("tool_packs") + if tool_packs is None: + # Not applied yet (system_about called outside the lifespan): resolve + # once rather than twice so an unknown pack warns once. + packs, ignored = _enabled_packs() + tool_packs = { + "enabled": sorted(packs), + "available": list(TOOL_PACK_NAMES), + "ignored": ignored, + } out = { "name": "AutoCAD MCP Pro", "version": __version__, @@ -5093,6 +5166,7 @@ async def system_about(ctx: Context = None) -> dict: "capabilities": b.capabilities().to_dict()["features"] if b else {}, "tool_profile": _active_tool_profile or {"profile": config.settings.tool_profile, "applied": False}, + "tool_packs": tool_packs, } # R20: omit total_tools when unknown rather than reporting a fake -1. if tool_count is not None: diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py new file mode 100644 index 0000000..d4edf19 --- /dev/null +++ b/tests/test_tool_packs.py @@ -0,0 +1,87 @@ +"""TOOL_PACKS: advertise only the vertical packs a client uses; core is always on.""" + +from __future__ import annotations + +import logging + +import pytest +import pytest_asyncio + +import config +import server + +pytestmark = pytest.mark.asyncio + +PID_TOOLS = { + "pid_symbol_list", + "pid_symbol_insert", + "pid_line_draw", + "pid_tag_parse", + "pid_graph", + "pid_instrument_index", + "pid_line_list", + "pid_equipment_list", + "pid_from_spec", +} + + +@pytest_asyncio.fixture(autouse=True) +async def _restore(monkeypatch): + yield + monkeypatch.setattr(config.settings, "tool_packs", "all") + await server._apply_tool_profile("full") + + +def test_pack_registry_names_real_tools_and_only_pid_ones(): + assert server.TOOL_PACK_NAMES == ("core", "pid") + assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS) + + +async def test_core_only_hides_every_pid_tool_but_keeps_block_define(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 "block_define" not in disabled and "entity_set_xdata" not in disabled + assert info["tool_packs"] == { + "enabled": ["core"], + "available": ["core", "pid"], + "ignored": [], + } + + +async def test_all_is_the_default_and_enables_pid(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"] + + +async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( + monkeypatch, caplog +): + monkeypatch.setattr(config.settings, "tool_packs", "pid,mech3000") + with caplog.at_level(logging.WARNING, logger="autocad_mcp"): + info = await server._apply_tool_profile("full") + assert info["tool_packs"] == { + "enabled": ["core", "pid"], + "available": ["core", "pid"], + "ignored": ["mech3000"], + } + assert "mech3000" in caplog.text + + +async def test_lean_intersects_with_packs(monkeypatch): + assert {"pid_symbol_insert", "pid_line_draw", "pid_graph"} <= server.LEAN_TOOL_NAMES + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("lean") + assert "pid_graph" in info["disabled_tools"] + assert "entity_create_line" not in info["disabled_tools"] + + +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"]