{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}

Mcp Oauth Remote Gateway

在无头网关上为远程 MCP 服务器手动完成 OAuth。

Skill 元数据

来源可选——使用 hermes skills install official/mcp/mcp-oauth-remote-gateway 安装
路径optional-skills/mcp/mcp-oauth-remote-gateway
版本1.0.0
作者Ben Barclay (benbarclay), Hermes Agent
许可证MIT
平台linux, macos
标签MCP, OAuth, PKCE, Remote-Deployment
相关 skillhermes-agent、mcporter、fastmcp

参考:完整 SKILL.md

INFO

以下是 Hermes 在触发此 skill 时加载的完整 skill 定义。这是 skill 激活时 agent 所看到的指令内容。

在远程 Hermes 网关上做 MCP OAuth

概览

Hermes 内置的 MCP OAuth 客户端会在 Hermes 进程内部的 127.0.0.1:<port> 上启动一个一次性 HTTP 监听器,并把这个环回地址注册为 OAuth redirect_uri。这对用户自己机器上的本地 CLI 工作得很好。但当 Hermes 作为远程网关运行(容器、VPS、消息 bot)时就完全失效了,因为用户的浏览器会把 127.0.0.1 解析到用户自己的笔记本,而不是远程容器——于是授权码永远到不了 Hermes。

本 skill 手动完成 OAuth 流程,并把得到的令牌写入 Hermes 令牌存储所期望的确切文件,这样后续的 /reload-mcp 就能找到缓存的令牌,完全跳过浏览器流程。

使用时机

当以下全部条件成立时使用此 skill:

  1. 用户想添加一个需要 OAuth(而非静态 Bearer 令牌)的远程 HTTP MCP 服务器。
  2. Hermes 作为远程网关运行(容器、VPS、Docker、托管服务)——而不是用户笔记本上的本地 CLI。
  3. 该服务器支持带 PKCE 和 RFC 7591 动态客户端注册(DCR)的 OAuth 2.1(大多数现代 MCP 服务器都支持——Better Stack、Linear、Cloudflare、Datadog 等)。如果它不支持 DCR(GitHub 是著名的例外),本 skill 不适用——改用预注册的 OAuth App 或 Personal Access Token。

不要用于:

  • 本地 CLI 版 Hermes——只需在 mcp_servers.<name> 里设 auth: oauth 然后 /reload-mcp。内置流程会打开浏览器并在 localhost 上捕获回调。完美工作。
  • 接受静态 Bearer 令牌(API key)的服务器——当用户愿意时,始终优先用 headers.Authorization: "Bearer <token>"。更简单,没有刷新流程。
  • GitHub Copilot MCP(api.githubcopilot.com/mcp/)——GitHub 不暴露 DCR。用 PAT 或预注册的 OAuth App(见陷阱 12)。

为什么内置 OAuth 流程在远程网关上失败

Hermes 原生的 MCP OAuth 客户端(tools/mcp_oauth.py):

  1. 挑一个空闲本地端口 P。
  2. 向 AS 注册一个动态 OAuth 客户端,发送 redirect_uri = http://127.0.0.1:P/callback。
  3. 在 127.0.0.1:P 上、Hermes 进程内部启动一个 HTTP 服务器。
  4. 打印授权 URL,并在其本地端点等待 code。

当 Hermes 远程运行时,redirect_uri 里的 127.0.0.1 是远程容器的环回地址,不是用户的。授权后,用户浏览器 302 跳转到 http://127.0.0.1:P/callback?code=...,这会解析到用户自己的笔记本并连接失败。回调永远到不了 Hermes 进程,流程超时,/reload-mcp 返回 "No MCP tools available" 且没有详情。

需要识别的症状:hermes 用户下出现 [xdg-open] <defunct> 进程、令牌目录为空或缺失($HERMES_HOME/mcp-tokens/)、以及 reload 在 change_detail 里没有任何 "Added/Reconnected: X" 行就响应了。

廉价的首选回退:内置流程自带的逃生口

在做任何手动令牌手术之前,先检查内置流程的回退是否已覆盖该部署。当 Hermes 检测到远程会话时,会在授权 URL 旁边打印两个选项(tools/mcp_oauth.py):

  1. 粘贴回传(Paste-back)——在交互式 TTY 上,一个 stdin 读取器与 HTTP 监听器竞速。用户授权后,浏览器连接 127.0.0.1:<port> 失败,他们把地址栏里的完整 URL(?code=...&state=...)粘贴回提示符。适用于 SSH 进来的 CLI 会话。
  2. SSH 端口转发——ssh -N -L <port>:127.0.0.1:<port> <user>@<host> 让跳转正常到达远程监听器。

两者都需要到 Hermes 主机的交互式终端。本 skill 剩余部分针对没有交互式 TTY 的情形——Hermes 纯粹作为消息网关/bot 运行,/reload-mcp 触发流程时面前没有人坐在提示符前。

首选正门:Hermes Dashboard(在手动令牌手术之前先试这个)

远程 Hermes 网关通常还会把 dashboard Web UI 作为一个独立进程运行(例如 hermes dashboard --host 0.0.0.0 --port <port>;用 ps aux | grep 'hermes dashboard' 检查)。它暴露了一个连接器/MCP 控制台——诸如 /api/mcp/servers、/api/mcp/status、/connectors 之类的端点(都有登录门槛;一个无 cookie 的 curl 返回 401/302 即证实它们存在)。

为什么 dashboard 能解决核心问题: 当用户在自己的浏览器里从 dashboard 发起 OAuth 时,跳转落到 dashboard 能捕获的上下文里——绕开了让 CLI/手动流程崩溃的 127.0.0.1 回调失败。因此"在远程网关上添加或重新认证一个 OAuth MCP 服务器"的正确升级顺序是:

  1. 在用户浏览器里用 Dashboard——预期的正门。添加服务器、跑 OAuth、reload,一切都以用户身份认证。没有复制粘贴回调的折腾,没有手写令牌文件。
  2. 手动令牌手术(本 skill 剩余部分)——当没有到 dashboard 的浏览器会话时(纯聊天/无头上下文)的回退。

找到 dashboard 的公网 URL。 dashboard 内部绑定到 0.0.0.0:<port>,但用户需要外部可达的 URL。大多数部署平台会把它注入环境——grep 出来,别让用户自己找:

env | grep -iE "HERMES_DASHBOARD_PUBLIC_URL|RAILWAY_PUBLIC_DOMAIN|RAILWAY_STATIC_URL|RAILWAY_SERVICE_.*_URL|PUBLIC_URL|BASE_URL|DOMAIN" \
  | sed -E 's/(TOKEN|SECRET|KEY|PASSWORD)=.*/\1=***REDACTED***/I'

HERMES_DASHBOARD_PUBLIC_URL 存在时以它为准。在 Railway 上还要查 RAILWAY_PUBLIC_DOMAIN / RAILWAY_STATIC_URL(*.up.railway.app 主机)和 RAILWAY_SERVICE_*_URL 变量,有时后者带更友好的自定义域名。把完整的 https:// URL 交给用户,指向 Connectors/MCP 区块。务必通过上面的 sed 脱敏——这些 env grep 就挨着 *_TOKEN/*_SECRET 变量。

dashboard 解决不了的问题(仍是主机端/shell 层面): 需要 shell 认证状态的 stdio 服务器(其凭据可能跨重启不持久的 CLI login 命令),以及任何从 $HERMES_HOME/.env 读凭据的东西。这些无论如何都在 dashboard 范围之外。

变通方案

手动完成 OAuth 流程,然后把得到的令牌写入 Hermes HermesTokenStorage 本来会写的确切文件,这样 /reload-mcp 时 Hermes 找到缓存令牌,完全跳过浏览器流程。

通过网关主机上的 terminal 工具运行下面的 shell 命令,并通过 execute_code 或 terminal 的 python3 调用来做 Python 步骤(PKCE 生成、令牌交换、文件写入)——文件写入必须与令牌交换在同一个代码块里完成(见陷阱 16)。

1. 确认是远程网关

env | grep -iE "HERMES|RAILWAY|CONTAINER"
echo "$DISPLAY $WAYLAND_DISPLAY $SSH_CLIENT"

无 display + 有远程指示 = 远程网关。tools/mcp_oauth.py::_can_open_browser() 用的就是这些环境变量,所以如果 Hermes 自己的自动检测说"headless",内置流程就不会工作。

2. 找到 HERMES_HOME 和配置路径

HERMES_HOME=$(python3 -c 'from hermes_constants import get_hermes_home; print(get_hermes_home())')
echo "config: $HERMES_HOME/config.yaml"
echo "tokens: $HERMES_HOME/mcp-tokens/"

3. 从 MCP 服务器发现 OAuth 元数据

MCP 服务器通过 RFC 9728(OAuth 2.0 受保护资源元数据)公布其 OAuth 配置。401 响应上的 WWW-Authenticate 头告诉你去哪找:

curl -sI https://mcp.example.com | grep -i www-authenticate
# → Bearer realm="mcp", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

并非每个服务器都返回 WWW-Authenticate。 有些返回一个裸的 {"errors":["Unauthorized"]} 401,没有认证发现提示。这时直接探测 well-known 路径:

for p in \
  /.well-known/oauth-protected-resource \
  /.well-known/oauth-authorization-server \
  /.well-known/openid-configuration ; do
  echo "=== $p ==="
  curl -s -A "python-httpx/0.27" "https://mcp.example.com$p" | head -c 400; echo
done

拉取资源元数据得到 authorization_servers,然后拉取 AS 的 /.well-known/oauth-authorization-server 得到 authorization_endpoint、token_endpoint 和 registration_endpoint。

陷阱:很多服务器在 Cloudflare 后面,会 403 拒绝裸 urllib user agent。本流程中的请求务必设置 User-Agent: python-httpx/0.27(或类似)。

4. 动态客户端注册(RFC 7591)

向 registration_endpoint POST:

{
  "client_name": "Hermes Agent (manual OAuth)",
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "<scopes_from_resource_metadata>"
}

如果 AS 的 scopes_supported 为空,就完全省略 scope——见第 5 步陷阱。用端口 8765(或任意端口——反正没有东西监听)。token_endpoint_auth_method: none 标记这是一个公开的 PKCE 客户端。保存返回的 client_id。

5. 用 PKCE 构建授权 URL

生成:

  • code_verifier:secrets.token_urlsafe(64)[:128]
  • code_challenge:base64url(sha256(code_verifier))(无填充)
  • state:secrets.token_urlsafe(24)

查询参数:response_type=code、client_id、redirect_uri、code_challenge、code_challenge_method=S256、state,外加 resource=<mcp_server_url>(RFC 8707——很多服务器要求把令牌绑定到特定 MCP 资源)。仅当 AS 元数据的 scopes_supported 是非空数组且/或资源元数据声明了具体 scope 时,才带上 scope=<空格分隔>。如果 scopes_supported: [],省略 scope 参数——服务器会自行授予其默认全集。对着空的 scopes_supported 编造 scope 字符串,在某些 AS 上会导致 invalid_scope 错误。

把 code_verifier 和 state 暂存到磁盘(例如 ~/.hermes/cache/scratch/.mcp-oauth-work/<server>.json,权限 0600)。第 7 步会用到它们,可能跨多个对话轮次。

6. 把授权 URL 给用户

在浏览器中打开此 URL:
<authorize_url>

批准后,你的浏览器会尝试加载 http://127.0.0.1:8765/callback
并连接失败——这是预期的。只需从地址栏复制完整 URL
(它会包含 ?code=...&state=...)并粘贴回这里。

7. 用 code 换取令牌

当用户粘贴回调 URL 时:

  1. 从查询字符串解析 code 和 state。
  2. 校验 state 与暂存值一致(CSRF 检查——不要跳过)。
  3. 向 token_endpoint POST application/x-www-form-urlencoded:
    • grant_type=authorization_code
    • code=<来自回调>
    • redirect_uri=<与第 4 步相同>
    • client_id=<来自第 4 步>
    • code_verifier=<暂存的>
    • resource=<mcp_server_url>(如果第 5 步 AS 要求了,这里也带上)
  4. 响应包含 access_token、refresh_token、token_type、expires_in、scope。

8. 按 Hermes 的确切 schema 写令牌

tools/mcp_oauth.py::HermesTokenStorage 期望在 $HERMES_HOME/mcp-tokens/ 下有两个文件(目录用 0o700 创建,文件用 0o600):

<server_name>.json——OAuthToken pydantic 模型:

{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 7200,
  "refresh_token": "...",
  "scope": "read write"
}

<server_name>.client.json——OAuthClientInformationFull 模型:

{
  "client_id": "...",
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "read write",
  "client_name": "..."
}

用 json.dumps(..., indent=2) 写每个文件。文件名用 re.sub(r'[^\w\-]', '_', server_name)[:128] 清洗——这与 Hermes 令牌存储里的 _safe_filename() 一致。

9. 把服务器加进 config.yaml

mcp_servers:
  <name>:
    url: "https://mcp.example.com"
    auth: oauth
    timeout: 180
    connect_timeout: 60

10. 在请用户 reload 之前先冒烟测试令牌

手动 POST 一个 MCP initialize 请求,确认令牌端到端可用——这能在用户又一次被 "No MCP tools available" 的 reload 搞糊涂之前,捕获 scope 配错、resource 值错误和 CF 拦截:

body = json.dumps({
    "jsonrpc": "2.0", "id": 1, "method": "initialize",
    "params": {
        "protocolVersion": "2025-06-18",
        "capabilities": {},
        "clientInfo": {"name": "hermes-debug", "version": "1.0"},
    },
}).encode()
# POST 到 MCP URL,带以下请求头:
#   Authorization: Bearer <access_token>
#   Accept: application/json, text/event-stream
#   Content-Type: application/json
#   MCP-Protocol-Version: 2025-06-18
#   User-Agent: python-httpx/0.27

期望 HTTP 200,Content-Type: text/event-stream,以及一个包含 serverInfo 和 capabilities 的 JSON-RPC 结果。不要用 urllib 及其默认 UA——Cloudflare 会 403 你,尽管 Hermes(用 httpx)会成功。scripts/diagnose-oauth-mcp.py 自动化了这个冒烟测试。

11. 告诉用户运行 /reload-mcp

reload 时,Hermes 看到 auth: oauth,调用 HermesTokenStorage.get_tokens(),找到你缓存的令牌,跳过浏览器流程,并注册 mcp_<name>_* 工具。刷新会在 expires_in 到期前自动发生。

陷阱与经验教训

  1. 不要假设"headless"就意味着"OAuth 不可能"。 内置流程对本地 CLI 工作得很好;问题严格出在用户浏览器和 Hermes 进程在不同机器上的远程部署。在断言 OAuth 不可行之前,先检查执行环境。

  2. 读源码,而不只是 skill 文档。 tools/mcp_oauth.py 和 website/docs/ 里的 MCP 配置参考才是权威参考。在告诉用户某功能"不存在"之前,先 grep 代码树。

  3. Cloudflare UA 过滤。 很多 MCP/OAuth 提供商在基础设施前加 Cloudflare,它会在元数据端点上 403 拒绝 python-urllib/* user agent,即使这些端点是公开的。本流程的每个请求都设 User-Agent: python-httpx/0.27(或任何类似浏览器的字符串)。Hermes 自己用 httpx,所以在真实连接路径上这从来不是问题。

  4. 授权和令牌请求都要带 resource。 RFC 8707 资源标识对大多数现代 MCP 服务器不是可选项——它把签发的令牌绑定到特定 MCP 资源 URL。漏掉它有时仍能工作,但可能拿到一个之后在 MCP 服务器上因 scope/audience 错误而失败的令牌。

  5. 末尾斜杠很关键。 有些服务器把资源公布为带末尾斜杠的 https://mcp.example.com/,并拒绝针对无斜杠变体签发的令牌。从 .well-known/oauth-protected-resource 响应中原样复制 resource 值。

  6. /reload-mcp 失败时是静默的。 如果 reload 显示 "No MCP tools available" 且没有 change_detail 行,说明某个服务器在配置里但连接失败,且没有错误冒泡出来。跟踪错误日志,用手动 initialize POST 直接冒烟测试令牌,如果一切看起来正常,请求一次完整进程重启。

  7. 熔断器可能在 /reload-mcp 后仍然存活。 tools/mcp_tool.py 维护一个模块级错误计数字典,阈值很小。一旦触发(例如令牌过期导致连续几次失败后),工具处理器会在调用服务器之前短路,于是没有成功调用能重置计数器。症状:reload 说 "Reconnected: X",但同一会话里后续调用仍以 "server unreachable" 失败。恢复顺序:先试 /reload-mcp(廉价,不打断聊天进程)——在当前构建上它能清空计数器;只有 reload 后实时调用仍短路时,才升级到完整网关进程重启。不要一上来就说"你必须重启"。

  8. 过期 access_token + 已触发的熔断器 = 死锁。 自动刷新逻辑运行在 MCP 调用路径内,而该路径一旦触发就被熔断器短路。单独手动刷新磁盘上的令牌没用——把手动令牌刷新与完整重启配对,而不是 /reload-mcp。

  9. 手动刷新时出现 invalid_grant 意味着 refresh token 已死——重新认证是唯一修法,不要循环重试。 当 access_token 过期足够久,refresh_token 也可能在服务端被撤销/过期。此时 grant_type=refresh_token POST 会返回 HTTP 400 {"error":"invalid_grant",...}(措辞各异:"Grant not found"、"Token expired"、"refresh token is invalid")。网关侧无恢复办法。把两种选项交回给用户:(a) 重跑完整手动 OAuth 流程(第 3–10 步),或 (b) 如果提供商提供静态个人 API key,改用它——没有刷新/过期循环,对无人值守的远程网关更耐用。尽早检测:在对 OAuth MCP 做任何 create/update 操作之前,比较 expires_at 与 time.time();若已过期,先尝试刷新,并立即暴露 invalid_grant,而不是做到一半才失败。

  10. 刷新成功但令牌仍被拒 = 服务端会话撤销;只有全新的 authorization_code 流程能修。 与陷阱 9 不同。存储的令牌文件看起来健康(expires_at 还很远、refresh_token 在),但实时 initialize POST 返回 401 invalid_token,JSON-RPC 体类似 {"error":{"code":-32002,"message":"Session expired. Please re-authenticate."}}。grant_type=refresh_token POST 可能成功(HTTP 200,新 access_token)——但这个全新令牌得到同样的 -32002。提供商在服务端撤销了底层 MCP 会话;OAuth 刷新链能重铸凭据,却无法重建已撤销的会话。当 OAuth MCP 报"not connected"时的决策规则:(1) 用手动 initialize POST 冒烟测试存储的 access_token;(2) 若 401 invalid_token,尝试刷新并冒烟测试新令牌;(3a) 新令牌可用 → 写入它 + 重启以清除熔断器;(3b) 新令牌仍得 -32002/"Session expired" → 停下,这是会话撤销,把授权 URL 交给用户做完整重新认证。scripts/diagnose-oauth-mcp.py 自动化第 1–2 步并打印你处于哪个分支。对于会话持续被撤销的无人值守网关,优先用静态 Personal API key。见 references/stripe-mcp-oauth-revocation.md,里面有一个周期性撤销 OAuth 会话的提供商(Stripe)的完整示例。

  11. 客户端信息文件不是可选的。 Hermes 需要 <server>.client.json 来知道刷新授权用的 client_id。跳过它意味着第一次刷新就失败,用户不得不重新认证——写这两个文件正是本 skill 的全部意义。

  12. 永远不要手敲 redirect URL 让用户去打开。 用 urllib.parse.urlencode() 编程生成授权 URL。scope 里的空格和 state 里的特殊字符会破坏字符串拼接出来的 URL。

  13. 安全:暂存文件包含 code_verifier。 令牌交换成功后立即删除 ~/.hermes/cache/scratch/.mcp-oauth-work/<server>.json。一次性身份凭证消费掉后没有理由保留。

  14. 按令牌端点实际返回的内容写。 AS 授予的 scope 可能比请求的窄(或宽)。把令牌交换响应里的 scope 写进 <server>.json,而不是第 5 步你请求的那个。当 scopes_supported: [] 时,你显式发送的 scope 列表在两个方向上都是权威的:有些服务器精确授予你列出的范围(为最小权限传窄 scope,或用户需要全部时枚举全集),有些在注册时不回显授予的 scope——只有令牌交换响应是权威的。

  15. OAuth 令牌常可同时作为对提供商公开 REST API 的 Bearer 令牌。 <server>.json 里的 access_token 往往不是"仅限 MCP"——只要授予了相应资源 scope,对提供商文档化的 REST API 发 Authorization: Bearer <token> 就能成功。这是 OAuth 2.0 规范,不是提供商的怪癖。当 MCP 服务器只读而你需要写操作时,在建议单独申请 API key 之前,先看看 OAuth 令牌能否直接打提供商的 REST API。

  16. 机密脱敏可能在工具输出里掩盖令牌。 如果开启了机密脱敏,令牌和长不透明字符串在工具结果输出里会渲染成 ***,于是你没法 print(response) 来跨轮次保留 access_token 可见性。再加上 authorization_code 授权的 code 是一次性的:如果你打印令牌交换响应,可能既丢了令牌又消费掉了 code,不得不带着新授权 URL 重来。务必在执行令牌交换的同一个代码块里,直接把 access_token 写到它最终的目标文件。 如果必须打印调试,只打印 len(access_token)、token_type、scope、expires_in——绝不打印机密本身。

  17. GitHub MCP(api.githubcopilot.com/mcp/)用的是预注册的机密 OAuth App,而非 DCR + PKCE 公开客户端。 它的客户端信息带真实的 client_secret 和 token_endpoint_auth_method: client_secret_post。向 https://github.com/login/oauth/access_token 的令牌交换 POST 必须把 client_secret 作为表单字段,连同 client_id、code、code_verifier、redirect_uri 一起发送(PKCE 在 secret 之上仍然生效)。redirect URI 在 OAuth App 配置里是固定的——你改不了,所以手动监听端口的技巧不适用;用户只需让浏览器在那个端口连接失败,然后把地址栏 URL 粘贴回来。

不要做的事

  • 不要用 mcp-remote 当回退。 它跑一个 npx 子进程,其 OAuth 回调服务器也在远程容器的 localhost 上——同样的问题。mcp-remote 只在 MCP 客户端根本不会说远程 HTTP 时才有帮助(Hermes 原生支持)。
  • 不要在用户明确要求 OAuth 时,硬推"粘贴你的 API 令牌,我来加请求头"。 只有在解释清楚原生 OAuth 流程为何在远程部署中失败之后,才提供静态令牌捷径。尊重用户为免轮换、限范围访问多花的功夫。
  • 不要在没读源码的情况下断言 Hermes 不支持某功能。 做能力断言前先 grep 源码树。

快速参考文件

  • scripts/diagnose-oauth-mcp.py——可重复运行、默认只读的诊断工具。给定一个服务器名,它会冒烟测试存储的 access_token、尝试刷新、冒烟测试新令牌,并打印你确切处于哪个恢复分支(TOKEN_OK = 熔断器/重启,REFRESH_FIXED = 持久化+重启,SESSION_REVOKED = 完整重新认证,REFRESH_DEAD = 完整重新认证/API key)。传 --write 可原子化地持久化一个可用的刷新后令牌。绝不打印机密值。当 OAuth MCP 服务器报"not connected"时,先跑这个——它编码了陷阱 7/9/10 的决策树。
  • references/stripe-mcp-oauth-revocation.md——一个完整示例(Stripe):某提供商周期性撤销其 OAuth 会话,以及持久修法——改用静态受限 API key。

相关

  • native-mcp——在 Hermes 中配置 MCP 的通用指南。权威配置参考在那里。
  • mcporter——外部 CLI 桥,用于 Hermes 配置之外的临时 MCP 调用。