fix: MCP toolset resolution for runtime and config (#3252)

Gateway sessions had their own inline toolset resolution that only read
platform_toolsets from config, which never includes MCP server names.
MCP tools were discovered and registered but invisible to the model.

- Replace duplicated gateway toolset resolution in _run_agent() and
  _run_background_task() with calls to the shared _get_platform_tools()
- Extend _get_platform_tools() to include globally enabled MCP servers
  at runtime (include_default_mcp_servers=True), while config-editing
  flows use include_default_mcp_servers=False to avoid persisting
  implicit MCP defaults into platform_toolsets
- Add homeassistant to PLATFORMS dict (was missing, caused KeyError)
- Fix CLI entry point to use _get_platform_tools() as well, so MCP
  tools are visible in CLI mode too
- Remove redundant platform_key reassignment in _run_background_task

Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
This commit is contained in:
Teknium 2026-03-26 13:39:41 -07:00 committed by GitHub
parent 2c719f0701
commit 62f8aa9b03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 262 additions and 132 deletions

9
cli.py
View file

@ -7288,12 +7288,9 @@ def main(
else: else:
toolsets_list.append(str(t)) toolsets_list.append(str(t))
else: else:
# Check config for CLI toolsets, fallback to hermes-cli # Use the shared resolver so MCP servers are included at runtime
config_cli_toolsets = CLI_CONFIG.get("platform_toolsets", {}).get("cli") from hermes_cli.tools_config import _get_platform_tools
if config_cli_toolsets and isinstance(config_cli_toolsets, list): toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
toolsets_list = config_cli_toolsets
else:
toolsets_list = ["hermes-cli"]
parsed_skills = _parse_skills_argument(skills) parsed_skills = _parse_skills_argument(skills)

View file

@ -257,7 +257,25 @@ def _resolve_runtime_agent_kwargs() -> dict:
} }
def _resolve_gateway_model() -> str: def _platform_config_key(platform: "Platform") -> str:
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
return "cli" if platform == Platform.LOCAL else platform.value
def _load_gateway_config() -> dict:
"""Load and parse ~/.hermes/config.yaml, returning {} on any error."""
try:
config_path = _hermes_home / 'config.yaml'
if config_path.exists():
import yaml
with open(config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f) or {}
except Exception:
logger.debug("Could not load gateway config from %s", _hermes_home / 'config.yaml')
return {}
def _resolve_gateway_model(config: dict | None = None) -> str:
"""Read model from env/config — mirrors the resolution in _run_agent_sync. """Read model from env/config — mirrors the resolution in _run_agent_sync.
Without this, temporary AIAgent instances (memory flush, /compress) fall Without this, temporary AIAgent instances (memory flush, /compress) fall
@ -265,19 +283,12 @@ def _resolve_gateway_model() -> str:
when the active provider is openai-codex. when the active provider is openai-codex.
""" """
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6" model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
try: cfg = config if config is not None else _load_gateway_config()
import yaml as _y model_cfg = cfg.get("model", {})
_cfg_path = _hermes_home / "config.yaml" if isinstance(model_cfg, str):
if _cfg_path.exists(): model = model_cfg
with open(_cfg_path, encoding="utf-8") as _f: elif isinstance(model_cfg, dict):
_cfg = _y.safe_load(_f) or {} model = model_cfg.get("default", model)
_model_cfg = _cfg.get("model", {})
if isinstance(_model_cfg, str):
model = _model_cfg
elif isinstance(_model_cfg, dict):
model = _model_cfg.get("default", model)
except Exception:
pass
return model return model
@ -3571,52 +3582,12 @@ class GatewayRunner:
) )
return return
# Read model from config via shared helper user_config = _load_gateway_config()
model = _resolve_gateway_model() model = _resolve_gateway_model(user_config)
platform_key = _platform_config_key(source.platform)
# Determine toolset (same logic as _run_agent) from hermes_cli.tools_config import _get_platform_tools
default_toolset_map = { enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
Platform.LOCAL: "hermes-cli",
Platform.TELEGRAM: "hermes-telegram",
Platform.DISCORD: "hermes-discord",
Platform.WHATSAPP: "hermes-whatsapp",
Platform.SLACK: "hermes-slack",
Platform.SIGNAL: "hermes-signal",
Platform.HOMEASSISTANT: "hermes-homeassistant",
Platform.EMAIL: "hermes-email",
Platform.DINGTALK: "hermes-dingtalk",
}
platform_toolsets_config = {}
try:
config_path = _hermes_home / 'config.yaml'
if config_path.exists():
import yaml
with open(config_path, 'r', encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
platform_toolsets_config = user_config.get("platform_toolsets", {})
except Exception:
pass
platform_config_key = {
Platform.LOCAL: "cli",
Platform.TELEGRAM: "telegram",
Platform.DISCORD: "discord",
Platform.WHATSAPP: "whatsapp",
Platform.SLACK: "slack",
Platform.SIGNAL: "signal",
Platform.HOMEASSISTANT: "homeassistant",
Platform.EMAIL: "email",
Platform.DINGTALK: "dingtalk",
}.get(source.platform, "telegram")
config_toolsets = platform_toolsets_config.get(platform_config_key)
if config_toolsets and isinstance(config_toolsets, list):
enabled_toolsets = config_toolsets
else:
default_toolset = default_toolset_map.get(source.platform, "hermes-telegram")
enabled_toolsets = [default_toolset]
platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value
pr = self._provider_routing pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
@ -4742,67 +4713,16 @@ class GatewayRunner:
from run_agent import AIAgent from run_agent import AIAgent
import queue import queue
# Determine toolset based on platform. user_config = _load_gateway_config()
# Check config.yaml for per-platform overrides, fallback to hardcoded defaults. platform_key = _platform_config_key(source.platform)
default_toolset_map = {
Platform.LOCAL: "hermes-cli",
Platform.TELEGRAM: "hermes-telegram",
Platform.DISCORD: "hermes-discord",
Platform.WHATSAPP: "hermes-whatsapp",
Platform.SLACK: "hermes-slack",
Platform.SIGNAL: "hermes-signal",
Platform.HOMEASSISTANT: "hermes-homeassistant",
Platform.EMAIL: "hermes-email",
Platform.DINGTALK: "hermes-dingtalk",
}
# Try to load platform_toolsets from config from hermes_cli.tools_config import _get_platform_tools
platform_toolsets_config = {} enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
try:
config_path = _hermes_home / 'config.yaml'
if config_path.exists():
import yaml
with open(config_path, 'r', encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
platform_toolsets_config = user_config.get("platform_toolsets", {})
except Exception as e:
logger.debug("Could not load platform_toolsets config: %s", e)
# Map platform enum to config key
platform_config_key = {
Platform.LOCAL: "cli",
Platform.TELEGRAM: "telegram",
Platform.DISCORD: "discord",
Platform.WHATSAPP: "whatsapp",
Platform.SLACK: "slack",
Platform.SIGNAL: "signal",
Platform.HOMEASSISTANT: "homeassistant",
Platform.EMAIL: "email",
Platform.DINGTALK: "dingtalk",
}.get(source.platform, "telegram")
# Use config override if present (list of toolsets), otherwise hardcoded default
config_toolsets = platform_toolsets_config.get(platform_config_key)
if config_toolsets and isinstance(config_toolsets, list):
enabled_toolsets = config_toolsets
else:
default_toolset = default_toolset_map.get(source.platform, "hermes-telegram")
enabled_toolsets = [default_toolset]
# Tool progress mode from config.yaml: "all", "new", "verbose", "off" # Tool progress mode from config.yaml: "all", "new", "verbose", "off"
# Falls back to env vars for backward compatibility # Falls back to env vars for backward compatibility
_progress_cfg = {}
try:
_tp_cfg_path = _hermes_home / "config.yaml"
if _tp_cfg_path.exists():
import yaml as _tp_yaml
with open(_tp_cfg_path, encoding="utf-8") as _tp_f:
_tp_data = _tp_yaml.safe_load(_tp_f) or {}
_progress_cfg = _tp_data.get("display", {})
except Exception:
pass
progress_mode = ( progress_mode = (
_progress_cfg.get("tool_progress") user_config.get("display", {}).get("tool_progress")
or os.getenv("HERMES_TOOL_PROGRESS_MODE") or os.getenv("HERMES_TOOL_PROGRESS_MODE")
or "all" or "all"
) )
@ -5025,7 +4945,7 @@ class GatewayRunner:
except Exception: except Exception:
pass pass
model = _resolve_gateway_model() model = _resolve_gateway_model(user_config)
try: try:
runtime_kwargs = _resolve_runtime_agent_kwargs() runtime_kwargs = _resolve_runtime_agent_kwargs()

View file

@ -131,6 +131,7 @@ PLATFORMS = {
"slack": {"label": "💼 Slack", "default_toolset": "hermes-slack"}, "slack": {"label": "💼 Slack", "default_toolset": "hermes-slack"},
"whatsapp": {"label": "📱 WhatsApp", "default_toolset": "hermes-whatsapp"}, "whatsapp": {"label": "📱 WhatsApp", "default_toolset": "hermes-whatsapp"},
"signal": {"label": "📡 Signal", "default_toolset": "hermes-signal"}, "signal": {"label": "📡 Signal", "default_toolset": "hermes-signal"},
"homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"},
"email": {"label": "📧 Email", "default_toolset": "hermes-email"}, "email": {"label": "📧 Email", "default_toolset": "hermes-email"},
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"}, "dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
} }
@ -378,7 +379,29 @@ def _platform_toolset_summary(config: dict, platforms: Optional[List[str]] = Non
return summary return summary
def _get_platform_tools(config: dict, platform: str) -> Set[str]: def _parse_enabled_flag(value, default: bool = True) -> bool:
"""Parse bool-like config values used by tool/platform settings."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
return True
if lowered in {"false", "0", "no", "off"}:
return False
return default
def _get_platform_tools(
config: dict,
platform: str,
*,
include_default_mcp_servers: bool = True,
) -> Set[str]:
"""Resolve which individual toolset names are enabled for a platform.""" """Resolve which individual toolset names are enabled for a platform."""
from toolsets import resolve_toolset from toolsets import resolve_toolset
@ -430,6 +453,37 @@ def _get_platform_tools(config: dict, platform: str) -> Set[str]:
enabled_toolsets.add(pts) enabled_toolsets.add(pts)
# else: known but not in config = user disabled it # else: known but not in config = user disabled it
# Preserve any explicit non-configurable toolset entries (for example,
# custom toolsets or MCP server names saved in platform_toolsets).
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
explicit_passthrough = {
ts
for ts in toolset_names
if ts not in configurable_keys
and ts not in plugin_ts_keys
and ts not in platform_default_keys
}
# MCP servers are expected to be available on all platforms by default.
# If the platform explicitly lists one or more MCP server names, treat that
# as an allowlist. Otherwise include every globally enabled MCP server.
mcp_servers = config.get("mcp_servers", {})
enabled_mcp_servers = {
name
for name, server_cfg in mcp_servers.items()
if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
}
explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
if include_default_mcp_servers:
if explicit_mcp_servers:
enabled_toolsets.update(explicit_mcp_servers)
else:
enabled_toolsets.update(enabled_mcp_servers)
else:
enabled_toolsets.update(explicit_mcp_servers)
return enabled_toolsets return enabled_toolsets
@ -1022,7 +1076,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
if first_install: if first_install:
for pkey in enabled_platforms: for pkey in enabled_platforms:
pinfo = PLATFORMS[pkey] pinfo = PLATFORMS[pkey]
current_enabled = _get_platform_tools(config, pkey) current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
# Uncheck toolsets that should be off by default # Uncheck toolsets that should be off by default
checklist_preselected = current_enabled - _DEFAULT_OFF_TOOLSETS checklist_preselected = current_enabled - _DEFAULT_OFF_TOOLSETS
@ -1074,7 +1128,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
platform_keys = [] platform_keys = []
for pkey in enabled_platforms: for pkey in enabled_platforms:
pinfo = PLATFORMS[pkey] pinfo = PLATFORMS[pkey]
current = _get_platform_tools(config, pkey) current = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
count = len(current) count = len(current)
total = len(_get_effective_configurable_toolsets()) total = len(_get_effective_configurable_toolsets())
platform_choices.append(f"Configure {pinfo['label']} ({count}/{total} enabled)") platform_choices.append(f"Configure {pinfo['label']} ({count}/{total} enabled)")
@ -1121,11 +1175,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
# Use the union of all platforms' current tools as the starting state # Use the union of all platforms' current tools as the starting state
all_current = set() all_current = set()
for pk in platform_keys: for pk in platform_keys:
all_current |= _get_platform_tools(config, pk) all_current |= _get_platform_tools(config, pk, include_default_mcp_servers=False)
new_enabled = _prompt_toolset_checklist("All platforms", all_current) new_enabled = _prompt_toolset_checklist("All platforms", all_current)
if new_enabled != all_current: if new_enabled != all_current:
for pk in platform_keys: for pk in platform_keys:
prev = _get_platform_tools(config, pk) prev = _get_platform_tools(config, pk, include_default_mcp_servers=False)
added = new_enabled - prev added = new_enabled - prev
removed = prev - new_enabled removed = prev - new_enabled
pinfo_inner = PLATFORMS[pk] pinfo_inner = PLATFORMS[pk]
@ -1147,7 +1201,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print(color(" ✓ Saved configuration for all platforms", Colors.GREEN)) print(color(" ✓ Saved configuration for all platforms", Colors.GREEN))
# Update choice labels # Update choice labels
for ci, pk in enumerate(platform_keys): for ci, pk in enumerate(platform_keys):
new_count = len(_get_platform_tools(config, pk)) new_count = len(_get_platform_tools(config, pk, include_default_mcp_servers=False))
total = len(_get_effective_configurable_toolsets()) total = len(_get_effective_configurable_toolsets())
platform_choices[ci] = f"Configure {PLATFORMS[pk]['label']} ({new_count}/{total} enabled)" platform_choices[ci] = f"Configure {PLATFORMS[pk]['label']} ({new_count}/{total} enabled)"
else: else:
@ -1159,7 +1213,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
pinfo = PLATFORMS[pkey] pinfo = PLATFORMS[pkey]
# Get current enabled toolsets for this platform # Get current enabled toolsets for this platform
current_enabled = _get_platform_tools(config, pkey) current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
# Show checklist # Show checklist
new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled) new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled)
@ -1192,7 +1246,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print() print()
# Update the choice label with new count # Update the choice label with new count
new_count = len(_get_platform_tools(config, pkey)) new_count = len(_get_platform_tools(config, pkey, include_default_mcp_servers=False))
total = len(_get_effective_configurable_toolsets()) total = len(_get_effective_configurable_toolsets())
platform_choices[idx] = f"Configure {pinfo['label']} ({new_count}/{total} enabled)" platform_choices[idx] = f"Configure {pinfo['label']} ({new_count}/{total} enabled)"
@ -1338,7 +1392,7 @@ def _configure_mcp_tools_interactive(config: dict):
def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str): def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str):
"""Add or remove built-in toolsets for a platform.""" """Add or remove built-in toolsets for a platform."""
enabled = _get_platform_tools(config, platform) enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False)
if action == "disable": if action == "disable":
updated = enabled - set(toolset_names) updated = enabled - set(toolset_names)
else: else:
@ -1424,7 +1478,7 @@ def tools_disable_enable_command(args):
return return
if action == "list": if action == "list":
_print_tools_list(_get_platform_tools(config, platform), _print_tools_list(_get_platform_tools(config, platform, include_default_mcp_servers=False),
config.get("mcp_servers") or {}, platform) config.get("mcp_servers") or {}, platform)
return return

View file

@ -218,3 +218,112 @@ class TestReasoningCommand:
assert result["final_response"] == "ok" assert result["final_response"] == "ok"
assert _CapturingAgent.last_init is not None assert _CapturingAgent.last_init is not None
assert _CapturingAgent.last_init["reasoning_config"] == {"enabled": False} assert _CapturingAgent.last_init["reasoning_config"] == {"enabled": False}
def test_run_agent_includes_enabled_mcp_servers_in_gateway_toolsets(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"platform_toolsets:\n"
" cli: [web, memory]\n"
"mcp_servers:\n"
" exa:\n"
" url: https://mcp.exa.ai/mcp\n"
" web-search-prime:\n"
" url: https://api.z.ai/api/mcp/web_search_prime/mcp\n",
encoding="utf-8",
)
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setattr(gateway_run, "_env_path", hermes_home / ".env")
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.setattr(
gateway_run,
"_resolve_runtime_agent_kwargs",
lambda: {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "test-key",
},
)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _CapturingAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
_CapturingAgent.last_init = None
runner = _make_runner()
source = SessionSource(
platform=Platform.LOCAL,
chat_id="cli",
chat_name="CLI",
chat_type="dm",
user_id="user-1",
)
result = asyncio.run(
runner._run_agent(
message="ping",
context_prompt="",
history=[],
source=source,
session_id="session-1",
session_key="agent:main:local:dm",
)
)
assert result["final_response"] == "ok"
assert _CapturingAgent.last_init is not None
enabled_toolsets = set(_CapturingAgent.last_init["enabled_toolsets"])
assert "web" in enabled_toolsets
assert "memory" in enabled_toolsets
assert "exa" in enabled_toolsets
assert "web-search-prime" in enabled_toolsets
def test_run_agent_homeassistant_uses_default_platform_toolset(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("", encoding="utf-8")
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setattr(gateway_run, "_env_path", hermes_home / ".env")
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.setattr(
gateway_run,
"_resolve_runtime_agent_kwargs",
lambda: {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "test-key",
},
)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _CapturingAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
_CapturingAgent.last_init = None
runner = _make_runner()
source = SessionSource(
platform=Platform.HOMEASSISTANT,
chat_id="ha",
chat_name="Home Assistant",
chat_type="dm",
user_id="user-1",
)
result = asyncio.run(
runner._run_agent(
message="ping",
context_prompt="",
history=[],
source=source,
session_id="session-1",
session_key="agent:main:homeassistant:dm",
)
)
assert result["final_response"] == "ok"
assert _CapturingAgent.last_init is not None
assert "homeassistant" in set(_CapturingAgent.last_init["enabled_toolsets"])

View file

@ -35,6 +35,39 @@ def test_platform_toolset_summary_uses_explicit_platform_list():
assert summary["cli"] == _get_platform_tools(config, "cli") assert summary["cli"] == _get_platform_tools(config, "cli")
def test_get_platform_tools_includes_enabled_mcp_servers_by_default():
config = {
"mcp_servers": {
"exa": {"url": "https://mcp.exa.ai/mcp"},
"web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"},
"disabled-server": {"url": "https://example.com/mcp", "enabled": False},
}
}
enabled = _get_platform_tools(config, "cli")
assert "exa" in enabled
assert "web-search-prime" in enabled
assert "disabled-server" not in enabled
def test_get_platform_tools_keeps_enabled_mcp_servers_with_explicit_builtin_selection():
config = {
"platform_toolsets": {"cli": ["web", "memory"]},
"mcp_servers": {
"exa": {"url": "https://mcp.exa.ai/mcp"},
"web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"},
},
}
enabled = _get_platform_tools(config, "cli")
assert "web" in enabled
assert "memory" in enabled
assert "exa" in enabled
assert "web-search-prime" in enabled
def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch): def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "auth.json").write_text( (tmp_path / "auth.json").write_text(

View file

@ -134,6 +134,23 @@ class TestToolsMixedTargets:
assert "web" not in saved["platform_toolsets"]["cli"] assert "web" not in saved["platform_toolsets"]["cli"]
assert "create_issue" in saved["mcp_servers"]["github"]["tools"]["exclude"] assert "create_issue" in saved["mcp_servers"]["github"]["tools"]["exclude"]
def test_builtin_toggle_does_not_persist_implicit_mcp_defaults(self):
config = {
"platform_toolsets": {"cli": ["web", "memory"]},
"mcp_servers": {"exa": {"url": "https://mcp.exa.ai/mcp"}},
}
with patch("hermes_cli.tools_config.load_config", return_value=config), \
patch("hermes_cli.tools_config.save_config") as mock_save:
tools_disable_enable_command(Namespace(
tools_action="disable",
names=["web"],
platform="cli",
))
saved = mock_save.call_args[0][0]
assert "web" not in saved["platform_toolsets"]["cli"]
assert "memory" in saved["platform_toolsets"]["cli"]
assert "exa" not in saved["platform_toolsets"]["cli"]
# ── List output ────────────────────────────────────────────────────────────── # ── List output ──────────────────────────────────────────────────────────────