179 lines
7.6 KiB
Python
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()
|