Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,16 @@ Key config knobs (under `delegation:` in `config.yaml`):
`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
`max_iterations`.

**MCP toolset resolution for named profiles:** When `_build_child_agent()`
is called with `profile_name` set (i.e. the delegation originated from a
named `agent_profiles` entry), MCP toolsets in the requested list bypass
the parent-intersection check and are resolved directly from the global
`mcp_servers` config. This prevents a silent failure mode where an
orchestrator that restricts its own MCP context (via `no_mcp` in
`platform_toolsets`) inadvertently starves child agents of domain MCP tools
they explicitly need. Non-MCP toolsets still go through parent intersection
— the security boundary for ad-hoc delegation is preserved. See #32668.

Synchronicity rule: delegate_task is **not** durable. For long-running
work that must outlive the current turn, use `cronjob` or
`terminal(background=True, notify_on_complete=True)` instead.
Expand Down
36 changes: 33 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,14 +792,16 @@ def _reload_runtime_env_preserving_config_authority() -> None:
# Bridge config.yaml values into the environment so os.getenv() picks them up.
# config.yaml is authoritative for terminal settings — overrides .env.
_config_path = _hermes_home / 'config.yaml'
_cfg: Dict[str, Any] = {}
if _config_path.exists():
try:
import yaml as _yaml
with open(_config_path, encoding="utf-8") as _f:
_cfg = _yaml.safe_load(_f) or {}
# Expand ${ENV_VAR} references before bridging to env vars.
from hermes_cli.config import _expand_env_vars
_cfg = _expand_env_vars(_cfg)
_expanded = _expand_env_vars(_cfg)
_cfg = _expanded if isinstance(_expanded, dict) else {}
# Top-level simple values (fallback only — don't override .env)
for _key, _val in _cfg.items():
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
Expand Down Expand Up @@ -975,7 +977,7 @@ def _reload_runtime_env_preserving_config_authority() -> None:
# Apply IPv4 preference if configured (before any HTTP clients are created).
try:
from hermes_constants import apply_ipv4_preference
_network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {})
_network_cfg = _cfg.get("network", {})
if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"):
apply_ipv4_preference(force=True)
except Exception as _bootstrap_exc:
Expand Down Expand Up @@ -1385,6 +1387,25 @@ def _platform_config_key(platform: "Platform") -> str:
return "cli" if platform == Platform.LOCAL else platform.value


def _active_platform_uses_no_mcp(config: dict) -> bool:
"""Return True if the current platform's toolsets include the no_mcp sentinel.

The active platform is resolved from the ``HERMES_PLATFORM`` env var (with the
``HERMES_SESSION_PLATFORM`` fallback used elsewhere in the codebase), defaulting
to ``"cli"``. When that platform's ``platform_toolsets`` list contains the
``no_mcp`` sentinel, eager MCP discovery can be skipped at startup and deferred
until the first child delegation that actually needs MCP toolsets.
"""
platform = (
os.environ.get("HERMES_PLATFORM")
or os.environ.get("HERMES_SESSION_PLATFORM")
or "cli"
)
platform_toolsets = (config or {}).get("platform_toolsets", {}) or {}
toolsets = platform_toolsets.get(platform, []) or []
return "no_mcp" in toolsets


def _teams_pipeline_plugin_enabled() -> bool:
"""Return True when the standalone Teams pipeline plugin is enabled."""
config = _load_gateway_config()
Expand Down Expand Up @@ -18432,7 +18453,16 @@ def restart_signal_handler():
try:
from tools.mcp_tool import discover_mcp_tools
_loop = asyncio.get_running_loop()
await _loop.run_in_executor(None, discover_mcp_tools)
_gateway_cfg = _load_gateway_config()
if _active_platform_uses_no_mcp(_gateway_cfg):
from tools.mcp_tool import mark_eager_discovery_skipped
mark_eager_discovery_skipped()
logger.info(
"MCP eager discovery skipped (platform uses no_mcp); "
"tools will load lazily on first delegation"
)
else:
await _loop.run_in_executor(None, discover_mcp_tools)
except Exception as e:
logger.debug("MCP tool discovery failed: %s", e)

Expand Down
42 changes: 41 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10862,6 +10862,32 @@ def _plugin_cli_discovery_needed() -> bool:
}


def _active_platform_uses_no_mcp_at_startup() -> bool:
"""Return True if the active platform's toolsets include the no_mcp sentinel.

Resolves the platform from ``HERMES_PLATFORM`` (with the
``HERMES_SESSION_PLATFORM`` fallback used elsewhere), defaulting to ``"cli"``,
and reads ``platform_toolsets`` directly from the raw config dict. Used to
decide whether eager MCP discovery can be skipped at CLI startup (Phase 2
lazy discovery). Best-effort: any failure returns False so discovery runs
as before.
"""
try:
from hermes_cli.config import read_raw_config

config = read_raw_config() or {}
except Exception:
return False
platform = (
os.environ.get("HERMES_PLATFORM")
or os.environ.get("HERMES_SESSION_PLATFORM")
or "cli"
)
platform_toolsets = config.get("platform_toolsets", {}) or {}
toolsets = platform_toolsets.get(platform, []) or []
return "no_mcp" in toolsets


def _prepare_agent_startup(args) -> None:
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
Expand All @@ -10886,9 +10912,23 @@ def _prepare_agent_startup(args) -> None:
# so inline is safe. Moved here from model_tools.py module scope
# to avoid freezing the gateway's event loop on its first message
# via the same lazy import path (#16856).
#
# When the active platform's toolsets include the ``no_mcp`` sentinel
# (e.g. api_server), skip eager discovery here too and let the first
# MCP-needing delegation trigger it lazily (Phase 2). Only the
# no_mcp platforms are affected; cli/cron/telegram run as before.
from tools.mcp_tool import discover_mcp_tools

discover_mcp_tools()
if _active_platform_uses_no_mcp_at_startup():
from tools.mcp_tool import mark_eager_discovery_skipped

mark_eager_discovery_skipped()
logger.debug(
"MCP eager discovery skipped at CLI startup "
"(platform uses no_mcp); tools will load lazily on first delegation"
)
else:
discover_mcp_tools()
except Exception:
logger.debug(
"MCP tool discovery failed at CLI startup",
Expand Down
101 changes: 100 additions & 1 deletion tests/tools/test_delegate_toolset_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from unittest.mock import MagicMock, patch
from types import SimpleNamespace

from tools.delegate_tool import _strip_blocked_tools
from tools.delegate_tool import _build_child_agent, _strip_blocked_tools


class TestToolsetIntersection:
Expand Down Expand Up @@ -64,3 +64,102 @@ def test_empty_intersection_yields_empty_toolsets(self):
scoped = [t for t in requested if t in parent_toolsets]

assert scoped == []


def _make_mcp_restricted_parent():
"""Parent agent whose MCP context is empty (e.g. no_mcp orchestrator).

enabled_toolsets=[] means the parent loaded no toolsets at all — the
intersection against it would normally drop every requested toolset.
"""
parent = MagicMock()
parent.enabled_toolsets = []
parent._delegate_depth = 0
parent._credential_pool = None
parent.tool_progress_callback = None
parent.thinking_callback = None
parent._print_fn = None
return parent


class TestProfileMcpToolsetBypass:
"""MCP toolsets declared by a named agent_profile bypass parent intersection.

Regression coverage for NousResearch/hermes-agent#32668: an orchestrator
that restricts its own MCP servers must still be able to hand domain MCP
toolsets to a child via a named profile. Non-MCP toolsets keep going
through the parent intersection (the security boundary).
"""

@patch("tools.delegate_tool._load_config", return_value={})
def test_profile_mcp_toolsets_bypass_parent_intersection(self, _):
"""profile_name set → MCP toolsets pass through even when parent has none."""
parent = _make_mcp_restricted_parent()

with patch("run_agent.AIAgent") as MockAgent:
MockAgent.return_value = MagicMock()

_build_child_agent(
task_index=0,
goal="Check mail",
context=None,
toolsets=["mcp-fastmail", "mcp-knowledge"],
model=None,
max_iterations=10,
task_count=1,
parent_agent=parent,
profile_name="mail",
)

child_toolsets = MockAgent.call_args[1]["enabled_toolsets"]
assert "mcp-fastmail" in child_toolsets
assert "mcp-knowledge" in child_toolsets

@patch("tools.delegate_tool._load_config", return_value={})
def test_no_profile_mcp_toolsets_still_intersected(self, _):
"""No profile → MCP toolset request is dropped (intersection enforced)."""
parent = _make_mcp_restricted_parent()

with patch("run_agent.AIAgent") as MockAgent:
MockAgent.return_value = MagicMock()

_build_child_agent(
task_index=0,
goal="Check mail",
context=None,
toolsets=["mcp-fastmail"],
model=None,
max_iterations=10,
task_count=1,
parent_agent=parent,
profile_name=None,
)

child_toolsets = MockAgent.call_args[1]["enabled_toolsets"]
assert "mcp-fastmail" not in child_toolsets

@patch("tools.delegate_tool._load_config", return_value={})
def test_profile_non_mcp_toolsets_still_intersected(self, _):
"""Even with a profile, non-MCP toolsets the parent lacks are dropped."""
parent = _make_mcp_restricted_parent()

with patch("run_agent.AIAgent") as MockAgent:
MockAgent.return_value = MagicMock()

_build_child_agent(
task_index=0,
goal="Browse the web",
context=None,
toolsets=["mcp-fastmail", "browser"],
model=None,
max_iterations=10,
task_count=1,
parent_agent=parent,
profile_name="mail",
)

child_toolsets = MockAgent.call_args[1]["enabled_toolsets"]
# MCP toolset bypasses intersection ...
assert "mcp-fastmail" in child_toolsets
# ... but the non-MCP toolset the parent never had is still dropped.
assert "browser" not in child_toolsets
Loading