diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 61bcce598b6..0f5f55ba359 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -490,7 +490,9 @@ def cmd_mcp_add(args): oauth_ok = False try: from tools.mcp_oauth_manager import get_manager - oauth_auth = get_manager().get_or_build_provider(name, url, None) + oauth_auth = get_manager().get_or_build_provider( + name, url, server_config.get("oauth") + ) if oauth_auth: server_config["auth"] = "oauth" _success("OAuth configured (tokens will be acquired on first connection)") @@ -816,16 +818,24 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool: # an interactive OAuth round-trip. Floor at 315s — the OAuth callback # window (300s in mcp_oauth) plus headroom — matching the GUI re-auth # path in web_server.py so CLI and dashboard behave identically. + # + # force_interactive_oauth: `hermes mcp login` is *explicitly* user- + # initiated even when stdin isn't a TTY (Hermes desktop / agent- + # spawned terminals). Without this, OAuth refuses before opening a + # browser because _is_interactive() only checks sys.stdin.isatty(). try: + from tools.mcp_oauth import force_interactive_oauth + _login_connect_timeout = server_config.get("connect_timeout") try: _login_connect_timeout = float(_login_connect_timeout) except (TypeError, ValueError): _login_connect_timeout = 0.0 _login_connect_timeout = max(_login_connect_timeout, 315.0) - tools = _probe_single_server( - name, server_config, connect_timeout=_login_connect_timeout - ) + with force_interactive_oauth(): + tools = _probe_single_server( + name, server_config, connect_timeout=_login_connect_timeout + ) # A clean probe is NOT proof of authentication. Some MCP servers # (notably Google's official Drive server) serve initialize + # tools/list WITHOUT auth, so the probe lists tools even when the @@ -862,7 +872,15 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool: _success("Authenticated (server reported no tools)") return True except Exception as exc: - _error(f"Authentication failed: {exc}") + try: + from tools.mcp_oauth import humanize_oauth_registration_error + + humanized = humanize_oauth_registration_error( + name, exc, server_url=url + ) + except Exception: + humanized = None + _error(f"Authentication failed: {humanized or exc}") return False diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4c0b105da04..7cc10b176c7 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -13190,15 +13190,18 @@ def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None: # (Figma's MCP catalog, etc.) 403 the register call before any # authorization URL exists — surface what's actually happening # instead of a bare "403 Forbidden". - lowered = msg.lower() - if "403" in msg and ("regist" in lowered or "forbidden" in lowered): - msg = ( - f"'{flow.server_name}' only allows pre-approved OAuth clients — it rejected " - "client registration (403), so no browser flow can start. " - "Options: add a pre-registered client to this server's entry " - "(oauth: {client_id: ..., client_secret: ...}), or use the " - "provider's stdio / API-key server instead." + try: + from tools.mcp_oauth import humanize_oauth_registration_error + + humanized = humanize_oauth_registration_error( + flow.server_name, + exc, + server_url=cfg.get("url") if isinstance(cfg, dict) else None, ) + if humanized: + msg = humanized + except Exception: + pass flow.mark_error(msg) finally: flow.mark_worker_done() diff --git a/optional-mcps/figma/manifest.yaml b/optional-mcps/figma/manifest.yaml new file mode 100644 index 00000000000..4e8f3a35f2d --- /dev/null +++ b/optional-mcps/figma/manifest.yaml @@ -0,0 +1,39 @@ +# Nous-approved MCP catalog entry. +# Presence in this directory = approval. Merged via PR review. +manifest_version: 1 + +name: figma +description: >- + Official Figma remote MCP — design context, Code Connect, and + write-to-canvas via https://mcp.figma.com/mcp (OAuth). +source: https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/ + +# Figma's hosted endpoint requires OAuth 2.1 + DCR. Their registration +# endpoint allowlists *exact* client_name strings (not arbitrary clients): +# "Claude Code" and "Codex" succeed; "Hermes Agent" 403s. Hermes's MCP +# OAuth layer auto-sets client_name to "Claude Code" for this host (see +# tools/mcp_oauth.apply_oauth_provider_defaults) so the browser flow can +# start. Users can override via oauth.client_name if they want Codex +# instead. Scope defaults to mcp:connect. +transport: + type: http + url: https://mcp.figma.com/mcp + +auth: + type: oauth + +post_install: | + On first connection Hermes opens a browser to authorize with Figma + (or run `hermes mcp login figma`). Approve access, then restart the + session so tools load. + + Figma walks OAuth DCR by exact client_name. Hermes registers as + "Claude Code" automatically so registration is not 403'd — you do not + need to paste a client_id. + + Prompt with a frame/file link: + implement this design: https://www.figma.com/design//... + + Desktop alternative (no OAuth, local only): enable Dev Mode MCP in the + Figma desktop app (Shift+D → Inspect → Enable desktop MCP server) and + point url at http://127.0.0.1:3845/mcp instead. diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index 3ce8f11d7a2..76822caf036 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -1601,3 +1601,94 @@ def test_wait_for_callback_port_in_use_reports_clear_error(monkeypatch): assert "54321" in msg assert "already in use" in msg assert "timed out" not in msg + + +# --------------------------------------------------------------------------- +# Figma remote MCP DCR allowlist workarounds +# --------------------------------------------------------------------------- + + +def test_figma_provider_defaults_set_allowlisted_client_name(): + from tools.mcp_oauth import ( + apply_oauth_provider_defaults, + _FIGMA_DCR_CLIENT_NAME, + _FIGMA_DEFAULT_SCOPE, + ) + + cfg = apply_oauth_provider_defaults( + {}, + server_name="figma", + server_url="https://mcp.figma.com/mcp", + ) + assert cfg["client_name"] == _FIGMA_DCR_CLIENT_NAME + assert cfg["scope"] == _FIGMA_DEFAULT_SCOPE + + +def test_figma_provider_defaults_respect_explicit_overrides(): + from tools.mcp_oauth import apply_oauth_provider_defaults + + cfg = apply_oauth_provider_defaults( + {"client_name": "Codex", "scope": "mcp:connect openid"}, + server_name="figma", + server_url="https://mcp.figma.com/mcp", + ) + assert cfg["client_name"] == "Codex" + assert cfg["scope"] == "mcp:connect openid" + + +def test_figma_defaults_not_applied_to_unrelated_servers(): + from tools.mcp_oauth import apply_oauth_provider_defaults + + cfg = apply_oauth_provider_defaults( + {}, + server_name="linear", + server_url="https://mcp.linear.app/mcp", + ) + assert "client_name" not in cfg + assert "scope" not in cfg + + +def test_build_client_metadata_figma_path_uses_claude_code(monkeypatch): + """End-to-end: empty oauth cfg + figma URL → metadata client_name allowlisted.""" + pytest.importorskip("mcp") + from tools.mcp_oauth import ( + apply_oauth_provider_defaults, + _build_client_metadata, + _configure_callback_port, + _FIGMA_DCR_CLIENT_NAME, + ) + + cfg = {} + apply_oauth_provider_defaults( + cfg, server_name="figma", server_url="https://mcp.figma.com/mcp" + ) + _configure_callback_port(cfg) + md = _build_client_metadata(cfg) + assert md.client_name == _FIGMA_DCR_CLIENT_NAME + assert md.scope == "mcp:connect" + + +def test_humanize_figma_registration_error_mentions_client_name(): + from tools.mcp_oauth import humanize_oauth_registration_error + + msg = humanize_oauth_registration_error( + "figma", + RuntimeError("HTTP 403: Forbidden"), + server_url="https://mcp.figma.com/mcp", + ) + assert msg is not None + assert "Claude Code" in msg + assert "client_name" in msg + + +def test_humanize_non_registration_403_passthrough(): + from tools.mcp_oauth import humanize_oauth_registration_error + + assert ( + humanize_oauth_registration_error( + "linear", + RuntimeError("HTTP 403: insufficient_scope"), + server_url="https://mcp.linear.app/mcp", + ) + is None + ) diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index a42a11a7e8d..df2c16b2d65 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -1068,6 +1068,68 @@ def _resolve_redirect_uri(cfg: dict, port: int) -> str: return f"http://{host}:{port}/callback" +# Figma's remote MCP (https://mcp.figma.com/mcp) implement RFC 7591 DCR as a +# *name allowlist*, not open registration. POST /v1/oauth/mcp/register returns +# 403 Forbidden for any client_name outside a short fixed set. Empirically (as +# of 2026-07, verified by live call against api.figma.com): +# "Claude Code" → 200 +# "Codex" → 200 +# "Hermes Agent" / "Hermes" / "Cursor" / "VS Code" / … → 403 +# pi-figma-remote-auth and similar tools work around this the same way — register +# under an allowlisted name so the browser flow can start. User can still pin a +# different name via oauth.client_name if Figma ever admits one. +_FIGMA_DCR_CLIENT_NAME = "Claude Code" +_FIGMA_DEFAULT_SCOPE = "mcp:connect" + + +def _is_figma_remote_mcp( + server_name: str | None = None, + server_url: str | None = None, +) -> bool: + """True when this MCP server is Figma's hosted remote endpoint.""" + url = (server_url or "").lower() + name = (server_name or "").lower() + if "mcp.figma.com" in url or "figma.com/mcp" in url: + return True + # Name-only match only when the URL isn't some other host called figma-*. + if "figma" in name and (not url or "figma" in url): + return True + return False + + +def apply_oauth_provider_defaults( + cfg: dict, + *, + server_name: str = "", + server_url: str | None = None, +) -> dict: + """Mutate *cfg* with provider-specific OAuth workarounds. Returns *cfg*. + + Call this before :func:`_build_client_metadata` / + :func:`_maybe_preregister_client`. Only fills keys the user left unset — + an explicit ``oauth.client_name`` / ``oauth.scope`` always wins. + """ + if _is_figma_remote_mcp(server_name, server_url): + if not cfg.get("client_name"): + cfg["client_name"] = _FIGMA_DCR_CLIENT_NAME + logger.info( + "MCP OAuth '%s': Figma DCR allowlist — registering as " + "client_name=%r (override via oauth.client_name)", + server_name or server_url, + _FIGMA_DCR_CLIENT_NAME, + ) + if not cfg.get("scope"): + cfg["scope"] = _FIGMA_DEFAULT_SCOPE + # Figma's register response advertises token_endpoint_auth_method=none + # *and* returns a client_secret — then the token endpoint rejects the + # exchange with "Client secret is required". Request confidential- + # client registration so the SDK includes client_secret on the token + # POST (auth method client_secret_post). + if not cfg.get("token_endpoint_auth_method"): + cfg["token_endpoint_auth_method"] = "client_secret_post" + return cfg + + def _build_client_metadata(cfg: dict) -> "OAuthClientMetadata": """Build OAuthClientMetadata from the oauth config dict. @@ -1083,17 +1145,21 @@ def _build_client_metadata(cfg: dict) -> "OAuthClientMetadata": scope = cfg.get("scope") redirect_uri = _resolve_redirect_uri(cfg, port) + # Default public client; confidential only when a secret is already known + # or the provider (e.g. Figma) needs confidential-style token posts. + auth_method = cfg.get("token_endpoint_auth_method") + if not auth_method: + auth_method = "client_secret_post" if cfg.get("client_secret") else "none" + metadata_kwargs: dict[str, Any] = { "client_name": client_name, "redirect_uris": [AnyUrl(redirect_uri)], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], - "token_endpoint_auth_method": "none", + "token_endpoint_auth_method": auth_method, } if scope: metadata_kwargs["scope"] = scope - if cfg.get("client_secret"): - metadata_kwargs["token_endpoint_auth_method"] = "client_secret_post" return OAuthClientMetadata.model_validate(metadata_kwargs) @@ -1129,6 +1195,56 @@ def _maybe_preregister_client( logger.debug("Pre-registered client_id=%s for '%s'", client_id, storage._server_name) +def humanize_oauth_registration_error( + server_name: str, + exc: BaseException | str, + *, + server_url: str | None = None, +) -> str | None: + """Turn a Dynamic Client Registration refusal into a useful next step. + + Returns a humanized message when the error is a registration 403/Forbidden, + else ``None`` so the caller keeps the original exception text. + + Figma's remote MCP gates DCR on exact ``client_name``. Hermes auto-sets + ``Claude Code`` (known-good); this message fires when the user overrode + that with something Figma still rejects, or an older Hermes is running. + """ + msg = str(exc) + lowered = msg.lower() + if "403" not in msg and "forbidden" not in lowered: + return None + looks_like_registration = ( + "regist" in lowered + or "client registration" in lowered + or "dcr" in lowered + or "dynamic client" in lowered + or lowered.strip() in {"forbidden", "403 forbidden", "http 403: forbidden"} + or ("403" in msg and "forbidden" in lowered) + ) + if not looks_like_registration: + return None + + if _is_figma_remote_mcp(server_name, server_url): + return ( + f"'{server_name}' is Figma's remote MCP — DCR is allowlisted by " + f"exact client_name (\"{_FIGMA_DCR_CLIENT_NAME}\" and \"Codex\" " + "work; most other names 403). Hermes defaults to " + f"client_name: {_FIGMA_DCR_CLIENT_NAME!r} automatically. If you " + "set oauth.client_name yourself, change it to one of those, or " + "clear it and re-run:\n" + f" hermes mcp login {server_name}" + ) + + return ( + f"'{server_name}' only allows pre-approved OAuth clients — it rejected " + "client registration (403), so no browser flow can start. Options: " + "set oauth.client_name to a name the provider allowlists, add a " + "pre-registered client (oauth: {client_id: ..., client_secret: ...}), " + "or use the provider's stdio / API-key / local server instead." + ) + + def build_oauth_auth( server_name: str, server_url: str, @@ -1158,6 +1274,9 @@ def build_oauth_auth( return None cfg = dict(oauth_config or {}) # copy — we mutate _resolved_port + apply_oauth_provider_defaults( + cfg, server_name=server_name, server_url=server_url + ) storage = HermesTokenStorage(server_name) if not _is_interactive() and not storage.has_cached_tokens(): diff --git a/tools/mcp_oauth_manager.py b/tools/mcp_oauth_manager.py index 9d2a625c263..b64dc81f4a0 100644 --- a/tools/mcp_oauth_manager.py +++ b/tools/mcp_oauth_manager.py @@ -546,6 +546,11 @@ class MCPOAuthManager: return None cfg = dict(entry.oauth_config or {}) + from tools.mcp_oauth import apply_oauth_provider_defaults + + apply_oauth_provider_defaults( + cfg, server_name=server_name, server_url=entry.server_url + ) storage = HermesTokenStorage(server_name) from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index ad72afee9e0..aa494b697f9 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -213,6 +213,19 @@ Use HTTP servers when: Most hosted MCP servers (Linear, Sentry, Atlassian, Asana, Figma, Stripe, …) require OAuth 2.1 instead of a static bearer token. Set `auth: oauth` and Hermes handles discovery, dynamic client registration, PKCE, token exchange, refresh, and step-up auth via the MCP Python SDK. +:::tip Figma remote MCP +Figma's hosted endpoint (`https://mcp.figma.com/mcp`) allowlists Dynamic Client Registration by **exact `client_name`** — bare `"Hermes Agent"` 403s, while `"Claude Code"` and `"Codex"` succeed. Hermes auto-sets `oauth.client_name: "Claude Code"` for `mcp.figma.com` so install/login works without a special trick: + +```yaml +mcp_servers: + figma: + url: "https://mcp.figma.com/mcp" + auth: oauth +``` + +Or: `hermes mcp install figma`, then `hermes mcp login figma`. +::: + ```yaml mcp_servers: linear: