Files
aiturk-hermes-ide/optional-skills/mcp/mcp-oauth-remote-gateway/scripts/diagnose-oauth-mcp.py
T

179 lines
7.6 KiB
Python

#!/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()