[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6GFZAN14T4DDF6F154XG7VP":3,"skill-same:01M6GFZAN14T4DDF6F154XG7VP":78},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":42,"linkOnly":43,"markdownContent":44,"markdownBody":45,"frontMatter":46,"refName":56,"commitSha":57,"skillDirectory":58,"forksCount":59,"repositoryHtmlUrl":60,"repositoryHomepageUrl":6,"ownerHtmlUrl":61,"ownerBlogUrl":6,"ownerDescription":62,"updatedAt":63,"install":64,"resources":77},"01M6GFZAN14T4DDF6F154XG7VP","mcp-oauth-remote-gateway",null,"Manual OAuth for remote MCP servers on headless gateways.","zapabob\u002FSkills","zapabob","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F130830376?v=4","01M6DBMYZAGRTFQDSQYW3W764P","峯岸 亮","01M6DBMZ3GKDSJ01NZ9XTZD7JD",2,"MIT","https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Farchive\u002Fskill-md-reviewed\u002Fsha256\u002Fe2\u002Fe21ffc19f8d1d517e4e7bf5ecbebebdcdb1845ce9c799234a449c617cb6562a0\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41],"OAuth","mcp oauth remote gateway","remote","MCP","gateway","fastmcp","mcp servers","deployment","gateways","linux","pkce","hermes","hermes agent","hermes-agent","mcporter","manual","headless","macos","2026-11-10 10:12:30",false,"---\nname: mcp-oauth-remote-gateway\ndescription: Manual OAuth for remote MCP servers on headless gateways.\nversion: 1.0.0\nauthor: Ben Barclay (benbarclay), Hermes Agent\nlicense: MIT\nplatforms: [linux, macos]\nmetadata:\n  hermes:\n    tags: [MCP, OAuth, PKCE, Remote-Deployment]\n    related_skills: [hermes-agent, mcporter, fastmcp]\n---\n\n# MCP OAuth on a Remote Hermes Gateway\n\n## Overview\n\nHermes' built-in MCP OAuth client runs a one-shot HTTP listener on `127.0.0.1:\u003Cport>`\ninside the Hermes process and registers that loopback address as the OAuth\n`redirect_uri`. That works perfectly for a local CLI on the user's own machine.\nIt breaks completely when Hermes runs as a remote gateway (container, VPS,\nmessaging bot), because the user's browser resolves `127.0.0.1` to the user's own\nlaptop, not the remote container — so the authorization code never reaches Hermes.\n\nThis skill does the OAuth dance by hand and writes the resulting tokens into the\nexact files Hermes' token storage expects, so a subsequent `\u002Freload-mcp` finds\ncached tokens and skips the browser flow entirely.\n\n## When to Use\n\nUse this skill when **all** of the following are true:\n\n1. The user wants to add a remote HTTP MCP server that requires OAuth (not a static Bearer token).\n2. Hermes is running as a **remote gateway** (container, VPS, Docker, managed service) — NOT a local CLI on the user's laptop.\n3. The server supports OAuth 2.1 with PKCE and RFC 7591 Dynamic Client Registration (most modern MCP servers do — Better Stack, Linear, Cloudflare, Datadog, etc.). If it doesn't support DCR (GitHub is the notable exception), this skill does not apply — use a pre-registered OAuth App or a Personal Access Token instead.\n\nDo NOT use this for:\n- **Local CLI Hermes** — just set `auth: oauth` in `mcp_servers.\u003Cname>` and `\u002Freload-mcp`. The built-in flow opens a browser and captures the callback on localhost. Works perfectly.\n- **Servers that accept a static Bearer token (API key)** — always prefer `headers.Authorization: \"Bearer \u003Ctoken>\"` when the user is willing. Simpler, no refresh dance.\n- **GitHub Copilot MCP** (`api.githubcopilot.com\u002Fmcp\u002F`) — GitHub does not expose DCR. Use a PAT or a pre-registered OAuth App (see pitfall 12).\n\n## Why the Built-in OAuth Flow Fails on a Remote Gateway\n\nHermes' native MCP OAuth client (`tools\u002Fmcp_oauth.py`):\n\n1. Picks a free local port `P`.\n2. Registers a dynamic OAuth client with the AS, sending `redirect_uri = http:\u002F\u002F127.0.0.1:P\u002Fcallback`.\n3. Starts an HTTP server on `127.0.0.1:P` **inside the Hermes process**.\n4. Prints the authorize URL and waits for the code at its local endpoint.\n\nWhen Hermes runs remotely, the `127.0.0.1` in the `redirect_uri` is the remote\ncontainer's loopback, not the user's. After authorizing, the user's browser 302s\nto `http:\u002F\u002F127.0.0.1:P\u002Fcallback?code=...`, which resolves to the user's own\nlaptop and fails to connect. The callback never reaches the Hermes process, the\nflow times out, and `\u002Freload-mcp` returns \"No MCP tools available\" with no detail.\n\nSymptoms to recognize: `[xdg-open] \u003Cdefunct>` processes under the hermes user, an\nempty or missing tokens directory (`$HERMES_HOME\u002Fmcp-tokens\u002F`), and a reload that\nresponds without any \"Added\u002FReconnected: X\" line in `change_detail`.\n\n## Cheap First Fallbacks: the Built-in Flow's Own Escape Hatches\n\nBefore any manual token surgery, check whether the built-in flow's fallbacks\nalready cover the deployment. When Hermes detects a remote session it prints two\noptions alongside the authorize URL (`tools\u002Fmcp_oauth.py`):\n\n1. **Paste-back** — on an interactive TTY, a stdin reader races the HTTP\n   listener. The user authorizes, the browser fails to connect to\n   `127.0.0.1:\u003Cport>`, and they paste the full address-bar URL\n   (`?code=...&state=...`) back at the prompt. Works for SSH'd-in CLI sessions.\n2. **SSH port-forward** — `ssh -N -L \u003Cport>:127.0.0.1:\u003Cport> \u003Cuser>@\u003Chost>`\n   makes the redirect reach the remote listener normally.\n\nBoth require an interactive terminal to the Hermes host. The rest of this skill\nis for when there is NO interactive TTY — Hermes running purely as a messaging\ngateway\u002Fbot where `\u002Freload-mcp` triggers the flow with nobody at a prompt.\n\n## Preferred Front Door: the Hermes Dashboard (try this BEFORE manual token surgery)\n\nA remote Hermes gateway often also runs the **dashboard** web UI as a SEPARATE\nprocess (e.g. `hermes dashboard --host 0.0.0.0 --port \u003Cport>`; check with\n`ps aux | grep 'hermes dashboard'`). It exposes a connector\u002FMCP console —\nendpoints like `\u002Fapi\u002Fmcp\u002Fservers`, `\u002Fapi\u002Fmcp\u002Fstatus`, and `\u002Fconnectors` (all\nlogin-gated; a cookieless curl returning 401\u002F302 confirms they exist).\n\n**Why the dashboard solves the core problem:** when the user drives OAuth from\nthe dashboard *in their own browser*, the redirect lands in a context the\ndashboard can capture — sidestepping the `127.0.0.1`-callback failure that breaks\nthe CLI\u002Fmanual flow. So the correct escalation order for \"add or re-auth an OAuth\nMCP server on a remote gateway\" is:\n\n1. **Dashboard, in the user's browser** — the intended front door. Add servers, run OAuth, reload, all authenticated as the user. No copy-paste-callback dance, no hand-writing token files.\n2. **Manual token surgery (the rest of this skill)** — the FALLBACK for when there's no browser session to the dashboard (pure-chat\u002Fheadless context).\n\n**Finding the dashboard's PUBLIC URL.** The dashboard binds internally to\n`0.0.0.0:\u003Cport>`, but the user needs the externally-reachable URL. Most deploy\nplatforms inject it into the environment — grep for it rather than making the\nuser hunt:\n\n```bash\nenv | grep -iE \"HERMES_DASHBOARD_PUBLIC_URL|RAILWAY_PUBLIC_DOMAIN|RAILWAY_STATIC_URL|RAILWAY_SERVICE_.*_URL|PUBLIC_URL|BASE_URL|DOMAIN\" \\\n  | sed -E 's\u002F(TOKEN|SECRET|KEY|PASSWORD)=.*\u002F\\1=***REDACTED***\u002FI'\n```\n\n`HERMES_DASHBOARD_PUBLIC_URL` is authoritative when present. On Railway also check\n`RAILWAY_PUBLIC_DOMAIN` \u002F `RAILWAY_STATIC_URL` (the `*.up.railway.app` host) and\n`RAILWAY_SERVICE_*_URL` vars, which sometimes carry a friendlier custom domain.\nHand the user the full `https:\u002F\u002F` URL and point them at the Connectors\u002FMCP\nsection. ALWAYS pipe through the `sed` redaction above — these env greps sit next\nto `*_TOKEN`\u002F`*_SECRET` vars.\n\n**What the dashboard does NOT fix (still host-side \u002F shell):** stdio servers that\nneed shell auth state (a CLI `login` command whose credentials may not persist\nacross restarts) and anything reading credentials from `$HERMES_HOME\u002F.env`. Those\nare out of the dashboard's scope regardless.\n\n## The Workaround\n\nDo the OAuth dance manually, then write the resulting tokens into the exact files\nHermes' `HermesTokenStorage` would have written, so on `\u002Freload-mcp` Hermes finds\ncached tokens and skips the browser flow entirely.\n\nRun the shell commands below through the `terminal` tool on the gateway host and\ndo the Python steps (PKCE generation, token exchange, file writes) via\n`execute_code` or a `terminal` python3 invocation — file writes must happen in\nthe SAME code block as the token exchange (see pitfall 16).\n\n### 1. Confirm it's a remote gateway\n\n```bash\nenv | grep -iE \"HERMES|RAILWAY|CONTAINER\"\necho \"$DISPLAY $WAYLAND_DISPLAY $SSH_CLIENT\"\n```\n\nNo display + a remote indicator = remote gateway. `tools\u002Fmcp_oauth.py::_can_open_browser()`\nuses these same env vars, so if Hermes' own auto-detect says \"headless\", the\nbuilt-in flow won't work.\n\n### 2. Find HERMES_HOME and the config path\n\n```bash\nHERMES_HOME=$(python3 -c 'from hermes_constants import get_hermes_home; print(get_hermes_home())')\necho \"config: $HERMES_HOME\u002Fconfig.yaml\"\necho \"tokens: $HERMES_HOME\u002Fmcp-tokens\u002F\"\n```\n\n### 3. Discover OAuth metadata from the MCP server\n\nMCP servers advertise their OAuth setup via RFC 9728 (OAuth 2.0 Protected\nResource Metadata). The `WWW-Authenticate` header on a 401 tells you where to look:\n\n```bash\ncurl -sI https:\u002F\u002Fmcp.example.com | grep -i www-authenticate\n# → Bearer realm=\"mcp\", resource_metadata=\"https:\u002F\u002Fmcp.example.com\u002F.well-known\u002Foauth-protected-resource\"\n```\n\n**Not every server returns `WWW-Authenticate`.** Some return a bare\n`{\"errors\":[\"Unauthorized\"]}` 401 with no auth-discovery hint. When that happens,\nprobe well-known paths directly:\n\n```bash\nfor p in \\\n  \u002F.well-known\u002Foauth-protected-resource \\\n  \u002F.well-known\u002Foauth-authorization-server \\\n  \u002F.well-known\u002Fopenid-configuration ; do\n  echo \"=== $p ===\"\n  curl -s -A \"python-httpx\u002F0.27\" \"https:\u002F\u002Fmcp.example.com$p\" | head -c 400; echo\ndone\n```\n\nFetch the resource metadata to get `authorization_servers`, then fetch the AS's\n`\u002F.well-known\u002Foauth-authorization-server` to get `authorization_endpoint`,\n`token_endpoint`, and `registration_endpoint`.\n\nPitfall: many servers sit behind Cloudflare and 403 bare `urllib` user agents.\nAlways set `User-Agent: python-httpx\u002F0.27` (or similar) on requests in this flow.\n\n### 4. Dynamic Client Registration (RFC 7591)\n\nPOST to the `registration_endpoint` with:\n\n```json\n{\n  \"client_name\": \"Hermes Agent (manual OAuth)\",\n  \"redirect_uris\": [\"http:\u002F\u002F127.0.0.1:8765\u002Fcallback\"],\n  \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n  \"response_types\": [\"code\"],\n  \"token_endpoint_auth_method\": \"none\",\n  \"scope\": \"\u003Cscopes_from_resource_metadata>\"\n}\n```\n\nOmit `scope` entirely if the AS's `scopes_supported` is empty — see step 5\npitfall. Use port `8765` (or any port — nothing will listen).\n`token_endpoint_auth_method: none` marks this as a public PKCE client. Save the\nreturned `client_id`.\n\n### 5. Build the authorize URL with PKCE\n\nGenerate:\n- `code_verifier`: `secrets.token_urlsafe(64)[:128]`\n- `code_challenge`: `base64url(sha256(code_verifier))` (no padding)\n- `state`: `secrets.token_urlsafe(24)`\n\nQuery params: `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`,\n`code_challenge_method=S256`, `state`, plus `resource=\u003Cmcp_server_url>` (RFC 8707 —\nmany servers require this to bind the token to the specific MCP resource). Include\n`scope=\u003Cspace-separated>` ONLY if the AS metadata's `scopes_supported` is a\nnon-empty array AND\u002FOR the resource metadata declares specific scopes. If\n`scopes_supported: []`, omit the `scope` parameter — the server grants its full\ndefault set on its own. Fabricating scope strings against an empty\n`scopes_supported` can cause `invalid_scope` errors on some ASes.\n\n**Stash `code_verifier` and `state` to disk** (e.g. `\u002Ftmp\u002F.mcp-oauth-work\u002F\u003Cserver>.json`,\n0600 perms). You need them for step 7, possibly across multiple chat turns.\n\n### 6. Give the user the authorize URL\n\n```\nOpen this URL in your browser:\n\u003Cauthorize_url>\n\nAfter approving, your browser will try to load http:\u002F\u002F127.0.0.1:8765\u002Fcallback\nand fail to connect — THAT'S EXPECTED. Just copy the entire URL from the\naddress bar (it will contain ?code=...&state=...) and paste it back here.\n```\n\n### 7. Exchange the code for tokens\n\nWhen the user pastes the callback URL:\n\n1. Parse `code` and `state` from the query string.\n2. **Verify `state` matches the stashed value** (CSRF check — do not skip).\n3. POST `application\u002Fx-www-form-urlencoded` to the `token_endpoint`:\n   - `grant_type=authorization_code`\n   - `code=\u003Cfrom callback>`\n   - `redirect_uri=\u003Csame as step 4>`\n   - `client_id=\u003Cfrom step 4>`\n   - `code_verifier=\u003Cstashed>`\n   - `resource=\u003Cmcp_server_url>` (if the AS required it in step 5, include here too)\n4. Response contains `access_token`, `refresh_token`, `token_type`, `expires_in`, `scope`.\n\n### 8. Write tokens in Hermes' exact schema\n\n`tools\u002Fmcp_oauth.py::HermesTokenStorage` expects two files under\n`$HERMES_HOME\u002Fmcp-tokens\u002F` (create dir with `0o700`, files with `0o600`):\n\n**`\u003Cserver_name>.json`** — the `OAuthToken` pydantic model:\n```json\n{\n  \"access_token\": \"...\",\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 7200,\n  \"refresh_token\": \"...\",\n  \"scope\": \"read write\"\n}\n```\n\n**`\u003Cserver_name>.client.json`** — the `OAuthClientInformationFull` model:\n```json\n{\n  \"client_id\": \"...\",\n  \"redirect_uris\": [\"http:\u002F\u002F127.0.0.1:8765\u002Fcallback\"],\n  \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n  \"response_types\": [\"code\"],\n  \"token_endpoint_auth_method\": \"none\",\n  \"scope\": \"read write\",\n  \"client_name\": \"...\"\n}\n```\n\nWrite each file via `json.dumps(..., indent=2)`. Sanitize the filename with\n`re.sub(r'[^\\w\\-]', '_', server_name)[:128]` — this matches `_safe_filename()` in\nHermes' token storage.\n\n### 9. Add the server to config.yaml\n\n```yaml\nmcp_servers:\n  \u003Cname>:\n    url: \"https:\u002F\u002Fmcp.example.com\"\n    auth: oauth\n    timeout: 180\n    connect_timeout: 60\n```\n\n### 10. Smoke-test the token BEFORE asking the user to reload\n\nManually POST an MCP `initialize` request to confirm the token works end-to-end —\nthis catches scope misconfigurations, wrong `resource` values, and CF blocks\nbefore the user is confused by another \"No MCP tools available\" reload:\n\n```python\nbody = json.dumps({\n    \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\",\n    \"params\": {\n        \"protocolVersion\": \"2025-06-18\",\n        \"capabilities\": {},\n        \"clientInfo\": {\"name\": \"hermes-debug\", \"version\": \"1.0\"},\n    },\n}).encode()\n# POST to the MCP URL with:\n#   Authorization: Bearer \u003Caccess_token>\n#   Accept: application\u002Fjson, text\u002Fevent-stream\n#   Content-Type: application\u002Fjson\n#   MCP-Protocol-Version: 2025-06-18\n#   User-Agent: python-httpx\u002F0.27\n```\n\nExpect HTTP 200 with `Content-Type: text\u002Fevent-stream` and a JSON-RPC result\ncontaining `serverInfo` and `capabilities`. **Do not use `urllib` with its default\nUA** — Cloudflare will 403 you even though Hermes (which uses httpx) will succeed.\n`scripts\u002Fdiagnose-oauth-mcp.py` automates this smoke test.\n\n### 11. Tell the user to run `\u002Freload-mcp`\n\nOn reload, Hermes sees `auth: oauth`, calls `HermesTokenStorage.get_tokens()`,\nfinds your cached tokens, skips the browser flow, and registers `mcp_\u003Cname>_*`\ntools. Refresh happens automatically before `expires_in` elapses.\n\n## Pitfalls & Lessons Learned\n\n1. **Do not assume \"headless\" means \"OAuth impossible.\"** The built-in flow works fine for local CLI; the issue is strictly remote deployments where the user's browser and the Hermes process are on different machines. Check the execution environment before claiming OAuth isn't an option.\n\n2. **Read the source, not just the skill docs.** `tools\u002Fmcp_oauth.py` and the MCP config reference in `website\u002Fdocs\u002F` are the authoritative references. Grep the tree before telling the user a feature \"doesn't exist.\"\n\n3. **Cloudflare UA filter.** Many MCP\u002FOAuth providers front their infra with Cloudflare, which 403s `python-urllib\u002F*` user agents on metadata endpoints even though those endpoints are public. Set `User-Agent: python-httpx\u002F0.27` (or any browser-like string) on every request in this flow. Hermes itself uses httpx, so this is never a problem in the real connection path.\n\n4. **Include `resource` in both authorize and token requests.** RFC 8707 resource indicators are not optional for most modern MCP servers — they bind the issued token to the specific MCP resource URL. Leaving it out sometimes still works but may yield a token that later fails at the MCP server with a scope\u002Faudience error.\n\n5. **Trailing slash matters.** Some servers advertise the resource as `https:\u002F\u002Fmcp.example.com\u002F` with a trailing slash and reject tokens issued against the no-slash variant. Copy the `resource` value verbatim from the `.well-known\u002Foauth-protected-resource` response.\n\n6. **`\u002Freload-mcp` is silent on failure.** If the reload shows \"No MCP tools available\" with no `change_detail` line, a server is in config but failed to connect and no error bubbled up. Tail the error log, smoke-test the token directly with a manual `initialize` POST, and — if everything looks good — ask for a full process restart.\n\n7. **Circuit breaker can survive `\u002Freload-mcp`.** `tools\u002Fmcp_tool.py` keeps a module-level error-count dict with a small threshold. Once tripped (e.g. after token expiry produces several consecutive failures), the tool handler can short-circuit before calling the server, so no successful call resets the counter. Symptom: reload says \"Reconnected: X\" but subsequent calls still fail with \"server unreachable\" in the same conversation. Recovery order: try `\u002Freload-mcp` FIRST (cheap, no chat-process blip) — on current builds it can clear the counter; only escalate to a full gateway process restart if a live call STILL short-circuits after reload. Do not lead with \"you must restart.\"\n\n8. **Refresh on an expired access_token + a tripped breaker is a deadlock.** The auto-refresh logic runs inside the MCP call path, which the breaker short-circuits once tripped. Manually refreshing the token on disk does not help by itself — pair a manual token refresh with a full restart, not a `\u002Freload-mcp`.\n\n9. **`invalid_grant` on a manual refresh means the refresh token is DEAD — re-auth is the only fix, do not loop.** When the access_token has been expired long enough, the refresh_token can also be revoked\u002Fexpired server-side. A `grant_type=refresh_token` POST then returns HTTP 400 `{\"error\":\"invalid_grant\",...}` (wording varies: \"Grant not found\", \"Token expired\", \"refresh token is invalid\"). There is NO recovery from the gateway side. Hand back to the user with two options: (a) re-run the full manual OAuth dance (steps 3–10), or (b) if the provider offers a static personal API key, switch to that — no refresh\u002Fexpiry cycle, more durable for an unattended remote gateway. Detect early: before any create\u002Fupdate operation against an OAuth MCP, check `expires_at` vs `time.time()`; if already expired, attempt the refresh first and surface `invalid_grant` immediately rather than failing mid-task.\n\n10. **A successful refresh that STILL yields a rejected token = server-side SESSION revocation; only a fresh authorization_code flow fixes it.** Distinct from pitfall 9. The stored token file can look healthy (`expires_at` well out, refresh_token present), yet a live `initialize` POST returns `401 invalid_token` with a JSON-RPC body like `{\"error\":{\"code\":-32002,\"message\":\"Session expired. Please re-authenticate.\"}}`. The `grant_type=refresh_token` POST may **succeed** (HTTP 200, new access_token) — yet the brand-new token gets the SAME `-32002`. The provider revoked the underlying MCP *session* server-side; the OAuth refresh chain re-mints credentials but cannot re-establish a revoked session. Decision rule when an OAuth MCP reports \"not connected\": (1) smoke-test the stored access_token with a manual `initialize` POST; (2) if `401 invalid_token`, attempt a refresh and smoke-test the NEW token; (3a) new token works → write it + restart to clear the breaker; (3b) new token STILL gets `-32002`\u002F\"Session expired\" → stop, this is session revocation, hand the user the authorize URL for a full re-auth. `scripts\u002Fdiagnose-oauth-mcp.py` automates steps 1–2 and prints which branch you're in. For an unattended gateway whose session keeps getting revoked, prefer a static Personal API key. See `references\u002Fstripe-mcp-oauth-revocation.md` for a worked example of a provider that revokes weekly.\n\n11. **Client info file is NOT optional.** Hermes needs `\u003Cserver>.client.json` to know the `client_id` for refresh grants. Skipping it means the first refresh fails and the user has to re-auth — writing both files is the whole point of this skill.\n\n12. **Never hand-type the redirect URL for the user to open.** Generate the authorize URL programmatically with `urllib.parse.urlencode()`. Spaces in scopes and special chars in `state` break string-concatenated URLs.\n\n13. **Security: the stash file contains the `code_verifier`.** Delete `\u002Ftmp\u002F.mcp-oauth-work\u002F\u003Cserver>.json` immediately after successful token exchange. There's no reason to keep a proof-of-identity secret around once it's consumed.\n\n14. **Write what the token endpoint actually returned.** The AS may grant a narrower (or wider) scope than requested. Write the `scope` from the token-exchange response to `\u003Cserver>.json`, not what you asked for in step 5. When `scopes_supported: []`, the explicit scope list you send IS authoritative both ways: some servers grant exactly what you list (pass narrow scopes for least-privilege, or enumerate the full set if the user needs everything), and some won't echo the granted scope back at registration time — only the token-exchange response is authoritative.\n\n15. **OAuth tokens often double as Bearer tokens against the provider's public REST API.** The access_token in `\u003Cserver>.json` is frequently not \"MCP-only\" — `Authorization: Bearer \u003Ctoken>` against the provider's documented REST API succeeds whenever the corresponding resource scope was granted. This is the OAuth 2.0 spec, not a provider quirk. When the MCP server is read-only but you need a write operation, check whether the OAuth token can hit the provider's REST API directly before suggesting a separate API key.\n\n16. **Secret redaction can mask tokens in tool output.** If secret redaction is enabled, tokens and long opaque strings render as `***` in tool-result output, so you cannot `print(response)` to keep the access_token visible across turns. Combined with single-use `code` values from authorization_code grants: if you print the token-exchange response, you may lose the token AND consume the code, forcing a restart with a fresh authorize URL. **Always write the access_token directly to its final destination file in the SAME code block that performs the token exchange.** If you must print for debugging, print only `len(access_token)`, `token_type`, `scope`, `expires_in` — never the secret.\n\n17. **GitHub MCP (`api.githubcopilot.com\u002Fmcp\u002F`) uses a pre-registered confidential OAuth App, not DCR + PKCE-public.** Its client info ships with a real `client_secret` and `token_endpoint_auth_method: client_secret_post`. The token-exchange POST to `https:\u002F\u002Fgithub.com\u002Flogin\u002Foauth\u002Faccess_token` must include `client_secret` as a form field alongside `client_id`, `code`, `code_verifier`, and `redirect_uri` (PKCE is still honored on top of the secret). The redirect URI is **fixed** in the OAuth App config — you cannot change it, so the manual listener-port trick doesn't apply; the user just lets the browser fail to connect on that port and pastes the address-bar URL back.\n\n## What NOT to do\n\n- **Don't use `mcp-remote` as a fallback.** It runs an npx subprocess whose OAuth callback server ALSO sits on the remote container's localhost — same problem. `mcp-remote` only helps when the MCP client doesn't speak remote HTTP at all (Hermes does natively).\n- **Don't push \"paste your API token and I'll add headers\"** if the user explicitly asked for OAuth. Offer the static-token shortcut only after explaining why the native OAuth flow fails in remote deployments. Respect the user's choice to do the extra legwork for rotation-free, scope-limited access.\n- **Don't claim Hermes doesn't support a feature without reading the source.** Grep the source tree before making capability claims.\n\n## Quick Reference Files\n\n- `scripts\u002Fdiagnose-oauth-mcp.py` — re-runnable, read-only-by-default diagnostic. Given a server name, it smoke-tests the stored access_token, attempts a refresh, smoke-tests the new token, and prints exactly which recovery branch you're in (`TOKEN_OK` = breaker\u002Frestart, `REFRESH_FIXED` = persist+restart, `SESSION_REVOKED` = full re-auth, `REFRESH_DEAD` = full re-auth\u002FAPI key). Pass `--write` to persist a working refreshed token atomically. Never prints secret values. **Run this FIRST when an OAuth MCP server reports \"not connected\"** — it encodes the pitfall 7\u002F9\u002F10 decision tree.\n- `references\u002Fstripe-mcp-oauth-revocation.md` — a worked example (Stripe) of a provider that revokes its OAuth session on a recurring basis, and the durable fix: switch to a static restricted API key.\n\n## Related\n\n- `native-mcp` — general guide to configuring MCP in Hermes. Authoritative config reference lives there.\n- `mcporter` — the external CLI bridge, for ad-hoc MCP calls outside of Hermes' config.\n","# MCP OAuth on a Remote Hermes Gateway\n\n## Overview\n\nHermes' built-in MCP OAuth client runs a one-shot HTTP listener on `127.0.0.1:\u003Cport>`\ninside the Hermes process and registers that loopback address as the OAuth\n`redirect_uri`. That works perfectly for a local CLI on the user's own machine.\nIt breaks completely when Hermes runs as a remote gateway (container, VPS,\nmessaging bot), because the user's browser resolves `127.0.0.1` to the user's own\nlaptop, not the remote container — so the authorization code never reaches Hermes.\n\nThis skill does the OAuth dance by hand and writes the resulting tokens into the\nexact files Hermes' token storage expects, so a subsequent `\u002Freload-mcp` finds\ncached tokens and skips the browser flow entirely.\n\n## When to Use\n\nUse this skill when **all** of the following are true:\n\n1. The user wants to add a remote HTTP MCP server that requires OAuth (not a static Bearer token).\n2. Hermes is running as a **remote gateway** (container, VPS, Docker, managed service) — NOT a local CLI on the user's laptop.\n3. The server supports OAuth 2.1 with PKCE and RFC 7591 Dynamic Client Registration (most modern MCP servers do — Better Stack, Linear, Cloudflare, Datadog, etc.). If it doesn't support DCR (GitHub is the notable exception), this skill does not apply — use a pre-registered OAuth App or a Personal Access Token instead.\n\nDo NOT use this for:\n- **Local CLI Hermes** — just set `auth: oauth` in `mcp_servers.\u003Cname>` and `\u002Freload-mcp`. The built-in flow opens a browser and captures the callback on localhost. Works perfectly.\n- **Servers that accept a static Bearer token (API key)** — always prefer `headers.Authorization: \"Bearer \u003Ctoken>\"` when the user is willing. Simpler, no refresh dance.\n- **GitHub Copilot MCP** (`api.githubcopilot.com\u002Fmcp\u002F`) — GitHub does not expose DCR. Use a PAT or a pre-registered OAuth App (see pitfall 12).\n\n## Why the Built-in OAuth Flow Fails on a Remote Gateway\n\nHermes' native MCP OAuth client (`tools\u002Fmcp_oauth.py`):\n\n1. Picks a free local port `P`.\n2. Registers a dynamic OAuth client with the AS, sending `redirect_uri = http:\u002F\u002F127.0.0.1:P\u002Fcallback`.\n3. Starts an HTTP server on `127.0.0.1:P` **inside the Hermes process**.\n4. Prints the authorize URL and waits for the code at its local endpoint.\n\nWhen Hermes runs remotely, the `127.0.0.1` in the `redirect_uri` is the remote\ncontainer's loopback, not the user's. After authorizing, the user's browser 302s\nto `http:\u002F\u002F127.0.0.1:P\u002Fcallback?code=...`, which resolves to the user's own\nlaptop and fails to connect. The callback never reaches the Hermes process, the\nflow times out, and `\u002Freload-mcp` returns \"No MCP tools available\" with no detail.\n\nSymptoms to recognize: `[xdg-open] \u003Cdefunct>` processes under the hermes user, an\nempty or missing tokens directory (`$HERMES_HOME\u002Fmcp-tokens\u002F`), and a reload that\nresponds without any \"Added\u002FReconnected: X\" line in `change_detail`.\n\n## Cheap First Fallbacks: the Built-in Flow's Own Escape Hatches\n\nBefore any manual token surgery, check whether the built-in flow's fallbacks\nalready cover the deployment. When Hermes detects a remote session it prints two\noptions alongside the authorize URL (`tools\u002Fmcp_oauth.py`):\n\n1. **Paste-back** — on an interactive TTY, a stdin reader races the HTTP\n   listener. The user authorizes, the browser fails to connect to\n   `127.0.0.1:\u003Cport>`, and they paste the full address-bar URL\n   (`?code=...&state=...`) back at the prompt. Works for SSH'd-in CLI sessions.\n2. **SSH port-forward** — `ssh -N -L \u003Cport>:127.0.0.1:\u003Cport> \u003Cuser>@\u003Chost>`\n   makes the redirect reach the remote listener normally.\n\nBoth require an interactive terminal to the Hermes host. The rest of this skill\nis for when there is NO interactive TTY — Hermes running purely as a messaging\ngateway\u002Fbot where `\u002Freload-mcp` triggers the flow with nobody at a prompt.\n\n## Preferred Front Door: the Hermes Dashboard (try this BEFORE manual token surgery)\n\nA remote Hermes gateway often also runs the **dashboard** web UI as a SEPARATE\nprocess (e.g. `hermes dashboard --host 0.0.0.0 --port \u003Cport>`; check with\n`ps aux | grep 'hermes dashboard'`). It exposes a connector\u002FMCP console —\nendpoints like `\u002Fapi\u002Fmcp\u002Fservers`, `\u002Fapi\u002Fmcp\u002Fstatus`, and `\u002Fconnectors` (all\nlogin-gated; a cookieless curl returning 401\u002F302 confirms they exist).\n\n**Why the dashboard solves the core problem:** when the user drives OAuth from\nthe dashboard *in their own browser*, the redirect lands in a context the\ndashboard can capture — sidestepping the `127.0.0.1`-callback failure that breaks\nthe CLI\u002Fmanual flow. So the correct escalation order for \"add or re-auth an OAuth\nMCP server on a remote gateway\" is:\n\n1. **Dashboard, in the user's browser** — the intended front door. Add servers, run OAuth, reload, all authenticated as the user. No copy-paste-callback dance, no hand-writing token files.\n2. **Manual token surgery (the rest of this skill)** — the FALLBACK for when there's no browser session to the dashboard (pure-chat\u002Fheadless context).\n\n**Finding the dashboard's PUBLIC URL.** The dashboard binds internally to\n`0.0.0.0:\u003Cport>`, but the user needs the externally-reachable URL. Most deploy\nplatforms inject it into the environment — grep for it rather than making the\nuser hunt:\n\n```bash\nenv | grep -iE \"HERMES_DASHBOARD_PUBLIC_URL|RAILWAY_PUBLIC_DOMAIN|RAILWAY_STATIC_URL|RAILWAY_SERVICE_.*_URL|PUBLIC_URL|BASE_URL|DOMAIN\" \\\n  | sed -E 's\u002F(TOKEN|SECRET|KEY|PASSWORD)=.*\u002F\\1=***REDACTED***\u002FI'\n```\n\n`HERMES_DASHBOARD_PUBLIC_URL` is authoritative when present. On Railway also check\n`RAILWAY_PUBLIC_DOMAIN` \u002F `RAILWAY_STATIC_URL` (the `*.up.railway.app` host) and\n`RAILWAY_SERVICE_*_URL` vars, which sometimes carry a friendlier custom domain.\nHand the user the full `https:\u002F\u002F` URL and point them at the Connectors\u002FMCP\nsection. ALWAYS pipe through the `sed` redaction above — these env greps sit next\nto `*_TOKEN`\u002F`*_SECRET` vars.\n\n**What the dashboard does NOT fix (still host-side \u002F shell):** stdio servers that\nneed shell auth state (a CLI `login` command whose credentials may not persist\nacross restarts) and anything reading credentials from `$HERMES_HOME\u002F.env`. Those\nare out of the dashboard's scope regardless.\n\n## The Workaround\n\nDo the OAuth dance manually, then write the resulting tokens into the exact files\nHermes' `HermesTokenStorage` would have written, so on `\u002Freload-mcp` Hermes finds\ncached tokens and skips the browser flow entirely.\n\nRun the shell commands below through the `terminal` tool on the gateway host and\ndo the Python steps (PKCE generation, token exchange, file writes) via\n`execute_code` or a `terminal` python3 invocation — file writes must happen in\nthe SAME code block as the token exchange (see pitfall 16).\n\n### 1. Confirm it's a remote gateway\n\n```bash\nenv | grep -iE \"HERMES|RAILWAY|CONTAINER\"\necho \"$DISPLAY $WAYLAND_DISPLAY $SSH_CLIENT\"\n```\n\nNo display + a remote indicator = remote gateway. `tools\u002Fmcp_oauth.py::_can_open_browser()`\nuses these same env vars, so if Hermes' own auto-detect says \"headless\", the\nbuilt-in flow won't work.\n\n### 2. Find HERMES_HOME and the config path\n\n```bash\nHERMES_HOME=$(python3 -c 'from hermes_constants import get_hermes_home; print(get_hermes_home())')\necho \"config: $HERMES_HOME\u002Fconfig.yaml\"\necho \"tokens: $HERMES_HOME\u002Fmcp-tokens\u002F\"\n```\n\n### 3. Discover OAuth metadata from the MCP server\n\nMCP servers advertise their OAuth setup via RFC 9728 (OAuth 2.0 Protected\nResource Metadata). The `WWW-Authenticate` header on a 401 tells you where to look:\n\n```bash\ncurl -sI https:\u002F\u002Fmcp.example.com | grep -i www-authenticate\n# → Bearer realm=\"mcp\", resource_metadata=\"https:\u002F\u002Fmcp.example.com\u002F.well-known\u002Foauth-protected-resource\"\n```\n\n**Not every server returns `WWW-Authenticate`.** Some return a bare\n`{\"errors\":[\"Unauthorized\"]}` 401 with no auth-discovery hint. When that happens,\nprobe well-known paths directly:\n\n```bash\nfor p in \\\n  \u002F.well-known\u002Foauth-protected-resource \\\n  \u002F.well-known\u002Foauth-authorization-server \\\n  \u002F.well-known\u002Fopenid-configuration ; do\n  echo \"=== $p ===\"\n  curl -s -A \"python-httpx\u002F0.27\" \"https:\u002F\u002Fmcp.example.com$p\" | head -c 400; echo\ndone\n```\n\nFetch the resource metadata to get `authorization_servers`, then fetch the AS's\n`\u002F.well-known\u002Foauth-authorization-server` to get `authorization_endpoint`,\n`token_endpoint`, and `registration_endpoint`.\n\nPitfall: many servers sit behind Cloudflare and 403 bare `urllib` user agents.\nAlways set `User-Agent: python-httpx\u002F0.27` (or similar) on requests in this flow.\n\n### 4. Dynamic Client Registration (RFC 7591)\n\nPOST to the `registration_endpoint` with:\n\n```json\n{\n  \"client_name\": \"Hermes Agent (manual OAuth)\",\n  \"redirect_uris\": [\"http:\u002F\u002F127.0.0.1:8765\u002Fcallback\"],\n  \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n  \"response_types\": [\"code\"],\n  \"token_endpoint_auth_method\": \"none\",\n  \"scope\": \"\u003Cscopes_from_resource_metadata>\"\n}\n```\n\nOmit `scope` entirely if the AS's `scopes_supported` is empty — see step 5\npitfall. Use port `8765` (or any port — nothing will listen).\n`token_endpoint_auth_method: none` marks this as a public PKCE client. Save the\nreturned `client_id`.\n\n### 5. Build the authorize URL with PKCE\n\nGenerate:\n- `code_verifier`: `secrets.token_urlsafe(64)[:128]`\n- `code_challenge`: `base64url(sha256(code_verifier))` (no padding)\n- `state`: `secrets.token_urlsafe(24)`\n\nQuery params: `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`,\n`code_challenge_method=S256`, `state`, plus `resource=\u003Cmcp_server_url>` (RFC 8707 —\nmany servers require this to bind the token to the specific MCP resource). Include\n`scope=\u003Cspace-separated>` ONLY if the AS metadata's `scopes_supported` is a\nnon-empty array AND\u002FOR the resource metadata declares specific scopes. If\n`scopes_supported: []`, omit the `scope` parameter — the server grants its full\ndefault set on its own. Fabricating scope strings against an empty\n`scopes_supported` can cause `invalid_scope` errors on some ASes.\n\n**Stash `code_verifier` and `state` to disk** (e.g. `\u002Ftmp\u002F.mcp-oauth-work\u002F\u003Cserver>.json`,\n0600 perms). You need them for step 7, possibly across multiple chat turns.\n\n### 6. Give the user the authorize URL\n\n```\nOpen this URL in your browser:\n\u003Cauthorize_url>\n\nAfter approving, your browser will try to load http:\u002F\u002F127.0.0.1:8765\u002Fcallback\nand fail to connect — THAT'S EXPECTED. Just copy the entire URL from the\naddress bar (it will contain ?code=...&state=...) and paste it back here.\n```\n\n### 7. Exchange the code for tokens\n\nWhen the user pastes the callback URL:\n\n1. Parse `code` and `state` from the query string.\n2. **Verify `state` matches the stashed value** (CSRF check — do not skip).\n3. POST `application\u002Fx-www-form-urlencoded` to the `token_endpoint`:\n   - `grant_type=authorization_code`\n   - `code=\u003Cfrom callback>`\n   - `redirect_uri=\u003Csame as step 4>`\n   - `client_id=\u003Cfrom step 4>`\n   - `code_verifier=\u003Cstashed>`\n   - `resource=\u003Cmcp_server_url>` (if the AS required it in step 5, include here too)\n4. Response contains `access_token`, `refresh_token`, `token_type`, `expires_in`, `scope`.\n\n### 8. Write tokens in Hermes' exact schema\n\n`tools\u002Fmcp_oauth.py::HermesTokenStorage` expects two files under\n`$HERMES_HOME\u002Fmcp-tokens\u002F` (create dir with `0o700`, files with `0o600`):\n\n**`\u003Cserver_name>.json`** — the `OAuthToken` pydantic model:\n```json\n{\n  \"access_token\": \"...\",\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 7200,\n  \"refresh_token\": \"...\",\n  \"scope\": \"read write\"\n}\n```\n\n**`\u003Cserver_name>.client.json`** — the `OAuthClientInformationFull` model:\n```json\n{\n  \"client_id\": \"...\",\n  \"redirect_uris\": [\"http:\u002F\u002F127.0.0.1:8765\u002Fcallback\"],\n  \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n  \"response_types\": [\"code\"],\n  \"token_endpoint_auth_method\": \"none\",\n  \"scope\": \"read write\",\n  \"client_name\": \"...\"\n}\n```\n\nWrite each file via `json.dumps(..., indent=2)`. Sanitize the filename with\n`re.sub(r'[^\\w\\-]', '_', server_name)[:128]` — this matches `_safe_filename()` in\nHermes' token storage.\n\n### 9. Add the server to config.yaml\n\n```yaml\nmcp_servers:\n  \u003Cname>:\n    url: \"https:\u002F\u002Fmcp.example.com\"\n    auth: oauth\n    timeout: 180\n    connect_timeout: 60\n```\n\n### 10. Smoke-test the token BEFORE asking the user to reload\n\nManually POST an MCP `initialize` request to confirm the token works end-to-end —\nthis catches scope misconfigurations, wrong `resource` values, and CF blocks\nbefore the user is confused by another \"No MCP tools available\" reload:\n\n```python\nbody = json.dumps({\n    \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\",\n    \"params\": {\n        \"protocolVersion\": \"2025-06-18\",\n        \"capabilities\": {},\n        \"clientInfo\": {\"name\": \"hermes-debug\", \"version\": \"1.0\"},\n    },\n}).encode()\n# POST to the MCP URL with:\n#   Authorization: Bearer \u003Caccess_token>\n#   Accept: application\u002Fjson, text\u002Fevent-stream\n#   Content-Type: application\u002Fjson\n#   MCP-Protocol-Version: 2025-06-18\n#   User-Agent: python-httpx\u002F0.27\n```\n\nExpect HTTP 200 with `Content-Type: text\u002Fevent-stream` and a JSON-RPC result\ncontaining `serverInfo` and `capabilities`. **Do not use `urllib` with its default\nUA** — Cloudflare will 403 you even though Hermes (which uses httpx) will succeed.\n`scripts\u002Fdiagnose-oauth-mcp.py` automates this smoke test.\n\n### 11. Tell the user to run `\u002Freload-mcp`\n\nOn reload, Hermes sees `auth: oauth`, calls `HermesTokenStorage.get_tokens()`,\nfinds your cached tokens, skips the browser flow, and registers `mcp_\u003Cname>_*`\ntools. Refresh happens automatically before `expires_in` elapses.\n\n## Pitfalls & Lessons Learned\n\n1. **Do not assume \"headless\" means \"OAuth impossible.\"** The built-in flow works fine for local CLI; the issue is strictly remote deployments where the user's browser and the Hermes process are on different machines. Check the execution environment before claiming OAuth isn't an option.\n\n2. **Read the source, not just the skill docs.** `tools\u002Fmcp_oauth.py` and the MCP config reference in `website\u002Fdocs\u002F` are the authoritative references. Grep the tree before telling the user a feature \"doesn't exist.\"\n\n3. **Cloudflare UA filter.** Many MCP\u002FOAuth providers front their infra with Cloudflare, which 403s `python-urllib\u002F*` user agents on metadata endpoints even though those endpoints are public. Set `User-Agent: python-httpx\u002F0.27` (or any browser-like string) on every request in this flow. Hermes itself uses httpx, so this is never a problem in the real connection path.\n\n4. **Include `resource` in both authorize and token requests.** RFC 8707 resource indicators are not optional for most modern MCP servers — they bind the issued token to the specific MCP resource URL. Leaving it out sometimes still works but may yield a token that later fails at the MCP server with a scope\u002Faudience error.\n\n5. **Trailing slash matters.** Some servers advertise the resource as `https:\u002F\u002Fmcp.example.com\u002F` with a trailing slash and reject tokens issued against the no-slash variant. Copy the `resource` value verbatim from the `.well-known\u002Foauth-protected-resource` response.\n\n6. **`\u002Freload-mcp` is silent on failure.** If the reload shows \"No MCP tools available\" with no `change_detail` line, a server is in config but failed to connect and no error bubbled up. Tail the error log, smoke-test the token directly with a manual `initialize` POST, and — if everything looks good — ask for a full process restart.\n\n7. **Circuit breaker can survive `\u002Freload-mcp`.** `tools\u002Fmcp_tool.py` keeps a module-level error-count dict with a small threshold. Once tripped (e.g. after token expiry produces several consecutive failures), the tool handler can short-circuit before calling the server, so no successful call resets the counter. Symptom: reload says \"Reconnected: X\" but subsequent calls still fail with \"server unreachable\" in the same conversation. Recovery order: try `\u002Freload-mcp` FIRST (cheap, no chat-process blip) — on current builds it can clear the counter; only escalate to a full gateway process restart if a live call STILL short-circuits after reload. Do not lead with \"you must restart.\"\n\n8. **Refresh on an expired access_token + a tripped breaker is a deadlock.** The auto-refresh logic runs inside the MCP call path, which the breaker short-circuits once tripped. Manually refreshing the token on disk does not help by itself — pair a manual token refresh with a full restart, not a `\u002Freload-mcp`.\n\n9. **`invalid_grant` on a manual refresh means the refresh token is DEAD — re-auth is the only fix, do not loop.** When the access_token has been expired long enough, the refresh_token can also be revoked\u002Fexpired server-side. A `grant_type=refresh_token` POST then returns HTTP 400 `{\"error\":\"invalid_grant\",...}` (wording varies: \"Grant not found\", \"Token expired\", \"refresh token is invalid\"). There is NO recovery from the gateway side. Hand back to the user with two options: (a) re-run the full manual OAuth dance (steps 3–10), or (b) if the provider offers a static personal API key, switch to that — no refresh\u002Fexpiry cycle, more durable for an unattended remote gateway. Detect early: before any create\u002Fupdate operation against an OAuth MCP, check `expires_at` vs `time.time()`; if already expired, attempt the refresh first and surface `invalid_grant` immediately rather than failing mid-task.\n\n10. **A successful refresh that STILL yields a rejected token = server-side SESSION revocation; only a fresh authorization_code flow fixes it.** Distinct from pitfall 9. The stored token file can look healthy (`expires_at` well out, refresh_token present), yet a live `initialize` POST returns `401 invalid_token` with a JSON-RPC body like `{\"error\":{\"code\":-32002,\"message\":\"Session expired. Please re-authenticate.\"}}`. The `grant_type=refresh_token` POST may **succeed** (HTTP 200, new access_token) — yet the brand-new token gets the SAME `-32002`. The provider revoked the underlying MCP *session* server-side; the OAuth refresh chain re-mints credentials but cannot re-establish a revoked session. Decision rule when an OAuth MCP reports \"not connected\": (1) smoke-test the stored access_token with a manual `initialize` POST; (2) if `401 invalid_token`, attempt a refresh and smoke-test the NEW token; (3a) new token works → write it + restart to clear the breaker; (3b) new token STILL gets `-32002`\u002F\"Session expired\" → stop, this is session revocation, hand the user the authorize URL for a full re-auth. `scripts\u002Fdiagnose-oauth-mcp.py` automates steps 1–2 and prints which branch you're in. For an unattended gateway whose session keeps getting revoked, prefer a static Personal API key. See `references\u002Fstripe-mcp-oauth-revocation.md` for a worked example of a provider that revokes weekly.\n\n11. **Client info file is NOT optional.** Hermes needs `\u003Cserver>.client.json` to know the `client_id` for refresh grants. Skipping it means the first refresh fails and the user has to re-auth — writing both files is the whole point of this skill.\n\n12. **Never hand-type the redirect URL for the user to open.** Generate the authorize URL programmatically with `urllib.parse.urlencode()`. Spaces in scopes and special chars in `state` break string-concatenated URLs.\n\n13. **Security: the stash file contains the `code_verifier`.** Delete `\u002Ftmp\u002F.mcp-oauth-work\u002F\u003Cserver>.json` immediately after successful token exchange. There's no reason to keep a proof-of-identity secret around once it's consumed.\n\n14. **Write what the token endpoint actually returned.** The AS may grant a narrower (or wider) scope than requested. Write the `scope` from the token-exchange response to `\u003Cserver>.json`, not what you asked for in step 5. When `scopes_supported: []`, the explicit scope list you send IS authoritative both ways: some servers grant exactly what you list (pass narrow scopes for least-privilege, or enumerate the full set if the user needs everything), and some won't echo the granted scope back at registration time — only the token-exchange response is authoritative.\n\n15. **OAuth tokens often double as Bearer tokens against the provider's public REST API.** The access_token in `\u003Cserver>.json` is frequently not \"MCP-only\" — `Authorization: Bearer \u003Ctoken>` against the provider's documented REST API succeeds whenever the corresponding resource scope was granted. This is the OAuth 2.0 spec, not a provider quirk. When the MCP server is read-only but you need a write operation, check whether the OAuth token can hit the provider's REST API directly before suggesting a separate API key.\n\n16. **Secret redaction can mask tokens in tool output.** If secret redaction is enabled, tokens and long opaque strings render as `***` in tool-result output, so you cannot `print(response)` to keep the access_token visible across turns. Combined with single-use `code` values from authorization_code grants: if you print the token-exchange response, you may lose the token AND consume the code, forcing a restart with a fresh authorize URL. **Always write the access_token directly to its final destination file in the SAME code block that performs the token exchange.** If you must print for debugging, print only `len(access_token)`, `token_type`, `scope`, `expires_in` — never the secret.\n\n17. **GitHub MCP (`api.githubcopilot.com\u002Fmcp\u002F`) uses a pre-registered confidential OAuth App, not DCR + PKCE-public.** Its client info ships with a real `client_secret` and `token_endpoint_auth_method: client_secret_post`. The token-exchange POST to `https:\u002F\u002Fgithub.com\u002Flogin\u002Foauth\u002Faccess_token` must include `client_secret` as a form field alongside `client_id`, `code`, `code_verifier`, and `redirect_uri` (PKCE is still honored on top of the secret). The redirect URI is **fixed** in the OAuth App config — you cannot change it, so the manual listener-port trick doesn't apply; the user just lets the browser fail to connect on that port and pastes the address-bar URL back.\n\n## What NOT to do\n\n- **Don't use `mcp-remote` as a fallback.** It runs an npx subprocess whose OAuth callback server ALSO sits on the remote container's localhost — same problem. `mcp-remote` only helps when the MCP client doesn't speak remote HTTP at all (Hermes does natively).\n- **Don't push \"paste your API token and I'll add headers\"** if the user explicitly asked for OAuth. Offer the static-token shortcut only after explaining why the native OAuth flow fails in remote deployments. Respect the user's choice to do the extra legwork for rotation-free, scope-limited access.\n- **Don't claim Hermes doesn't support a feature without reading the source.** Grep the source tree before making capability claims.\n\n## Quick Reference Files\n\n- `scripts\u002Fdiagnose-oauth-mcp.py` — re-runnable, read-only-by-default diagnostic. Given a server name, it smoke-tests the stored access_token, attempts a refresh, smoke-tests the new token, and prints exactly which recovery branch you're in (`TOKEN_OK` = breaker\u002Frestart, `REFRESH_FIXED` = persist+restart, `SESSION_REVOKED` = full re-auth, `REFRESH_DEAD` = full re-auth\u002FAPI key). Pass `--write` to persist a working refreshed token atomically. Never prints secret values. **Run this FIRST when an OAuth MCP server reports \"not connected\"** — it encodes the pitfall 7\u002F9\u002F10 decision tree.\n- `references\u002Fstripe-mcp-oauth-revocation.md` — a worked example (Stripe) of a provider that revokes its OAuth session on a recurring basis, and the durable fix: switch to a static restricted API key.\n\n## Related\n\n- `native-mcp` — general guide to configuring MCP in Hermes. Authoritative config reference lives there.\n- `mcporter` — the external CLI bridge, for ad-hoc MCP calls outside of Hermes' config.\n",{"name":5,"author":47,"license":15,"version":48,"metadata":49,"platforms":55,"description":7},"Ben Barclay (benbarclay), Hermes Agent","1.0.0",{"hermes":50},{"tags":51,"related_skills":54},[27,24,52,53],"PKCE","Remote-Deployment",[37,38,29],[33,41],"main","0d1434f89f4bc6b07e9d272accd8c1013768820f","archive\u002Fskill-md-reviewed\u002Fsha256\u002Fe2\u002Fe21ffc19f8d1d517e4e7bf5ecbebebdcdb1845ce9c799234a449c617cb6562a0",0,"https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills","https:\u002F\u002Fgithub.com\u002Fzapabob","The Open University of JAPAN　2019-now\r\n\r\nTokyo University of Science　2007-2010","2026-10-26 16:29:01",{"source":8,"skillName":5,"commands":65,"prompt":75,"zipAvailable":76},[66,69,72],{"tool":67,"command":68},"npx","npx skills add zapabob\u002FSkills --skill mcp-oauth-remote-gateway",{"tool":70,"command":71},"bunx","bunx skills add zapabob\u002FSkills --skill mcp-oauth-remote-gateway",{"tool":73,"command":74},"pnpm","pnpm dlx skills add zapabob\u002FSkills --skill mcp-oauth-remote-gateway","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Farchive\u002Fskill-md-reviewed\u002Fsha256\u002Fe2\u002Fe21ffc19f8d1d517e4e7bf5ecbebebdcdb1845ce9c799234a449c617cb6562a0\u002FSKILL.md",true,[],[79,109,144,171,198,215],{"skillNo":80,"name":81,"nameZh":6,"description":82,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":83,"categories":84,"keywords":94,"publishedAt":42},"01M6GJTE1GESGGF9K028Y7753E","xurl","X\u002FTwitter via xurl CLI: post, search, DM, media, v2 API.","https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Fregistry\u002Fskills\u002Fxurl\u002Fvariants\u002Fhermes-agent\u002FSKILL.md",[85,88,89],{"categoryNo":86,"categoryName":87,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口",{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":90,"categoryName":91,"taxonomyType":92,"taxonomyTypeDesc":93},"01JBSEEDCAT000000000000030","开发工程师","PROFESSION","适用职业",[81,95,41,96,97,33,98,99,100,101,102,103,104,35,36,105,56,106,107,108],"openclaw","cli","github","windows","official","search","https","social-media","api","Media","twitter","social","via","post",{"skillNo":110,"name":111,"nameZh":6,"description":112,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":113,"categories":114,"keywords":126,"publishedAt":42},"01M6GJT8S1PXQ8WPXT1HSNBWH2","writing-plans","Write implementation plans: bite-sized tasks, paths, code.","https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Fregistry\u002Fskills\u002Fwriting-plans\u002Fvariants\u002Fhermes-agent\u002FSKILL.md",[115,118,121,122,123],{"categoryNo":116,"categoryName":117,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000015","视觉设计",{"categoryNo":119,"categoryName":120,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000018","写作与文案",{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":90,"categoryName":91,"taxonomyType":92,"taxonomyTypeDesc":93},{"categoryNo":124,"categoryName":125,"taxonomyType":92,"taxonomyTypeDesc":93},"01JBSEEDCAT000000000000036","内容创作者",[127,128,129,130,41,131,33,132,98,133,134,135,136,137,138,139,140,141,142,143],"writing","writing plans","plans","design","review","workflow","documentation","tasks","development","code","planning","code-review","subagent","write","superpowers","paths","test-driven-development",{"skillNo":145,"name":146,"nameZh":6,"description":147,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":148,"categories":149,"keywords":153,"publishedAt":42},"01M6GJST0H5X7WHBJ3BZK0DVKH","whisper","OpenAI's general-purpose speech recognition model. Supports 99 languages, transcription, translation to English, and language identification. Six model sizes from tiny (39M params) to large (1550M params). Use for speech-to-text, podcast transcription, or multilingual audio processing. Best for robust, multilingual ASR.","https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Fregistry\u002Fskills\u002Fwhisper\u002Fvariants\u002Fhermes-agent-optional\u002FSKILL.md",[150],{"categoryNo":151,"categoryName":152,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000016","图像与多媒体",[146,41,33,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170],"openai","speech","multimodal","Transformers","ASR","Audio","text","model","speech recognition","use","general","multilingual","translation","english","identification","processing","Audio Processing",{"skillNo":172,"name":173,"nameZh":6,"description":174,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":175,"categories":176,"keywords":180,"publishedAt":42},"01M6GJSMRQ8EETZTYMG72P70EA","weights-and-biases","W&B: log ML experiments, sweeps, model registry, dashboards.","https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Fregistry\u002Fskills\u002Fweights-and-biases\u002Fvariants\u002Fhermes-agent\u002FSKILL.md",[177],{"categoryNo":178,"categoryName":179,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000013","分析与可视化",[181,41,182,183,98,184,185,186,187,188,189,161,190,191,192,193,194,195,196,197],"weights and biases","tensorflow","experiments","pytorch","visualization","registry","real-time","tuning","dashboards","experiment","log","time","Experiment Tracking","Model Registry","Hyperparameter Tuning","WandB","Real-Time Visualization",{"skillNo":199,"name":200,"nameZh":6,"description":201,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":202,"categories":203,"keywords":205,"publishedAt":42},"01M6GJS19KDPRY112JTR326S06","watchers","Poll RSS, JSON APIs, and GitHub with watermark dedup.","https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Fregistry\u002Fskills\u002Fwatchers\u002Fvariants\u002Fhermes-agent-optional\u002FSKILL.md",[204],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},[200,41,206,97,33,207,208,209,210,211,212,35,36,213,214],"terminal","automation","http","monitoring","cron","json","rss","devops","with",{"skillNo":216,"name":217,"nameZh":6,"description":218,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":219,"categories":220,"keywords":223,"publishedAt":42},"01M6GJRHJD2A6Q830SWF9W48BC","serving-llms-vllm","vLLM: high-throughput LLM serving, OpenAI API, quantization.","https:\u002F\u002Fgithub.com\u002Fzapabob\u002FSkills\u002Fblob\u002Fmain\u002Fregistry\u002Fskills\u002Fvllm\u002Fvariants\u002Fhermes-agent\u002FSKILL.md",[221,222],{"categoryNo":86,"categoryName":87,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":90,"categoryName":91,"taxonomyType":92,"taxonomyTypeDesc":93},[224,225,226,33,227,154,228,229,230,157,231,103,232,233,234,235,236,237,238,239],"llms","vLLM","serving llms vllm","research","openai api","tensor","Inference Serving","Production","llm serving","High Throughput","PagedAttention","Continuous Batching","Quantization","Tensor Parallelism","high-throughput","throughput"]