原文连接:https://mp.weixin.qq.com/s?__biz=MzAwMTYwNzE2Mg==&mid=2651039841&idx=1&sn=4dd5c57803ec07ac8f1ff90eb340d6b0&chksm=81201cf4b65795e2bfb7fe1b56c7f2f608968458f21d90e7d126ba1dadb68f933c5c3f68bf09&cur_album_id=4469690989619855360&scene=189#wechat_redirect

封闭的系统终将被遗忘,开放的协议才能连接未来。


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 的所有修改都在 _lockthreading.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_resourcesread_resourcelist_promptsget_prompt

  • 如果服务器本身不暴露相应 capability,就算你把 resources: true 或 prompts: true 打开,这些 utility tools 也不会出现

所以“都不设”并不只是“注册全部原生工具”,而是“注册全部原生工具,并在 capability 存在时附加 utility tools”。


发现与注册:从 MCP 服务器到 Agent 工具

discover_mcp_tools()(第 2362 行)是整个流程的入口。

连接阶段

  1. _load_mcp_config() 从 config.yaml 读取 mcp_servers 段,做环境变量插值(${VAR} 语法)

  2. 过滤掉 enabled: false 的服务器

  3. 对每个服务器调用 _discover_and_register_server(),通过 asyncio.gather() 并行连接

  4. 外层 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_lockasyncio.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_KEYAWS_SECRET_ACCESS_KEY 等敏感变量,它们不会传给 MCP 子进程。服务器只能看到你显式给它的变量。这是一个安全底线——即使你安装了一个不可信的 MCP 服务器,它也拿不到你的其他凭证。


OSV 恶意软件扫描

在 stdio 子进程启动前,Hermes 还会做一件事:查 OSV(Open Source Vulnerabilities)数据库。

但它的覆盖范围没有表面上那么宽。当前实现只对能从启动命令里推断出包生态和包名的服务器生效,典型是 npxuvxpipx。如果你写的是 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

工作方式:

  1. 从命令推断生态系统——npx 意味着 npm,uvx/pipx 意味着 PyPI

  2. 从参数解析包名和版本——处理 @scope/name@version 和 name[extras]==version 格式

  3. 查询 https://api.osv.dev/v1/query\,只关注 MAL-* 前缀的 advisory(确认的恶意软件,不是普通 CVE)

  4. 有恶意软件记录 → 阻止启动;没有、网络错误或根本无法识别包来源 → 放行

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"           # 显示名

认证流程

  1. 客户端元数据构建_build_client_metadata()(第 408 行)构造 OAuth 客户端信息。token_endpoint_auth_method 默认是 "none"(PKCE 公开客户端),如果配了 client_secret 则用 "client_secret_post"

  2. 浏览器授权_redirect_handler() 打印 URL 并尝试打开浏览器

  3. 回调服务器_wait_for_callback() 在 127.0.0.1:{port}/callback 启动临时 HTTP 服务器,等待 OAuth redirect,300 秒超时

  4. 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 的视角,这就是一个普通工具。

排查连接问题

如果工具没出现:

  1. 确认 mcp 包已安装:pip show mcp

  2. 确认 npx 可用:which npx(MCP 的 Node.js 服务器需要 Node.js 环境)

  3. 看日志:hermes gateway 模式下日志里搜 MCP server

  4. 检查 enabled: false 是否误设了

如果工具调用失败:

  1. 看日志里的 MCP tool ... call failed 条目

  2. 检查熔断:如果日志说"unreachable after 3 consecutive failures",服务器可能真的挂了

  3. 认证问题: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,受速率限制、模型白名单和工具循环轮数约束。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐