fix(mcp): make Figma remote OAuth work via DCR allowlist defaults

Figma's mcp.figma.com register endpoint is a client_name allowlist
(Claude Code / Codex succeed; Hermes Agent 403s) and returns a client
secret while advertising auth_method=none, then requires the secret on
token exchange. Auto-set client_name + client_secret_post for Figma
hosts, pass oauth cfg through login/add paths, force interactive OAuth
for hermes mcp login from non-TTY desktop shells, and ship a catalog
entry. Proven: hermes mcp login figma → 26 tools.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 00:53:16 -05:00
parent 3c388db06b
commit 1eb5ee1eaa
7 changed files with 304 additions and 16 deletions

View file

@ -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

View file

@ -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()

View file

@ -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/<fileKey>/...
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.

View file

@ -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
)

View file

@ -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():

View file

@ -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

View file

@ -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: