diff --git a/skills/mcp/mcp-oauth-remote-gateway/SKILL.md b/skills/mcp/mcp-oauth-remote-gateway/SKILL.md new file mode 100644 index 000000000000..11fc14b964bd --- /dev/null +++ b/skills/mcp/mcp-oauth-remote-gateway/SKILL.md @@ -0,0 +1,348 @@ +--- +name: mcp-oauth-remote-gateway +description: Connect an OAuth-gated remote MCP server (Better Stack, Linear, Cloudflare, Datadog, Stripe, etc.) to Hermes when Hermes runs as a REMOTE gateway (container, VPS, messaging bot) where the built-in browser OAuth flow cannot capture the localhost callback. Manually performs RFC 7591 Dynamic Client Registration + RFC 7636 PKCE + authorization_code exchange and writes tokens in the exact schema Hermes persistence expects. +version: 1.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [MCP, OAuth, PKCE, remote-deployment] + related_skills: [native-mcp, mcporter] +--- + +# MCP OAuth on a Remote Hermes Gateway + +## Overview + +Hermes' built-in MCP OAuth client runs a one-shot HTTP listener on `127.0.0.1:` +inside the Hermes process and registers that loopback address as the OAuth +`redirect_uri`. That works perfectly for a local CLI on the user's own machine. +It breaks completely when Hermes runs as a remote gateway (container, VPS, +messaging bot), because the user's browser resolves `127.0.0.1` to the user's own +laptop, not the remote container — so the authorization code never reaches Hermes. + +This skill does the OAuth dance by hand and writes the resulting tokens into the +exact files Hermes' token storage expects, so a subsequent `/reload-mcp` finds +cached tokens and skips the browser flow entirely. + +## When to Use + +Use this skill when **all** of the following are true: + +1. The user wants to add a remote HTTP MCP server that requires OAuth (not a static Bearer token). +2. Hermes is running as a **remote gateway** (container, VPS, Docker, managed service) — NOT a local CLI on the user's laptop. +3. 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. + +Do NOT use this for: +- **Local CLI Hermes** — just set `auth: oauth` in `mcp_servers.` and `/reload-mcp`. The built-in flow opens a browser and captures the callback on localhost. Works perfectly. +- **Servers that accept a static Bearer token (API key)** — always prefer `headers.Authorization: "Bearer "` when the user is willing. Simpler, no refresh dance. +- **GitHub Copilot MCP** (`api.githubcopilot.com/mcp/`) — GitHub does not expose DCR. Use a PAT or a pre-registered OAuth App (see pitfall 12). + +## Why the Built-in OAuth Flow Fails on a Remote Gateway + +Hermes' native MCP OAuth client (`tools/mcp_oauth.py`): + +1. Picks a free local port `P`. +2. Registers a dynamic OAuth client with the AS, sending `redirect_uri = http://127.0.0.1:P/callback`. +3. Starts an HTTP server on `127.0.0.1:P` **inside the Hermes process**. +4. Prints the authorize URL and waits for the code at its local endpoint. + +When Hermes runs remotely, the `127.0.0.1` in the `redirect_uri` is the remote +container's loopback, not the user's. After authorizing, the user's browser 302s +to `http://127.0.0.1:P/callback?code=...`, which resolves to the user's own +laptop and fails to connect. The callback never reaches the Hermes process, the +flow times out, and `/reload-mcp` returns "No MCP tools available" with no detail. + +Symptoms to recognize: `[xdg-open] ` processes under the hermes user, an +empty or missing tokens directory (`$HERMES_HOME/mcp-tokens/`), and a reload that +responds without any "Added/Reconnected: X" line in `change_detail`. + +## Preferred Front Door: the Hermes Dashboard (try this BEFORE manual token surgery) + +A remote Hermes gateway often also runs the **dashboard** web UI as a SEPARATE +process (e.g. `hermes dashboard --host 0.0.0.0 --port `; check with +`ps aux | grep 'hermes dashboard'`). It exposes a connector/MCP console — +endpoints like `/api/mcp/servers`, `/api/mcp/status`, and `/connectors` (all +login-gated; a cookieless curl returning 401/302 confirms they exist). + +**Why the dashboard solves the core problem:** when the user drives OAuth from +the dashboard *in their own browser*, the redirect lands in a context the +dashboard can capture — sidestepping the `127.0.0.1`-callback failure that breaks +the CLI/manual flow. So the correct escalation order for "add or re-auth an OAuth +MCP server on a remote gateway" is: + +1. **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. +2. **Manual token surgery (the rest of this skill)** — the FALLBACK for when there's no browser session to the dashboard (pure-chat/headless context). + +**Finding the dashboard's PUBLIC URL.** The dashboard binds internally to +`0.0.0.0:`, but the user needs the externally-reachable URL. Most deploy +platforms inject it into the environment — grep for it rather than making the +user hunt: + +```bash +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` is authoritative when present. On Railway also check +`RAILWAY_PUBLIC_DOMAIN` / `RAILWAY_STATIC_URL` (the `*.up.railway.app` host) and +`RAILWAY_SERVICE_*_URL` vars, which sometimes carry a friendlier custom domain. +Hand the user the full `https://` URL and point them at the Connectors/MCP +section. ALWAYS pipe through the `sed` redaction above — these env greps sit next +to `*_TOKEN`/`*_SECRET` vars. + +**What the dashboard does NOT fix (still host-side / shell):** stdio servers that +need shell auth state (a CLI `login` command whose credentials may not persist +across restarts) and anything reading credentials from `$HERMES_HOME/.env`. Those +are out of the dashboard's scope regardless. + +## The Workaround + +Do the OAuth dance manually, then write the resulting tokens into the exact files +Hermes' `HermesTokenStorage` would have written, so on `/reload-mcp` Hermes finds +cached tokens and skips the browser flow entirely. + +### 1. Confirm it's a remote gateway + +```bash +env | grep -iE "HERMES|RAILWAY|CONTAINER" +echo "$DISPLAY $WAYLAND_DISPLAY $SSH_CLIENT" +``` + +No display + a remote indicator = remote gateway. `tools/mcp_oauth.py::_can_open_browser()` +uses these same env vars, so if Hermes' own auto-detect says "headless", the +built-in flow won't work. + +### 2. Find HERMES_HOME and the config path + +```bash +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. Discover OAuth metadata from the MCP server + +MCP servers advertise their OAuth setup via RFC 9728 (OAuth 2.0 Protected +Resource Metadata). The `WWW-Authenticate` header on a 401 tells you where to look: + +```bash +curl -sI https://mcp.example.com | grep -i www-authenticate +# → Bearer realm="mcp", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" +``` + +**Not every server returns `WWW-Authenticate`.** Some return a bare +`{"errors":["Unauthorized"]}` 401 with no auth-discovery hint. When that happens, +probe well-known paths directly: + +```bash +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 +``` + +Fetch the resource metadata to get `authorization_servers`, then fetch the AS's +`/.well-known/oauth-authorization-server` to get `authorization_endpoint`, +`token_endpoint`, and `registration_endpoint`. + +Pitfall: many servers sit behind Cloudflare and 403 bare `urllib` user agents. +Always set `User-Agent: python-httpx/0.27` (or similar) on requests in this flow. + +### 4. Dynamic Client Registration (RFC 7591) + +POST to the `registration_endpoint` with: + +```json +{ + "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": "" +} +``` + +Omit `scope` entirely if the AS's `scopes_supported` is empty — see step 5 +pitfall. Use port `8765` (or any port — nothing will listen). +`token_endpoint_auth_method: none` marks this as a public PKCE client. Save the +returned `client_id`. + +### 5. Build the authorize URL with PKCE + +Generate: +- `code_verifier`: `secrets.token_urlsafe(64)[:128]` +- `code_challenge`: `base64url(sha256(code_verifier))` (no padding) +- `state`: `secrets.token_urlsafe(24)` + +Query params: `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, +`code_challenge_method=S256`, `state`, plus `resource=` (RFC 8707 — +many servers require this to bind the token to the specific MCP resource). Include +`scope=` ONLY if the AS metadata's `scopes_supported` is a +non-empty array AND/OR the resource metadata declares specific scopes. If +`scopes_supported: []`, omit the `scope` parameter — the server grants its full +default set on its own. Fabricating scope strings against an empty +`scopes_supported` can cause `invalid_scope` errors on some ASes. + +**Stash `code_verifier` and `state` to disk** (e.g. `/tmp/.mcp-oauth-work/.json`, +0600 perms). You need them for step 7, possibly across multiple chat turns. + +### 6. Give the user the authorize URL + +``` +Open this URL in your browser: + + +After approving, your browser will try to load http://127.0.0.1:8765/callback +and fail to connect — THAT'S EXPECTED. Just copy the entire URL from the +address bar (it will contain ?code=...&state=...) and paste it back here. +``` + +### 7. Exchange the code for tokens + +When the user pastes the callback URL: + +1. Parse `code` and `state` from the query string. +2. **Verify `state` matches the stashed value** (CSRF check — do not skip). +3. POST `application/x-www-form-urlencoded` to the `token_endpoint`: + - `grant_type=authorization_code` + - `code=` + - `redirect_uri=` + - `client_id=` + - `code_verifier=` + - `resource=` (if the AS required it in step 5, include here too) +4. Response contains `access_token`, `refresh_token`, `token_type`, `expires_in`, `scope`. + +### 8. Write tokens in Hermes' exact schema + +`tools/mcp_oauth.py::HermesTokenStorage` expects two files under +`$HERMES_HOME/mcp-tokens/` (create dir with `0o700`, files with `0o600`): + +**`.json`** — the `OAuthToken` pydantic model: +```json +{ + "access_token": "...", + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": "...", + "scope": "read write" +} +``` + +**`.client.json`** — the `OAuthClientInformationFull` model: +```json +{ + "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": "..." +} +``` + +Write each file via `json.dumps(..., indent=2)`. Sanitize the filename with +`re.sub(r'[^\w\-]', '_', server_name)[:128]` — this matches `_safe_filename()` in +Hermes' token storage. + +### 9. Add the server to config.yaml + +```yaml +mcp_servers: + : + url: "https://mcp.example.com" + auth: oauth + timeout: 180 + connect_timeout: 60 +``` + +### 10. Smoke-test the token BEFORE asking the user to reload + +Manually POST an MCP `initialize` request to confirm the token works end-to-end — +this catches scope misconfigurations, wrong `resource` values, and CF blocks +before the user is confused by another "No MCP tools available" reload: + +```python +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 to the MCP URL with: +# Authorization: Bearer +# Accept: application/json, text/event-stream +# Content-Type: application/json +# MCP-Protocol-Version: 2025-06-18 +# User-Agent: python-httpx/0.27 +``` + +Expect HTTP 200 with `Content-Type: text/event-stream` and a JSON-RPC result +containing `serverInfo` and `capabilities`. **Do not use `urllib` with its default +UA** — Cloudflare will 403 you even though Hermes (which uses httpx) will succeed. +`scripts/diagnose-oauth-mcp.py` automates this smoke test. + +### 11. Tell the user to run `/reload-mcp` + +On reload, Hermes sees `auth: oauth`, calls `HermesTokenStorage.get_tokens()`, +finds your cached tokens, skips the browser flow, and registers `mcp__*` +tools. Refresh happens automatically before `expires_in` elapses. + +## Pitfalls & Lessons Learned + +1. **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. + +2. **Read the source, not just the skill docs.** `tools/mcp_oauth.py` and the MCP config reference in `website/docs/` are the authoritative references. Grep the tree before telling the user a feature "doesn't exist." + +3. **Cloudflare UA filter.** Many MCP/OAuth providers front their infra with Cloudflare, which 403s `python-urllib/*` user agents on metadata endpoints even though those endpoints are public. Set `User-Agent: python-httpx/0.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. + +4. **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/audience error. + +5. **Trailing slash matters.** Some servers advertise the resource as `https://mcp.example.com/` with a trailing slash and reject tokens issued against the no-slash variant. Copy the `resource` value verbatim from the `.well-known/oauth-protected-resource` response. + +6. **`/reload-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. + +7. **Circuit breaker can survive `/reload-mcp`.** `tools/mcp_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 `/reload-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." + +8. **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 `/reload-mcp`. + +9. **`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/expired 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/expiry cycle, more durable for an unattended remote gateway. Detect early: before any create/update 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. + +10. **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`/"Session expired" → stop, this is session revocation, hand the user the authorize URL for a full re-auth. `scripts/diagnose-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/stripe-mcp-oauth-revocation.md` for a worked example of a provider that revokes weekly. + +11. **Client info file is NOT optional.** Hermes needs `.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. + +12. **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. + +13. **Security: the stash file contains the `code_verifier`.** Delete `/tmp/.mcp-oauth-work/.json` immediately after successful token exchange. There's no reason to keep a proof-of-identity secret around once it's consumed. + +14. **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 `.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. + +15. **OAuth tokens often double as Bearer tokens against the provider's public REST API.** The access_token in `.json` is frequently not "MCP-only" — `Authorization: Bearer ` 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. + +16. **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. + +17. **GitHub MCP (`api.githubcopilot.com/mcp/`) 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://github.com/login/oauth/access_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. + +## What NOT to do + +- **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). +- **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. +- **Don't claim Hermes doesn't support a feature without reading the source.** Grep the source tree before making capability claims. + +## Quick Reference Files + +- `scripts/diagnose-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/restart, `REFRESH_FIXED` = persist+restart, `SESSION_REVOKED` = full re-auth, `REFRESH_DEAD` = full re-auth/API 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/9/10 decision tree. +- `references/stripe-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. + +## Related + +- `native-mcp` — general guide to configuring MCP in Hermes. Authoritative config reference lives there. +- `mcporter` — the external CLI bridge, for ad-hoc MCP calls outside of Hermes' config. diff --git a/skills/mcp/mcp-oauth-remote-gateway/references/stripe-mcp-oauth-revocation.md b/skills/mcp/mcp-oauth-remote-gateway/references/stripe-mcp-oauth-revocation.md new file mode 100644 index 000000000000..56b55183134e --- /dev/null +++ b/skills/mcp/mcp-oauth-remote-gateway/references/stripe-mcp-oauth-revocation.md @@ -0,0 +1,62 @@ +# Stripe MCP (`mcp.stripe.com`) — recurring OAuth session revocation, fix with a restricted key + +A worked example of pitfall 9/10 in SKILL.md: a provider whose OAuth session dies +on a recurring basis, where the durable fix is to drop OAuth for a static API key. + +## Symptom (the "dies after a while" complaint) +Stripe MCP works for a few days then goes "not connected." Auto-refresh is healthy +in between (the access token is 1h-lived and rotates fine), so it LOOKS like a +refresh-token expiry or a max-session cap — it is neither. Roughly weekly, Stripe +**revokes the entire OAuth grant server-side**. The next `grant_type=refresh_token` +POST returns: + +``` +HTTP 400 {"error":"invalid_grant","error_description":"Invalid refresh token"} +``` + +The whole grant is dead — not just the short-lived access token — so auto-refresh +cannot recover it. It requires a fresh interactive browser consent flow, which a +headless remote gateway cannot drive. Don't be fooled by a green smoke-test at any +given moment: the failure is intermittent revocation, not a permanently broken token. + +## Why the three usual hypotheses are all wrong +Per Stripe's OAuth docs (https://docs.stripe.com/stripe-apps/api-authentication/oauth): +- **Access tokens** expire in **1 hour**. +- **Refresh tokens** expire after **1 year**, and are **rolled on every exchange** — so + as long as you refresh at least once a year they never naturally expire. +- Hermes auto-refreshes independently of whether you call Stripe tools, so "not using + the tools enough" is irrelevant. + +So a *recurring* death cannot be refresh-token expiry (1yr) or "max OAuth session +length" (no clean documented cap). It is server-side session revocation. Do NOT +loop on refresh. + +## The durable fix: drop OAuth, use a restricted API key as a Bearer token +Stripe's MCP docs (https://docs.stripe.com/mcp) are explicit that for +non-interactive / agent use, OAuth is the wrong tool — `mcp.stripe.com` accepts a +**static restricted key** (`rk_live_...`) as a Bearer token. A restricted key has +**no session, no refresh, no expiry** — it works until revoked, ending the re-auth +cycle entirely. + +config.yaml change (no token files needed — delete the OAuth dance for this server): +```yaml +mcp_servers: + stripe: + url: https://mcp.stripe.com + headers: + Authorization: *** rk_live_..." # restricted key from Dashboard + # Stripe-Account: "acct_xxx" # only for Connect platform / connected-account calls +``` + +Generate the key in Stripe Dashboard → Developers → API keys → **Restricted keys**. +Grant least-privilege scopes for what the bot actually does: +- account reads: **read** on Charges, Customers, Subscriptions, Coupons/Promotion codes +- refunds / writes: add the corresponding **write** scopes +Then `/reload-mcp` (full restart only if the breaker is tripped, per pitfall 7). + +## Decision rule +For ANY unattended remote-gateway MCP server that keeps getting its session revoked +(`invalid_grant` on refresh, or `-32002 "Session expired"` after a successful refresh), +and whose provider offers a static API key — prefer the static key over OAuth. OAuth's +refresh dance is for interactive clients; it is a liability for a headless gateway. +Stripe (restricted key) and Linear (Personal API key) both fit this rule. diff --git a/skills/mcp/mcp-oauth-remote-gateway/scripts/diagnose-oauth-mcp.py b/skills/mcp/mcp-oauth-remote-gateway/scripts/diagnose-oauth-mcp.py new file mode 100644 index 000000000000..a5df26255870 --- /dev/null +++ b/skills/mcp/mcp-oauth-remote-gateway/scripts/diagnose-oauth-mcp.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Diagnose an OAuth-gated remote MCP server's connection state. + +Decides which recovery branch you're in WITHOUT mutating disk by default: + 1. Smoke-test the stored access_token against the MCP endpoint (initialize). + 2. If 401, attempt a refresh and smoke-test the freshly-minted token. + +Branches printed at the end: + TOKEN_OK -> stored token works; "not connected" is the circuit + breaker (SKILL pitfall 7) -> restart the gateway. + REFRESH_FIXED -> refresh minted a working token; pass --write to persist + it (atomic, 0600), then restart to clear the breaker. + SESSION_REVOKED -> refresh succeeds but new token STILL gets -32002 + "Session expired" (pitfall 10) -> full interactive + re-auth required; refresh loop will NOT help. + REFRESH_DEAD -> refresh grant itself returns invalid_grant (pitfall 9) + -> full interactive re-auth, or switch to a static API key. + +Usage: + python3 diagnose-oauth-mcp.py [--mcp-url URL] [--token-endpoint URL] [--write] + + matches the files in $HERMES_HOME/mcp-tokens/.json etc. + If --mcp-url / --token-endpoint are omitted, they're read from the token's + `resource` field and the AS .well-known metadata respectively. + +NEVER prints secret values — only lengths, scope, expiry, and HTTP status. +""" +import json, os, sys, time, argparse, urllib.request, urllib.error, urllib.parse + +UA = "python-httpx/0.27" # CF blocks default urllib UA on many providers + + +def _hermes_home(): + # Prefer Hermes' own resolver (profile-safe); fall back to env then ~/.hermes. + try: + from hermes_constants import get_hermes_home + return str(get_hermes_home()) + except Exception: + return os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes") + + +def _tokens_dir(): + return os.path.join(_hermes_home(), "mcp-tokens") + + +def _post(url, data=None, headers=None, form=False, timeout=30): + if form: + body = urllib.parse.urlencode(data).encode() + else: + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request(url, data=body, method="POST") + for k, v in (headers or {}).items(): + req.add_header(k, v) + req.add_header("User-Agent", UA) + try: + r = urllib.request.urlopen(req, timeout=timeout) + return r.status, dict(r.headers), r.read() + except urllib.error.HTTPError as e: + return e.code, dict(e.headers), e.read() + + +def _get_json(url, timeout=20): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + return json.loads(urllib.request.urlopen(req, timeout=timeout).read()) + + +def _mcp_initialize(mcp_url, access_token): + status, hdrs, body = _post( + mcp_url, + data={ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "hermes-diag", "version": "1.0"}}, + }, + headers={ + "Authorization": "Bearer " + access_token, + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + ) + txt = body[:400].decode(errors="replace") + ok = status == 200 and ("serverInfo" in txt or '"result"' in txt) + expired = "-32002" in txt or "Session expired" in txt or "invalid_token" in (hdrs.get("WWW-Authenticate") or "") + return ok, expired, status, txt + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("server") + ap.add_argument("--mcp-url") + ap.add_argument("--token-endpoint") + ap.add_argument("--write", action="store_true", help="persist a working refreshed token") + args = ap.parse_args() + + tdir = _tokens_dir() + tpath = os.path.join(tdir, args.server + ".json") + cpath = os.path.join(tdir, args.server + ".client.json") + tok = json.load(open(tpath)) + client = json.load(open(cpath)) + + mcp_url = args.mcp_url or tok.get("resource") + if not mcp_url: + print("FATAL: no --mcp-url and no `resource` in token file"); sys.exit(2) + resource = tok.get("resource") or mcp_url + + print(f"server={args.server} mcp_url={mcp_url}") + exp = tok.get("expires_at") + if isinstance(exp, (int, float)): + print(f"stored expires_at in {round((exp - time.time())/60)} min") + + # Step 1: stored token + ok, expired, status, txt = _mcp_initialize(mcp_url, tok["access_token"]) + print(f"[1] stored-token initialize -> HTTP {status} ok={ok} expired={expired}") + if ok: + print("BRANCH=TOKEN_OK -> stored token works; 'not connected' is the breaker (7). Restart the gateway.") + return + + # Step 2: refresh + if not tok.get("refresh_token"): + print("BRANCH=REFRESH_DEAD -> no refresh_token present; full re-auth required.") + return + token_ep = args.token_endpoint + if not token_ep: + # derive AS metadata from the host root + host = urllib.parse.urlsplit(mcp_url) + as_url = f"{host.scheme}://{host.netloc}/.well-known/oauth-authorization-server" + try: + token_ep = _get_json(as_url)["token_endpoint"] + except Exception as e: + print(f"FATAL: cannot discover token_endpoint ({e}); pass --token-endpoint"); sys.exit(2) + print(f"[2] token_endpoint={token_ep}") + + rstatus, _rh, rbody = _post( + token_ep, form=True, + data={"grant_type": "refresh_token", "refresh_token": tok["refresh_token"], + "client_id": client["client_id"], "resource": resource}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if rstatus != 200: + print(f"[2] refresh -> HTTP {rstatus} {rbody[:200].decode(errors='replace')}") + print("BRANCH=REFRESH_DEAD -> refresh grant rejected (9). Full re-auth or switch to static API key.") + return + j = json.loads(rbody) + new_at = j["access_token"] + print(f"[2] refresh -> HTTP 200 new_token_len={len(new_at)} scope={j.get('scope')} " + f"expires_in={j.get('expires_in')} rotated_refresh={bool(j.get('refresh_token'))}") + + # Step 2b: smoke-test the freshly-minted token + ok2, expired2, status2, txt2 = _mcp_initialize(mcp_url, new_at) + print(f"[2b] new-token initialize -> HTTP {status2} ok={ok2} expired={expired2}") + if ok2: + if args.write: + new = dict(tok) + new.update({"access_token": new_at, "token_type": j.get("token_type", "Bearer"), + "expires_in": j.get("expires_in", tok.get("expires_in")), + "scope": j.get("scope", tok.get("scope")), + "expires_at": time.time() + float(j.get("expires_in", 3600))}) + if j.get("refresh_token"): + new["refresh_token"] = j["refresh_token"] + tmp = tpath + ".tmp" + open(tmp, "w").write(json.dumps(new, indent=2)) + os.chmod(tmp, 0o600) + os.replace(tmp, tpath) + print(f" wrote {tpath} (0600). NOW RESTART the gateway to clear the breaker.") + print("BRANCH=REFRESH_FIXED -> refreshed token works. Persist (--write) + restart gateway.") + return + + if expired2: + print("BRANCH=SESSION_REVOKED -> refresh succeeds but new token STILL -32002 'Session expired' (10).") + print(" Refresh loop will NOT help. Full interactive authorization_code re-auth required.") + print(" For an unattended gateway, prefer a static Personal API key instead.") + return + print(f"BRANCH=UNKNOWN -> new token failed for a non-session reason: {txt2[:200]}") + + +if __name__ == "__main__": + main()