mcp-oauth-remote-gateway

Manual OAuth for remote MCP servers on headless gateways.

  • MCP
  • OAuth
  • PKCE
  • Remote-Deployment

Declared platforms: linux · macos

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/mcp/mcp-oauth-remote-gateway'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

scripts/diagnose-oauth-mcp.py

scripts/diagnose-oauth-mcp.pyBrowse 3 files
View on GitHub
← Back to SKILL.md
#!/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 <server_name> [--mcp-url URL] [--token-endpoint URL] [--write]   <server_name> matches the files in $HERMES_HOME/mcp-tokens/<server>.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() 
Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 312.
UA** — Cloudflare will 403 you even though Hermes (which uses httpx) will succeed.`scripts/diagnose-oauth-mcp.py` automates this smoke test.
SKILL.mdView in source ↗
Source excerpt starting at line 341.
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.
SKILL.mdView in source ↗
Source excerpt starting at line 365.
- `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.