From 91f43d541c318a7eb5a6a09eb2c0e3a3fe0ac922 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 14 Jul 2026 07:19:29 -0400 Subject: [PATCH] fix(mcp): isolate large schemas behind catalog profiles --- DEVLOG.md | 25 ++++ hermes_cli/tools_config.py | 55 ++++++-- tests/hermes_cli/test_tools_config.py | 73 +++++++++++ tests/tools/test_mcp_dynamic_discovery.py | 121 ++++++++++++++++++ tools/mcp_tool.py | 46 ++++++- .../docs/reference/mcp-config-reference.md | 42 ++++++ 6 files changed, 344 insertions(+), 18 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 32c478ce0d2d..923e391f4e2f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -22,6 +22,31 @@ Removed the deployment-specific `victor`→`default` cron owner alias and restor - Profile tests plus multiplex secret-scope regression → 56 passed. - Full `tests/cron` with an isolated temporary `HERMES_HOME` after rebasing onto the latest `origin/axiom` → 702 passed, 2 warnings. - Live checks: the shared registry remained stable at 8 Victor / 9 Sentinel rows across a full scheduler minute after all six profile gateways reloaded; Victor sees exactly its eight jobs and explicit default sees zero. The weekly-backup job ran through Victor's profile-local launcher and created a 3.5 GB archive; the Relay watcher ran silently without a script-resolution failure. +## 2026-07-14 — Isolate large MCP schemas behind catalog profiles + +### Summary + +Separated MCP connectivity from model-visible schema exposure so large +catalog-capable integrations no longer inject their full workflow vocabulary +into ordinary sessions by default. + +### What changed + +- Added `mcp_servers..exposure` (`auto`, `catalog`, `direct`, `off`) and + optional platform scoping through `expose_on`. +- Large servers with a complete three-operation catalog bridge now expose only + that bridge by default; deferred operations live in an explicit + `-direct` session/tool profile. +- Hardened platform MCP allowlists and `no_mcp` so direct profiles cannot + bypass platform scope or accidentally pull in unrelated servers. +- Added provider-facing schema-size/vocabulary regression coverage and + operator configuration documentation. + +### Verification + +- See AXI-103 PR verification for focused MCP/platform tests and the real + provider-facing tool-list before/after measurement. + ## 2026-07-13 — Add provider-pluggable music generation plugin diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 5fc7570f6a96..cc70dc839c69 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1603,22 +1603,36 @@ def _parse_enabled_flag(value, default: bool = True) -> bool: return default -def enabled_mcp_server_names(config: dict) -> Set[str]: +def enabled_mcp_server_names(config: dict, platform: Optional[str] = None) -> Set[str]: """Names of MCP servers globally enabled in config.yaml. Shared by the gateway/CLI platform resolver (``_get_platform_tools``) and the cron per-job toolset resolver (``cron.scheduler``) so every path agrees on MCP membership. A server is enabled unless its config sets an explicitly falsey ``enabled`` (per ``_parse_enabled_flag``: false/0/no/off) — a missing - flag or an unrecognized value is treated as enabled. + flag or an unrecognized value is treated as enabled. When ``platform`` is + provided, an optional per-server ``expose_on`` string/list limits schema + exposure without disconnecting or de-authenticating the server. """ mcp_servers = (config or {}).get("mcp_servers") or {} - return { - str(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) - } + enabled = set() + for name, server_cfg in mcp_servers.items(): + if not isinstance(server_cfg, dict): + continue + if not _parse_enabled_flag(server_cfg.get("enabled", True), default=True): + continue + expose_on = server_cfg.get("expose_on") + if platform is not None and expose_on is not None: + if isinstance(expose_on, str): + allowed_platforms = {expose_on} + elif isinstance(expose_on, (list, tuple, set)): + allowed_platforms = {str(value) for value in expose_on} + else: + allowed_platforms = set() + if platform not in allowed_platforms: + continue + enabled.add(str(name)) + return enabled def _exempt_explicit_platform_native( @@ -1885,14 +1899,23 @@ def _get_platform_tools( # If the platform explicitly lists one or more MCP server names, treat that # as an allowlist. Otherwise include every globally enabled MCP server. # Special sentinel: "no_mcp" in the toolset list disables all MCP servers. - enabled_mcp_servers = enabled_mcp_server_names(config) + configured_mcp_servers = enabled_mcp_server_names(config) + enabled_mcp_servers = enabled_mcp_server_names(config, platform=platform) + # Catalog-capable servers may register non-catalog operations in a separate, + # opt-in ``-direct`` profile. Treat both aliases as MCP so explicit + # allowlists and the no_mcp sentinel remain fail-closed. + allowed_direct_profiles = {f"{name}-direct" for name in enabled_mcp_servers} + allowed_mcp_profiles = enabled_mcp_servers | allowed_direct_profiles + configured_mcp_profiles = configured_mcp_servers | { + f"{name}-direct" for name in configured_mcp_servers + } # Allow "no_mcp" sentinel to opt out of all MCP servers for this platform if "no_mcp" in toolset_names: explicit_mcp_servers = set() - enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers - {"no_mcp"}) + enabled_toolsets.update(explicit_passthrough - configured_mcp_profiles - {"no_mcp"}) else: - explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers - enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers) + explicit_mcp_servers = explicit_passthrough & allowed_mcp_profiles + enabled_toolsets.update(explicit_passthrough - configured_mcp_profiles) if include_default_mcp_servers: if explicit_mcp_servers or "no_mcp" in toolset_names: enabled_toolsets.update(explicit_mcp_servers) @@ -1900,6 +1923,14 @@ def _get_platform_tools( enabled_toolsets.update(enabled_mcp_servers) else: enabled_toolsets.update(explicit_mcp_servers) + # Direct profiles hold deferred operations; the base profile holds the + # catalog bridge and must accompany them for a complete surface. Apply this + # even when default MCP servers are disabled for a narrow caller. + enabled_toolsets.update( + profile.removesuffix("-direct") + for profile in explicit_mcp_servers + if profile.endswith("-direct") + ) # Honor agent.disabled_toolsets from config.yaml — allows users to # globally suppress specific toolsets (e.g. "memory") across all diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 8a14fba3c6fd..98fdf8622659 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -25,6 +25,7 @@ gui_toolset_label, _visible_providers, tools_command, + enabled_mcp_server_names, ) @@ -493,6 +494,78 @@ def test_get_platform_tools_includes_enabled_mcp_servers_by_default(): assert "disabled-server" not in enabled +def test_enabled_mcp_servers_honor_platform_exposure_scope(): + config = { + "mcp_servers": { + "forge": { + "url": "https://forge.example/mcp", + "expose_on": ["webhook"], + }, + "shared": {"url": "https://shared.example/mcp"}, + } + } + + assert enabled_mcp_server_names(config, platform="discord") == {"shared"} + assert enabled_mcp_server_names(config, platform="webhook") == {"forge", "shared"} + # Platform-less callers retain the legacy connectivity view. + assert enabled_mcp_server_names(config) == {"forge", "shared"} + + +def test_get_platform_tools_allows_explicit_direct_profile_without_default_catalog_alias(): + config = { + "platform_toolsets": {"webhook": ["web", "forge-direct"]}, + "mcp_servers": { + "forge": {"url": "https://forge.example/mcp", "exposure": "catalog"}, + "other": {"url": "https://other.example/mcp"}, + }, + } + + enabled = _get_platform_tools(config, "webhook") + + assert "forge-direct" in enabled + # Direct profiles include the catalog bridge plus the deferred operations. + assert "forge" in enabled + assert "other" not in enabled + + narrow_enabled = _get_platform_tools( + config, "webhook", include_default_mcp_servers=False + ) + assert {"forge", "forge-direct"}.issubset(narrow_enabled) + assert "other" not in narrow_enabled + + +def test_get_platform_tools_no_mcp_blocks_direct_profiles_too(): + config = { + "platform_toolsets": {"discord": ["web", "forge-direct", "no_mcp"]}, + "mcp_servers": { + "forge": {"url": "https://forge.example/mcp", "exposure": "catalog"}, + }, + } + + enabled = _get_platform_tools(config, "discord") + + assert "forge" not in enabled + assert "forge-direct" not in enabled + + +def test_explicit_direct_profile_cannot_bypass_expose_on_scope(): + config = { + "platform_toolsets": {"discord": ["web", "forge-direct"]}, + "mcp_servers": { + "forge": { + "url": "https://forge.example/mcp", + "exposure": "catalog", + "expose_on": ["webhook"], + }, + }, + } + + enabled = _get_platform_tools(config, "discord") + + assert "forge" not in enabled + assert "forge-direct" not in enabled + + def test_get_platform_tools_keeps_enabled_mcp_servers_with_explicit_builtin_selection(): config = { "platform_toolsets": {"cli": ["web", "memory"]}, diff --git a/tests/tools/test_mcp_dynamic_discovery.py b/tests/tools/test_mcp_dynamic_discovery.py index f7eac572b8db..c7a490e03cd4 100644 --- a/tests/tools/test_mcp_dynamic_discovery.py +++ b/tests/tools/test_mcp_dynamic_discovery.py @@ -35,6 +35,127 @@ def test_exposes_live_server_aliases(self, mock_registry): assert validate_toolset("my_srv") is True assert "mcp__my_srv__my_tool" in resolve_toolset("my_srv") + def test_large_catalog_server_defaults_to_catalog_only(self, mock_registry): + server = MCPServerTask("forge") + server._tools = [ + _make_mcp_tool("catalog.search", "Discover operations"), + _make_mcp_tool("catalog.describe", "Describe operations"), + _make_mcp_tool("catalog.call", "Invoke an operation"), + *[_make_mcp_tool(f"issues.operation_{i}", "Issue workflow") for i in range(25)], + ] + server.session = MagicMock() + + with patch("tools.registry.registry", mock_registry): + registered = _register_server_tools("forge", server, {}) + + catalog_names = set(mock_registry.get_tool_names_for_toolset("mcp-forge")) + direct_names = set(mock_registry.get_tool_names_for_toolset("mcp-forge-direct")) + assert len(registered) == 28 # connected/discovered, even when not exposed by default + assert catalog_names == { + "mcp__forge__catalog_search", + "mcp__forge__catalog_describe", + "mcp__forge__catalog_call", + } + assert len(direct_names) == 25 + assert mock_registry.get_toolset_alias_target("forge") == "mcp-forge" + assert mock_registry.get_toolset_alias_target("forge-direct") == "mcp-forge-direct" + + def test_catalog_exposure_can_be_explicit_for_small_server(self, mock_registry): + server = MCPServerTask("small") + server._tools = [ + _make_mcp_tool("catalog.search"), + _make_mcp_tool("catalog.describe"), + _make_mcp_tool("catalog.call"), + _make_mcp_tool("items.list"), + ] + server.session = MagicMock() + + with patch("tools.registry.registry", mock_registry): + _register_server_tools("small", server, {"exposure": "catalog"}) + + assert len(mock_registry.get_tool_names_for_toolset("mcp-small")) == 3 + assert len(mock_registry.get_tool_names_for_toolset("mcp-small-direct")) == 1 + + def test_direct_exposure_preserves_legacy_single_toolset(self, mock_registry): + server = MCPServerTask("forge") + server._tools = [ + _make_mcp_tool("catalog.search"), + _make_mcp_tool("catalog.describe"), + _make_mcp_tool("catalog.call"), + _make_mcp_tool("runs.open"), + ] + server.session = MagicMock() + + with patch("tools.registry.registry", mock_registry): + _register_server_tools("forge", server, {"exposure": "direct"}) + + directly_exposed = set(mock_registry.get_tool_names_for_toolset("mcp-forge")) + assert { + "mcp__forge__catalog_search", + "mcp__forge__catalog_describe", + "mcp__forge__catalog_call", + "mcp__forge__runs_open", + }.issubset(directly_exposed) + assert mock_registry.get_tool_names_for_toolset("mcp-forge-direct") == [] + + def test_off_exposure_keeps_connection_but_registers_no_schemas(self, mock_registry): + server = MCPServerTask("forge") + server._tools = [_make_mcp_tool("catalog.search"), _make_mcp_tool("runs.open")] + server.session = MagicMock() + + with patch("tools.registry.registry", mock_registry): + registered = _register_server_tools("forge", server, {"exposure": "off"}) + + assert registered == [] + assert mock_registry.get_all_tool_names() == [] + + def test_catalog_profile_reduces_real_model_tool_definitions(self, mock_registry): + """The provider-facing schema assembly sees only catalog tools by default.""" + import json + import model_tools + + server = MCPServerTask("forge") + server._tools = [ + _make_mcp_tool("catalog.search", "Discover operations"), + _make_mcp_tool("catalog.describe", "Describe operations"), + _make_mcp_tool("catalog.call", "Invoke an operation"), + *[ + _make_mcp_tool( + f"runs.operation_{i}", + "AgentRun mode: EXECUTE | RESEARCH | REVIEW | DISCUSS", + ) + for i in range(25) + ], + ] + server.session = MagicMock() + + with ( + patch("tools.registry.registry", mock_registry), + patch("model_tools.registry", mock_registry), + patch("tools.mcp_tool._make_check_fn", return_value=lambda: True), + ): + _register_server_tools("forge", server, {}) + model_tools._clear_tool_defs_cache() + ordinary = model_tools.get_tool_definitions( + enabled_toolsets=["forge"], quiet_mode=True + ) + explicit = model_tools.get_tool_definitions( + enabled_toolsets=["forge", "forge-direct"], quiet_mode=True + ) + + ordinary_text = json.dumps(ordinary, sort_keys=True) + explicit_text = json.dumps(explicit, sort_keys=True) + ordinary_names = {tool["function"]["name"] for tool in ordinary} + assert ordinary_names == { + "mcp__forge__catalog_search", + "mcp__forge__catalog_describe", + "mcp__forge__catalog_call", + } + assert "AgentRun" not in ordinary_text + assert "EXECUTE" not in ordinary_text + assert "mcp__forge__runs_operation_0" in explicit_text + assert len(ordinary_text.encode()) < len(explicit_text.encode()) / 4 + class TestRefreshTools: """Tests for MCPServerTask._refresh_tools nuke-and-repave cycle.""" diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 4438a715718a..624d3860e0bc 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -4938,6 +4938,9 @@ def _existing_tool_names() -> List[str]: return names +_AUTO_CATALOG_MIN_TOOLS = 20 + + def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> List[str]: """Register tools from an already-connected server into the registry. @@ -4954,6 +4957,7 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li registered_names: List[str] = [] toolset_name = f"mcp-{name}" + direct_toolset_name = f"{toolset_name}-direct" # Selective tool loading: honour include/exclude lists from config. # Rules (matching issue #690 spec): @@ -4972,10 +4976,34 @@ def _should_register(tool_name: str) -> bool: return tool_name not in exclude_set return True - for mcp_tool in server._tools: - if not _should_register(mcp_tool.name): - logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name) - continue + selected_tools = [tool for tool in server._tools if _should_register(tool.name)] + catalog_names = {"catalog.search", "catalog.describe", "catalog.call"} + selected_names = {tool.name for tool in selected_tools} + exposure = str(config.get("exposure", "auto") or "auto").strip().lower() + if exposure not in {"auto", "catalog", "direct", "off"}: + logger.warning( + "MCP server '%s': unknown exposure %r; using backward-compatible direct exposure", + name, + exposure, + ) + exposure = "direct" + if exposure == "auto": + # Large servers with a complete catalog bridge default to three small + # discovery/invocation schemas. Small and non-catalog servers preserve + # the historical direct behavior. + exposure = ( + "catalog" + if ( + catalog_names.issubset(selected_names) + and len(selected_tools) >= _AUTO_CATALOG_MIN_TOOLS + ) + else "direct" + ) + if exposure == "off": + logger.info("MCP server '%s': connected with schema exposure disabled", name) + return [] + + for mcp_tool in selected_tools: # Scan tool description for prompt injection patterns _scan_mcp_description(name, mcp_tool.name, mcp_tool.description or "") @@ -4993,9 +5021,12 @@ def _should_register(tool_name: str) -> bool: ) continue + registration_toolset = toolset_name + if exposure == "catalog" and mcp_tool.name not in catalog_names: + registration_toolset = direct_toolset_name registry.register( name=tool_name_prefixed, - toolset=toolset_name, + toolset=registration_toolset, schema=schema, handler=_make_tool_handler(name, mcp_tool.name, server.tool_timeout), check_fn=_make_check_fn(name), @@ -5014,7 +5045,8 @@ def _should_register(tool_name: str) -> bool: "get_prompt": _make_get_prompt_handler, } check_fn = _make_check_fn(name) - for entry in _select_utility_schemas(name, server, config): + utility_entries = [] if exposure == "catalog" else _select_utility_schemas(name, server, config) + for entry in utility_entries: schema = entry["schema"] handler_key = entry["handler_key"] handler = _handler_factories[handler_key](name, server.tool_timeout) @@ -5044,6 +5076,8 @@ def _should_register(tool_name: str) -> bool: if registered_names: registry.register_toolset_alias(name, toolset_name) + if exposure == "catalog" and registry.get_tool_names_for_toolset(direct_toolset_name): + registry.register_toolset_alias(f"{name}-direct", direct_toolset_name) return registered_names diff --git a/website/docs/reference/mcp-config-reference.md b/website/docs/reference/mcp-config-reference.md index 2809f709f2ea..86b67039f53d 100644 --- a/website/docs/reference/mcp-config-reference.md +++ b/website/docs/reference/mcp-config-reference.md @@ -31,6 +31,8 @@ mcp_servers: # client_key: "/path/to/key.pem" # optional, when key lives in a separate file enabled: true + exposure: auto + expose_on: [cli, discord] timeout: 120 connect_timeout: 60 supports_parallel_tool_calls: false @@ -54,6 +56,8 @@ mcp_servers: | `client_cert` | string or list | HTTP | mTLS client certificate. String = path to a PEM file containing cert + key. List `[cert, key]` = separate files. List `[cert, key, password]` = encrypted key | | `client_key` | string | HTTP | Path to the client private key, when `client_cert` is a string and the key is in a separate file | | `enabled` | bool | both | Skip the server entirely when false | +| `exposure` | `auto`, `catalog`, `direct`, or `off` | both | Controls which connected server schemas are model-visible (default: `auto`) | +| `expose_on` | string or list | both | Limits model-visible aliases to named Hermes platforms without disconnecting the server | | `timeout` | number | both | Tool call timeout in seconds (default: `300`) | | `connect_timeout` | number | both | Initial connection timeout in seconds (default: `60`) | | `supports_parallel_tool_calls` | bool | both | Allow tools from this server to run concurrently | @@ -71,6 +75,44 @@ mcp_servers: | `resources` | bool-like | Enable/disable `list_resources` + `read_resource` | | `prompts` | bool-like | Enable/disable `list_prompts` + `get_prompt` | +## Schema exposure and activation + +Connectivity and model exposure are separate. An enabled MCP server can remain +connected and authenticated while ordinary sessions receive only a small +catalog bridge or no schemas at all. + +- `auto` (default): large servers exposing the complete `catalog.search`, + `catalog.describe`, and `catalog.call` bridge are catalog-only by default; + small or non-catalog servers retain direct exposure for compatibility. +- `catalog`: expose only those three catalog operations. Other discovered + operations remain available in the opt-in `-direct` tool profile. +- `direct`: preserve legacy behavior and expose all filtered operations. +- `off`: keep the server configured/connected but expose no model tools. + +Catalog-only mode is not a weaker authorization path. `catalog.call` invokes +the server's normal operation and therefore retains its authorization and +mutation checks. + +Use `expose_on` to scope the base catalog/direct alias by platform: + +```yaml +mcp_servers: + forge: + url: "https://forge.example/api/mcp" + exposure: catalog + expose_on: [discord, api_server, webhook] + +platform_toolsets: + discord: [hermes-discord, forge] # catalog bridge only + webhook: [hermes-webhook, forge-direct] # explicit full profile +``` + +`-direct` is a structured tool-profile selection; message text never +activates it. Words such as “review”, “execute”, or “research” do not change a +session's tools. Gateway/webhook route toolsets and platform toolsets are +resolved at the session boundary and are part of the cached agent signature. +The `no_mcp` sentinel blocks both base and `-direct` profiles. + ## Filtering semantics ### `include`