{/* 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 |
| 相关 skill | hermes-agent、mcporter、fastmcp |
参考:完整 SKILL.md
以下是 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:
- 用户想添加一个需要 OAuth(而非静态 Bearer 令牌)的远程 HTTP MCP 服务器。
- Hermes 作为远程网关运行(容器、VPS、Docker、托管服务)——而不是用户笔记本上的本地 CLI。
- 该服务器支持带 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):
- 挑一个空闲本地端口
P。 - 向 AS 注册一个动态 OAuth 客户端,发送
redirect_uri = http://127.0.0.1:P/callback。 - 在
127.0.0.1:P上、Hermes 进程内部启动一个 HTTP 服务器。 - 打印授权 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):
- 粘贴回传(Paste-back)——在交互式 TTY 上,一个 stdin 读取器与 HTTP 监听器竞速。用户授权后,浏览器连接
127.0.0.1:<port>失败,他们把地址栏里的完整 URL(?code=...&state=...)粘贴回提示符。适用于 SSH 进来的 CLI 会话。 - 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 服务器"的正确升级顺序是:
- 在用户浏览器里用 Dashboard——预期的正门。添加服务器、跑 OAuth、reload,一切都以用户身份认证。没有复制粘贴回调的折腾,没有手写令牌文件。
- 手动令牌手术(本 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 时:
- 从查询字符串解析
code和state。 - 校验
state与暂存值一致(CSRF 检查——不要跳过)。 - 向
token_endpointPOSTapplication/x-www-form-urlencoded:grant_type=authorization_codecode=<来自回调>redirect_uri=<与第 4 步相同>client_id=<来自第 4 步>code_verifier=<暂存的>resource=<mcp_server_url>(如果第 5 步 AS 要求了,这里也带上)
- 响应包含
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 到期前自动发生。
陷阱与经验教训
-
不要假设"headless"就意味着"OAuth 不可能"。 内置流程对本地 CLI 工作得很好;问题严格出在用户浏览器和 Hermes 进程在不同机器上的远程部署。在断言 OAuth 不可行之前,先检查执行环境。
-
读源码,而不只是 skill 文档。
tools/mcp_oauth.py和website/docs/里的 MCP 配置参考才是权威参考。在告诉用户某功能"不存在"之前,先 grep 代码树。 -
Cloudflare UA 过滤。 很多 MCP/OAuth 提供商在基础设施前加 Cloudflare,它会在元数据端点上 403 拒绝
python-urllib/*user agent,即使这些端点是公开的。本流程的每个请求都设User-Agent: python-httpx/0.27(或任何类似浏览器的字符串)。Hermes 自己用 httpx,所以在真实连接路径上这从来不是问题。 -
授权和令牌请求都要带
resource。 RFC 8707 资源标识对大多数现代 MCP 服务器不是可选项——它把签发的令牌绑定到特定 MCP 资源 URL。漏掉它有时仍能工作,但可能拿到一个之后在 MCP 服务器上因 scope/audience 错误而失败的令牌。 -
末尾斜杠很关键。 有些服务器把资源公布为带末尾斜杠的
https://mcp.example.com/,并拒绝针对无斜杠变体签发的令牌。从.well-known/oauth-protected-resource响应中原样复制resource值。 -
/reload-mcp失败时是静默的。 如果 reload 显示 "No MCP tools available" 且没有change_detail行,说明某个服务器在配置里但连接失败,且没有错误冒泡出来。跟踪错误日志,用手动initializePOST 直接冒烟测试令牌,如果一切看起来正常,请求一次完整进程重启。 -
熔断器可能在
/reload-mcp后仍然存活。tools/mcp_tool.py维护一个模块级错误计数字典,阈值很小。一旦触发(例如令牌过期导致连续几次失败后),工具处理器会在调用服务器之前短路,于是没有成功调用能重置计数器。症状:reload 说 "Reconnected: X",但同一会话里后续调用仍以 "server unreachable" 失败。恢复顺序:先试/reload-mcp(廉价,不打断聊天进程)——在当前构建上它能清空计数器;只有 reload 后实时调用仍短路时,才升级到完整网关进程重启。不要一上来就说"你必须重启"。 -
过期 access_token + 已触发的熔断器 = 死锁。 自动刷新逻辑运行在 MCP 调用路径内,而该路径一旦触发就被熔断器短路。单独手动刷新磁盘上的令牌没用——把手动令牌刷新与完整重启配对,而不是
/reload-mcp。 -
手动刷新时出现
invalid_grant意味着 refresh token 已死——重新认证是唯一修法,不要循环重试。 当 access_token 过期足够久,refresh_token 也可能在服务端被撤销/过期。此时grant_type=refresh_tokenPOST 会返回 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,而不是做到一半才失败。 -
刷新成功但令牌仍被拒 = 服务端会话撤销;只有全新的 authorization_code 流程能修。 与陷阱 9 不同。存储的令牌文件看起来健康(
expires_at还很远、refresh_token 在),但实时initializePOST 返回401 invalid_token,JSON-RPC 体类似{"error":{"code":-32002,"message":"Session expired. Please re-authenticate."}}。grant_type=refresh_tokenPOST 可能成功(HTTP 200,新 access_token)——但这个全新令牌得到同样的-32002。提供商在服务端撤销了底层 MCP 会话;OAuth 刷新链能重铸凭据,却无法重建已撤销的会话。当 OAuth MCP 报"not connected"时的决策规则:(1) 用手动initializePOST 冒烟测试存储的 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)的完整示例。 -
客户端信息文件不是可选的。 Hermes 需要
<server>.client.json来知道刷新授权用的client_id。跳过它意味着第一次刷新就失败,用户不得不重新认证——写这两个文件正是本 skill 的全部意义。 -
永远不要手敲 redirect URL 让用户去打开。 用
urllib.parse.urlencode()编程生成授权 URL。scope 里的空格和 state 里的特殊字符会破坏字符串拼接出来的 URL。 -
安全:暂存文件包含
code_verifier。 令牌交换成功后立即删除~/.hermes/cache/scratch/.mcp-oauth-work/<server>.json。一次性身份凭证消费掉后没有理由保留。 -
按令牌端点实际返回的内容写。 AS 授予的 scope 可能比请求的窄(或宽)。把令牌交换响应里的
scope写进<server>.json,而不是第 5 步你请求的那个。当scopes_supported: []时,你显式发送的 scope 列表在两个方向上都是权威的:有些服务器精确授予你列出的范围(为最小权限传窄 scope,或用户需要全部时枚举全集),有些在注册时不回显授予的 scope——只有令牌交换响应是权威的。 -
OAuth 令牌常可同时作为对提供商公开 REST API 的 Bearer 令牌。
<server>.json里的 access_token 往往不是"仅限 MCP"——只要授予了相应资源 scope,对提供商文档化的 REST API 发Authorization: Bearer <token>就能成功。这是 OAuth 2.0 规范,不是提供商的怪癖。当 MCP 服务器只读而你需要写操作时,在建议单独申请 API key 之前,先看看 OAuth 令牌能否直接打提供商的 REST API。 -
机密脱敏可能在工具输出里掩盖令牌。 如果开启了机密脱敏,令牌和长不透明字符串在工具结果输出里会渲染成
***,于是你没法print(response)来跨轮次保留 access_token 可见性。再加上 authorization_code 授权的code是一次性的:如果你打印令牌交换响应,可能既丢了令牌又消费掉了 code,不得不带着新授权 URL 重来。务必在执行令牌交换的同一个代码块里,直接把 access_token 写到它最终的目标文件。 如果必须打印调试,只打印len(access_token)、token_type、scope、expires_in——绝不打印机密本身。 -
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 调用。