OptionalMCPVersion 1.0.0

MCP OAuth on a Remote Hermes Gateway: Manual Token Guide

Manual OAuth for remote MCP servers on headless gateways.

Written by Neura Market from the official Hermes Agent documentation for Mcp Oauth Remote Gateway. Commands, paths, and version numbers are reproduced from the source unchanged.

Read the official documentation

Hermes' built-in MCP OAuth client listens for the callback on 127.0.0.1 inside the Hermes process. That works when Hermes runs on your laptop and fails when it runs in a container, on a VPS, or as a messaging bot, because your browser resolves 127.0.0.1 to your own machine. This skill walks the OAuth flow by hand and writes the resulting tokens into the files Hermes' token storage reads, so a later /reload-mcp finds them and skips the browser step. Reach for it when you are adding an OAuth-protected remote MCP server to a headless gateway and there is no interactive terminal to paste a callback URL into.

What it does

The skill performs the OAuth 2.1 authorization-code flow with PKCE outside of Hermes, then persists the access token, refresh token, and client registration in the exact schema HermesTokenStorage expects under $HERMES_HOME/mcp-tokens/. On the next /reload-mcp, Hermes sees auth: oauth in the server config, calls get_tokens(), finds the cached credentials, and registers the mcp__* tools without opening a browser. Refresh happens automatically before expires_in elapses.

In practice you are doing four things: discovering the authorization server's metadata, registering a public client, generating a PKCE challenge and authorize URL for the user to open in their own browser, and exchanging the pasted callback code for tokens. The skill also covers the diagnostic path when a token that looks healthy is rejected, because that is where most of the operational pain lives.

Before you start

The skill is optional and installed on demand. It targets linux and macos. Version 1.0.0, MIT licensed, authored by Ben Barclay (benbarclay) with Hermes Agent. The skill path is optional-skills/mcp/mcp-oauth-remote-gateway.

All three of these must be true before the manual flow is the right answer:

  1. The MCP server you want to add is remote HTTP and requires OAuth, not a static Bearer token.
  2. Hermes is running as a remote gateway: container, VPS, Docker, or 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, including Better Stack, Linear, Cloudflare, and Datadog. GitHub is the notable exception and is handled separately below.

You will be running shell commands through the terminal tool on the gateway host. The Python steps (PKCE generation, token exchange, file writes) go through execute_code or a terminal python3 invocation. File writes must happen in the same code block as the token exchange; pitfall 16 explains why.

Why the built-in flow fails on a remote gateway

Hermes' native MCP OAuth client (tools/mcp_oauth.py) picks a free local port P, registers a dynamic client with the authorization server using redirect_uri = http://127.0.0.1:P/callback, starts an HTTP server on 127.0.0.1:P inside the Hermes process, prints the authorize URL, and waits for the code at its local endpoint.

When Hermes runs remotely, that 127.0.0.1 is the container's loopback, not the user's. After authorizing, the 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 Hermes, 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.

Try the built-in fallbacks first

Before any manual token surgery, check whether the built-in flow's own escape hatches cover the deployment. When Hermes detects a remote session it prints two options alongside the authorize URL (tools/mcp_oauth.py):

  1. Paste-back. On an interactive TTY, a stdin reader races the HTTP listener. The user authorizes, the browser fails to connect to 127.0.0.1:, and they paste the full address-bar URL (?code=...&state=...) back at the prompt. Works for SSH'd-in CLI sessions.
  2. SSH port-forward. ssh -N -L :127.0.0.1: @ makes the redirect reach the remote listener normally.

Both require an interactive terminal to the Hermes host. The rest of this skill is for when there is no interactive TTY: Hermes running purely as a messaging gateway or bot, where /reload-mcp triggers the flow with nobody at a prompt.

Preferred front door: the Hermes dashboard

A remote Hermes gateway often also runs the dashboard web UI as a separate process (for example hermes dashboard --host 0.0.0.0 --port ; check with ps aux | grep 'hermes dashboard'). It exposes a connector/MCP console with endpoints like /api/mcp/servers, /api/mcp/status, and /connectors, all login-gated. A cookieless curl returning 401 or 302 confirms they exist.

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. 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 is no browser session to the dashboard, such as a pure-chat or 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:

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 and 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, because these env greps sit next to *_TOKEN and *_SECRET vars.

What the dashboard does not fix, and what stays host-side: 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 manual workaround

Do the OAuth dance by hand, then write the tokens into the exact files HermesTokenStorage would have written. On /reload-mcp, Hermes finds cached tokens and skips the browser flow entirely.

1. Confirm it's a remote gateway

env | grep -iE "HERMES|RAILWAY|CONTAINER"
echo "$DISPLAY $WAYLAND_DISPLAY $SSH_CLIENT"

No display plus a remote indicator means 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 will not work.

2. Find HERMES_HOME and the config path

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/"

You need both paths for the rest of the flow. The config file is where the server entry goes in step 9; the tokens directory is where the two JSON files go in step 8.

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:

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:

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.

One pitfall here: 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:

{
  "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": "<scopes_from_resource_metadata>"
}

Omit scope entirely if the AS's scopes_supported is empty; see the step 5 pitfall. Use port 8765 (or any port, since 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, which many servers require 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 (for example ~/.hermes/cache/scratch/.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:
<authorize_url>

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.

Generate the URL programmatically with urllib.parse.urlencode(). Never hand-type it: spaces in scopes and special characters in state break string-concatenated URLs.

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. This is the CSRF check; do not skip it.

  3. POST application/x-www-form-urlencoded to the token_endpoint with:

    • grant_type=authorization_code
    • code=
    • redirect_uri=
    • client_id=
    • code_verifier=
    • resource= (if the AS required it in step 5, include here too)
  4. The 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 the directory with 0o700 and the files with 0o600.

.json holds the OAuthToken pydantic model:

{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 7200,
  "refresh_token": "...",
  "scope": "read write"
}

.client.json holds the OAuthClientInformationFull model:

{
  "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], which matches _safe_filename() in Hermes' token storage.

The 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, so writing both files is the whole point of this skill.

9. Add the server to config.yaml

mcp_servers:
  <name>:
    url: "https://mcp.example.com"
    auth: oauth
    timeout: 180
    connect_timeout: 60

10. Smoke-test the token before asking the user to reload

POST an MCP initialize request manually to confirm the token works end to end. This catches scope misconfigurations, wrong resource values, and Cloudflare blocks before the user is confused by another "No MCP tools available" reload:

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 <access_token>
#   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.

When not to use it

  • Local CLI Hermes. Just set auth: oauth in mcp_servers. and run /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 17.

Two more things to avoid. Do not use mcp-remote as a fallback: it runs an npx subprocess whose OAuth callback server also sits on the remote container's localhost, which is the same problem. mcp-remote only helps when the MCP client does not speak remote HTTP at all, and Hermes does natively. And do not 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, and respect the user's choice to do the extra legwork for rotation-free, scope-limited access.

Limits and gotchas

  1. "Headless" does not mean "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 is not an option.
  2. Read the source, not just the skill docs. tools/mcp_oauth.py and the MCP config reference in website/docs/ are authoritative. Grep the tree before telling the user a feature does not exist.
  3. Cloudflare UA filter. Many MCP and 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 or 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. The circuit breaker can survive /reload-mcp. tools/mcp_tool.py keeps a module-level error-count dict with a small threshold. Once tripped, for example 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, since it is cheap and causes 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 with 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. When the access token has been expired long enough, the refresh token can also be revoked or 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: re-run the full manual OAuth dance (steps 3 through 10), or, if the provider offers a static personal API key, switch to that for no refresh or expiry cycle and more durability on an unattended remote gateway. Detect early: before any create or update operation against an OAuth MCP, check expires_at against 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 means server-side session revocation. 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 and restart to clear the breaker; (3b) new token still gets -32002 or "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 and 2 and prints which branch you are 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. The 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 characters in state break string-concatenated URLs.
  13. Security: the stash file contains the code_verifier. Delete ~/.hermes/cache/scratch/.mcp-oauth-work/.json immediately after a successful token exchange. There is no reason to keep a proof-of-identity secret around once it is 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, so pass narrow scopes for least privilege or enumerate the full set if the user needs everything, and some will not 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, printing the token-exchange response 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, and expires_in, never the secret.
  17. GitHub MCP (api.githubcopilot.com/mcp/) uses a pre-registered confidential OAuth App, not DCR plus 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 and cannot be changed, so the manual listener-port trick does not apply. The user just lets the browser fail to connect on that port and pastes the address-bar URL back.

Quick reference files

  • scripts/diagnose-oauth-mcp.py is a 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 are in: TOKEN_OK means breaker or restart, REFRESH_FIXED means persist and restart, SESSION_REVOKED means full re-auth, REFRESH_DEAD means full re-auth or API key. Pass --write to persist a working refreshed token atomically. It never prints secret values. Run this first when an OAuth MCP server reports "not connected"; it encodes the pitfall 7, 9, and 10 decision tree.
  • references/stripe-mcp-oauth-revocation.md is 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.

What pairs with this

  • native-mcp is the general guide to configuring MCP in Hermes, and the authoritative config reference lives there.
  • mcporter is the external CLI bridge, useful for ad-hoc MCP calls outside of Hermes' config.
  • hermes-agent and fastmcp are listed as related skills in the upstream metadata and are worth reading if you are building or debugging the server side of the connection.

Skills the docs pair this with

More MCP skills