mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73141 from NousResearch/bb/figma-mcp-oauth
fix(mcp): Figma remote OAuth via DCR allowlist defaults
This commit is contained in:
commit
c8ec2a6f3c
7 changed files with 304 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
39
optional-mcps/figma/manifest.yaml
Normal file
39
optional-mcps/figma/manifest.yaml
Normal 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.
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue