转载--Hermes Agent 17 | MCP 集成:连接万物的开放协议
封闭的系统终将被遗忘,开放的协议才能连接未来。
MCP 是什么,为什么重要
MCP 是 Anthropic 发起的开放协议,定义了 AI Agent(客户端)和工具服务(服务器)之间的通信标准。你可以把它理解为"AI 工具领域的 USB 接口"——任何实现了 MCP 协议的服务,都能被任何支持 MCP 的 Agent 调用。
在 Hermes 里,MCP 的价值是:不修改任何 Hermes 代码,就能给 Agent 加无限多的工具。 社区已经有几百个 MCP 服务器——文件系统、GitHub、PostgreSQL、Slack、Jira、浏览器自动化——直接配上就能用。
架构:一个专属的后台事件循环
tools/mcp_tool.py 的架构文档写得很清楚(第 54-62 行):
A dedicated background event loop (_mcp_loop) runs in a daemon thread.
Each MCP server runs as a long-lived asyncio Task on this loop, keeping
its transport context alive. Tool call coroutines are scheduled onto the
loop via run_coroutine_threadsafe().
翻译成图:
┌── 主线程 (AIAgent 同步调用) ──────────────────────────┐
│ │
│ Agent 调用 mcp_github_create_issue(args) │
│ → _make_tool_handler() 返回的 sync handler │
│ → _run_on_mcp_loop(async_call, timeout) │
│ → asyncio.run_coroutine_threadsafe(coro, _mcp_loop) │
│ → future.result(timeout=120s) │
│ │
└────────────────────────┬───────────────────────────────┘
│ 跨线程投递
┌────────────────────────▼───────────────────────────────┐
│ MCP 后台 daemon 线程 (_mcp_thread) │
│ └── _mcp_loop (asyncio event loop) │
│ ├── MCPServerTask("github") ← 长驻 asyncio Task │
│ │ └── ClientSession (stdio / HTTP transport) │
│ ├── MCPServerTask("postgres") │
│ └── MCPServerTask("filesystem") │
└────────────────────────────────────────────────────────┘
为什么要一个专属线程? 因为 MCP 的传输层(stdio 子进程或 HTTP 长连接)需要一个常驻的 asyncio 事件循环来维持连接。而 AIAgent 的主循环是同步的。专属线程把两者解耦——Agent 线程调工具时投递一个协程到 MCP 线程,等结果返回。
线程安全(第 64-68 行):_servers 字典和 _mcp_loop/_mcp_thread 的所有修改都在 _lock(threading.Lock())保护下。代码注释特别提到"safe regardless of GIL presence (e.g. Python 3.13+ free-threading)"——面向未来的无 GIL Python。
配置:一段 YAML 接入一个 MCP 服务器
在默认 ~/.hermes/config.yaml 里加 mcp_servers 段:
mcp_servers:
# Stdio 传输——启动一个本地子进程
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env: {}
timeout: 120 # 单次工具调用超时(默认 120s)
connect_timeout: 60 # 首次连接超时(默认 60s)
# Stdio 传输——带环境变量
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..."
# HTTP 传输——连接远程服务器
remote_api:
url: "https://my-mcp-server.example.com/mcp"
headers:
Authorization: "Bearer sk-..."
timeout: 180
# 带 OAuth 认证
cloud_service:
url: "https://mcp.cloud.example.com/v1"
auth: "oauth"
oauth:
client_id: "pre-registered-id"
scope: "read write"
# 临时禁用
experimental:
command: "python"
args: ["-m", "my_experimental_server"]
enabled: false
两种传输方式互斥:有 command 走 stdio,有 url 走 HTTP/StreamableHTTP。如果两个都写了,HTTP 优先,日志会打 warning。
工具过滤
如果一个 MCP 服务器暴露了 20 个工具但你只想用其中 3 个:
mcp_servers:
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
tools:
include: [create_issue, search_repos, get_file_contents]
也可以用黑名单:
tools:
exclude: [delete_repo, force_push]
include 优先于 exclude。更准确地说:
-
include/exclude只过滤 server-native MCP tools -
resources/prompts控制 Hermes 附加注册的 4 个 utility tools:mcp_<server>_list_resources、read_resource、list_prompts、get_prompt -
如果服务器本身不暴露相应 capability,就算你把
resources: true或prompts: true打开,这些 utility tools 也不会出现
所以“都不设”并不只是“注册全部原生工具”,而是“注册全部原生工具,并在 capability 存在时附加 utility tools”。
发现与注册:从 MCP 服务器到 Agent 工具
discover_mcp_tools()(第 2362 行)是整个流程的入口。
连接阶段
-
_load_mcp_config()从config.yaml读取mcp_servers段,做环境变量插值(${VAR}语法) -
过滤掉
enabled: false的服务器 -
对每个服务器调用
_discover_and_register_server(),通过asyncio.gather()并行连接 -
外层 120 秒总超时,每个服务器有自己的
connect_timeout(默认 60 秒)
MCPServerTask.run():连接生命周期
MCPServerTask(第 774 行)是每个 MCP 服务器的长驻管理器。run() 方法(第 1084 行)是它的主循环:
async def run(self, config: dict):
retries = 0
initial_retries = 0
backoff = 1.0
while True:
try:
if self._is_http():
await self._run_http(config)
else:
await self._run_stdio(config)
if self._shutdown_event.is_set():
break # 正常关闭
self.session = None
continue # OAuth 恢复后重连
except Exception as exc:
self.session = None
# 首次连接失败:最多重试 3 次
# 运行中断连:最多重试 5 次
# 指数退避:1s → 2s → 4s → ... → 60s 封顶
首次连接和运行中断连有独立的重试计数器:
-
首次连接:
_MAX_INITIAL_CONNECT_RETRIES = 3——启动时 DNS 抖动或网络短暂不通,给 3 次机会 -
运行中断重连:
_MAX_RECONNECT_RETRIES = 5——已经连上了又断了,给 5 次机会 -
退避上限:
_MAX_BACKOFF_SECONDS = 60——最多等 1 分钟
工具注册
连接成功后,_register_server_tools()(第 2148 行)把 MCP 服务器暴露的工具注册到 Hermes 的全局 ToolRegistry:
registry.register(
name="mcp_github_create_issue", # 前缀命名避免冲突
toolset="mcp-github", # 工具集名称
schema=schema, # OpenAI 格式的函数定义
handler=_make_tool_handler(...), # 同步包装器
check_fn=_make_check_fn(name), # 连接是否存活
is_async=False,
)
工具命名规则是 mcp_{服务器名}_{工具名}——比如 GitHub 服务器的 create_issue 工具变成 mcp_github_create_issue。前缀保证不会和内置工具或其他 MCP 服务器的工具冲突。
注册完还会做一件事(第 2251 行):
registry.register_toolset_alias(name, toolset_name)
这样 resolve_toolset("github") 能正确解析到 "mcp-github"——在 config.yaml 的 tools.enabled 和 tools.disabled 里可以直接用短名。
防碰撞
如果 MCP 工具的前缀名和内置工具重名(极端情况),注册会被跳过:
existing_toolset = registry.get_toolset_for_tool(tool_name_prefixed)
if existing_toolset and not existing_toolset.startswith("mcp-"):
logger.warning("... collides with built-in tool ... — skipping to preserve built-in")
continue
MCP 之间的同名覆盖则是允许的——server refresh 或两个 MCP 服务器恰好暴露同名工具时,后注册的覆盖前者。
动态工具刷新:nuke-and-repave
MCP 协议定义了一个 notifications/tools/list_changed 通知——服务器可以在运行时告诉客户端"我的工具列表变了"。Hermes 的处理方式是"全拆全建"。
不过这里有一个容易忽略的前提:这条热刷新链依赖当前安装的 MCP SDK 同时支持 notification types 和 **message_handler**。 如果 SDK 版本太老,这套动态刷新会被源码显式关闭,日志里会看到 dynamic tool discovery disabled 一类提示;那时工具变化只能靠手动 /reload-mcp 重新加载。
_refresh_tools()(第 848 行):
async def _refresh_tools(self):
async with self._refresh_lock:
# 1. 记录旧工具
old_tool_names = set(self._registered_tool_names)
# 2. 从服务器重新拉取工具列表
tools_result = await self.session.list_tools()
# 3. 反注册所有旧工具
for prefixed_name in self._registered_tool_names:
registry.deregister(prefixed_name)
# 4. 用新列表重新注册
self._registered_tool_names = _register_server_tools(...)
# 5. 记录变化(added / removed)
为什么不做 diff 只更新变化的?因为 MCP 工具的 schema 也可能变——同一个工具名可能改了参数。全拆全建虽然粗暴,但保证了一致性。_refresh_lock(asyncio.Lock)防止快速连续的通知导致并发刷新。
日志会 warning 级别记录变化——"verify these changes are expected"。如果你看到一个工具突然出现或消失,日志里有据可查。
工具调用分发:从 Agent 到 MCP 服务器
Agent 调用一个 MCP 工具时,实际执行路径:
Agent 调用 mcp_github_create_issue({"title": "Bug", "body": "..."})
│
▼
_make_tool_handler() 返回的 sync handler
│
├─ 1. 熔断检查:该服务器连续失败 ≥3 次?→ 直接返回错误
├─ 2. 连接检查:server.session 存在?
├─ 3. 构造 async _call():await server.session.call_tool(tool_name, args)
├─ 4. 投递到 MCP 线程:_run_on_mcp_loop(_call(), timeout)
├─ 5. 等待结果(最多 120s)
├─ 6. 成功 → 重置错误计数 → 返回 JSON 结果
└─ 7. 失败:
├─ 认证错误 → _handle_auth_error_and_retry() → 可能重试
└─ 其他错误 → 累加错误计数 → 返回 sanitized 错误
熔断器
_server_error_counts(模块级字典)跟踪每个服务器的连续错误次数。阈值是 3 次(_CIRCUIT_BREAKER_THRESHOLD)。
if _server_error_counts.get(server_name, 0) >= _CIRCUIT_BREAKER_THRESHOLD:
return json.dumps({
"error": f"MCP server '{server_name}' is unreachable after "
f"{_CIRCUIT_BREAKER_THRESHOLD} consecutive failures. "
f"Do NOT retry this tool — use alternative approaches ..."
})
注意错误信息里的"Do NOT retry this tool"——这是写给模型看的。如果不明确告诉模型"别再试了",它可能会无限重试,浪费 token 和时间。成功调用会重置计数器为 0。
凭证清洗
错误消息返回给模型前,_sanitize_error() 会用正则清洗(第 213 行):
_CREDENTIAL_PATTERN = re.compile(
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"|password=[^\s&,;\"']{1,255}" # password=...
r"|secret=[^\s&,;\"']{1,255}" # secret=...
r")", re.IGNORECASE,
)
MCP 服务器崩溃时可能在错误消息里泄露环境变量中的 token。这层清洗保证即使服务器出错,模型也看不到真实凭证。
Stdio 安全:环境变量隔离
当 MCP 服务器通过 stdio 传输启动时,Hermes 会创建一个过滤后的环境,而不是把当前进程的全部环境变量传给子进程。
_build_safe_env()(第 194 行):
_SAFE_ENV_KEYS = frozenset({
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
})
def _build_safe_env(user_env):
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
只有 8 个基线变量 + XDG 系列变量从当前进程继承。你配在 env: 里的变量会叠加上去。
这意味着什么? 如果你的 ~/.hermes/.env 里有 OPENAI_API_KEY、AWS_SECRET_ACCESS_KEY 等敏感变量,它们不会传给 MCP 子进程。服务器只能看到你显式给它的变量。这是一个安全底线——即使你安装了一个不可信的 MCP 服务器,它也拿不到你的其他凭证。
OSV 恶意软件扫描
在 stdio 子进程启动前,Hermes 还会做一件事:查 OSV(Open Source Vulnerabilities)数据库。
但它的覆盖范围没有表面上那么宽。当前实现只对能从启动命令里推断出包生态和包名的服务器生效,典型是 npx、uvx、pipx。如果你写的是 python -m my_server、自定义二进制,或者参数里根本解析不出包名,这层检查会直接跳过。
check_package_for_malware()(tools/osv_check.py 第 26 行):
def check_package_for_malware(command, args) -> Optional[str]:
ecosystem = _infer_ecosystem(command) # npx → npm, uvx → PyPI
package, version = _parse_package_from_args(args, ecosystem)
malware = _query_osv(package, ecosystem, version)
if malware:
return f"BLOCKED: Package '{package}' ({ecosystem}) has known malware advisories: ..."
return None
工作方式:
-
从命令推断生态系统——
npx意味着 npm,uvx/pipx意味着 PyPI -
从参数解析包名和版本——处理
@scope/name@version和name[extras]==version格式 -
查询
https://api.osv.dev/v1/query\,只关注MAL-*前缀的 advisory(确认的恶意软件,不是普通 CVE) -
有恶意软件记录 → 阻止启动;没有、网络错误或根本无法识别包来源 → 放行
Fail-open 设计:网络错误时不阻止(第 49 行 logger.debug("... (allowing)"))。因为这是启动时的可选检查,不能因为 OSV API 暂时不可达就阻止所有 MCP 服务器启动。
查询延迟约 300ms,整个文件只有 155 行。灵感来自 Block/goose 项目的扩展恶意软件检查。更准确地说,它是对包管理器启动的 stdio MCP server 的一层供应链补充检查,不是对所有 stdio server 的通用审计。
OAuth 2.1 PKCE 认证
远程 MCP 服务器可能需要 OAuth 认证。tools/mcp_oauth.py(526 行)实现了完整的 OAuth 2.1 PKCE 公开客户端流程。
配置方式
mcp_servers:
cloud_service:
url: "https://mcp.cloud.example.com/v1"
auth: "oauth"
oauth:
client_id: "pre-registered-id" # 可选:预注册的客户端 ID
client_secret: "secret" # 可选:客户端密钥
scope: "read write" # 可选:请求的权限范围
redirect_port: 0 # 0 = 自动选择可用端口
client_name: "My Agent" # 显示名
认证流程
-
客户端元数据构建:
_build_client_metadata()(第 408 行)构造 OAuth 客户端信息。token_endpoint_auth_method默认是"none"(PKCE 公开客户端),如果配了client_secret则用"client_secret_post" -
浏览器授权:
_redirect_handler()打印 URL 并尝试打开浏览器 -
回调服务器:
_wait_for_callback()在127.0.0.1:{port}/callback启动临时 HTTP 服务器,等待 OAuth redirect,300 秒超时 -
Token 持久化:
HermesTokenStorage(第 175 行)把 token 存到默认~/.hermes/mcp-tokens/{server_name}.json,客户端信息存到{server_name}.client.json。如果启用了 profile,这个目录会随HERMES_HOME切换。
MCPOAuthManager:集中管理
tools/mcp_oauth_manager.py(414 行)是 OAuth 状态的集中管理器:
-
Provider 缓存:
get_or_build_provider()幂等地创建或复用 OAuth provider -
磁盘变更检测:
invalidate_if_disk_changed()监控 token 文件的 mtime——如果外部进程刷新了 token(比如一个 cron job),运行中的 MCP 会话会自动拾取新 token -
401 去重:
handle_401()处理认证失败时的恢复。多个并发工具调用同时拿到 401 时,只有第一个触发恢复流程,其余等待结果——防止 thundering herd
认证恢复成功后,MCPServerTask 的 _reconnect_event 被设置,run() 循环干净地退出当前传输上下文并重新连接——整个 MCP 会话用新 token 重建。
Sampling:MCP 服务器反过来调用 LLM
MCP 协议有一个双向能力:服务器可以通过 sampling/createMessage 请求客户端(Hermes)做一次 LLM 推理。这叫 sampling。
使用场景:一个数据分析 MCP 服务器执行 SQL 查询后,想让 LLM 总结结果再返回——它不需要自己调 LLM API,直接通过 sampling 让 Hermes 帮它调。
SamplingHandler
SamplingHandler(第 403 行)管理每个服务器的 sampling 请求:
速率限制:滑动窗口,默认每分钟最多 10 次(max_rpm)
模型解析:
-
服务器可以在请求里建议模型(
modelPreferences) -
配置里的
model字段可以覆盖 -
allowed_models白名单限制可用模型
工具循环治理:max_tool_rounds(默认 5)限制 sampling 请求中的工具调用轮数。设为 0 完全禁用工具循环。这防止了一个 MCP 服务器通过 sampling 无限地调用工具。
消息格式转换:MCP 的 SamplingMessage 和 OpenAI 的消息格式不同。SamplingHandler 做双向转换——请求从 MCP 格式转为 OpenAI 格式调 LLM,响应从 OpenAI 格式转回 MCP 格式返回服务器。
配置示例:
mcp_servers:
analysis:
command: "npx"
args: ["-y", "analysis-server"]
sampling:
enabled: true
model: "gemini-3-flash" # 用便宜模型处理 sampling
max_tokens_cap: 4096
timeout: 30
max_rpm: 10
max_tool_rounds: 5
提示词注入检测
MCP 服务器暴露的工具 description 字段可能包含注入攻击——试图通过 description 里的文字影响 Agent 行为。
_scan_mcp_description()(第 253 行附近)在注册工具时扫描 description:
-
"ignore previous instructions"
-
"you are now a"
-
"system:"、
<system>、<human> -
curl/wget 等网络命令
-
eval/exec/import 等代码执行模式
检测到会打 WARNING 级别日志,但不阻止注册——因为误报风险太高(正常的 tool description 可能包含这些词汇作为示例或说明)。
实战:接入一个 MCP 服务器
以 GitHub MCP 服务器为例。
第一步:安装 MCP SDK
pip install mcp
Hermes 的 MCP 支持是可选依赖——没装 mcp 包时,整个模块静默跳过(第 90 行 _MCP_AVAILABLE = False)。
第二步:配置
在默认 ~/.hermes/config.yaml 里加:
mcp_servers:
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_your_token_here"
第三步:验证连接
先做一条不会受交互式 slash command 影响的验证:
hermes mcp test github
如果这一步通过,再进交互式会话确认工具是否注册成功:
hermes chat
# 进入后执行
/tools
你应该能看到 mcp_github_* 开头的工具出现在列表里。
这里不要用 hermes chat -q "/tools" 代替。当前 -q 是单次查询模式,/tools 会被当成普通用户消息,而不是 slash command。
第四步:使用
帮我在 my-org/my-repo 里创建一个 issue,标题是"优化数据库查询性能",
描述里列出需要检查的三个关键 SQL。
Agent 会自动调用 mcp_github_create_issue。你不需要告诉它"用 MCP 工具"——从 Agent 的视角,这就是一个普通工具。
排查连接问题
如果工具没出现:
-
确认
mcp包已安装:pip show mcp -
确认
npx可用:which npx(MCP 的 Node.js 服务器需要 Node.js 环境) -
看日志:
hermes gateway模式下日志里搜MCP server -
检查
enabled: false是否误设了
如果工具调用失败:
-
看日志里的
MCP tool ... call failed条目 -
检查熔断:如果日志说"unreachable after 3 consecutive failures",服务器可能真的挂了
-
认证问题:401 错误会触发自动恢复,但如果 token 过期了需要手动刷新
小结
这一讲把 Hermes 的 MCP 集成拆完了。
架构:专属后台 daemon 线程 + asyncio 事件循环,每个 MCP 服务器是一个长驻的 MCPServerTask。工具调用通过 run_coroutine_threadsafe() 从 Agent 主线程投递到 MCP 线程。threading.Lock() 保护共享状态,面向 Python 3.13+ free-threading 兼容。
发现与注册:discover_mcp_tools() 并行连接所有配置的服务器,_register_server_tools() 把 MCP 工具以 mcp_{server}_{tool} 的前缀名注册到全局 ToolRegistry。除了 include / exclude 过滤 server-native tools 之外,还可能按 capability 附加 resources/prompts utility tools,并注册 toolset alias。
动态刷新:当当前 MCP SDK 支持 notification types + message_handler 时,notifications/tools/list_changed 会触发 nuke-and-repave——全部反注册再重新注册。asyncio.Lock 防止并发刷新;如果 SDK 太老,这条能力会被自动关闭,只能靠手动 /reload-mcp。
韧性:首次连接 3 次重试 + 运行中 5 次重连,指数退避最高 60 秒。熔断器在连续 3 次失败后短路,错误信息显式告诉模型"别再试了"。
安全:stdio 环境变量过滤(只传 8 个基线变量 + 显式声明的)、错误消息凭证清洗、OSV 恶意软件扫描(针对可识别包来源的 stdio server,且只看 MAL-* advisory)、工具 description 注入检测。
OAuth:完整的 2.1 PKCE 流程,token 持久化到当前 HERMES_HOME/mcp-tokens/,MCPOAuthManager 处理 401 去重和磁盘变更检测。
Sampling:MCP 服务器可以反向调用 LLM,受速率限制、模型白名单和工具循环轮数约束。
更多推荐

所有评论(0)