feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
MCP (Model Context Protocol) Client Support
|
|
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
Connects to external MCP servers via stdio or HTTP/StreamableHTTP transport,
|
|
|
|
|
discovers their tools, and registers them into the hermes-agent tool registry
|
|
|
|
|
so the agent can call them like any built-in tool.
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
Configuration is read from ~/.hermes/config.yaml under the ``mcp_servers`` key.
|
|
|
|
|
The ``mcp`` Python package is optional -- if not installed, this module is a
|
|
|
|
|
no-op and logs a debug message.
|
|
|
|
|
|
|
|
|
|
Example config::
|
|
|
|
|
|
|
|
|
|
mcp_servers:
|
|
|
|
|
filesystem:
|
|
|
|
|
command: "npx"
|
|
|
|
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
|
|
|
|
env: {}
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
timeout: 120 # per-tool-call timeout in seconds (default: 120)
|
|
|
|
|
connect_timeout: 60 # initial connection timeout (default: 60)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
github:
|
|
|
|
|
command: "npx"
|
|
|
|
|
args: ["-y", "@modelcontextprotocol/server-github"]
|
|
|
|
|
env:
|
|
|
|
|
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..."
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
remote_api:
|
|
|
|
|
url: "https://my-mcp-server.example.com/mcp"
|
|
|
|
|
headers:
|
|
|
|
|
Authorization: "Bearer sk-..."
|
|
|
|
|
timeout: 180
|
2026-03-09 03:37:38 -07:00
|
|
|
analysis:
|
|
|
|
|
command: "npx"
|
|
|
|
|
args: ["-y", "analysis-server"]
|
|
|
|
|
sampling: # server-initiated LLM requests
|
|
|
|
|
enabled: true # default: true
|
|
|
|
|
model: "gemini-3-flash" # override model (optional)
|
|
|
|
|
max_tokens_cap: 4096 # max tokens per request
|
|
|
|
|
timeout: 30 # LLM call timeout (seconds)
|
|
|
|
|
max_rpm: 10 # max requests per minute
|
|
|
|
|
allowed_models: [] # model whitelist (empty = all)
|
|
|
|
|
max_tool_rounds: 5 # tool loop limit (0 = disable)
|
|
|
|
|
log_level: "info" # audit verbosity
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
|
|
|
|
|
Features:
|
|
|
|
|
- Stdio transport (command + args) and HTTP/StreamableHTTP transport (url)
|
|
|
|
|
- Automatic reconnection with exponential backoff (up to 5 retries)
|
|
|
|
|
- Environment variable filtering for stdio subprocesses (security)
|
|
|
|
|
- Credential stripping in error messages returned to the LLM
|
|
|
|
|
- Configurable per-server timeouts for tool calls and connections
|
|
|
|
|
- Thread-safe architecture with dedicated background event loop
|
2026-03-09 03:37:38 -07:00
|
|
|
- Sampling support: MCP servers can request LLM completions via
|
|
|
|
|
sampling/createMessage (text and tool-use responses)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
Architecture:
|
|
|
|
|
A dedicated background event loop (_mcp_loop) runs in a daemon thread.
|
2026-03-02 21:22:00 +03:00
|
|
|
Each MCP server runs as a long-lived asyncio Task on this loop, keeping
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
its transport context alive. Tool call coroutines are scheduled onto the
|
|
|
|
|
loop via ``run_coroutine_threadsafe()``.
|
2026-03-02 21:22:00 +03:00
|
|
|
|
|
|
|
|
On shutdown, each server Task is signalled to exit its ``async with``
|
|
|
|
|
block, ensuring the anyio cancel-scope cleanup happens in the *same*
|
|
|
|
|
Task that opened the connection (required by anyio).
|
2026-03-02 22:08:32 +03:00
|
|
|
|
|
|
|
|
Thread safety:
|
|
|
|
|
_servers and _mcp_loop/_mcp_thread are accessed from both the MCP
|
|
|
|
|
background thread and caller threads. All mutations are protected by
|
|
|
|
|
_lock so the code is safe regardless of GIL presence (e.g. Python 3.13+
|
|
|
|
|
free-threading).
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
2026-03-09 03:37:38 -07:00
|
|
|
import math
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
import os
|
|
|
|
|
import re
|
2026-03-14 05:44:00 -07:00
|
|
|
import shutil
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
import threading
|
2026-03-09 03:37:38 -07:00
|
|
|
import time
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Graceful import -- MCP SDK is an optional dependency
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_MCP_AVAILABLE = False
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
_MCP_HTTP_AVAILABLE = False
|
2026-03-09 03:37:38 -07:00
|
|
|
_MCP_SAMPLING_TYPES = False
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
try:
|
|
|
|
|
from mcp import ClientSession, StdioServerParameters
|
|
|
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
|
_MCP_AVAILABLE = True
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
try:
|
|
|
|
|
from mcp.client.streamable_http import streamablehttp_client
|
|
|
|
|
_MCP_HTTP_AVAILABLE = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
_MCP_HTTP_AVAILABLE = False
|
2026-03-09 03:37:38 -07:00
|
|
|
# Sampling types -- separated so older SDK versions don't break MCP support
|
|
|
|
|
try:
|
|
|
|
|
from mcp.types import (
|
|
|
|
|
CreateMessageResult,
|
|
|
|
|
CreateMessageResultWithTools,
|
|
|
|
|
ErrorData,
|
|
|
|
|
SamplingCapability,
|
|
|
|
|
SamplingToolsCapability,
|
|
|
|
|
TextContent,
|
|
|
|
|
ToolUseContent,
|
|
|
|
|
)
|
|
|
|
|
_MCP_SAMPLING_TYPES = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
logger.debug("MCP sampling types not available -- sampling disabled")
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
except ImportError:
|
|
|
|
|
logger.debug("mcp package not installed -- MCP tool support disabled")
|
|
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Constants
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_DEFAULT_TOOL_TIMEOUT = 120 # seconds for tool calls
|
2026-03-02 19:02:28 -08:00
|
|
|
_DEFAULT_CONNECT_TIMEOUT = 60 # seconds for initial connection per server
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
_MAX_RECONNECT_RETRIES = 5
|
|
|
|
|
_MAX_BACKOFF_SECONDS = 60
|
|
|
|
|
|
|
|
|
|
# Environment variables that are safe to pass to stdio subprocesses
|
|
|
|
|
_SAFE_ENV_KEYS = frozenset({
|
|
|
|
|
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Regex for credential patterns to strip from error messages
|
|
|
|
|
_CREDENTIAL_PATTERN = re.compile(
|
|
|
|
|
r"(?:"
|
|
|
|
|
r"ghp_[A-Za-z0-9_]{1,255}" # GitHub PAT
|
|
|
|
|
r"|sk-[A-Za-z0-9_]{1,255}" # OpenAI-style key
|
|
|
|
|
r"|Bearer\s+\S+" # Bearer token
|
|
|
|
|
r"|token=[^\s&,;\"']{1,255}" # token=...
|
|
|
|
|
r"|key=[^\s&,;\"']{1,255}" # key=...
|
|
|
|
|
r"|API_KEY=[^\s&,;\"']{1,255}" # API_KEY=...
|
|
|
|
|
r"|password=[^\s&,;\"']{1,255}" # password=...
|
|
|
|
|
r"|secret=[^\s&,;\"']{1,255}" # secret=...
|
|
|
|
|
r")",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Security helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _build_safe_env(user_env: Optional[dict]) -> dict:
|
|
|
|
|
"""Build a filtered environment dict for stdio subprocesses.
|
|
|
|
|
|
|
|
|
|
Only passes through safe baseline variables (PATH, HOME, etc.) and XDG_*
|
|
|
|
|
variables from the current process environment, plus any variables
|
|
|
|
|
explicitly specified by the user in the server config.
|
|
|
|
|
|
|
|
|
|
This prevents accidentally leaking secrets like API keys, tokens, or
|
|
|
|
|
credentials to MCP server subprocesses.
|
|
|
|
|
"""
|
|
|
|
|
env = {}
|
|
|
|
|
for key, value in os.environ.items():
|
|
|
|
|
if key in _SAFE_ENV_KEYS or key.startswith("XDG_"):
|
|
|
|
|
env[key] = value
|
|
|
|
|
if user_env:
|
|
|
|
|
env.update(user_env)
|
|
|
|
|
return env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitize_error(text: str) -> str:
|
|
|
|
|
"""Strip credential-like patterns from error text before returning to LLM.
|
|
|
|
|
|
|
|
|
|
Replaces tokens, keys, and other secrets with [REDACTED] to prevent
|
|
|
|
|
accidental credential exposure in tool error responses.
|
|
|
|
|
"""
|
|
|
|
|
return _CREDENTIAL_PATTERN.sub("[REDACTED]", text)
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-14 05:44:00 -07:00
|
|
|
def _prepend_path(env: dict, directory: str) -> dict:
|
|
|
|
|
"""Prepend *directory* to env PATH if it is not already present."""
|
|
|
|
|
updated = dict(env or {})
|
|
|
|
|
if not directory:
|
|
|
|
|
return updated
|
|
|
|
|
|
|
|
|
|
existing = updated.get("PATH", "")
|
|
|
|
|
parts = [part for part in existing.split(os.pathsep) if part]
|
|
|
|
|
if directory not in parts:
|
|
|
|
|
parts = [directory, *parts]
|
|
|
|
|
updated["PATH"] = os.pathsep.join(parts) if parts else directory
|
|
|
|
|
return updated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
|
|
|
|
|
"""Resolve a stdio MCP command against the exact subprocess environment.
|
|
|
|
|
|
|
|
|
|
This primarily exists to make bare ``npx``/``npm``/``node`` commands work
|
|
|
|
|
reliably even when MCP subprocesses run under a filtered PATH.
|
|
|
|
|
"""
|
|
|
|
|
resolved_command = os.path.expanduser(str(command).strip())
|
|
|
|
|
resolved_env = dict(env or {})
|
|
|
|
|
|
|
|
|
|
if os.sep not in resolved_command:
|
|
|
|
|
path_arg = resolved_env["PATH"] if "PATH" in resolved_env else None
|
|
|
|
|
which_hit = shutil.which(resolved_command, path=path_arg)
|
|
|
|
|
if which_hit:
|
|
|
|
|
resolved_command = which_hit
|
|
|
|
|
elif resolved_command in {"npx", "npm", "node"}:
|
|
|
|
|
hermes_home = os.path.expanduser(
|
|
|
|
|
os.getenv(
|
|
|
|
|
"HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes")
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
candidates = [
|
|
|
|
|
os.path.join(hermes_home, "node", "bin", resolved_command),
|
|
|
|
|
os.path.join(os.path.expanduser("~"), ".local", "bin", resolved_command),
|
|
|
|
|
]
|
|
|
|
|
for candidate in candidates:
|
|
|
|
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
|
|
|
resolved_command = candidate
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
command_dir = os.path.dirname(resolved_command)
|
|
|
|
|
if command_dir:
|
|
|
|
|
resolved_env = _prepend_path(resolved_env, command_dir)
|
|
|
|
|
|
|
|
|
|
return resolved_command, resolved_env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_connect_error(exc: BaseException) -> str:
|
|
|
|
|
"""Render nested MCP connection errors into an actionable short message."""
|
|
|
|
|
|
|
|
|
|
def _find_missing(current: BaseException) -> Optional[str]:
|
|
|
|
|
nested = getattr(current, "exceptions", None)
|
|
|
|
|
if nested:
|
|
|
|
|
for child in nested:
|
|
|
|
|
missing = _find_missing(child)
|
|
|
|
|
if missing:
|
|
|
|
|
return missing
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(current, FileNotFoundError):
|
|
|
|
|
if getattr(current, "filename", None):
|
|
|
|
|
return str(current.filename)
|
|
|
|
|
match = re.search(r"No such file or directory: '([^']+)'", str(current))
|
|
|
|
|
if match:
|
|
|
|
|
return match.group(1)
|
|
|
|
|
for attr in ("__cause__", "__context__"):
|
|
|
|
|
nested_exc = getattr(current, attr, None)
|
|
|
|
|
if isinstance(nested_exc, BaseException):
|
|
|
|
|
missing = _find_missing(nested_exc)
|
|
|
|
|
if missing:
|
|
|
|
|
return missing
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def _flatten_messages(current: BaseException) -> List[str]:
|
|
|
|
|
nested = getattr(current, "exceptions", None)
|
|
|
|
|
if nested:
|
|
|
|
|
flattened: List[str] = []
|
|
|
|
|
for child in nested:
|
|
|
|
|
flattened.extend(_flatten_messages(child))
|
|
|
|
|
return flattened
|
|
|
|
|
messages = []
|
|
|
|
|
text = str(current).strip()
|
|
|
|
|
if text:
|
|
|
|
|
messages.append(text)
|
|
|
|
|
for attr in ("__cause__", "__context__"):
|
|
|
|
|
nested_exc = getattr(current, attr, None)
|
|
|
|
|
if isinstance(nested_exc, BaseException):
|
|
|
|
|
messages.extend(_flatten_messages(nested_exc))
|
|
|
|
|
return messages or [current.__class__.__name__]
|
|
|
|
|
|
|
|
|
|
missing = _find_missing(exc)
|
|
|
|
|
if missing:
|
|
|
|
|
message = f"missing executable '{missing}'"
|
|
|
|
|
if os.path.basename(missing) in {"npx", "npm", "node"}:
|
|
|
|
|
message += (
|
|
|
|
|
" (ensure Node.js is installed and PATH includes its bin directory, "
|
|
|
|
|
"or set mcp_servers.<name>.command to an absolute path and include "
|
|
|
|
|
"that directory in mcp_servers.<name>.env.PATH)"
|
|
|
|
|
)
|
|
|
|
|
return _sanitize_error(message)
|
|
|
|
|
|
|
|
|
|
deduped: List[str] = []
|
|
|
|
|
for item in _flatten_messages(exc):
|
|
|
|
|
if item not in deduped:
|
|
|
|
|
deduped.append(item)
|
|
|
|
|
return _sanitize_error("; ".join(deduped[:3]))
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 03:37:38 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Sampling -- server-initiated LLM requests (MCP sampling/createMessage)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _safe_numeric(value, default, coerce=int, minimum=1):
|
|
|
|
|
"""Coerce a config value to a numeric type, returning *default* on failure.
|
|
|
|
|
|
|
|
|
|
Handles string values from YAML (e.g. ``"10"`` instead of ``10``),
|
|
|
|
|
non-finite floats, and values below *minimum*.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
result = coerce(value)
|
|
|
|
|
if isinstance(result, float) and not math.isfinite(result):
|
|
|
|
|
return default
|
|
|
|
|
return max(result, minimum)
|
|
|
|
|
except (TypeError, ValueError, OverflowError):
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SamplingHandler:
|
|
|
|
|
"""Handles sampling/createMessage requests for a single MCP server.
|
|
|
|
|
|
|
|
|
|
Each MCPServerTask that has sampling enabled creates one SamplingHandler.
|
|
|
|
|
The handler is callable and passed directly to ``ClientSession`` as
|
|
|
|
|
the ``sampling_callback``. All state (rate-limit timestamps, metrics,
|
|
|
|
|
tool-loop counters) lives on the instance -- no module-level globals.
|
|
|
|
|
|
|
|
|
|
The callback is async and runs on the MCP background event loop. The
|
|
|
|
|
sync LLM call is offloaded to a thread via ``asyncio.to_thread()`` so
|
|
|
|
|
it doesn't block the event loop.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
_STOP_REASON_MAP = {"stop": "endTurn", "length": "maxTokens", "tool_calls": "toolUse"}
|
|
|
|
|
|
|
|
|
|
def __init__(self, server_name: str, config: dict):
|
|
|
|
|
self.server_name = server_name
|
|
|
|
|
self.max_rpm = _safe_numeric(config.get("max_rpm", 10), 10, int)
|
|
|
|
|
self.timeout = _safe_numeric(config.get("timeout", 30), 30, float)
|
|
|
|
|
self.max_tokens_cap = _safe_numeric(config.get("max_tokens_cap", 4096), 4096, int)
|
|
|
|
|
self.max_tool_rounds = _safe_numeric(
|
|
|
|
|
config.get("max_tool_rounds", 5), 5, int, minimum=0,
|
|
|
|
|
)
|
|
|
|
|
self.model_override = config.get("model")
|
|
|
|
|
self.allowed_models = config.get("allowed_models", [])
|
|
|
|
|
|
|
|
|
|
_log_levels = {"debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING}
|
|
|
|
|
self.audit_level = _log_levels.get(
|
|
|
|
|
str(config.get("log_level", "info")).lower(), logging.INFO,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Per-instance state
|
|
|
|
|
self._rate_timestamps: List[float] = []
|
|
|
|
|
self._tool_loop_count = 0
|
|
|
|
|
self.metrics = {"requests": 0, "errors": 0, "tokens_used": 0, "tool_use_count": 0}
|
|
|
|
|
|
|
|
|
|
# -- Rate limiting -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _check_rate_limit(self) -> bool:
|
|
|
|
|
"""Sliding-window rate limiter. Returns True if request is allowed."""
|
|
|
|
|
now = time.time()
|
|
|
|
|
window = now - 60
|
|
|
|
|
self._rate_timestamps[:] = [t for t in self._rate_timestamps if t > window]
|
|
|
|
|
if len(self._rate_timestamps) >= self.max_rpm:
|
|
|
|
|
return False
|
|
|
|
|
self._rate_timestamps.append(now)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# -- Model resolution ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _resolve_model(self, preferences) -> Optional[str]:
|
|
|
|
|
"""Config override > server hint > None (use default)."""
|
|
|
|
|
if self.model_override:
|
|
|
|
|
return self.model_override
|
|
|
|
|
if preferences and hasattr(preferences, "hints") and preferences.hints:
|
|
|
|
|
for hint in preferences.hints:
|
|
|
|
|
if hasattr(hint, "name") and hint.name:
|
|
|
|
|
return hint.name
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# -- Message conversion --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _extract_tool_result_text(block) -> str:
|
|
|
|
|
"""Extract text from a ToolResultContent block."""
|
|
|
|
|
if not hasattr(block, "content") or block.content is None:
|
|
|
|
|
return ""
|
|
|
|
|
items = block.content if isinstance(block.content, list) else [block.content]
|
|
|
|
|
return "\n".join(item.text for item in items if hasattr(item, "text"))
|
|
|
|
|
|
|
|
|
|
def _convert_messages(self, params) -> List[dict]:
|
|
|
|
|
"""Convert MCP SamplingMessages to OpenAI format.
|
|
|
|
|
|
|
|
|
|
Uses ``msg.content_as_list`` (SDK helper) so single-block and
|
|
|
|
|
list-of-blocks are handled uniformly. Dispatches per block type
|
|
|
|
|
with ``isinstance`` on real SDK types when available, falling back
|
|
|
|
|
to duck-typing via ``hasattr`` for compatibility.
|
|
|
|
|
"""
|
|
|
|
|
messages: List[dict] = []
|
|
|
|
|
for msg in params.messages:
|
|
|
|
|
blocks = msg.content_as_list if hasattr(msg, "content_as_list") else (
|
|
|
|
|
msg.content if isinstance(msg.content, list) else [msg.content]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Separate blocks by kind
|
|
|
|
|
tool_results = [b for b in blocks if hasattr(b, "toolUseId")]
|
|
|
|
|
tool_uses = [b for b in blocks if hasattr(b, "name") and hasattr(b, "input") and not hasattr(b, "toolUseId")]
|
|
|
|
|
content_blocks = [b for b in blocks if not hasattr(b, "toolUseId") and not (hasattr(b, "name") and hasattr(b, "input"))]
|
|
|
|
|
|
|
|
|
|
# Emit tool result messages (role: tool)
|
|
|
|
|
for tr in tool_results:
|
|
|
|
|
messages.append({
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": tr.toolUseId,
|
|
|
|
|
"content": self._extract_tool_result_text(tr),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Emit assistant tool_calls message
|
|
|
|
|
if tool_uses:
|
|
|
|
|
tc_list = []
|
|
|
|
|
for tu in tool_uses:
|
|
|
|
|
tc_list.append({
|
|
|
|
|
"id": getattr(tu, "id", f"call_{len(tc_list)}"),
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {
|
|
|
|
|
"name": tu.name,
|
|
|
|
|
"arguments": json.dumps(tu.input) if isinstance(tu.input, dict) else str(tu.input),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
msg_dict: dict = {"role": msg.role, "tool_calls": tc_list}
|
|
|
|
|
# Include any accompanying text
|
|
|
|
|
text_parts = [b.text for b in content_blocks if hasattr(b, "text")]
|
|
|
|
|
if text_parts:
|
|
|
|
|
msg_dict["content"] = "\n".join(text_parts)
|
|
|
|
|
messages.append(msg_dict)
|
|
|
|
|
elif content_blocks:
|
|
|
|
|
# Pure text/image content
|
|
|
|
|
if len(content_blocks) == 1 and hasattr(content_blocks[0], "text"):
|
|
|
|
|
messages.append({"role": msg.role, "content": content_blocks[0].text})
|
|
|
|
|
else:
|
|
|
|
|
parts = []
|
|
|
|
|
for block in content_blocks:
|
|
|
|
|
if hasattr(block, "text"):
|
|
|
|
|
parts.append({"type": "text", "text": block.text})
|
|
|
|
|
elif hasattr(block, "data") and hasattr(block, "mimeType"):
|
|
|
|
|
parts.append({
|
|
|
|
|
"type": "image_url",
|
|
|
|
|
"image_url": {"url": f"data:{block.mimeType};base64,{block.data}"},
|
|
|
|
|
})
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Unsupported sampling content block type: %s (skipped)",
|
|
|
|
|
type(block).__name__,
|
|
|
|
|
)
|
|
|
|
|
if parts:
|
|
|
|
|
messages.append({"role": msg.role, "content": parts})
|
|
|
|
|
|
|
|
|
|
return messages
|
|
|
|
|
|
|
|
|
|
# -- Error helper --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _error(message: str, code: int = -1):
|
|
|
|
|
"""Return ErrorData (MCP spec) or raise as fallback."""
|
|
|
|
|
if _MCP_SAMPLING_TYPES:
|
|
|
|
|
return ErrorData(code=code, message=message)
|
|
|
|
|
raise Exception(message)
|
|
|
|
|
|
|
|
|
|
# -- Response building ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _build_tool_use_result(self, choice, response):
|
|
|
|
|
"""Build a CreateMessageResultWithTools from an LLM tool_calls response."""
|
|
|
|
|
self.metrics["tool_use_count"] += 1
|
|
|
|
|
|
|
|
|
|
# Tool loop governance
|
|
|
|
|
if self.max_tool_rounds == 0:
|
|
|
|
|
self._tool_loop_count = 0
|
|
|
|
|
return self._error(
|
|
|
|
|
f"Tool loops disabled for server '{self.server_name}' (max_tool_rounds=0)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._tool_loop_count += 1
|
|
|
|
|
if self._tool_loop_count > self.max_tool_rounds:
|
|
|
|
|
self._tool_loop_count = 0
|
|
|
|
|
return self._error(
|
|
|
|
|
f"Tool loop limit exceeded for server '{self.server_name}' "
|
|
|
|
|
f"(max {self.max_tool_rounds} rounds)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
content_blocks = []
|
|
|
|
|
for tc in choice.message.tool_calls:
|
|
|
|
|
args = tc.function.arguments
|
|
|
|
|
if isinstance(args, str):
|
|
|
|
|
try:
|
|
|
|
|
parsed = json.loads(args)
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"MCP server '%s': malformed tool_calls arguments "
|
|
|
|
|
"from LLM (wrapping as raw): %.100s",
|
|
|
|
|
self.server_name, args,
|
|
|
|
|
)
|
|
|
|
|
parsed = {"_raw": args}
|
|
|
|
|
else:
|
|
|
|
|
parsed = args if isinstance(args, dict) else {"_raw": str(args)}
|
|
|
|
|
|
|
|
|
|
content_blocks.append(ToolUseContent(
|
|
|
|
|
type="tool_use",
|
|
|
|
|
id=tc.id,
|
|
|
|
|
name=tc.function.name,
|
|
|
|
|
input=parsed,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
logger.log(
|
|
|
|
|
self.audit_level,
|
|
|
|
|
"MCP server '%s' sampling response: model=%s, tokens=%s, tool_calls=%d",
|
|
|
|
|
self.server_name, response.model,
|
|
|
|
|
getattr(getattr(response, "usage", None), "total_tokens", "?"),
|
|
|
|
|
len(content_blocks),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return CreateMessageResultWithTools(
|
|
|
|
|
role="assistant",
|
|
|
|
|
content=content_blocks,
|
|
|
|
|
model=response.model,
|
|
|
|
|
stopReason="toolUse",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _build_text_result(self, choice, response):
|
|
|
|
|
"""Build a CreateMessageResult from a normal text response."""
|
|
|
|
|
self._tool_loop_count = 0 # reset on text response
|
|
|
|
|
response_text = choice.message.content or ""
|
|
|
|
|
|
|
|
|
|
logger.log(
|
|
|
|
|
self.audit_level,
|
|
|
|
|
"MCP server '%s' sampling response: model=%s, tokens=%s",
|
|
|
|
|
self.server_name, response.model,
|
|
|
|
|
getattr(getattr(response, "usage", None), "total_tokens", "?"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return CreateMessageResult(
|
|
|
|
|
role="assistant",
|
|
|
|
|
content=TextContent(type="text", text=_sanitize_error(response_text)),
|
|
|
|
|
model=response.model,
|
|
|
|
|
stopReason=self._STOP_REASON_MAP.get(choice.finish_reason, "endTurn"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -- Session kwargs helper -----------------------------------------------
|
|
|
|
|
|
|
|
|
|
def session_kwargs(self) -> dict:
|
|
|
|
|
"""Return kwargs to pass to ClientSession for sampling support."""
|
|
|
|
|
return {
|
|
|
|
|
"sampling_callback": self,
|
|
|
|
|
"sampling_capabilities": SamplingCapability(
|
|
|
|
|
tools=SamplingToolsCapability(),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# -- Main callback -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def __call__(self, context, params):
|
|
|
|
|
"""Sampling callback invoked by the MCP SDK.
|
|
|
|
|
|
|
|
|
|
Conforms to ``SamplingFnT`` protocol. Returns
|
|
|
|
|
``CreateMessageResult``, ``CreateMessageResultWithTools``, or
|
|
|
|
|
``ErrorData``.
|
|
|
|
|
"""
|
|
|
|
|
# Rate limit
|
|
|
|
|
if not self._check_rate_limit():
|
|
|
|
|
logger.warning(
|
|
|
|
|
"MCP server '%s' sampling rate limit exceeded (%d/min)",
|
|
|
|
|
self.server_name, self.max_rpm,
|
|
|
|
|
)
|
|
|
|
|
self.metrics["errors"] += 1
|
|
|
|
|
return self._error(
|
|
|
|
|
f"Sampling rate limit exceeded for server '{self.server_name}' "
|
|
|
|
|
f"({self.max_rpm} requests/minute)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Resolve model
|
|
|
|
|
model = self._resolve_model(getattr(params, "modelPreferences", None))
|
|
|
|
|
|
2026-03-11 20:52:19 -07:00
|
|
|
# Get auxiliary LLM client via centralized router
|
|
|
|
|
from agent.auxiliary_client import call_llm
|
2026-03-09 03:37:38 -07:00
|
|
|
|
2026-03-11 20:52:19 -07:00
|
|
|
# Model whitelist check (we need to resolve model before calling)
|
|
|
|
|
resolved_model = model or self.model_override or ""
|
2026-03-09 03:37:38 -07:00
|
|
|
|
2026-03-11 20:52:19 -07:00
|
|
|
if self.allowed_models and resolved_model and resolved_model not in self.allowed_models:
|
2026-03-09 03:37:38 -07:00
|
|
|
logger.warning(
|
|
|
|
|
"MCP server '%s' requested model '%s' not in allowed_models",
|
|
|
|
|
self.server_name, resolved_model,
|
|
|
|
|
)
|
|
|
|
|
self.metrics["errors"] += 1
|
|
|
|
|
return self._error(
|
|
|
|
|
f"Model '{resolved_model}' not allowed for server "
|
|
|
|
|
f"'{self.server_name}'. Allowed: {', '.join(self.allowed_models)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Convert messages
|
|
|
|
|
messages = self._convert_messages(params)
|
|
|
|
|
if hasattr(params, "systemPrompt") and params.systemPrompt:
|
|
|
|
|
messages.insert(0, {"role": "system", "content": params.systemPrompt})
|
|
|
|
|
|
|
|
|
|
# Build LLM call kwargs
|
|
|
|
|
max_tokens = min(params.maxTokens, self.max_tokens_cap)
|
2026-03-11 20:52:19 -07:00
|
|
|
call_temperature = None
|
2026-03-09 03:37:38 -07:00
|
|
|
if hasattr(params, "temperature") and params.temperature is not None:
|
2026-03-11 20:52:19 -07:00
|
|
|
call_temperature = params.temperature
|
2026-03-09 03:37:38 -07:00
|
|
|
|
|
|
|
|
# Forward server-provided tools
|
2026-03-11 20:52:19 -07:00
|
|
|
call_tools = None
|
2026-03-09 03:37:38 -07:00
|
|
|
server_tools = getattr(params, "tools", None)
|
|
|
|
|
if server_tools:
|
2026-03-11 20:52:19 -07:00
|
|
|
call_tools = [
|
2026-03-09 03:37:38 -07:00
|
|
|
{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {
|
|
|
|
|
"name": getattr(t, "name", ""),
|
|
|
|
|
"description": getattr(t, "description", "") or "",
|
2026-03-20 07:23:20 +11:00
|
|
|
"parameters": _normalize_mcp_input_schema(
|
|
|
|
|
getattr(t, "inputSchema", None)
|
|
|
|
|
),
|
2026-03-09 03:37:38 -07:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
for t in server_tools
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
logger.log(
|
|
|
|
|
self.audit_level,
|
|
|
|
|
"MCP server '%s' sampling request: model=%s, max_tokens=%d, messages=%d",
|
|
|
|
|
self.server_name, resolved_model, max_tokens, len(messages),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Offload sync LLM call to thread (non-blocking)
|
|
|
|
|
def _sync_call():
|
2026-03-11 20:52:19 -07:00
|
|
|
return call_llm(
|
|
|
|
|
task="mcp",
|
|
|
|
|
model=resolved_model or None,
|
|
|
|
|
messages=messages,
|
|
|
|
|
temperature=call_temperature,
|
|
|
|
|
max_tokens=max_tokens,
|
|
|
|
|
tools=call_tools,
|
|
|
|
|
timeout=self.timeout,
|
|
|
|
|
)
|
2026-03-09 03:37:38 -07:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
response = await asyncio.wait_for(
|
|
|
|
|
asyncio.to_thread(_sync_call), timeout=self.timeout,
|
|
|
|
|
)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
self.metrics["errors"] += 1
|
|
|
|
|
return self._error(
|
|
|
|
|
f"Sampling LLM call timed out after {self.timeout}s "
|
|
|
|
|
f"for server '{self.server_name}'"
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
self.metrics["errors"] += 1
|
|
|
|
|
return self._error(
|
|
|
|
|
f"Sampling LLM call failed: {_sanitize_error(str(exc))}"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-10 02:24:53 +03:00
|
|
|
# Guard against empty choices (content filtering, provider errors)
|
|
|
|
|
if not getattr(response, "choices", None):
|
|
|
|
|
self.metrics["errors"] += 1
|
|
|
|
|
return self._error(
|
|
|
|
|
f"LLM returned empty response (no choices) for server "
|
|
|
|
|
f"'{self.server_name}'"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-09 03:37:38 -07:00
|
|
|
# Track metrics
|
|
|
|
|
choice = response.choices[0]
|
|
|
|
|
self.metrics["requests"] += 1
|
|
|
|
|
total_tokens = getattr(getattr(response, "usage", None), "total_tokens", 0)
|
|
|
|
|
if isinstance(total_tokens, int):
|
|
|
|
|
self.metrics["tokens_used"] += total_tokens
|
|
|
|
|
|
|
|
|
|
# Dispatch based on response type
|
|
|
|
|
if (
|
|
|
|
|
choice.finish_reason == "tool_calls"
|
|
|
|
|
and hasattr(choice.message, "tool_calls")
|
|
|
|
|
and choice.message.tool_calls
|
|
|
|
|
):
|
|
|
|
|
return self._build_tool_use_result(choice, response)
|
|
|
|
|
|
|
|
|
|
return self._build_text_result(choice, response)
|
|
|
|
|
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
# ---------------------------------------------------------------------------
|
2026-03-02 21:22:00 +03:00
|
|
|
# Server task -- each MCP server lives in one long-lived asyncio Task
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-03-02 21:22:00 +03:00
|
|
|
class MCPServerTask:
|
|
|
|
|
"""Manages a single MCP server connection in a dedicated asyncio Task.
|
|
|
|
|
|
|
|
|
|
The entire connection lifecycle (connect, discover, serve, disconnect)
|
|
|
|
|
runs inside one asyncio Task so that anyio cancel-scopes created by
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
the transport client are entered and exited in the same Task context.
|
|
|
|
|
|
|
|
|
|
Supports both stdio and HTTP/StreamableHTTP transports.
|
2026-03-02 21:22:00 +03:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__slots__ = (
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
"name", "session", "tool_timeout",
|
|
|
|
|
"_task", "_ready", "_shutdown_event", "_tools", "_error", "_config",
|
2026-03-14 06:22:02 -07:00
|
|
|
"_sampling", "_registered_tool_names",
|
2026-03-02 21:22:00 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def __init__(self, name: str):
|
|
|
|
|
self.name = name
|
|
|
|
|
self.session: Optional[Any] = None
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
self.tool_timeout: float = _DEFAULT_TOOL_TIMEOUT
|
2026-03-02 21:22:00 +03:00
|
|
|
self._task: Optional[asyncio.Task] = None
|
|
|
|
|
self._ready = asyncio.Event()
|
|
|
|
|
self._shutdown_event = asyncio.Event()
|
|
|
|
|
self._tools: list = []
|
|
|
|
|
self._error: Optional[Exception] = None
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
self._config: dict = {}
|
2026-03-09 03:37:38 -07:00
|
|
|
self._sampling: Optional[SamplingHandler] = None
|
2026-03-14 06:22:02 -07:00
|
|
|
self._registered_tool_names: list[str] = []
|
2026-03-02 21:22:00 +03:00
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
def _is_http(self) -> bool:
|
|
|
|
|
"""Check if this server uses HTTP transport."""
|
|
|
|
|
return "url" in self._config
|
|
|
|
|
|
|
|
|
|
async def _run_stdio(self, config: dict):
|
|
|
|
|
"""Run the server using stdio transport."""
|
2026-03-02 21:22:00 +03:00
|
|
|
command = config.get("command")
|
|
|
|
|
args = config.get("args", [])
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
user_env = config.get("env")
|
2026-03-02 21:22:00 +03:00
|
|
|
|
|
|
|
|
if not command:
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
raise ValueError(
|
2026-03-02 21:22:00 +03:00
|
|
|
f"MCP server '{self.name}' has no 'command' in config"
|
|
|
|
|
)
|
|
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
safe_env = _build_safe_env(user_env)
|
2026-03-14 05:44:00 -07:00
|
|
|
command, safe_env = _resolve_stdio_command(command, safe_env)
|
2026-03-02 21:22:00 +03:00
|
|
|
server_params = StdioServerParameters(
|
|
|
|
|
command=command,
|
|
|
|
|
args=args,
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
env=safe_env if safe_env else None,
|
2026-03-02 21:22:00 +03:00
|
|
|
)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-09 03:37:38 -07:00
|
|
|
sampling_kwargs = self._sampling.session_kwargs() if self._sampling else {}
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
async with stdio_client(server_params) as (read_stream, write_stream):
|
2026-03-09 03:37:38 -07:00
|
|
|
async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
await session.initialize()
|
|
|
|
|
self.session = session
|
|
|
|
|
await self._discover_tools()
|
|
|
|
|
self._ready.set()
|
|
|
|
|
await self._shutdown_event.wait()
|
|
|
|
|
|
|
|
|
|
async def _run_http(self, config: dict):
|
|
|
|
|
"""Run the server using HTTP/StreamableHTTP transport."""
|
|
|
|
|
if not _MCP_HTTP_AVAILABLE:
|
|
|
|
|
raise ImportError(
|
|
|
|
|
f"MCP server '{self.name}' requires HTTP transport but "
|
|
|
|
|
"mcp.client.streamable_http is not available. "
|
|
|
|
|
"Upgrade the mcp package to get HTTP support."
|
|
|
|
|
)
|
2026-03-02 21:22:00 +03:00
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
url = config["url"]
|
|
|
|
|
headers = config.get("headers")
|
|
|
|
|
connect_timeout = config.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT)
|
|
|
|
|
|
2026-03-09 03:37:38 -07:00
|
|
|
sampling_kwargs = self._sampling.session_kwargs() if self._sampling else {}
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
async with streamablehttp_client(
|
|
|
|
|
url,
|
|
|
|
|
headers=headers,
|
|
|
|
|
timeout=float(connect_timeout),
|
|
|
|
|
) as (read_stream, write_stream, _get_session_id):
|
2026-03-09 03:37:38 -07:00
|
|
|
async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
await session.initialize()
|
|
|
|
|
self.session = session
|
|
|
|
|
await self._discover_tools()
|
|
|
|
|
self._ready.set()
|
|
|
|
|
await self._shutdown_event.wait()
|
|
|
|
|
|
|
|
|
|
async def _discover_tools(self):
|
|
|
|
|
"""Discover tools from the connected session."""
|
|
|
|
|
if self.session is None:
|
|
|
|
|
return
|
|
|
|
|
tools_result = await self.session.list_tools()
|
|
|
|
|
self._tools = (
|
|
|
|
|
tools_result.tools
|
|
|
|
|
if hasattr(tools_result, "tools")
|
|
|
|
|
else []
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def run(self, config: dict):
|
|
|
|
|
"""Long-lived coroutine: connect, discover tools, wait, disconnect.
|
|
|
|
|
|
|
|
|
|
Includes automatic reconnection with exponential backoff if the
|
|
|
|
|
connection drops unexpectedly (unless shutdown was requested).
|
|
|
|
|
"""
|
|
|
|
|
self._config = config
|
|
|
|
|
self.tool_timeout = config.get("timeout", _DEFAULT_TOOL_TIMEOUT)
|
2026-03-02 19:02:28 -08:00
|
|
|
|
2026-03-09 03:37:38 -07:00
|
|
|
# Set up sampling handler if enabled and SDK types are available
|
|
|
|
|
sampling_config = config.get("sampling", {})
|
|
|
|
|
if sampling_config.get("enabled", True) and _MCP_SAMPLING_TYPES:
|
|
|
|
|
self._sampling = SamplingHandler(self.name, sampling_config)
|
|
|
|
|
else:
|
|
|
|
|
self._sampling = None
|
|
|
|
|
|
2026-03-02 19:02:28 -08:00
|
|
|
# Validate: warn if both url and command are present
|
|
|
|
|
if "url" in config and "command" in config:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"MCP server '%s' has both 'url' and 'command' in config. "
|
|
|
|
|
"Using HTTP transport ('url'). Remove 'command' to silence "
|
|
|
|
|
"this warning.",
|
|
|
|
|
self.name,
|
|
|
|
|
)
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
retries = 0
|
|
|
|
|
backoff = 1.0
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
if self._is_http():
|
|
|
|
|
await self._run_http(config)
|
|
|
|
|
else:
|
|
|
|
|
await self._run_stdio(config)
|
|
|
|
|
# Normal exit (shutdown requested) -- break out
|
|
|
|
|
break
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
self.session = None
|
|
|
|
|
|
|
|
|
|
# If this is the first connection attempt, report the error
|
|
|
|
|
if not self._ready.is_set():
|
|
|
|
|
self._error = exc
|
2026-03-02 21:22:00 +03:00
|
|
|
self._ready.set()
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
return
|
2026-03-02 21:22:00 +03:00
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
# If shutdown was requested, don't reconnect
|
|
|
|
|
if self._shutdown_event.is_set():
|
|
|
|
|
logger.debug(
|
|
|
|
|
"MCP server '%s' disconnected during shutdown: %s",
|
|
|
|
|
self.name, exc,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
retries += 1
|
|
|
|
|
if retries > _MAX_RECONNECT_RETRIES:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"MCP server '%s' failed after %d reconnection attempts, "
|
|
|
|
|
"giving up: %s",
|
|
|
|
|
self.name, _MAX_RECONNECT_RETRIES, exc,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
"MCP server '%s' connection lost (attempt %d/%d), "
|
|
|
|
|
"reconnecting in %.0fs: %s",
|
|
|
|
|
self.name, retries, _MAX_RECONNECT_RETRIES,
|
|
|
|
|
backoff, exc,
|
|
|
|
|
)
|
|
|
|
|
await asyncio.sleep(backoff)
|
|
|
|
|
backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS)
|
|
|
|
|
|
|
|
|
|
# Check again after sleeping
|
|
|
|
|
if self._shutdown_event.is_set():
|
|
|
|
|
return
|
|
|
|
|
finally:
|
|
|
|
|
self.session = None
|
2026-03-02 21:22:00 +03:00
|
|
|
|
|
|
|
|
async def start(self, config: dict):
|
|
|
|
|
"""Create the background Task and wait until ready (or failed)."""
|
|
|
|
|
self._task = asyncio.ensure_future(self.run(config))
|
|
|
|
|
await self._ready.wait()
|
|
|
|
|
if self._error:
|
|
|
|
|
raise self._error
|
|
|
|
|
|
|
|
|
|
async def shutdown(self):
|
|
|
|
|
"""Signal the Task to exit and wait for clean resource teardown."""
|
|
|
|
|
self._shutdown_event.set()
|
|
|
|
|
if self._task and not self._task.done():
|
|
|
|
|
try:
|
|
|
|
|
await asyncio.wait_for(self._task, timeout=10)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"MCP server '%s' shutdown timed out, cancelling task",
|
|
|
|
|
self.name,
|
|
|
|
|
)
|
|
|
|
|
self._task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await self._task
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
pass
|
|
|
|
|
self.session = None
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
|
2026-03-02 21:22:00 +03:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Module-level state
|
|
|
|
|
# ---------------------------------------------------------------------------
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-02 21:22:00 +03:00
|
|
|
_servers: Dict[str, MCPServerTask] = {}
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
# Dedicated event loop running in a background daemon thread.
|
|
|
|
|
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
_mcp_thread: Optional[threading.Thread] = None
|
|
|
|
|
|
2026-03-02 22:08:32 +03:00
|
|
|
# Protects _mcp_loop, _mcp_thread, and _servers from concurrent access.
|
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
def _ensure_mcp_loop():
|
|
|
|
|
"""Start the background event loop thread if not already running."""
|
|
|
|
|
global _mcp_loop, _mcp_thread
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
|
|
|
|
if _mcp_loop is not None and _mcp_loop.is_running():
|
|
|
|
|
return
|
|
|
|
|
_mcp_loop = asyncio.new_event_loop()
|
|
|
|
|
_mcp_thread = threading.Thread(
|
|
|
|
|
target=_mcp_loop.run_forever,
|
|
|
|
|
name="mcp-event-loop",
|
|
|
|
|
daemon=True,
|
|
|
|
|
)
|
|
|
|
|
_mcp_thread.start()
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_on_mcp_loop(coro, timeout: float = 30):
|
|
|
|
|
"""Schedule a coroutine on the MCP event loop and block until done."""
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
|
|
|
|
loop = _mcp_loop
|
|
|
|
|
if loop is None or not loop.is_running():
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
raise RuntimeError("MCP event loop is not running")
|
2026-03-02 22:08:32 +03:00
|
|
|
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
return future.result(timeout=timeout)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Config loading
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _load_mcp_config() -> Dict[str, dict]:
|
|
|
|
|
"""Read ``mcp_servers`` from the Hermes config file.
|
|
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
Returns a dict of ``{server_name: server_config}`` or empty dict.
|
|
|
|
|
Server config can contain either ``command``/``args``/``env`` for stdio
|
|
|
|
|
transport or ``url``/``headers`` for HTTP transport, plus optional
|
|
|
|
|
``timeout`` and ``connect_timeout`` overrides.
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
from hermes_cli.config import load_config
|
|
|
|
|
config = load_config()
|
|
|
|
|
servers = config.get("mcp_servers")
|
|
|
|
|
if not servers or not isinstance(servers, dict):
|
|
|
|
|
return {}
|
|
|
|
|
return servers
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.debug("Failed to load MCP config: %s", exc)
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-03-02 21:22:00 +03:00
|
|
|
# Server connection helper
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-03-02 21:22:00 +03:00
|
|
|
async def _connect_server(name: str, config: dict) -> MCPServerTask:
|
|
|
|
|
"""Create an MCPServerTask, start it, and return when ready.
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
The server Task keeps the connection alive in the background.
|
2026-03-02 21:22:00 +03:00
|
|
|
Call ``server.shutdown()`` (on the same event loop) to tear it down.
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
Raises:
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
ValueError: if required config keys are missing.
|
|
|
|
|
ImportError: if HTTP transport is needed but not available.
|
2026-03-02 21:22:00 +03:00
|
|
|
Exception: on connection or initialization failure.
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
"""
|
2026-03-02 21:22:00 +03:00
|
|
|
server = MCPServerTask(name)
|
|
|
|
|
await server.start(config)
|
|
|
|
|
return server
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Handler / check-fn factories
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
"""Return a sync handler that calls an MCP tool via the background loop.
|
|
|
|
|
|
|
|
|
|
The handler conforms to the registry's dispatch interface:
|
|
|
|
|
``handler(args_dict, **kwargs) -> str``
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _handler(args: dict, **kwargs) -> str:
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
|
|
|
|
server = _servers.get(server_name)
|
2026-03-02 21:22:00 +03:00
|
|
|
if not server or not server.session:
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
return json.dumps({
|
|
|
|
|
"error": f"MCP server '{server_name}' is not connected"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async def _call():
|
2026-03-02 21:22:00 +03:00
|
|
|
result = await server.session.call_tool(tool_name, arguments=args)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
# MCP CallToolResult has .content (list of content blocks) and .isError
|
|
|
|
|
if result.isError:
|
|
|
|
|
error_text = ""
|
|
|
|
|
for block in (result.content or []):
|
|
|
|
|
if hasattr(block, "text"):
|
|
|
|
|
error_text += block.text
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
return json.dumps({
|
|
|
|
|
"error": _sanitize_error(
|
|
|
|
|
error_text or "MCP tool returned an error"
|
|
|
|
|
)
|
|
|
|
|
})
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
# Collect text from content blocks
|
|
|
|
|
parts: List[str] = []
|
|
|
|
|
for block in (result.content or []):
|
|
|
|
|
if hasattr(block, "text"):
|
|
|
|
|
parts.append(block.text)
|
|
|
|
|
return json.dumps({"result": "\n".join(parts) if parts else ""})
|
|
|
|
|
|
|
|
|
|
try:
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
return _run_on_mcp_loop(_call(), timeout=tool_timeout)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
except Exception as exc:
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
logger.error(
|
|
|
|
|
"MCP tool %s/%s call failed: %s",
|
|
|
|
|
server_name, tool_name, exc,
|
|
|
|
|
)
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": _sanitize_error(
|
|
|
|
|
f"MCP call failed: {type(exc).__name__}: {exc}"
|
|
|
|
|
)
|
|
|
|
|
})
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
return _handler
|
|
|
|
|
|
|
|
|
|
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
def _make_list_resources_handler(server_name: str, tool_timeout: float):
|
|
|
|
|
"""Return a sync handler that lists resources from an MCP server."""
|
|
|
|
|
|
|
|
|
|
def _handler(args: dict, **kwargs) -> str:
|
|
|
|
|
with _lock:
|
|
|
|
|
server = _servers.get(server_name)
|
|
|
|
|
if not server or not server.session:
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": f"MCP server '{server_name}' is not connected"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async def _call():
|
|
|
|
|
result = await server.session.list_resources()
|
|
|
|
|
resources = []
|
|
|
|
|
for r in (result.resources if hasattr(result, "resources") else []):
|
|
|
|
|
entry = {}
|
|
|
|
|
if hasattr(r, "uri"):
|
|
|
|
|
entry["uri"] = str(r.uri)
|
|
|
|
|
if hasattr(r, "name"):
|
|
|
|
|
entry["name"] = r.name
|
|
|
|
|
if hasattr(r, "description") and r.description:
|
|
|
|
|
entry["description"] = r.description
|
|
|
|
|
if hasattr(r, "mimeType") and r.mimeType:
|
|
|
|
|
entry["mimeType"] = r.mimeType
|
|
|
|
|
resources.append(entry)
|
|
|
|
|
return json.dumps({"resources": resources})
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return _run_on_mcp_loop(_call(), timeout=tool_timeout)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error(
|
|
|
|
|
"MCP %s/list_resources failed: %s", server_name, exc,
|
|
|
|
|
)
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": _sanitize_error(
|
|
|
|
|
f"MCP call failed: {type(exc).__name__}: {exc}"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return _handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_read_resource_handler(server_name: str, tool_timeout: float):
|
|
|
|
|
"""Return a sync handler that reads a resource by URI from an MCP server."""
|
|
|
|
|
|
|
|
|
|
def _handler(args: dict, **kwargs) -> str:
|
|
|
|
|
with _lock:
|
|
|
|
|
server = _servers.get(server_name)
|
|
|
|
|
if not server or not server.session:
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": f"MCP server '{server_name}' is not connected"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
uri = args.get("uri")
|
|
|
|
|
if not uri:
|
|
|
|
|
return json.dumps({"error": "Missing required parameter 'uri'"})
|
|
|
|
|
|
|
|
|
|
async def _call():
|
|
|
|
|
result = await server.session.read_resource(uri)
|
|
|
|
|
# read_resource returns ReadResourceResult with .contents list
|
|
|
|
|
parts: List[str] = []
|
|
|
|
|
contents = result.contents if hasattr(result, "contents") else []
|
|
|
|
|
for block in contents:
|
|
|
|
|
if hasattr(block, "text"):
|
|
|
|
|
parts.append(block.text)
|
|
|
|
|
elif hasattr(block, "blob"):
|
|
|
|
|
parts.append(f"[binary data, {len(block.blob)} bytes]")
|
|
|
|
|
return json.dumps({"result": "\n".join(parts) if parts else ""})
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return _run_on_mcp_loop(_call(), timeout=tool_timeout)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error(
|
|
|
|
|
"MCP %s/read_resource failed: %s", server_name, exc,
|
|
|
|
|
)
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": _sanitize_error(
|
|
|
|
|
f"MCP call failed: {type(exc).__name__}: {exc}"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return _handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_list_prompts_handler(server_name: str, tool_timeout: float):
|
|
|
|
|
"""Return a sync handler that lists prompts from an MCP server."""
|
|
|
|
|
|
|
|
|
|
def _handler(args: dict, **kwargs) -> str:
|
|
|
|
|
with _lock:
|
|
|
|
|
server = _servers.get(server_name)
|
|
|
|
|
if not server or not server.session:
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": f"MCP server '{server_name}' is not connected"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async def _call():
|
|
|
|
|
result = await server.session.list_prompts()
|
|
|
|
|
prompts = []
|
|
|
|
|
for p in (result.prompts if hasattr(result, "prompts") else []):
|
|
|
|
|
entry = {}
|
|
|
|
|
if hasattr(p, "name"):
|
|
|
|
|
entry["name"] = p.name
|
|
|
|
|
if hasattr(p, "description") and p.description:
|
|
|
|
|
entry["description"] = p.description
|
|
|
|
|
if hasattr(p, "arguments") and p.arguments:
|
|
|
|
|
entry["arguments"] = [
|
|
|
|
|
{
|
|
|
|
|
"name": a.name,
|
|
|
|
|
**({"description": a.description} if hasattr(a, "description") and a.description else {}),
|
|
|
|
|
**({"required": a.required} if hasattr(a, "required") else {}),
|
|
|
|
|
}
|
|
|
|
|
for a in p.arguments
|
|
|
|
|
]
|
|
|
|
|
prompts.append(entry)
|
|
|
|
|
return json.dumps({"prompts": prompts})
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return _run_on_mcp_loop(_call(), timeout=tool_timeout)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error(
|
|
|
|
|
"MCP %s/list_prompts failed: %s", server_name, exc,
|
|
|
|
|
)
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": _sanitize_error(
|
|
|
|
|
f"MCP call failed: {type(exc).__name__}: {exc}"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return _handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_get_prompt_handler(server_name: str, tool_timeout: float):
|
|
|
|
|
"""Return a sync handler that gets a prompt by name from an MCP server."""
|
|
|
|
|
|
|
|
|
|
def _handler(args: dict, **kwargs) -> str:
|
|
|
|
|
with _lock:
|
|
|
|
|
server = _servers.get(server_name)
|
|
|
|
|
if not server or not server.session:
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": f"MCP server '{server_name}' is not connected"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
name = args.get("name")
|
|
|
|
|
if not name:
|
|
|
|
|
return json.dumps({"error": "Missing required parameter 'name'"})
|
|
|
|
|
arguments = args.get("arguments", {})
|
|
|
|
|
|
|
|
|
|
async def _call():
|
|
|
|
|
result = await server.session.get_prompt(name, arguments=arguments)
|
|
|
|
|
# GetPromptResult has .messages list
|
|
|
|
|
messages = []
|
|
|
|
|
for msg in (result.messages if hasattr(result, "messages") else []):
|
|
|
|
|
entry = {}
|
|
|
|
|
if hasattr(msg, "role"):
|
|
|
|
|
entry["role"] = msg.role
|
|
|
|
|
if hasattr(msg, "content"):
|
|
|
|
|
content = msg.content
|
|
|
|
|
if hasattr(content, "text"):
|
|
|
|
|
entry["content"] = content.text
|
|
|
|
|
elif isinstance(content, str):
|
|
|
|
|
entry["content"] = content
|
|
|
|
|
else:
|
|
|
|
|
entry["content"] = str(content)
|
|
|
|
|
messages.append(entry)
|
|
|
|
|
resp = {"messages": messages}
|
|
|
|
|
if hasattr(result, "description") and result.description:
|
|
|
|
|
resp["description"] = result.description
|
|
|
|
|
return json.dumps(resp)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return _run_on_mcp_loop(_call(), timeout=tool_timeout)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error(
|
|
|
|
|
"MCP %s/get_prompt failed: %s", server_name, exc,
|
|
|
|
|
)
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"error": _sanitize_error(
|
|
|
|
|
f"MCP call failed: {type(exc).__name__}: {exc}"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return _handler
|
|
|
|
|
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
def _make_check_fn(server_name: str):
|
|
|
|
|
"""Return a check function that verifies the MCP connection is alive."""
|
|
|
|
|
|
|
|
|
|
def _check() -> bool:
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
|
|
|
|
server = _servers.get(server_name)
|
2026-03-02 21:22:00 +03:00
|
|
|
return server is not None and server.session is not None
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
return _check
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Discovery & registration
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-03-20 07:23:20 +11:00
|
|
|
def _normalize_mcp_input_schema(schema: dict | None) -> dict:
|
|
|
|
|
"""Normalize MCP input schemas for LLM tool-calling compatibility."""
|
|
|
|
|
if not schema:
|
|
|
|
|
return {"type": "object", "properties": {}}
|
|
|
|
|
|
|
|
|
|
if schema.get("type") == "object" and "properties" not in schema:
|
|
|
|
|
return {**schema, "properties": {}}
|
|
|
|
|
|
|
|
|
|
return schema
|
|
|
|
|
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
def _convert_mcp_schema(server_name: str, mcp_tool) -> dict:
|
|
|
|
|
"""Convert an MCP tool listing to the Hermes registry schema format.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
server_name: The logical server name for prefixing.
|
|
|
|
|
mcp_tool: An MCP ``Tool`` object with ``.name``, ``.description``,
|
|
|
|
|
and ``.inputSchema``.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A dict suitable for ``registry.register(schema=...)``.
|
|
|
|
|
"""
|
|
|
|
|
# Sanitize: replace hyphens and dots with underscores for LLM API compatibility
|
|
|
|
|
safe_tool_name = mcp_tool.name.replace("-", "_").replace(".", "_")
|
|
|
|
|
safe_server_name = server_name.replace("-", "_").replace(".", "_")
|
|
|
|
|
prefixed_name = f"mcp_{safe_server_name}_{safe_tool_name}"
|
|
|
|
|
return {
|
|
|
|
|
"name": prefixed_name,
|
|
|
|
|
"description": mcp_tool.description or f"MCP tool {mcp_tool.name} from {server_name}",
|
2026-03-20 07:23:20 +11:00
|
|
|
"parameters": _normalize_mcp_input_schema(mcp_tool.inputSchema),
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 03:04:17 -07:00
|
|
|
def _sync_mcp_toolsets(server_names: Optional[List[str]] = None) -> None:
|
|
|
|
|
"""Expose each MCP server as a standalone toolset and inject into hermes-* sets.
|
|
|
|
|
|
|
|
|
|
Creates a real toolset entry in TOOLSETS for each server name (e.g.
|
|
|
|
|
TOOLSETS["github"] = {"tools": ["mcp_github_list_files", ...]}). This
|
|
|
|
|
makes raw server names resolvable in platform_toolsets overrides.
|
|
|
|
|
|
|
|
|
|
Also injects all MCP tools into hermes-* umbrella toolsets for the
|
|
|
|
|
default behavior.
|
|
|
|
|
|
|
|
|
|
Skips server names that collide with built-in toolsets.
|
|
|
|
|
"""
|
|
|
|
|
from toolsets import TOOLSETS
|
|
|
|
|
|
|
|
|
|
if server_names is None:
|
|
|
|
|
server_names = list(_load_mcp_config().keys())
|
|
|
|
|
|
|
|
|
|
existing = _existing_tool_names()
|
|
|
|
|
all_mcp_tools: List[str] = []
|
|
|
|
|
|
|
|
|
|
for server_name in server_names:
|
|
|
|
|
safe_prefix = f"mcp_{server_name.replace('-', '_').replace('.', '_')}_"
|
|
|
|
|
server_tools = sorted(
|
|
|
|
|
t for t in existing if t.startswith(safe_prefix)
|
|
|
|
|
)
|
|
|
|
|
all_mcp_tools.extend(server_tools)
|
|
|
|
|
|
|
|
|
|
# Don't overwrite a built-in toolset that happens to share the name.
|
|
|
|
|
existing_ts = TOOLSETS.get(server_name)
|
|
|
|
|
if existing_ts and not str(existing_ts.get("description", "")).startswith("MCP server '"):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Skipping MCP toolset alias '%s' — a built-in toolset already uses that name",
|
|
|
|
|
server_name,
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
TOOLSETS[server_name] = {
|
|
|
|
|
"description": f"MCP server '{server_name}' tools",
|
|
|
|
|
"tools": server_tools,
|
|
|
|
|
"includes": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Also inject into hermes-* umbrella toolsets for default behavior.
|
|
|
|
|
for ts_name, ts in TOOLSETS.items():
|
|
|
|
|
if not ts_name.startswith("hermes-"):
|
|
|
|
|
continue
|
|
|
|
|
for tool_name in all_mcp_tools:
|
|
|
|
|
if tool_name not in ts["tools"]:
|
|
|
|
|
ts["tools"].append(tool_name)
|
|
|
|
|
|
|
|
|
|
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
def _build_utility_schemas(server_name: str) -> List[dict]:
|
|
|
|
|
"""Build schemas for the MCP utility tools (resources & prompts).
|
|
|
|
|
|
|
|
|
|
Returns a list of (schema, handler_factory_name) tuples encoded as dicts
|
|
|
|
|
with keys: schema, handler_key.
|
|
|
|
|
"""
|
|
|
|
|
safe_name = server_name.replace("-", "_").replace(".", "_")
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"schema": {
|
|
|
|
|
"name": f"mcp_{safe_name}_list_resources",
|
|
|
|
|
"description": f"List available resources from MCP server '{server_name}'",
|
|
|
|
|
"parameters": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"handler_key": "list_resources",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"schema": {
|
|
|
|
|
"name": f"mcp_{safe_name}_read_resource",
|
|
|
|
|
"description": f"Read a resource by URI from MCP server '{server_name}'",
|
|
|
|
|
"parameters": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"uri": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "URI of the resource to read",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"required": ["uri"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"handler_key": "read_resource",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"schema": {
|
|
|
|
|
"name": f"mcp_{safe_name}_list_prompts",
|
|
|
|
|
"description": f"List available prompts from MCP server '{server_name}'",
|
|
|
|
|
"parameters": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"handler_key": "list_prompts",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"schema": {
|
|
|
|
|
"name": f"mcp_{safe_name}_get_prompt",
|
|
|
|
|
"description": f"Get a prompt by name from MCP server '{server_name}'",
|
|
|
|
|
"parameters": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"name": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "Name of the prompt to retrieve",
|
|
|
|
|
},
|
|
|
|
|
"arguments": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"description": "Optional arguments to pass to the prompt",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"required": ["name"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"handler_key": "get_prompt",
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 06:22:02 -07:00
|
|
|
def _normalize_name_filter(value: Any, label: str) -> set[str]:
|
|
|
|
|
"""Normalize include/exclude config to a set of tool names."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return set()
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return {value}
|
|
|
|
|
if isinstance(value, (list, tuple, set)):
|
|
|
|
|
return {str(item) for item in value}
|
|
|
|
|
logger.warning("MCP config %s must be a string or list of strings; ignoring %r", label, value)
|
|
|
|
|
return set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_boolish(value: Any, default: bool = True) -> bool:
|
|
|
|
|
"""Parse a bool-like config value with safe fallback."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
return value
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
lowered = value.strip().lower()
|
|
|
|
|
if lowered in {"true", "1", "yes", "on"}:
|
|
|
|
|
return True
|
|
|
|
|
if lowered in {"false", "0", "no", "off"}:
|
|
|
|
|
return False
|
|
|
|
|
logger.warning("MCP config expected a boolean-ish value, got %r; using default=%s", value, default)
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_UTILITY_CAPABILITY_METHODS = {
|
|
|
|
|
"list_resources": "list_resources",
|
|
|
|
|
"read_resource": "read_resource",
|
|
|
|
|
"list_prompts": "list_prompts",
|
|
|
|
|
"get_prompt": "get_prompt",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 {}
|
|
|
|
|
resources_enabled = _parse_boolish(tools_filter.get("resources"), default=True)
|
|
|
|
|
prompts_enabled = _parse_boolish(tools_filter.get("prompts"), default=True)
|
|
|
|
|
|
|
|
|
|
selected: List[dict] = []
|
|
|
|
|
for entry in _build_utility_schemas(server_name):
|
|
|
|
|
handler_key = entry["handler_key"]
|
|
|
|
|
if handler_key in {"list_resources", "read_resource"} and not resources_enabled:
|
|
|
|
|
logger.debug("MCP server '%s': skipping utility '%s' (resources disabled)", server_name, handler_key)
|
|
|
|
|
continue
|
|
|
|
|
if handler_key in {"list_prompts", "get_prompt"} and not prompts_enabled:
|
|
|
|
|
logger.debug("MCP server '%s': skipping utility '%s' (prompts disabled)", server_name, handler_key)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
required_method = _UTILITY_CAPABILITY_METHODS[handler_key]
|
|
|
|
|
if not hasattr(server.session, required_method):
|
|
|
|
|
logger.debug(
|
|
|
|
|
"MCP server '%s': skipping utility '%s' (session lacks %s)",
|
|
|
|
|
server_name,
|
|
|
|
|
handler_key,
|
|
|
|
|
required_method,
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
selected.append(entry)
|
|
|
|
|
return selected
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 22:08:32 +03:00
|
|
|
def _existing_tool_names() -> List[str]:
|
|
|
|
|
"""Return tool names for all currently connected servers."""
|
|
|
|
|
names: List[str] = []
|
2026-03-14 06:22:02 -07:00
|
|
|
for _sname, server in _servers.items():
|
|
|
|
|
if hasattr(server, "_registered_tool_names"):
|
|
|
|
|
names.extend(server._registered_tool_names)
|
|
|
|
|
continue
|
2026-03-02 22:08:32 +03:00
|
|
|
for mcp_tool in server._tools:
|
2026-03-14 06:22:02 -07:00
|
|
|
schema = _convert_mcp_schema(server.name, mcp_tool)
|
2026-03-02 22:08:32 +03:00
|
|
|
names.append(schema["name"])
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
|
|
|
|
"""Connect to a single MCP server, discover tools, and register them.
|
|
|
|
|
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
Also registers utility tools for MCP Resources and Prompts support
|
|
|
|
|
(list_resources, read_resource, list_prompts, get_prompt).
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
Returns list of registered tool names.
|
|
|
|
|
"""
|
|
|
|
|
from tools.registry import registry
|
|
|
|
|
from toolsets import create_custom_toolset
|
|
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
connect_timeout = config.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT)
|
|
|
|
|
server = await asyncio.wait_for(
|
|
|
|
|
_connect_server(name, config),
|
|
|
|
|
timeout=connect_timeout,
|
|
|
|
|
)
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
|
|
|
|
_servers[name] = server
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
registered_names: List[str] = []
|
|
|
|
|
toolset_name = f"mcp-{name}"
|
|
|
|
|
|
2026-03-12 02:31:09 +03:00
|
|
|
# Selective tool loading: honour include/exclude lists from config.
|
|
|
|
|
# Rules (matching issue #690 spec):
|
|
|
|
|
# tools.include — whitelist: only these tool names are registered
|
|
|
|
|
# tools.exclude — blacklist: all tools EXCEPT these are registered
|
2026-03-14 06:22:02 -07:00
|
|
|
# include takes precedence over exclude
|
2026-03-12 02:31:09 +03:00
|
|
|
# Neither set → register all tools (backward-compatible default)
|
|
|
|
|
tools_filter = config.get("tools") or {}
|
2026-03-14 06:22:02 -07:00
|
|
|
include_set = _normalize_name_filter(tools_filter.get("include"), f"mcp_servers.{name}.tools.include")
|
|
|
|
|
exclude_set = _normalize_name_filter(tools_filter.get("exclude"), f"mcp_servers.{name}.tools.exclude")
|
2026-03-12 02:31:09 +03:00
|
|
|
|
|
|
|
|
def _should_register(tool_name: str) -> bool:
|
|
|
|
|
if include_set:
|
|
|
|
|
return tool_name in include_set
|
|
|
|
|
if exclude_set:
|
|
|
|
|
return tool_name not in exclude_set
|
|
|
|
|
return True
|
|
|
|
|
|
2026-03-02 21:22:00 +03:00
|
|
|
for mcp_tool in server._tools:
|
2026-03-12 02:31:09 +03:00
|
|
|
if not _should_register(mcp_tool.name):
|
|
|
|
|
logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name)
|
|
|
|
|
continue
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
schema = _convert_mcp_schema(name, mcp_tool)
|
|
|
|
|
tool_name_prefixed = schema["name"]
|
|
|
|
|
|
|
|
|
|
registry.register(
|
|
|
|
|
name=tool_name_prefixed,
|
|
|
|
|
toolset=toolset_name,
|
|
|
|
|
schema=schema,
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
handler=_make_tool_handler(name, mcp_tool.name, server.tool_timeout),
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
check_fn=_make_check_fn(name),
|
|
|
|
|
is_async=False,
|
|
|
|
|
description=schema["description"],
|
|
|
|
|
)
|
|
|
|
|
registered_names.append(tool_name_prefixed)
|
|
|
|
|
|
2026-03-14 06:22:02 -07:00
|
|
|
# Register MCP Resources & Prompts utility tools, filtered by config and
|
|
|
|
|
# only when the server actually supports the corresponding capability.
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
_handler_factories = {
|
|
|
|
|
"list_resources": _make_list_resources_handler,
|
|
|
|
|
"read_resource": _make_read_resource_handler,
|
|
|
|
|
"list_prompts": _make_list_prompts_handler,
|
|
|
|
|
"get_prompt": _make_get_prompt_handler,
|
|
|
|
|
}
|
|
|
|
|
check_fn = _make_check_fn(name)
|
2026-03-14 06:22:02 -07:00
|
|
|
for entry in _select_utility_schemas(name, server, config):
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
schema = entry["schema"]
|
|
|
|
|
handler_key = entry["handler_key"]
|
|
|
|
|
handler = _handler_factories[handler_key](name, server.tool_timeout)
|
|
|
|
|
|
|
|
|
|
registry.register(
|
|
|
|
|
name=schema["name"],
|
|
|
|
|
toolset=toolset_name,
|
|
|
|
|
schema=schema,
|
|
|
|
|
handler=handler,
|
|
|
|
|
check_fn=check_fn,
|
|
|
|
|
is_async=False,
|
|
|
|
|
description=schema["description"],
|
|
|
|
|
)
|
|
|
|
|
registered_names.append(schema["name"])
|
|
|
|
|
|
2026-03-14 06:22:02 -07:00
|
|
|
server._registered_tool_names = list(registered_names)
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
# Create a custom toolset so these tools are discoverable
|
|
|
|
|
if registered_names:
|
|
|
|
|
create_custom_toolset(
|
|
|
|
|
name=toolset_name,
|
|
|
|
|
description=f"MCP tools from {name} server",
|
|
|
|
|
tools=registered_names,
|
|
|
|
|
)
|
|
|
|
|
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
transport_type = "HTTP" if "url" in config else "stdio"
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
logger.info(
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
"MCP server '%s' (%s): registered %d tool(s): %s",
|
|
|
|
|
name, transport_type, len(registered_names),
|
|
|
|
|
", ".join(registered_names),
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
)
|
|
|
|
|
return registered_names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Public API
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def discover_mcp_tools() -> List[str]:
|
|
|
|
|
"""Entry point: load config, connect to MCP servers, register tools.
|
|
|
|
|
|
|
|
|
|
Called from ``model_tools._discover_tools()``. Safe to call even when
|
|
|
|
|
the ``mcp`` package is not installed (returns empty list).
|
|
|
|
|
|
2026-03-02 22:08:32 +03:00
|
|
|
Idempotent for already-connected servers. If some servers failed on a
|
|
|
|
|
previous call, only the missing ones are retried.
|
2026-03-02 21:34:21 +03:00
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
Returns:
|
|
|
|
|
List of all registered MCP tool names.
|
|
|
|
|
"""
|
|
|
|
|
if not _MCP_AVAILABLE:
|
|
|
|
|
logger.debug("MCP SDK not available -- skipping MCP tool discovery")
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
servers = _load_mcp_config()
|
|
|
|
|
if not servers:
|
|
|
|
|
logger.debug("No MCP servers configured")
|
|
|
|
|
return []
|
|
|
|
|
|
2026-03-12 02:31:09 +03:00
|
|
|
# Only attempt servers that aren't already connected and are enabled
|
|
|
|
|
# (enabled: false skips the server entirely without removing its config)
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
2026-03-12 02:31:09 +03:00
|
|
|
new_servers = {
|
2026-03-14 06:22:02 -07:00
|
|
|
k: v
|
|
|
|
|
for k, v in servers.items()
|
|
|
|
|
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
|
2026-03-12 02:31:09 +03:00
|
|
|
}
|
2026-03-02 22:08:32 +03:00
|
|
|
|
|
|
|
|
if not new_servers:
|
2026-03-18 03:04:17 -07:00
|
|
|
_sync_mcp_toolsets(list(servers.keys()))
|
2026-03-02 22:08:32 +03:00
|
|
|
return _existing_tool_names()
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
# Start the background event loop for MCP connections
|
|
|
|
|
_ensure_mcp_loop()
|
|
|
|
|
|
|
|
|
|
all_tools: List[str] = []
|
2026-03-02 19:02:28 -08:00
|
|
|
failed_count = 0
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-02 19:02:28 -08:00
|
|
|
async def _discover_one(name: str, cfg: dict) -> List[str]:
|
|
|
|
|
"""Connect to a single server and return its registered tool names."""
|
2026-03-10 02:27:59 +03:00
|
|
|
return await _discover_and_register_server(name, cfg)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-02 19:02:28 -08:00
|
|
|
async def _discover_all():
|
|
|
|
|
nonlocal failed_count
|
2026-03-10 02:27:59 +03:00
|
|
|
server_names = list(new_servers.keys())
|
2026-03-02 19:02:28 -08:00
|
|
|
# Connect to all servers in PARALLEL
|
|
|
|
|
results = await asyncio.gather(
|
|
|
|
|
*(_discover_one(name, cfg) for name, cfg in new_servers.items()),
|
|
|
|
|
return_exceptions=True,
|
|
|
|
|
)
|
2026-03-10 02:27:59 +03:00
|
|
|
for name, result in zip(server_names, results):
|
2026-03-02 19:02:28 -08:00
|
|
|
if isinstance(result, Exception):
|
|
|
|
|
failed_count += 1
|
2026-03-14 05:44:00 -07:00
|
|
|
command = new_servers.get(name, {}).get("command")
|
2026-03-10 02:27:59 +03:00
|
|
|
logger.warning(
|
2026-03-14 05:44:00 -07:00
|
|
|
"Failed to connect to MCP server '%s'%s: %s",
|
|
|
|
|
name,
|
|
|
|
|
f" (command={command})" if command else "",
|
|
|
|
|
_format_connect_error(result),
|
2026-03-10 02:27:59 +03:00
|
|
|
)
|
2026-03-02 19:02:28 -08:00
|
|
|
elif isinstance(result, list):
|
|
|
|
|
all_tools.extend(result)
|
|
|
|
|
else:
|
|
|
|
|
failed_count += 1
|
|
|
|
|
|
|
|
|
|
# Per-server timeouts are handled inside _discover_and_register_server.
|
|
|
|
|
# The outer timeout is generous: 120s total for parallel discovery.
|
|
|
|
|
_run_on_mcp_loop(_discover_all(), timeout=120)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-18 03:04:17 -07:00
|
|
|
_sync_mcp_toolsets(list(servers.keys()))
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-02 19:02:28 -08:00
|
|
|
# Print summary
|
|
|
|
|
total_servers = len(new_servers)
|
|
|
|
|
ok_servers = total_servers - failed_count
|
|
|
|
|
if all_tools or failed_count:
|
|
|
|
|
summary = f" MCP: {len(all_tools)} tool(s) from {ok_servers} server(s)"
|
|
|
|
|
if failed_count:
|
|
|
|
|
summary += f" ({failed_count} failed)"
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
logger.info(summary)
|
2026-03-02 19:02:28 -08:00
|
|
|
|
2026-03-02 22:08:32 +03:00
|
|
|
# Return ALL registered tools (existing + newly discovered)
|
|
|
|
|
return _existing_tool_names()
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
|
|
|
|
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
def get_mcp_status() -> List[dict]:
|
|
|
|
|
"""Return status of all configured MCP servers for banner display.
|
|
|
|
|
|
|
|
|
|
Returns a list of dicts with keys: name, transport, tools, connected.
|
|
|
|
|
Includes both successfully connected servers and configured-but-failed ones.
|
|
|
|
|
"""
|
|
|
|
|
result: List[dict] = []
|
|
|
|
|
|
|
|
|
|
# Get configured servers from config
|
|
|
|
|
configured = _load_mcp_config()
|
|
|
|
|
if not configured:
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
with _lock:
|
|
|
|
|
active_servers = dict(_servers)
|
|
|
|
|
|
|
|
|
|
for name, cfg in configured.items():
|
|
|
|
|
transport = "http" if "url" in cfg else "stdio"
|
|
|
|
|
server = active_servers.get(name)
|
|
|
|
|
if server and server.session is not None:
|
2026-03-09 03:37:38 -07:00
|
|
|
entry = {
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
"name": name,
|
|
|
|
|
"transport": transport,
|
2026-03-14 06:22:02 -07:00
|
|
|
"tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools),
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
"connected": True,
|
2026-03-09 03:37:38 -07:00
|
|
|
}
|
|
|
|
|
if server._sampling:
|
|
|
|
|
entry["sampling"] = dict(server._sampling.metrics)
|
|
|
|
|
result.append(entry)
|
feat(mcp): banner integration, /reload-mcp command, resources & prompts
Banner integration:
- MCP Servers section in CLI startup banner between Tools and Skills
- Shows each server with transport type, tool count, connection status
- Failed servers shown in red; section hidden when no MCP configured
- Summary line includes MCP server count
- Removed raw print() calls from discovery (banner handles display)
/reload-mcp command:
- New slash command in both CLI and gateway
- Disconnects all MCP servers, re-reads config.yaml, reconnects
- Reports what changed (added/removed/reconnected servers)
- Allows adding/removing MCP servers without restarting
Resources & Prompts support:
- 4 utility tools registered per server: list_resources, read_resource,
list_prompts, get_prompt
- Exposes MCP Resources (data sources) and Prompts (templates) as tools
- Proper parameter schemas (uri for read_resource, name for get_prompt)
- Handles text and binary resource content
- 23 new tests covering schemas, handlers, and registration
Test coverage: 74 MCP tests total, 1186 tests pass overall.
2026-03-02 19:15:59 -08:00
|
|
|
else:
|
|
|
|
|
result.append({
|
|
|
|
|
"name": name,
|
|
|
|
|
"transport": transport,
|
|
|
|
|
"tools": 0,
|
|
|
|
|
"connected": False,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 03:48:44 -07:00
|
|
|
def probe_mcp_server_tools() -> Dict[str, List[tuple]]:
|
|
|
|
|
"""Temporarily connect to configured MCP servers and list their tools.
|
|
|
|
|
|
|
|
|
|
Designed for ``hermes tools`` interactive configuration — connects to each
|
|
|
|
|
enabled server, grabs tool names and descriptions, then disconnects.
|
|
|
|
|
Does NOT register tools in the Hermes registry.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict mapping server name to list of (tool_name, description) tuples.
|
|
|
|
|
Servers that fail to connect are omitted from the result.
|
|
|
|
|
"""
|
|
|
|
|
if not _MCP_AVAILABLE:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
servers_config = _load_mcp_config()
|
|
|
|
|
if not servers_config:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
enabled = {
|
|
|
|
|
k: v for k, v in servers_config.items()
|
|
|
|
|
if _parse_boolish(v.get("enabled", True), default=True)
|
|
|
|
|
}
|
|
|
|
|
if not enabled:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
_ensure_mcp_loop()
|
|
|
|
|
|
|
|
|
|
result: Dict[str, List[tuple]] = {}
|
|
|
|
|
probed_servers: List[MCPServerTask] = []
|
|
|
|
|
|
|
|
|
|
async def _probe_all():
|
|
|
|
|
names = list(enabled.keys())
|
|
|
|
|
coros = []
|
|
|
|
|
for name, cfg in enabled.items():
|
|
|
|
|
ct = cfg.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT)
|
|
|
|
|
coros.append(asyncio.wait_for(_connect_server(name, cfg), timeout=ct))
|
|
|
|
|
|
|
|
|
|
outcomes = await asyncio.gather(*coros, return_exceptions=True)
|
|
|
|
|
|
|
|
|
|
for name, outcome in zip(names, outcomes):
|
|
|
|
|
if isinstance(outcome, Exception):
|
|
|
|
|
logger.debug("Probe: failed to connect to '%s': %s", name, outcome)
|
|
|
|
|
continue
|
|
|
|
|
probed_servers.append(outcome)
|
|
|
|
|
tools = []
|
|
|
|
|
for t in outcome._tools:
|
|
|
|
|
desc = getattr(t, "description", "") or ""
|
|
|
|
|
tools.append((t.name, desc))
|
|
|
|
|
result[name] = tools
|
|
|
|
|
|
|
|
|
|
# Shut down all probed connections
|
|
|
|
|
await asyncio.gather(
|
|
|
|
|
*(s.shutdown() for s in probed_servers),
|
|
|
|
|
return_exceptions=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
_run_on_mcp_loop(_probe_all(), timeout=120)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.debug("MCP probe failed: %s", exc)
|
|
|
|
|
finally:
|
|
|
|
|
_stop_mcp_loop()
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
def shutdown_mcp_servers():
|
2026-03-02 21:22:00 +03:00
|
|
|
"""Close all MCP server connections and stop the background loop.
|
|
|
|
|
|
|
|
|
|
Each server Task is signalled to exit its ``async with`` block so that
|
|
|
|
|
the anyio cancel-scope cleanup happens in the same Task that opened it.
|
2026-03-02 22:08:32 +03:00
|
|
|
All servers are shut down in parallel via ``asyncio.gather``.
|
2026-03-02 21:22:00 +03:00
|
|
|
"""
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
servers_snapshot = list(_servers.values())
|
2026-03-02 22:08:32 +03:00
|
|
|
|
|
|
|
|
# Fast path: nothing to shut down.
|
feat(mcp): add HTTP transport, reconnection, security hardening
Upgrades the MCP client implementation from PR #291 with:
- HTTP/Streamable HTTP transport: support 'url' key in config for remote
MCP servers (Notion, Slack, Sentry, Supabase, etc.)
- Automatic reconnection with exponential backoff (1s-60s, 5 retries)
when a server connection drops unexpectedly
- Environment variable filtering: only pass safe vars (PATH, HOME, etc.)
plus user-specified env to stdio subprocesses (prevents secret leaks)
- Credential stripping: sanitize error messages before returning to the
LLM (strips GitHub PATs, OpenAI keys, Bearer tokens, etc.)
- Configurable per-server timeouts: 'timeout' and 'connect_timeout' keys
- Fix shutdown race condition in servers_snapshot variable scoping
Test coverage: 50 tests (up from 30), including new tests for env
filtering, credential sanitization, HTTP config detection, reconnection
logic, and configurable timeouts.
All 1162 tests pass (1162 passed, 3 skipped, 0 failed).
2026-03-02 18:40:03 -08:00
|
|
|
if not servers_snapshot:
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
_stop_mcp_loop()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
async def _shutdown():
|
2026-03-02 22:08:32 +03:00
|
|
|
results = await asyncio.gather(
|
|
|
|
|
*(server.shutdown() for server in servers_snapshot),
|
|
|
|
|
return_exceptions=True,
|
|
|
|
|
)
|
|
|
|
|
for server, result in zip(servers_snapshot, results):
|
|
|
|
|
if isinstance(result, Exception):
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Error closing MCP server '%s': %s", server.name, result,
|
|
|
|
|
)
|
|
|
|
|
with _lock:
|
|
|
|
|
_servers.clear()
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
|
|
|
|
loop = _mcp_loop
|
|
|
|
|
if loop is not None and loop.is_running():
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
try:
|
2026-03-02 22:08:32 +03:00
|
|
|
future = asyncio.run_coroutine_threadsafe(_shutdown(), loop)
|
2026-03-02 21:22:00 +03:00
|
|
|
future.result(timeout=15)
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
except Exception as exc:
|
|
|
|
|
logger.debug("Error during MCP shutdown: %s", exc)
|
|
|
|
|
|
|
|
|
|
_stop_mcp_loop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stop_mcp_loop():
|
|
|
|
|
"""Stop the background event loop and join its thread."""
|
|
|
|
|
global _mcp_loop, _mcp_thread
|
2026-03-02 22:08:32 +03:00
|
|
|
with _lock:
|
|
|
|
|
loop = _mcp_loop
|
|
|
|
|
thread = _mcp_thread
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
_mcp_loop = None
|
2026-03-02 22:08:32 +03:00
|
|
|
_mcp_thread = None
|
|
|
|
|
if loop is not None:
|
|
|
|
|
loop.call_soon_threadsafe(loop.stop)
|
|
|
|
|
if thread is not None:
|
|
|
|
|
thread.join(timeout=5)
|
|
|
|
|
loop.close()
|