feat(server): TOOL_PACKS — advertise only the vertical packs a client uses

TOOL_PACKS=all (default) | comma list of core,pid. core is everything not
claimed by another pack and can never be dropped; unknown entries are
ignored with a warning. Pack hiding composes with TOOL_PROFILE=lean (the
lean set now carries pid_symbol_insert / pid_line_draw / pid_graph, 50
tools) and ENABLE_3D. system_about reports {enabled, available, ignored};
cad_batch's unknown_tool rejection names the TOOL_PACKS gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 12:55:38 +03:00
co-authored by Claude Opus 5
parent 42154e025b
commit e911a3a8fb
5 changed files with 177 additions and 7 deletions
+4
View File
@@ -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
+3 -2
View File
@@ -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.
<details>
<summary><b>All 16 environment variables</b></summary>
<summary><b>All 17 environment variables</b></summary>
| 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 |
+4
View File
@@ -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).
+79 -5
View File
@@ -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:
+87
View File
@@ -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"]