feat(server): tool profiles, paper-space layouts + viewports, ISO 286 fits, opt-in COM 3D solids

- TOOL_PROFILE=lean|core|full applied in the lifespan via server-level enable/disable; system_about reports the active profile; opt-in solid_* tools are hidden while ENABLE_3D=false
- layouts: layout_list/create/set_current + viewport_create (scaled model viewports) on both backends; drawing_export_pdf(layout=...); ezdxf uses TILEMODE + active-layout binding, COM switches ActiveLayout and restores it
- ISO 286: engineering/fits.py authored IT4-11 + fundamental-deviation tables (1-500 mm; shafts d..p/h/js, holes D..H/JS) with clear out-of-scope errors; dimension_linear/radius/diameter gained fit= resolved from the measured nominal
- tolerances: DIMTM now signed in deviation/limit modes so double-positive fits (p6 +0.035/+0.022) render correctly; legacy positive-magnitude contract preserved
- solids: solid_box/cylinder/extrude/revolve/boolean native on COM (AddBox/AddCylinder/AddRegion+AddExtrudedSolid/AddRevolvedSolid/Boolean); ezdxf reports the honest solid_3d capability boundary
- capability maps: paper_space native, new viewport_render feature, ENABLE_3D-gated solid_3d
- 60 new tests (profiles, layouts, viewports, layout PDF, fits, gating); version pins in tests now read version.__version__

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Umutcan Edizsalan
2026-07-23 15:15:45 +03:00
co-authored by Cursor
parent 6d7c39e012
commit 3cbe30df54
12 changed files with 3007 additions and 595 deletions
+429 -146
View File
File diff suppressed because it is too large Load Diff
+582 -124
View File
File diff suppressed because it is too large Load Diff
+570 -125
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -43,5 +43,16 @@ class Settings:
)
self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip()
# Exposed tool profile: "full" (default, everything), "core" (hides the
# raw escape hatches and long-tail tools) or "lean" (a curated ~46-tool
# drafting/inspection core for clients with tight tool caps). Invalid
# values fall back to "full" with a warning at startup.
self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip()
# Opt-in 3D solids (COM backend only). Disabled by default because the
# headless backend cannot produce ACIS solids and live 3D modifies real
# documents; enable deliberately.
self.enable_3d: bool = os.environ.get("ENABLE_3D", "false").lower().strip() == "true"
settings = Settings()
+181
View File
@@ -0,0 +1,181 @@
"""ISO 286-1/2 limits-and-fits lookup (authored table data, not formulas).
`fit_lookup("H7", 20.0)` resolves a fit code into upper/lower deviations in
millimetres, ready to feed `tolerances.build_dim_override` (tol_mode
"deviation"). Values are the published ISO 286 table values — the standard's
rounding rules make naive formula evaluation drift by 1 µm or a full step in
places, so the tables are authored and pinned by tests instead.
Scope (deliberate, documented):
- Nominal sizes over 1 mm up to and including 500 mm.
- IT grades 4-11 (IT4 is carried for the shaft-k rule and future hole mirrors).
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p.
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS).
- Transition/interference HOLE letters (K, M, N, P) require the ISO 286
delta-rule and sub-stepped r/s tables; they are intentionally excluded and
raise a ValueError naming the supported set.
This covers the preferred general-mechanical fits (H7/g6, H7/h6, H7/k6,
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Size steps: (over, up_to_incl) in mm — ISO 286-1 main steps, 1..500 mm.
_SIZE_STEPS: tuple[tuple[float, float], ...] = (
(1, 3),
(3, 6),
(6, 10),
(10, 18),
(18, 30),
(30, 50),
(50, 80),
(80, 120),
(120, 180),
(180, 250),
(250, 315),
(315, 400),
(400, 500),
)
# Standard tolerance values IT4..IT11 in µm per size step (ISO 286-1 Table 1).
_IT_GRADES = (4, 5, 6, 7, 8, 9, 10, 11)
_IT_TABLE: tuple[tuple[int, ...], ...] = (
# IT4 IT5 IT6 IT7 IT8 IT9 IT10 IT11
(3, 4, 6, 10, 14, 25, 40, 60), # >1-3
(4, 5, 8, 12, 18, 30, 48, 75), # >3-6
(4, 6, 9, 15, 22, 36, 58, 90), # >6-10
(5, 8, 11, 18, 27, 43, 70, 110), # >10-18
(6, 9, 13, 21, 33, 52, 84, 130), # >18-30
(7, 11, 16, 25, 39, 62, 100, 160), # >30-50
(8, 13, 19, 30, 46, 74, 120, 190), # >50-80
(10, 15, 22, 35, 54, 87, 140, 220), # >80-120
(12, 18, 25, 40, 63, 100, 160, 250), # >120-180
(14, 20, 29, 46, 72, 115, 185, 290), # >180-250
(16, 23, 32, 52, 81, 130, 210, 320), # >250-315
(18, 25, 36, 57, 89, 140, 230, 360), # >315-400
(20, 27, 40, 63, 97, 155, 250, 400), # >400-500
)
# Fundamental deviations for shafts in µm per size step (ISO 286-1 Table 2/3).
# d..g carry the upper deviation es (negative); k..p carry the lower
# deviation ei (positive). h is es=0; js is symmetric (±IT/2).
_SHAFT_ES: dict[str, tuple[int, ...]] = {
"d": (-20, -30, -40, -50, -65, -80, -100, -120, -145, -170, -190, -210, -230),
"e": (-14, -20, -25, -32, -40, -50, -60, -72, -85, -100, -110, -125, -135),
"f": (-6, -10, -13, -16, -20, -25, -30, -36, -43, -50, -56, -62, -68),
"g": (-2, -4, -5, -6, -7, -9, -10, -12, -14, -15, -17, -18, -20),
}
_SHAFT_EI: dict[str, tuple[int, ...]] = {
# k applies to IT4..IT7 only (other grades have ei = 0 per the standard).
"k": (1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 4, 4, 5),
"m": (2, 4, 6, 7, 8, 9, 11, 13, 15, 17, 20, 21, 23),
"n": (4, 8, 10, 12, 15, 17, 20, 23, 27, 31, 34, 37, 40),
"p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68),
}
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p")
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS")
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$")
@dataclass(frozen=True)
class FitDeviation:
"""Resolved fit: deviations in mm relative to the nominal size."""
code: str
nominal: float
upper_mm: float
lower_mm: float
it_grade: int
it_value_mm: float
def to_dict(self) -> dict:
return {
"code": self.code,
"nominal": self.nominal,
"upper_mm": self.upper_mm,
"lower_mm": self.lower_mm,
"it_grade": self.it_grade,
"it_value_mm": self.it_value_mm,
}
def parse_fit_code(code: str) -> tuple[str, int]:
"""Split e.g. 'H7' -> ('H', 7) or 'js6' -> ('js', 6)."""
match = _FIT_CODE_RE.match(code or "")
if not match:
raise ValueError(
f"Invalid fit code {code!r}. Expected letter + IT grade, e.g. H7, g6, js9."
)
return match.group(1), int(match.group(2))
def _size_index(nominal_mm: float) -> int:
if not 1.0 < nominal_mm <= 500.0:
raise ValueError(
f"Nominal size {nominal_mm} mm is outside the authored ISO 286 range "
"(over 1 mm up to and including 500 mm)."
)
for index, (over, up_to) in enumerate(_SIZE_STEPS):
if over < nominal_mm <= up_to:
return index
raise ValueError(f"No ISO 286 size step covers {nominal_mm} mm") # pragma: no cover
def _it_um(grade: int, size_index: int) -> int:
if grade not in _IT_GRADES:
raise ValueError(f"IT grade {grade} is outside the authored range IT4-IT11.")
return _IT_TABLE[size_index][_IT_GRADES.index(grade)]
def fit_lookup(code: str, nominal_mm: float) -> FitDeviation:
"""Resolve a fit code for a nominal size into mm deviations.
Holes use uppercase letters, shafts lowercase (ISO 286 convention).
Raises ValueError for codes outside the authored scope.
"""
letter, grade = parse_fit_code(code)
index = _size_index(float(nominal_mm))
it_um = _it_um(grade, index)
if letter in ("js", "JS"):
upper_um = it_um / 2.0
lower_um = -it_um / 2.0
elif letter == "h":
upper_um, lower_um = 0.0, float(-it_um)
elif letter == "H":
upper_um, lower_um = float(it_um), 0.0
elif letter in _SHAFT_ES: # d, e, f, g — clearance shafts
es = float(_SHAFT_ES[letter][index])
upper_um, lower_um = es, es - it_um
elif letter in ("D", "E", "F", "G"): # mirrored clearance holes: EI = -es
ei = float(-_SHAFT_ES[letter.lower()][index])
upper_um, lower_um = ei + it_um, ei
elif letter in _SHAFT_EI: # k, m, n, p — transition/interference shafts
if letter == "k" and not 4 <= grade <= 7:
ei = 0.0 # ISO 286: k has ei = 0 outside IT4-IT7
else:
ei = float(_SHAFT_EI[letter][index])
upper_um, lower_um = ei + it_um, ei
else:
raise ValueError(
f"Fit letter {letter!r} is outside the authored scope. Supported: "
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. "
"Transition/interference hole letters (K, M, N, P) need the ISO 286 "
"delta-rule and are not authored yet."
)
return FitDeviation(
code=f"{letter}{grade}",
nominal=float(nominal_mm),
upper_mm=round(upper_um / 1000.0, 6),
lower_mm=round(lower_um / 1000.0, 6),
it_grade=grade,
it_value_mm=round(it_um / 1000.0, 6),
)
+7 -2
View File
@@ -50,12 +50,17 @@ def build_dim_override(
elif mode == "deviation":
if tol_upper is None or tol_lower is None:
raise ValueError("tol_mode='deviation' requires tol_upper and tol_lower.")
# DIMTM convention: the displayed lower deviation is -DIMTM. Callers
# pass tol_lower as the magnitude of the minus deviation (0.01 means
# -0.01, legacy contract); a *negative* tol_lower expresses a plus
# lower deviation, which double-positive ISO 286 fits (e.g. p6
# +0.035/+0.022) require.
override.update(
{
"dimtol": 1,
"dimlim": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
"dimtm": float(tol_lower),
}
)
elif mode == "limit":
@@ -66,7 +71,7 @@ def build_dim_override(
"dimlim": 1,
"dimtol": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
"dimtm": float(tol_lower),
}
)
elif mode == "basic":
+887 -192
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -5,6 +5,7 @@ from unittest.mock import patch
import pytest
from backends.base import CapabilityMap, FeatureCapability
from version import __version__
def test_capability_types_serialize_stably():
@@ -32,7 +33,11 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
"reason": None,
}
assert caps["features"]["mleader"]["mode"] == "composite"
assert caps["features"]["paper_space"]["supported"] is False
# v1.4: paper-space layouts/viewports are native on both backends;
# projecting model content through viewports stays COM-only, and 3D
# ACIS solids remain impossible headlessly.
assert caps["features"]["paper_space"]["supported"] is True
assert caps["features"]["viewport_render"]["supported"] is False
assert caps["features"]["solid_3d"]["supported"] is False
@@ -70,5 +75,5 @@ async def test_system_about_uses_package_version_and_capability_summary(backend)
result = await server.system_about(ctx=Ctx())
assert result["version"] == "1.3.0"
assert result["version"] == __version__
assert result["capabilities"]["table"]["mode"] == "composite"
+3 -4
View File
@@ -10,6 +10,7 @@ import pytest
import server
from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from version import __version__
async def _clean_drawing(backend) -> None:
@@ -42,7 +43,7 @@ async def test_delivery_writes_hashed_manifest_and_reopen_parity(backend, tmp_pa
assert dxf["status"] == "created"
assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest()
assert manifest["parity"]["ok"] is True
assert manifest["version"] == "1.3.0"
assert manifest["version"] == __version__
assert manifest["capabilities"]["backend"] == "ezdxf"
@@ -107,9 +108,7 @@ async def test_snapshot_parity_rejects_incomplete_entity_inventory(backend, monk
assert source["listed_entity_count"] == 1
assert source["inventory_complete"] is False
assert result["ok"] is False
assert "source_inventory_complete" in {
item["field"] for item in result["differences"]
}
assert "source_inventory_complete" in {item["field"] for item in result["differences"]}
@pytest.mark.asyncio
+118
View File
@@ -0,0 +1,118 @@
"""ISO 286 fit-table lookups pinned against published table values."""
from __future__ import annotations
import pytest
from fastmcp.exceptions import ToolError
from engineering.fits import FitDeviation, fit_lookup, parse_fit_code
from engineering.tolerances import build_dim_override
from server import _fit_to_tolerances
def _um(value_mm: float) -> float:
return round(value_mm * 1000.0, 3)
# (code, nominal, upper µm, lower µm) — published ISO 286-2 values.
CANONICAL_FITS = [
("H7", 20.0, 21, 0),
("H7", 40.0, 25, 0),
("H8", 30.0, 33, 0),
("H9", 6.0, 30, 0),
("H7", 200.0, 46, 0),
("g6", 20.0, -7, -20),
("g6", 6.0, -4, -12),
("f7", 40.0, -25, -50),
("e8", 25.0, -40, -73),
("d9", 100.0, -120, -207),
("h6", 30.0, 0, -13), # 30 mm is inside the >18-30 step (inclusive upper bound)
("h7", 50.0, 0, -25),
("k6", 20.0, 15, 2),
("m6", 25.0, 21, 8),
("n6", 20.0, 28, 15),
("p6", 20.0, 35, 22),
("js9", 25.0, 26, -26),
("G7", 6.0, 16, 4),
("F8", 40.0, 64, 25),
("D10", 30.0, 149, 65),
]
@pytest.mark.parametrize("code,nominal,upper_um,lower_um", CANONICAL_FITS)
def test_canonical_fit_values(code, nominal, upper_um, lower_um):
deviation = fit_lookup(code, nominal)
assert isinstance(deviation, FitDeviation)
assert _um(deviation.upper_mm) == pytest.approx(upper_um), f"{code}@{nominal} upper"
assert _um(deviation.lower_mm) == pytest.approx(lower_um), f"{code}@{nominal} lower"
def test_k_outside_it4_to_7_has_zero_lower_deviation():
deviation = fit_lookup("k9", 20.0)
assert deviation.lower_mm == 0.0
assert _um(deviation.upper_mm) == 52 # IT9 @ 18-30
def test_parse_fit_code():
assert parse_fit_code("H7") == ("H", 7)
assert parse_fit_code("js10") == ("js", 10)
with pytest.raises(ValueError):
parse_fit_code("77")
with pytest.raises(ValueError):
parse_fit_code("")
@pytest.mark.parametrize(
"code,nominal",
[
("K7", 20.0), # transition hole letters need the delta-rule — excluded
("P7", 20.0),
("x6", 20.0), # letter outside the authored subset (regex rejects)
("H3", 20.0), # grade below authored range
("H12", 20.0), # grade above authored range
("H7", 0.5), # below 1 mm
("H7", 600.0), # above 500 mm
],
)
def test_out_of_scope_lookups_raise(code, nominal):
with pytest.raises(ValueError):
fit_lookup(code, nominal)
def test_double_positive_fit_renders_correct_dimvars():
"""p6-style fits (+upper/+lower) must produce a negative DIMTM so AutoCAD
displays the plus lower deviation."""
deviation = fit_lookup("p6", 20.0)
override, _ = build_dim_override(
tol_upper=deviation.upper_mm,
tol_lower=-deviation.lower_mm, # server convention: positive = minus
tol_mode="deviation",
)
assert override["dimtp"] == pytest.approx(0.035)
assert override["dimtm"] == pytest.approx(-0.022) # displayed as +0.022
def test_fit_to_tolerances_contract():
tol_upper, tol_lower, tol_mode, text = _fit_to_tolerances("H7", 20.0, None, None, "none", None)
assert tol_upper == pytest.approx(0.021)
assert tol_lower == pytest.approx(0.0)
assert tol_mode == "deviation"
assert text == "<> H7"
def test_fit_to_tolerances_rejects_mixed_usage():
with pytest.raises(ToolError):
_fit_to_tolerances("H7", 20.0, 0.1, None, "none", None)
with pytest.raises(ToolError):
_fit_to_tolerances("H7", 20.0, None, None, "symmetric", None)
with pytest.raises(ToolError):
_fit_to_tolerances("Q9", 20.0, None, None, "none", None)
def test_fit_passthrough_without_code():
assert _fit_to_tolerances(None, 20.0, 0.1, 0.05, "deviation", "x") == (
0.1,
0.05,
"deviation",
"x",
)
+134
View File
@@ -0,0 +1,134 @@
"""Paper-space layouts, viewports, layout PDF export and opt-in 3D solids."""
from __future__ import annotations
from importlib.util import find_spec
import ezdxf
import pytest
import pytest_asyncio
import config
import server
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def backend():
b = EzdxfBackend()
await b.connect()
await b.drawing_new()
yield b
await b.disconnect()
async def test_layout_list_reports_model_and_current(backend):
result = await backend.layout_list()
assert result["ok"] is True
assert "Model" in result["layouts"]
assert isinstance(result["current"], str)
async def test_layout_create_and_duplicate_rejected(backend):
created = await backend.layout_create("A3-Sheet")
assert created["ok"] is True
listing = await backend.layout_list()
assert "A3-Sheet" in listing["layouts"]
duplicate = await backend.layout_create("A3-Sheet")
assert duplicate["ok"] is False
async def test_layout_set_current_roundtrip(backend):
await backend.layout_create("A3-Sheet")
switched = await backend.layout_set_current("A3-Sheet")
assert switched["ok"] is True
assert (await backend.layout_list())["current"] == "A3-Sheet"
back = await backend.layout_set_current("Model")
assert back["ok"] is True
missing = await backend.layout_set_current("NOPE")
assert missing["ok"] is False
async def test_viewport_create_scaled(backend, tmp_path):
await backend.layout_create("A3-Sheet")
result = await backend.viewport_create(
"A3-Sheet",
center_x=200,
center_y=140,
width=180,
height=120,
view_center_x=50,
view_center_y=25,
scale=0.5,
)
assert result["ok"] is True
assert result["view_height"] == pytest.approx(240.0) # height / scale
exported = tmp_path / "layout_roundtrip.dxf"
save = await backend.drawing_export_dxf(str(exported))
assert save["ok"] is True
doc = ezdxf.readfile(str(exported))
viewports = doc.layouts.get("A3-Sheet").query("VIEWPORT")
assert len(viewports) >= 1
created = [vp for vp in viewports if vp.dxf.handle == result["handle"]]
assert created, "created viewport handle must survive the DXF roundtrip"
assert created[0].dxf.view_height == pytest.approx(240.0)
async def test_viewport_rejects_model_and_bad_scale(backend):
bad_layout = await backend.viewport_create("Model", 0, 0, 10, 10, 0, 0, 1.0)
assert bad_layout["ok"] is False
await backend.layout_create("A3-Sheet")
bad_scale = await backend.viewport_create("A3-Sheet", 0, 0, 10, 10, 0, 0, 0.0)
assert bad_scale["ok"] is False
@pytest.mark.skipif(find_spec("matplotlib") is None, reason="matplotlib not installed")
async def test_export_pdf_layout(backend, tmp_path):
await backend.layout_create("A3-Sheet")
await backend.entity_create_line(0, 0, 100, 0)
target = tmp_path / "layout.pdf"
result = await backend.drawing_export_pdf(str(target), layout="A3-Sheet")
assert result["ok"] is True
assert result["layout"] == "A3-Sheet"
assert "note" in result # honest viewport-projection limitation
assert target.is_file() and target.stat().st_size > 0
missing = await backend.drawing_export_pdf(str(tmp_path / "x.pdf"), layout="NOPE")
assert missing["ok"] is False
async def test_solids_unsupported_headlessly(backend):
box = await backend.solid_box(0, 0, 0, 10, 10, 10)
cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10)
extrude = await backend.solid_extrude("FF", 10)
revolve = await backend.solid_revolve("FF", 0, 0, 0, 1)
boolean = await backend.solid_boolean("FF", "FE", "union")
for result in (box, cylinder, extrude, revolve, boolean):
assert result["ok"] is False
assert result["capability"] == "solid_3d"
async def test_capability_map_reports_paper_space_and_solids(backend):
features = backend.capabilities().to_dict()["features"]
assert features["paper_space"]["supported"] is True
assert features["viewport_render"]["supported"] is False
assert features["solid_3d"]["supported"] is False
class TestSolidToolGating:
@pytest.mark.asyncio
async def test_solid_tools_hidden_without_enable_3d(self, monkeypatch):
monkeypatch.setattr(config.settings, "enable_3d", False)
info = await server._apply_tool_profile("full")
assert server.SOLID_TOOL_NAMES <= set(info["disabled_tools"])
await server._apply_tool_profile("full")
@pytest.mark.asyncio
async def test_solid_tools_visible_with_enable_3d(self, monkeypatch):
monkeypatch.setattr(config.settings, "enable_3d", True)
info = await server._apply_tool_profile("full")
assert not (server.SOLID_TOOL_NAMES & set(info["disabled_tools"]))
monkeypatch.setattr(config.settings, "enable_3d", False)
await server._apply_tool_profile("full")
+78
View File
@@ -0,0 +1,78 @@
"""Tool-profile (TOOL_PROFILE=lean/core/full) behavior."""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import config
import server
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture(autouse=True)
async def _restore_full_profile():
"""Profiles mutate global server enablement; always restore full."""
yield
await server._apply_tool_profile("full")
async def _registered_names() -> set[str]:
return {tool.name for tool in await server._registered_tools() if getattr(tool, "name", None)}
async def test_lean_names_all_exist_in_registry():
registered = await _registered_names()
missing = server.LEAN_TOOL_NAMES - registered
assert not missing, f"LEAN_TOOL_NAMES references unknown tools: {sorted(missing)}"
async def test_core_exclusions_all_exist_in_registry():
registered = await _registered_names()
missing = server.CORE_EXCLUDED_TOOL_NAMES - registered
assert not missing, f"CORE_EXCLUDED_TOOL_NAMES references unknown tools: {sorted(missing)}"
async def test_full_profile_hides_only_gated_solids():
"""Full hides nothing except the opt-in 3D tools while ENABLE_3D=false."""
info = await server._apply_tool_profile("full")
assert info["profile"] == "full"
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
assert info["enabled_count"] == info["registered_count"] - len(server.SOLID_TOOL_NAMES)
async def test_invalid_profile_falls_back_to_full():
info = await server._apply_tool_profile("does-not-exist")
assert info["profile"] == "full"
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
async def test_lean_profile_filters_client_view(monkeypatch):
monkeypatch.setattr(config.settings, "backend", "ezdxf")
monkeypatch.setattr(config.settings, "tool_profile", "lean")
async with Client(server.mcp) as client:
visible = {tool.name for tool in await client.list_tools()}
assert visible == set(server.LEAN_TOOL_NAMES)
async def test_core_profile_hides_escape_hatches_keeps_engineering(monkeypatch):
monkeypatch.setattr(config.settings, "backend", "ezdxf")
monkeypatch.setattr(config.settings, "tool_profile", "core")
async with Client(server.mcp) as client:
visible = {tool.name for tool in await client.list_tools()}
assert "system_run_command" not in visible
assert "system_run_lisp" not in visible
assert "gear_draw_spur_front_view" in visible
assert "drawing_refine" in visible
registered = await _registered_names()
assert visible == registered - server.CORE_EXCLUDED_TOOL_NAMES - server.SOLID_TOOL_NAMES
async def test_profile_switch_is_idempotent():
await server._apply_tool_profile("lean")
info = await server._apply_tool_profile("full")
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
lean_again = await server._apply_tool_profile("lean")
assert lean_again["enabled_count"] == len(server.LEAN_TOOL_NAMES)