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:
co-authored by
Claude Opus 5
parent
42154e025b
commit
e911a3a8fb
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user