diff --git a/evals/mcp_device_flow.py b/evals/mcp_device_flow.py new file mode 100644 index 0000000000..5b9ea60208 --- /dev/null +++ b/evals/mcp_device_flow.py @@ -0,0 +1,148 @@ +"""Local RFC 8628 wire fixture; no external credentials or provider claims. + +Run: python evals/mcp_device_flow.py --repo /path/to/checkout +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +from urllib.parse import parse_qs + +DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" + + +@contextmanager +def oauth_fixture(mode="success"): + wire = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def reply(self, status, data, headers=None): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + for key, value in (headers or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): # noqa: N802 + wire.append({"path": self.path, "method": "GET"}) + if self.path == "/mcp": + if self.headers.get("Authorization") == "Bearer fixture-access": + return self.reply(200, {"authenticated": True}) + return self.reply(401, {}, {"WWW-Authenticate": f'Bearer resource_metadata="{base}/prm"'}) + if self.path == "/prm" or "oauth-protected-resource" in self.path: + return self.reply(200, {"resource": base + ("/wrong" if mode == "resource" else "/mcp"), + "authorization_servers": [base]}) + if "oauth-authorization-server" in self.path: + metadata = {"issuer": base + ("/wrong" if mode == "issuer" else ""), + "authorization_endpoint": base + "/authorize", "token_endpoint": base + "/token", + "response_types_supported": ["code"], "code_challenge_methods_supported": ["S256"], + "grant_types_supported": [DEVICE_GRANT, "refresh_token"]} + if mode != "unsupported": + metadata["device_authorization_endpoint"] = base + "/device" + if mode != "preregistered": + metadata["registration_endpoint"] = base + "/register" + else: + metadata.pop("authorization_endpoint") + return self.reply(200, metadata) + self.reply(404, {}) + + def do_POST(self): # noqa: N802 + raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) + data = json.loads(raw) if "json" in self.headers.get("Content-Type", "") else { + key: value[0] for key, value in parse_qs(raw.decode()).items()} + wire.append({"path": self.path, "method": "POST", "data": data, "at": time.monotonic()}) + if self.path == "/register": + if DEVICE_GRANT not in data.get("grant_types", []): + return self.reply(400, {"error": "invalid_client_metadata"}) + return self.reply(201, {**data, "client_id": "fixture-client"}) + if self.path == "/device": + return self.reply(200, {"device_code": "fixture-device-secret", "user_code": "TEST-CODE", + "verification_uri": base + "/verify", "interval": 0.01, + "expires_in": 0.1 if mode == "expiry" else 30}) + if self.path == "/token": + polls = sum(row["path"] == "/token" for row in wire) + if mode == "preregistered" and data.get("client_secret") != "fixture-client-secret": + return self.reply(401, {"error": "invalid_client"}) + if mode in {"denied", "expiry", "malformed"}: + if mode == "malformed": + return self.reply(200, {"access_token": {"secret": "fixture-device-secret"}}) + return self.reply(400, {"error": "access_denied" if mode == "denied" else "authorization_pending", + "error_description": "fixture-device-secret MUST NOT BE PRINTED"}) + if polls <= 2 and mode == "success": + return self.reply(400, {"error": "authorization_pending" if polls == 1 else "slow_down"}) + return self.reply(200, {"access_token": "fixture-access", "refresh_token": "fixture-refresh", + "token_type": "Bearer", "expires_in": 3600}) + if self.path == "/mcp": + if self.headers.get("Authorization") != "Bearer fixture-access": + return self.reply(401, {}, {"WWW-Authenticate": f'Bearer resource_metadata="{base}/prm"'}) + method = data.get("method") + if "id" not in data: + return self.reply(202, {}) + result = {"initialize": {"protocolVersion": data.get("params", {}).get("protocolVersion"), + "capabilities": {"tools": {}}, + "serverInfo": {"name": "fixture", "version": "1"}}, + "tools/list": {"tools": [{"name": "fixture_echo", "description": "Fixture tool", + "inputSchema": {"type": "object", "properties": {}}}]}} + return self.reply(200, {"jsonrpc": "2.0", "id": data["id"], "result": result.get(method, {})}) + self.reply(404, {}) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + base = f"http://127.0.0.1:{server.server_port}" + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + yield base, wire + finally: + server.shutdown() + server.server_close() + worker.join() + + +def run_cli(repo, mode): + with tempfile.TemporaryDirectory(prefix="hermes-device-wire-") as directory, oauth_fixture(mode) as (base, wire): + home = Path(directory) + oauth = {"flow": "device", "cimd": False, "scope": "fixture.read", "timeout": 15} + if mode == "preregistered": + oauth.update(client_id="fixture-client", client_secret="fixture-client-secret") + config = {"mcp_servers": {"fixture": {"url": base + "/mcp", "auth": "oauth", "oauth": oauth}}} + (home / "config.yaml").write_text(json.dumps(config)) + env = {key: value for key, value in os.environ.items() + if not key.startswith("HERMES_") and not any(part in key for part in ("API_KEY", "TOKEN", "SECRET"))} + env.update(HOME=str(home), HERMES_HOME=str(home), PYTHONPATH=str(repo), PYTHONDONTWRITEBYTECODE="1") + command = ["reauth", "fixture"] if mode == "preregistered" else ["login", "fixture", "--flow", "device"] + result = subprocess.run([sys.executable, "-m", "hermes_cli.main", "mcp", *command], + cwd=repo, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=40) + token_path = home / "mcp-tokens" / "fixture.json" + refresh_output = None + if token_path.exists(): + tokens = json.loads(token_path.read_text()) + tokens["expires_at"] = time.time() - 60 + token_path.write_text(json.dumps(tokens)) + refreshed = subprocess.run([sys.executable, "-m", "hermes_cli.main", "mcp", "test", "fixture"], + cwd=repo, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30) + refresh_output = refreshed.stdout + refreshed.stderr + return {"mode": mode, "returncode": result.returncode, "output": result.stdout + result.stderr, + "token_persisted": token_path.exists(), "refresh_output": refresh_output, "wire": wire} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--repo", type=Path, required=True) + parser.add_argument("--mode", default="success") + args = parser.parse_args() + print(json.dumps(run_cli(args.repo, args.mode), indent=2)) diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index c2ba4df047..22a011f55b 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -595,7 +595,7 @@ def cmd_mcp_test(args): headers = cfg.get("headers", {}) if cfg.get("auth", "") == "oauth": - _info("Auth: OAuth 2.1 PKCE") + _info("Auth: OAuth 2.0") elif headers: for k, v in headers.items(): if isinstance(v, str) and ("key" in k.lower() or "auth" in k.lower()): @@ -620,11 +620,11 @@ def cmd_mcp_test(args): print() -def _reauth_oauth_server(name: str, server_config: dict) -> bool: +def _reauth_oauth_server(name: str, server_config: dict, *, flow: str | None = None) -> bool: """Force a fresh OAuth flow for one server. Returns True on success. - Wipes cached OAuth state (disk + in-process MCPOAuthManager cache), re-probes to trigger the - browser flow, and verifies a token actually landed. Shared by ``login`` and ``reauth``. + Browser login clears cached state and re-probes. Device login replaces state only after + approval. Both verify a token landed. Shared by ``login`` and ``reauth``. """ url = server_config.get("url") if not url: @@ -635,9 +635,15 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool: _info("Use `hermes mcp remove` + `hermes mcp add` to reconfigure auth.") return False + oauth_cfg = server_config.get("oauth") or {} + selected_flow = flow or oauth_cfg.get("flow", "browser") + if selected_flow not in {"browser", "device"}: + _error("oauth.flow must be browser or device") + return False try: from tools.mcp_oauth_manager import get_manager - get_manager().remove(name) + if selected_flow == "browser": + get_manager().remove(name) except Exception as exc: _warning(f"Could not clear existing OAuth state: {exc}") @@ -656,9 +662,13 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool: _login_connect_timeout = float(server_config.get("connect_timeout")) except (TypeError, ValueError): _login_connect_timeout = 0.0 + if selected_flow == "device": + from tools.mcp_oauth_device import login_device + asyncio.run(login_device(name, url, oauth_cfg)) + probe_config = {**server_config, "oauth": {**oauth_cfg, "flow": selected_flow}} with force_interactive_oauth(): tools = _probe_single_server( - name, server_config, connect_timeout=max(_login_connect_timeout, 315.0) + name, probe_config, connect_timeout=max(_login_connect_timeout, 315.0) ) # A clean probe is NOT proof of authentication: some servers (e.g. Google Drive) serve # initialize + tools/list without auth, so the flow may have failed (e.g. DCR 400 for @@ -697,10 +707,10 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool: def cmd_mcp_login(args): - """Force re-authentication for an OAuth-based MCP server (wipes cached tokens, re-runs the flow).""" + """Run an explicit browser or device authorization for an OAuth-based MCP server.""" cfg = _lookup_server(args.name, _get_mcp_servers()) if cfg is not None: - _reauth_oauth_server(args.name, cfg) + _reauth_oauth_server(args.name, cfg, flow=getattr(args, "flow", None)) def cmd_mcp_reauth(args): diff --git a/hermes_cli/subcommands/mcp.py b/hermes_cli/subcommands/mcp.py index 8f97cb2d6d..05bd593fe8 100644 --- a/hermes_cli/subcommands/mcp.py +++ b/hermes_cli/subcommands/mcp.py @@ -55,6 +55,9 @@ def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None: mcp_login_p = mcp_sub.add_parser( "login", help="Force re-authentication for an OAuth-based MCP server") mcp_login_p.add_argument("name", help="Server name to re-authenticate") + mcp_login_p.add_argument( + "--flow", choices=["browser", "device"], default=None, + help="OAuth flow (overrides oauth.flow): browser PKCE or RFC 8628 device code") mcp_reauth_p = mcp_sub.add_parser( "reauth", help="Re-authenticate one OAuth MCP server, or all of them (--all)") diff --git a/tests/tools/test_mcp_device_flow.py b/tests/tools/test_mcp_device_flow.py new file mode 100644 index 0000000000..8bbd830d18 --- /dev/null +++ b/tests/tools/test_mcp_device_flow.py @@ -0,0 +1,34 @@ +"""RFC 8628 login invariants through the production CLI and local HTTP.""" +from pathlib import Path + +import pytest + +from evals.mcp_device_flow import DEVICE_GRANT, run_cli + + +@pytest.mark.parametrize("mode", ["success", "preregistered"]) +def test_device_login_registers_authorizes_and_persists(mode): + result = run_cli(Path(__file__).resolve().parents[2], mode) + assert result["token_persisted"], result + assert "TEST-CODE" in result["output"] + assert "Authenticated" in result["output"] + tokens = [row for row in result["wire"] if row["path"] == "/token"] + polls = [row for row in tokens if row["data"]["grant_type"] != "refresh_token"] + assert any(row["data"]["grant_type"] == "refresh_token" for row in tokens), result + assert "Connected" in result["refresh_output"], result + assert all(row["data"]["grant_type"] == DEVICE_GRANT for row in polls) + assert all(row["data"]["resource"].endswith("/mcp") for row in polls) + if mode == "success": + assert polls[2]["at"] - polls[1]["at"] >= 5 + else: + assert not any(row["path"] == "/register" for row in result["wire"]) + + +@pytest.mark.parametrize("mode", ["denied", "expiry", "unsupported", "issuer", "resource", "malformed"]) +def test_device_login_failure_does_not_persist_or_disclose_credentials(mode): + result = run_cli(Path(__file__).resolve().parents[2], mode) + assert "unrecognized arguments" not in result["output"], result + assert not result["token_persisted"], result + assert "Authentication failed" in result["output"], result + assert "fixture-device-secret" not in result["output"], result + assert "Authenticated" not in result["output"], result diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index 0ba5854dde..c286cdac62 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -295,6 +295,9 @@ class HermesTokenStorage: ``fixup(data)`` may rewrite the raw dict before validation.""" data = _read_json(path) cls = _sdk_class(sdk_name) if data is not None else None + if cls is not None and sdk_name == "OAuthMetadata" and data.get("device_authorization_endpoint"): + from tools.mcp_oauth_device import DeviceOAuthMetadata + cls = DeviceOAuthMetadata if cls is None: return None if fixup is not None: diff --git a/tools/mcp_oauth_device.py b/tools/mcp_oauth_device.py new file mode 100644 index 0000000000..d4f9be3e58 --- /dev/null +++ b/tools/mcp_oauth_device.py @@ -0,0 +1,193 @@ +"""Explicit RFC 8628 MCP login, sharing SDK discovery, client auth and token storage. + +The SDK still owns runtime requests and refresh. Device authorization is only +started by `hermes mcp login/reauth`, never a background reconnect. +""" +from __future__ import annotations + +import asyncio +import math +import sys +import time + +from mcp.shared.auth import OAuthMetadata +from pydantic import AnyHttpUrl + +DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" + + +class DeviceOAuthMetadata(OAuthMetadata): + # RFC 8414 makes authorization_endpoint optional for grants not using it. + authorization_endpoint: AnyHttpUrl | None = None + device_authorization_endpoint: AnyHttpUrl + + +async def _discover(client, provider): + from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, + extract_resource_metadata_from_www_auth, + handle_protected_resource_response, + validate_metadata_issuer, + ) + context = provider.context + response = await client.get(context.server_url) + challenge = extract_resource_metadata_from_www_auth(response) + for url in build_protected_resource_metadata_discovery_urls(challenge, context.server_url): + response = await client.get(url) + prm = await handle_protected_resource_response(response) + if prm: + await provider._validate_resource_match(prm) + context.protected_resource_metadata = prm + context.auth_server_url = str(prm.authorization_servers[0]) + break + for url in build_oauth_authorization_server_metadata_discovery_urls(context.auth_server_url, context.server_url): + response = await client.get(url) + if response.status_code == 404: + continue + data = _payload(response, "OAuth metadata") + if not data.get("device_authorization_endpoint"): + raise RuntimeError("Server does not advertise device authorization; use --flow browser if supported") + metadata = DeviceOAuthMetadata.model_validate(data) + if context.auth_server_url: + validate_metadata_issuer(metadata, context.auth_server_url) + grants = metadata.grant_types_supported + if grants is not None and DEVICE_GRANT not in grants: + raise RuntimeError("Server does not advertise the device_code grant") + context.oauth_metadata = metadata + return + raise RuntimeError("No OAuth authorization server metadata found") + + +def _payload(response, label): + try: + data = response.json() + except ValueError: + raise RuntimeError(f"{label}: invalid JSON response") from None + if not isinstance(data, dict): + raise RuntimeError(f"{label}: expected a JSON object") + if not 200 <= response.status_code < 300: + # Descriptions and arbitrary error values may contain credentials. + raise RuntimeError(f"{label} failed (HTTP {response.status_code})") + return data + + +async def _register(client, provider, cfg): + from mcp.shared.auth import OAuthClientInformationFull + from mcp.client.auth.oauth2 import OAuthRegistrationError, check_registration_usable + + context = provider.context + metadata = context.client_metadata.model_dump(mode="json", exclude_none=True) + metadata.update(grant_types=[DEVICE_GRANT, "refresh_token"], response_types=[]) + if cfg.get("client_id"): + data = {**metadata, "client_id": cfg["client_id"]} + if cfg.get("client_secret"): + data["client_secret"] = cfg["client_secret"] + else: + endpoint = context.oauth_metadata.registration_endpoint + if not endpoint: + raise RuntimeError("Server has no registration endpoint; configure oauth.client_id (and client_secret if required)") + response = await client.post(str(endpoint), json=metadata) + data = _payload(response, "Client registration") + data["issuer"] = str(context.oauth_metadata.issuer) + context.client_info = OAuthClientInformationFull.model_validate(data) + provider._coerce_client_secret_post() + try: + check_registration_usable(context.client_info) + except OAuthRegistrationError: + raise RuntimeError("Device OAuth client has unsupported or incomplete token endpoint authentication") from None + + +def _positive_seconds(value, label): + value = float(value) + if not math.isfinite(value) or value <= 0: + raise RuntimeError(f"Device authorization has invalid {label}") + return value + + +async def _authorize(client, provider, cfg): + from tools.mcp_tool import sdk_httpx + + context = provider.context + resource = context.get_resource_url() + data = {"client_id": context.client_info.client_id, "resource": resource} + if context.client_metadata.scope: + data["scope"] = context.client_metadata.scope + data, headers = context.prepare_token_auth(data, {}) + response = await client.post(str(context.oauth_metadata.device_authorization_endpoint), data=data, headers=headers) + authorization = _payload(response, "Device authorization") + for key in ("device_code", "user_code", "verification_uri"): + if not isinstance(authorization.get(key), str) or not authorization[key]: + raise RuntimeError(f"Device authorization is missing {key}") + verification = AnyHttpUrl(authorization["verification_uri"]) + interval = _positive_seconds(authorization.get("interval", 5), "interval") + deadline = time.monotonic() + min(_positive_seconds(authorization["expires_in"], "expires_in"), + _positive_seconds(cfg.get("timeout", 300), "timeout")) + print(f"\n MCP OAuth: open {verification} on any device.\n Code: {authorization['user_code']}\n" + " Waiting for approval...\n", file=sys.stderr, flush=True) + token_data = {"client_id": context.client_info.client_id, "device_code": authorization["device_code"], + "grant_type": DEVICE_GRANT, "resource": resource} + token_data, headers = context.prepare_token_auth(token_data, {}) + httpx = sdk_httpx() + while True: + remaining = deadline - time.monotonic() + if remaining <= interval: + raise RuntimeError("Device authorization expired before approval; run login again") + await asyncio.sleep(interval) + request = provider._prepare_token_request(httpx.Request("POST", str(context.oauth_metadata.token_endpoint), + data=token_data, headers=headers)) + try: + response = await asyncio.wait_for(client.send(request), timeout=deadline - time.monotonic()) + except (TimeoutError, httpx.TimeoutException): + # RFC 8628 requires reducing polling frequency after connection timeouts. + interval *= 2 + continue + if 200 <= response.status_code < 300: + from mcp.shared.auth import OAuthToken + tokens = OAuthToken.model_validate(_payload(response, "Device token")) + if not tokens.access_token: + raise RuntimeError("Device token response has no access token") + if tokens.scope is None: + tokens.scope = context.client_metadata.scope + return tokens + try: + error = response.json().get("error") + except (ValueError, AttributeError): + error = None + if error == "authorization_pending": + continue + if error == "slow_down": + interval += 5 + continue + safe_error = error if error in {"access_denied", "expired_token"} else f"HTTP {response.status_code}" + raise RuntimeError(f"Device authorization failed: {safe_error}") + + +async def login_device(name, server_url, oauth_config): + """Authorize then commit state in the active profile; failed grants preserve old state.""" + from tools.mcp_oauth import _build_client_metadata + from tools.mcp_oauth_manager import HermesMCPOAuthProvider, get_manager + from tools.mcp_oauth_provider import prepare_oauth_config + from tools.mcp_tool import sdk_httpx + + cfg, storage = prepare_oauth_config(name, server_url, oauth_config) + # Device flow never binds a callback socket or uses the hosted browser CIMD. + cfg["_resolved_port"] = cfg.get("redirect_port", 8420) + provider = HermesMCPOAuthProvider(server_url=server_url, server_name=name, storage=storage, + client_metadata=_build_client_metadata(cfg), + token_user_agent=cfg.get("user_agent")) + httpx = sdk_httpx() + try: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + await _discover(client, provider) + await _register(client, provider, cfg) + tokens = await _authorize(client, provider, cfg) + except (ValueError, TypeError, KeyError): + raise RuntimeError("Device OAuth response has invalid fields") from None + except httpx.HTTPError: + raise RuntimeError("Device OAuth network request failed") from None + # Validate the entire grant before touching disk; reuse the existing scoped store. + await storage.set_client_info(provider.context.client_info) + storage.save_oauth_metadata(provider.context.oauth_metadata) + await storage.set_tokens(tokens) + get_manager().evict(name) diff --git a/tools/mcp_oauth_provider.py b/tools/mcp_oauth_provider.py index 38757caa9a..08f3ed5535 100644 --- a/tools/mcp_oauth_provider.py +++ b/tools/mcp_oauth_provider.py @@ -29,12 +29,24 @@ class HermesProviderMixin: _hermes_logger: logging.Logger = logger - def __init__(self, *args: Any, token_user_agent: str | None = None, **kwargs: Any): + def __init__(self, *args: Any, token_user_agent: str | None = None, oauth_flow: str = "browser", **kwargs: Any): super().__init__(*args, **kwargs) + self._hermes_oauth_flow = oauth_flow # oauth.user_agent — stamped onto token-endpoint requests only; some authorization servers/WAFs # reject httpx's default (#75576). self._hermes_token_user_agent = token_user_agent + async def _perform_authorization(self): + info = self.context.client_info + grants = getattr(info, "grant_types", None) or [] + if (getattr(self, "_hermes_oauth_flow", "browser") == "device" + or ("urn:ietf:params:oauth:grant-type:device_code" in grants and "authorization_code" not in grants)): + from tools.mcp_oauth import OAuthNonInteractiveError + raise OAuthNonInteractiveError( + "MCP device authorization requires `hermes mcp login --flow device`; " + "background reconnects cannot start a device login") + return await super()._perform_authorization() + def _prepare_token_request(self, request): """Stamp the configured User-Agent onto a token/refresh request.""" ua = getattr(self, "_hermes_token_user_agent", None) # tests build via __new__ @@ -127,4 +139,5 @@ def build_provider_kwargs(cfg: dict, storage: "HermesTokenStorage", *, ssh_proxy # `oauth.timeout` bounds the callback waiter's poll loop instead. "callback_handler": mo._make_callback_waiter(port, cfg.get("_cimd_url"), timeout=float(cfg.get("timeout", 300))), "token_user_agent": mo.token_request_user_agent(cfg), + "oauth_flow": cfg.get("flow", "browser"), **mo.cimd_provider_kwargs(cfg)} diff --git a/website/docs/reference/mcp-config-reference.md b/website/docs/reference/mcp-config-reference.md index 35bb31e64d..f1e4fbdba8 100644 --- a/website/docs/reference/mcp-config-reference.md +++ b/website/docs/reference/mcp-config-reference.md @@ -337,6 +337,38 @@ Behavior: - Token refresh is automatic; re-authorization only happens when refresh fails - Only applies to HTTP/StreamableHTTP transport (`url`-based servers) +### Device-code login (RFC 8628) + +For an authorization server advertising `device_authorization_endpoint`, explicitly choose +device authorization from a terminal on the machine running Hermes: + +```bash +hermes mcp login protected_api --flow device +``` + +Open the printed verification URL on any device and enter the displayed user code. +Hermes polls for approval, respects `authorization_pending` and `slow_down`, and stops +on denial or expiry. No browser is launched and no callback listener is needed. +`oauth.timeout` bounds the approval wait (default 300 seconds), also limited by the code's lifetime. + +Set `oauth.flow: device` on the server to make `hermes mcp login` and `hermes mcp reauth` +(including `reauth --all`) use device authorization. `login --flow browser` overrides that +setting for one login; browser PKCE remains the default. Unsupported metadata produces +an actionable error rather than silently falling back to a different flow. + +Device login requests the device and refresh grants during dynamic registration, or uses +your configured `oauth.client_id`, `oauth.client_secret`, and `oauth.token_endpoint_auth_method`. +The registered client must permit device authorization. The browser CIMD document is not used. +`oauth.scope` is sent on the device authorization request; `oauth.user_agent` also applies +to token polling. Tokens, registration, and issuer metadata stay in the active profile's +MCP token store and the existing runtime refresh path reuses them after a restart. +Failed device grants do not replace previously saved credentials. + +Initial device login is terminal-only: dashboard/browser callbacks and background reconnects +do not start device authorization. Run the login command against the **same profile and host** +as the gateway. An expired/rejected device grant without a usable refresh token requires +another explicit login. + ### Client identification: CIMD and DCR Hermes identifies itself to authorization servers with a **Client ID Metadata Document** (CIMD), the mechanism the MCP `2026-07-28` spec adopted in place of Dynamic Client Registration. The document is published at