Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	cli.py
#	hermes_cli/main.py
#	run_agent.py
#	tests/hermes_cli/test_cmd_update.py
#	tools/mcp_tool.py
#	web/src/lib/gatewayClient.ts
This commit is contained in:
Brooklyn Nicholson
2026-05-18 01:26:56 -05:00
260 changed files with 24547 additions and 13573 deletions
+103 -15
View File
@@ -91,6 +91,7 @@ import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
@@ -492,6 +493,73 @@ def _cache_mcp_image_block(block) -> str:
return f"MEDIA:{image_path}"
# ---------------------------------------------------------------------------
# Remote MCP URL validation
# ---------------------------------------------------------------------------
class InvalidMcpUrlError(ValueError):
"""Raised when a remote MCP server's ``url`` cannot be parsed as http(s)://.
Validated once at startup so we fail fast with a clear message instead of
burning through the reconnect-backoff loop on every attempt. (Ported from
anomalyco/opencode#25019.)
"""
def _validate_remote_mcp_url(server_name: str, url: Any) -> str:
"""Return the URL as a string if it's a valid http(s) remote MCP URL.
Raises :class:`InvalidMcpUrlError` otherwise with a message naming the
offending server, so users can spot the bad entry in their config.
Accepts:
- ``http://host`` / ``https://host`` with optional port, path, query
- IPv4, IPv6 (bracketed), DNS hostnames
Rejects:
- Non-string values (``None``, dicts, ints)
- Missing scheme (``example.com/mcp``)
- Non-http(s) schemes (``file://``, ``ws://``, ``stdio:`` — stdio servers
use the ``command`` key, not ``url``)
- Empty host (``http://``, ``https:///path``)
"""
if not isinstance(url, str):
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': expected a string, got "
f"{type(url).__name__}"
)
stripped = url.strip()
if not stripped:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': empty url"
)
try:
parsed = urlparse(stripped)
except Exception as exc: # urlparse is very permissive — belt and braces
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': {stripped!r} ({exc})"
) from exc
if parsed.scheme.lower() not in {"http", "https"}:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': scheme must be http or "
f"https, got {parsed.scheme!r} ({stripped!r})"
)
if not parsed.netloc:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': missing host ({stripped!r})"
)
# ``urlparse`` accepts ``http://:8080`` (empty host, explicit port).
# Reject that — we need a real host.
if not parsed.hostname:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': missing hostname "
f"({stripped!r})"
)
return stripped
def _format_connect_error(exc: BaseException) -> str:
"""Render nested MCP connection errors into an actionable short message."""
@@ -1094,6 +1162,7 @@ class MCPServerTask:
}
for tool_name in stale_tool_names:
registry.deregister(tool_name)
_forget_mcp_tool_server(tool_name)
# 3. Re-register with fresh tool list
self._tools = new_mcp_tools
@@ -1614,6 +1683,7 @@ class MCPServerTask:
self._pending_refresh_tasks.clear()
for tool_name in list(getattr(self, "_registered_tool_names", [])):
registry.deregister(tool_name)
_forget_mcp_tool_server(tool_name)
self._registered_tool_names = []
self.session = None
@@ -1984,11 +2054,20 @@ def _handle_session_expired_and_retry(
# ``is_mcp_tool_parallel_safe()`` for the parallel-execution check in run_agent.
_parallel_safe_servers: set = set()
# Exact MCP tool-name provenance. MCP tool names are formatted as
# ``mcp_{sanitized_server}_{sanitized_tool}``, which is ambiguous when server
# names contain underscores (``mcp_a_b_tool`` could be server ``a`` + tool
# ``b_tool`` or server ``a_b`` + tool ``tool``). Keep the server component
# captured at registration time so parallel safety never relies on prefix
# guessing.
_mcp_tool_server_names: Dict[str, str] = {}
# Dedicated event loop running in a background daemon thread.
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
_mcp_thread: Optional[threading.Thread] = None
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers, and _stdio_pids.
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers,
# _mcp_tool_server_names, and _stdio_pids.
_lock = threading.Lock()
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
@@ -2871,6 +2950,19 @@ _UTILITY_CAPABILITY_ATTRS = {
}
def _track_mcp_tool_server(tool_name: str, server_name: str) -> None:
"""Remember the exact MCP server that registered *tool_name*."""
safe_server_name = sanitize_mcp_name_component(server_name)
with _lock:
_mcp_tool_server_names[tool_name] = safe_server_name
def _forget_mcp_tool_server(tool_name: str) -> None:
"""Forget MCP server provenance for a deregistered tool."""
with _lock:
_mcp_tool_server_names.pop(tool_name, None)
def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]:
"""Select utility schemas based on config and server capabilities."""
tools_filter = config.get("tools") or {}
@@ -3005,6 +3097,7 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li
is_async=False,
description=schema["description"],
)
_track_mcp_tool_server(tool_name_prefixed, name)
registered_names.append(tool_name_prefixed)
# Register MCP Resources & Prompts utility tools, filtered by config and
@@ -3041,6 +3134,7 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li
is_async=False,
description=schema["description"],
)
_track_mcp_tool_server(util_name, name)
registered_names.append(util_name)
if registered_names:
@@ -3225,24 +3319,19 @@ def discover_mcp_tools() -> List[str]:
def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
"""Check if an MCP tool belongs to a server that supports parallel tool calls.
MCP tool names follow the pattern ``mcp_{server}_{tool}``. This extracts
the server component and checks it against the set of servers whose config
includes ``supports_parallel_tool_calls: true``.
MCP tool names follow the pattern ``mcp_{server}_{tool}``, but that string
shape is ambiguous when server names contain underscores. Use the exact
server provenance captured at registration time rather than prefix
matching, then check whether that server's config includes
``supports_parallel_tool_calls: true``.
Returns False for non-MCP tools or tools from servers without the flag.
"""
if not tool_name.startswith("mcp_"):
return False
# Strip the "mcp_" prefix and extract the server name.
# Tool names are: mcp_{sanitized_server}_{sanitized_tool}
# We need to check all possible server prefixes because the server name
# itself may contain underscores after sanitization.
rest = tool_name[4:] # strip "mcp_"
with _lock:
for server_name in _parallel_safe_servers:
if rest.startswith(server_name + "_") and len(rest) > len(server_name) + 1:
return True
return False
server_name = _mcp_tool_server_names.get(tool_name)
return bool(server_name and server_name in _parallel_safe_servers)
def get_mcp_status() -> List[dict]:
@@ -3415,7 +3504,6 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
sessions can still be in flight.
"""
import signal as _signal
import time as _time
with _lock:
pids: Dict[int, str] = {}
@@ -3440,7 +3528,7 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
pass
# Phase 2: Wait for graceful exit
_time.sleep(2)
time.sleep(2)
# Phase 3: SIGKILL any survivors
_sigkill = getattr(_signal, "SIGKILL", _signal.SIGTERM)