Compare commits

...

4 Commits

Author SHA1 Message Date
Jonathan Barket
f2840711a1 feat(tools): add "no_mcp" sentinel to exclude MCP servers per platform
Currently, MCP servers are included on all platforms by default. If a
platform's toolset list does not explicitly name any MCP servers, every
globally enabled MCP server is injected. There is no way to opt a
platform out of MCP servers entirely.

This matters for the API server platform when used as an execution
backend — each spawned agent session gets the full MCP tool schema
injected into its system prompt, dramatically inflating token usage
(e.g. 57K tokens vs 9K without MCP tools) and slowing response times.

Add a "no_mcp" sentinel value for platform_toolsets. When present in a
platform's toolset list, all MCP servers are excluded for that platform.
Other platforms are unaffected.

Usage in config.yaml:

    platform_toolsets:
      api_server:
        - terminal
        - file
        - web
        - no_mcp    # exclude all MCP servers

The sentinel is filtered out of the final toolset — it does not appear
as an actual toolset name.
2026-04-07 17:59:48 -07:00
Teknium
9041128e2c fix: use camelCase structuredContent attr, prefer structured over text
- The MCP SDK Pydantic model uses camelCase (structuredContent), not
  snake_case (structured_content). The original getattr was a silent no-op.
- When structuredContent is present, return it AS the result instead of
  alongside text — the structured payload is the machine-readable data.
- Move test file to tests/tools/ and fix fake class to use camelCase.
- Patch _run_on_mcp_loop in tests so the handler actually executes.
2026-04-07 17:59:48 -07:00
r266-tech
d77733dd4d test(mcp): add structured_content preservation tests 2026-04-07 17:59:48 -07:00
r266-tech
5e1d45f74c fix(mcp): preserve structured_content in tool call results
MCP CallToolResult may include structured_content (a JSON object) alongside
content blocks. The tool handler previously only forwarded concatenated text
from content blocks, silently dropping the structured payload.

This breaks MCP tools that return a minimal human text in content while
putting the actual machine-usable payload in structured_content.

Now, when structured_content is present, it is included in the returned
JSON under the 'structuredContent' key.

Fixes NousResearch/hermes-agent#5874
2026-04-07 17:59:48 -07:00
4 changed files with 166 additions and 4 deletions

View File

@@ -554,6 +554,7 @@ def _get_platform_tools(
# MCP servers are expected to be available on all platforms by default. # 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 # If the platform explicitly lists one or more MCP server names, treat that
# as an allowlist. Otherwise include every globally enabled MCP server. # as an allowlist. Otherwise include every globally enabled MCP server.
# Special sentinel: "no_mcp" in the toolset list disables all MCP servers.
mcp_servers = config.get("mcp_servers") or {} mcp_servers = config.get("mcp_servers") or {}
enabled_mcp_servers = { enabled_mcp_servers = {
name name
@@ -561,10 +562,15 @@ def _get_platform_tools(
if isinstance(server_cfg, dict) if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True) and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
} }
explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers # Allow "no_mcp" sentinel to opt out of all MCP servers for this platform
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers) if "no_mcp" in toolset_names:
explicit_mcp_servers = set()
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers - {"no_mcp"})
else:
explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
if include_default_mcp_servers: if include_default_mcp_servers:
if explicit_mcp_servers: if explicit_mcp_servers or "no_mcp" in toolset_names:
enabled_toolsets.update(explicit_mcp_servers) enabled_toolsets.update(explicit_mcp_servers)
else: else:
enabled_toolsets.update(enabled_mcp_servers) enabled_toolsets.update(enabled_mcp_servers)

View File

@@ -72,6 +72,45 @@ def test_get_platform_tools_keeps_enabled_mcp_servers_with_explicit_builtin_sele
assert "web-search-prime" in enabled assert "web-search-prime" in enabled
def test_get_platform_tools_no_mcp_sentinel_excludes_all_mcp_servers():
"""The 'no_mcp' sentinel in platform_toolsets excludes all MCP servers."""
config = {
"platform_toolsets": {"cli": ["web", "terminal", "no_mcp"]},
"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 "terminal" in enabled
assert "exa" not in enabled
assert "web-search-prime" not in enabled
assert "no_mcp" not in enabled
def test_get_platform_tools_no_mcp_sentinel_does_not_affect_other_platforms():
"""The 'no_mcp' sentinel only affects the platform it's configured on."""
config = {
"platform_toolsets": {
"api_server": ["web", "terminal", "no_mcp"],
},
"mcp_servers": {
"exa": {"url": "https://mcp.exa.ai/mcp"},
},
}
# api_server should exclude MCP
api_enabled = _get_platform_tools(config, "api_server")
assert "exa" not in api_enabled
# cli (not configured with no_mcp) should include MCP
cli_enabled = _get_platform_tools(config, "cli")
assert "exa" in cli_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

@@ -0,0 +1,111 @@
"""Tests for MCP tool structuredContent preservation."""
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tools import mcp_tool
class _FakeContentBlock:
"""Minimal content block with .text and .type attributes."""
def __init__(self, text: str, block_type: str = "text"):
self.text = text
self.type = block_type
class _FakeCallToolResult:
"""Minimal CallToolResult stand-in.
Uses camelCase ``structuredContent`` / ``isError`` to match the real
MCP SDK Pydantic model (``mcp.types.CallToolResult``).
"""
def __init__(self, content, is_error=False, structuredContent=None):
self.content = content
self.isError = is_error
self.structuredContent = structuredContent
def _fake_run_on_mcp_loop(coro, timeout=30):
"""Run an MCP coroutine directly in a fresh event loop."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@pytest.fixture
def _patch_mcp_server():
"""Patch _servers and the MCP event loop so _make_tool_handler can run."""
fake_session = MagicMock()
fake_server = SimpleNamespace(session=fake_session)
with patch.dict(mcp_tool._servers, {"test-server": fake_server}), \
patch("tools.mcp_tool._run_on_mcp_loop", side_effect=_fake_run_on_mcp_loop):
yield fake_session
class TestStructuredContentPreservation:
"""Ensure structuredContent from CallToolResult is forwarded."""
def test_text_only_result(self, _patch_mcp_server):
"""When no structuredContent, result is text-only (existing behaviour)."""
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("hello")],
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
raw = handler({})
data = json.loads(raw)
assert data == {"result": "hello"}
def test_structured_content_is_the_result(self, _patch_mcp_server):
"""When structuredContent is present, it becomes the result directly."""
session = _patch_mcp_server
payload = {"value": "secret-123", "revealed": True}
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("OK")],
structuredContent=payload,
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
raw = handler({})
data = json.loads(raw)
assert data["result"] == payload
def test_structured_content_none_falls_back_to_text(self, _patch_mcp_server):
"""When structuredContent is explicitly None, fall back to text."""
session = _patch_mcp_server
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[_FakeContentBlock("done")],
structuredContent=None,
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
raw = handler({})
data = json.loads(raw)
assert data == {"result": "done"}
def test_empty_text_with_structured_content(self, _patch_mcp_server):
"""When content blocks are empty but structuredContent exists."""
session = _patch_mcp_server
payload = {"status": "ok", "data": [1, 2, 3]}
session.call_tool = AsyncMock(
return_value=_FakeCallToolResult(
content=[],
structuredContent=payload,
)
)
handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
raw = handler({})
data = json.loads(raw)
assert data["result"] == payload

View File

@@ -1253,7 +1253,13 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
for block in (result.content or []): for block in (result.content or []):
if hasattr(block, "text"): if hasattr(block, "text"):
parts.append(block.text) parts.append(block.text)
return json.dumps({"result": "\n".join(parts) if parts else ""}) text_result = "\n".join(parts) if parts else ""
# Prefer structuredContent (machine-readable JSON) over plain text
structured = getattr(result, "structuredContent", None)
if structured is not None:
return json.dumps({"result": structured})
return json.dumps({"result": text_result})
try: try:
return _run_on_mcp_loop(_call(), timeout=tool_timeout) return _run_on_mcp_loop(_call(), timeout=tool_timeout)