Skip to content

Commit a4c0640

Browse files
committed
feat(mcp): smart loading — connect_strategy, tool budget, per-session scoping, meta-tools
Four levers to control MCP server loading, reducing startup time and context-window bloat for users with many MCP servers. Lever 1 — connect_strategy (per-server): startup (default): connect at Hermes startup, register all tools lazy: register tool schemas but defer subprocess spawn until first call on_demand: invisible until explicitly loaded via /mcp load or mcp_load_server Lever 2 — mcp_tool_budget (global): Caps the number of MCP tool schemas in the LLM prompt. LRU eviction drops least-used servers' tools when budget exceeded. Meta-tools (mcp_list_servers, mcp_load_server) always survive eviction. Default: 0 (unlimited, backward compatible). Lever 3 — /mcp slash commands: /mcp list → show all servers, status, tool counts, strategy /mcp enable <name> → activate a lazy/on_demand server now /mcp disable <name> → mark an on_demand server inactive for this session /mcp load <name> → same as enable Lever 4 — meta-tools (always available): mcp_list_servers — returns all configured servers with status/strategy mcp_load_server — activates a lazy or on_demand server mid-session Bug fixes discovered during audit: - _activate_lazy_server removed from _lazy_servers before connecting, leaving failed connections in limbo (not retryable, not connected) - /mcp disable called _clear_session_loaded_servers() which cleared ALL on_demand servers, not just the named one - Lazy server stub tools had no toolset alias registered, making them invisible to the platform resolver - Meta-tools registered into toolset 'mcp' which doesn't exist in any platform composite — tools were invisible to the LLM Refs: NousResearch#66473 (umbrella), NousResearch#63626 (lazy MCP), NousResearch#6839 (tool schema deferral), NousResearch#45955 (per-session scoping)
1 parent d59b79f commit a4c0640

6 files changed

Lines changed: 588 additions & 23 deletions

File tree

cli.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8790,6 +8790,8 @@ def process_command(self, command: str) -> bool:
87908790
# The auto-reload path (file watcher) calls _reload_mcp directly
87918791
# without this confirmation.
87928792
self._confirm_and_reload_mcp(cmd_original)
8793+
elif canonical == "mcp":
8794+
self._handle_mcp_command(cmd_original)
87938795
elif canonical == "reload-skills":
87948796
with self._busy_command(self._slow_command_status(cmd_original)):
87958797
self._reload_skills()
@@ -10876,6 +10878,108 @@ def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
1087610878
with self._busy_command(self._slow_command_status(cmd_original)):
1087710879
self._reload_mcp()
1087810880

10881+
def _handle_mcp_command(self, cmd_original: str) -> None:
10882+
"""Handle /mcp list|enable|disable|load <name>."""
10883+
parts = cmd_original.strip().split(None, 2)
10884+
subcmd = parts[1].lower() if len(parts) > 1 else "list"
10885+
arg = parts[2] if len(parts) > 2 else ""
10886+
10887+
from tools.mcp_tool import (
10888+
_load_mcp_config, _servers, _lock,
10889+
_is_lazy_server, _is_on_demand_server,
10890+
_is_server_loaded_in_session, _mark_server_loaded_in_session,
10891+
_activate_lazy_server, _activate_on_demand_server,
10892+
_get_connect_strategy, _parse_boolish,
10893+
_remove_session_loaded_server,
10894+
)
10895+
10896+
if subcmd == "list":
10897+
servers = _load_mcp_config()
10898+
if not servers:
10899+
print(" No MCP servers configured.")
10900+
return
10901+
10902+
print(f" MCP servers ({len(servers)} configured):")
10903+
for name, cfg in sorted(servers.items()):
10904+
enabled = _parse_boolish(cfg.get("enabled", True), default=True)
10905+
if not enabled:
10906+
print(f" {name}: disabled")
10907+
continue
10908+
10909+
strategy = _get_connect_strategy(cfg)
10910+
transport = "http" if "url" in cfg else "stdio"
10911+
10912+
with _lock:
10913+
server = _servers.get(name)
10914+
connected = server is not None and server.session is not None
10915+
tool_count = len(getattr(server, "_registered_tool_names", [])) if server else 0
10916+
10917+
is_lazy = _is_lazy_server(name)
10918+
is_ondemand = _is_on_demand_server(name)
10919+
loaded = _is_server_loaded_in_session(name) if is_ondemand else True
10920+
10921+
status = "connected" if connected else "lazy" if is_lazy else "on_demand" if is_ondemand else "disconnected"
10922+
active = "active" if (connected or (is_lazy and not is_ondemand) or loaded) else "inactive"
10923+
print(f" {name}: {status} ({transport}, {tool_count} tools, {active})")
10924+
10925+
elif subcmd == "enable":
10926+
if not arg:
10927+
print(" Usage: /mcp enable <server-name>")
10928+
return
10929+
servers = _load_mcp_config()
10930+
if arg not in servers:
10931+
print(f" Unknown MCP server '{arg}'. Use /mcp list to see available servers.")
10932+
return
10933+
strategy = _get_connect_strategy(servers[arg])
10934+
if strategy == "on_demand":
10935+
if _activate_on_demand_server(arg):
10936+
print(f" ✅ MCP server '{arg}' loaded and activated.")
10937+
else:
10938+
print(f" ❌ Failed to load MCP server '{arg}'.")
10939+
elif strategy == "lazy":
10940+
if _activate_lazy_server(arg):
10941+
print(f" ✅ MCP server '{arg}' activated.")
10942+
else:
10943+
print(f" ❌ Failed to activate MCP server '{arg}'.")
10944+
else:
10945+
print(f" MCP server '{arg}' is already configured as 'startup' (always connected).")
10946+
10947+
elif subcmd == "disable":
10948+
if not arg:
10949+
print(" Usage: /mcp disable <server-name>")
10950+
return
10951+
# For on_demand servers, mark as not loaded in this session
10952+
if _is_on_demand_server(arg):
10953+
_remove_session_loaded_server(arg)
10954+
print(f" MCP server '{arg}' disabled for this session.")
10955+
else:
10956+
print(f" MCP server '{arg}' cannot be disabled mid-session. Use /reload-mcp to restart all servers.")
10957+
10958+
elif subcmd == "load":
10959+
if not arg:
10960+
print(" Usage: /mcp load <server-name>")
10961+
return
10962+
servers = _load_mcp_config()
10963+
if arg not in servers:
10964+
print(f" Unknown MCP server '{arg}'. Use /mcp list to see available servers.")
10965+
return
10966+
strategy = _get_connect_strategy(servers[arg])
10967+
if strategy == "on_demand":
10968+
if _activate_on_demand_server(arg):
10969+
print(f" ✅ MCP server '{arg}' loaded and activated.")
10970+
else:
10971+
print(f" ❌ Failed to load MCP server '{arg}'.")
10972+
elif strategy == "lazy":
10973+
if _activate_lazy_server(arg):
10974+
print(f" ✅ MCP server '{arg}' activated.")
10975+
else:
10976+
print(f" ❌ Failed to activate MCP server '{arg}'.")
10977+
else:
10978+
print(f" MCP server '{arg}' is already connected (startup strategy).")
10979+
10980+
else:
10981+
print(f" Unknown subcommand '{subcmd}'. Use: list, enable <name>, disable <name>, load <name>")
10982+
1087910983
def _reload_mcp(self):
1088010984
"""Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
1088110985

hermes_cli/commands.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ class CommandDef:
214214
cli_only=True),
215215
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
216216
aliases=("reload_mcp",)),
217+
CommandDef("mcp", "Manage MCP servers: list, enable, disable, load", "Tools & Skills",
218+
args_hint="[list|enable <name>|disable <name>|load <name>]",
219+
subcommands=("list", "enable", "disable", "load")),
217220
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
218221
"Tools & Skills", aliases=("reload_skills",)),
219222
CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP", "Tools & Skills",

hermes_cli/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,12 @@ def _ensure_hermes_home_managed(home: Path):
13811381
# small so a slow/dead server adds little to first-response latency.
13821382
"mcp_discovery_timeout": 1.5,
13831383

1384+
# MCP tool schema budget: max MCP tool schemas to inject into the
1385+
# LLM system prompt. When the total MCP tools exceed this budget,
1386+
# least-recently-used servers' tools are dropped. 0 = unlimited
1387+
# (current default, backward compatible).
1388+
"mcp_tool_budget": 0,
1389+
13841390
# Tool-output truncation thresholds. When terminal output or a
13851391
# single read_file page exceeds these limits, Hermes truncates the
13861392
# payload sent to the model (keeping head + tail for terminal,

model_tools.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,35 @@
3838
# advisory (#33924) is logged once per name, not on every tool recompute.
3939
_WARNED_DISABLED_BUNDLES: set = set()
4040

41+
# MCP tool budget tracking: LRU timestamps for MCP tool calls.
42+
# Keyed by tool name, value is monotonic time of last use.
43+
_mcp_tool_last_used: Dict[str, float] = {}
44+
_mcp_tool_last_used_lock = threading.Lock()
45+
46+
47+
def _resolve_mcp_tool_budget() -> int:
48+
"""Read the MCP tool budget from config. 0 = unlimited."""
49+
try:
50+
from hermes_cli.config import load_config
51+
cfg = load_config() or {}
52+
return int(cfg.get("mcp_tool_budget", 0))
53+
except Exception:
54+
return 0
55+
56+
57+
def _get_mcp_tool_last_used(tool_name: str) -> float:
58+
"""Return the last-used timestamp for an MCP tool (0 if never used)."""
59+
with _mcp_tool_last_used_lock:
60+
return _mcp_tool_last_used.get(tool_name, 0.0)
61+
62+
63+
def _mark_mcp_tool_used(tool_name: str) -> None:
64+
"""Record that an MCP tool was just used (for LRU eviction)."""
65+
if not tool_name.startswith("mcp__"):
66+
return
67+
with _mcp_tool_last_used_lock:
68+
_mcp_tool_last_used[tool_name] = time.monotonic()
69+
4170

4271
# =============================================================================
4372
# Async Bridging (single source of truth -- used by registry.dispatch too)
@@ -450,6 +479,38 @@ def _compute_tool_definitions(
450479
# descriptions that don't actually exist, and hallucinates calls to them.
451480
available_tool_names = {t["function"]["name"] for t in filtered_tools}
452481

482+
# Apply MCP tool budget: if configured, cap the number of MCP tool
483+
# schemas in the prompt. Meta-tools (mcp_list_servers, mcp_load_server)
484+
# are always included. When the budget is exceeded, tools from
485+
# least-recently-used servers are dropped first.
486+
_mcp_tool_budget = _resolve_mcp_tool_budget()
487+
if _mcp_tool_budget > 0:
488+
mcp_tools = [
489+
t for t in filtered_tools
490+
if t.get("function", {}).get("name", "").startswith("mcp__")
491+
and t.get("function", {}).get("name", "") not in ("mcp_list_servers", "mcp_load_server")
492+
]
493+
if len(mcp_tools) > _mcp_tool_budget:
494+
# Keep meta-tools, drop excess MCP tools
495+
non_mcp_tools = [
496+
t for t in filtered_tools
497+
if not t.get("function", {}).get("name", "").startswith("mcp__")
498+
or t.get("function", {}).get("name", "") in ("mcp_list_servers", "mcp_load_server")
499+
]
500+
# Sort MCP tools by LRU: keep the most recently used ones
501+
mcp_tools.sort(
502+
key=lambda t: _get_mcp_tool_last_used(t.get("function", {}).get("name", "")),
503+
reverse=True,
504+
)
505+
filtered_tools = non_mcp_tools + mcp_tools[:_mcp_tool_budget]
506+
# Recompute available_tool_names
507+
available_tool_names = {t["function"]["name"] for t in filtered_tools}
508+
logger.debug(
509+
"MCP tool budget: capped %d MCP tools to %d (dropped %d)",
510+
len(mcp_tools), _mcp_tool_budget,
511+
len(mcp_tools) - _mcp_tool_budget,
512+
)
513+
453514
# Rebuild execute_code schema to only list sandbox tools that are actually
454515
# available. Without this, the model sees "web_search is available in
455516
# execute_code" even when the API key isn't configured or the toolset is
@@ -1289,6 +1350,9 @@ def _dispatch(next_args: Dict[str, Any]) -> Any:
12891350
turn_id=turn_id or "",
12901351
api_request_id=api_request_id or "",
12911352
)
1353+
1354+
# Track MCP tool usage for LRU eviction
1355+
_mark_mcp_tool_used(function_name)
12921356
finally:
12931357
if _approval_tokens is not None and reset_current_observability_context is not None:
12941358
try:

0 commit comments

Comments
 (0)