diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index d879b28894..b8162d8849 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -10,6 +10,7 @@ import { getSettings } from "../settings"; import { DECO_STORE_URL, isDecoHostedMcp } from "@/core/deco-constants"; +import { isFigmaConnection } from "@/core/provider-helpers"; import { WellKnownOrgMCPId } from "@decocms/mesh-sdk"; import { PrometheusSerializer } from "@opentelemetry/exporter-prometheus"; import { Hono } from "hono"; @@ -707,6 +708,13 @@ export async function createApp(options: CreateAppOptions = {}) { const authorization = c.req.header("Authorization"); if (authorization) headers["Authorization"] = authorization; + // Figma MCP: inject X-Figma-Token header for register endpoint + // Figma's Dynamic Client Registration requires a Personal Access Token + const isFigma = isFigmaConnection(connection.connection_url); + if (isFigma && endpoint === "register" && connection.connection_token) { + headers["X-Figma-Token"] = connection.connection_token; + } + // For token endpoint, we may need to rewrite the 'resource' parameter in the body // (same reason as authorize: auth servers validate it's their actual endpoint) let requestBody: BodyInit | undefined; @@ -726,6 +734,21 @@ export async function createApp(options: CreateAppOptions = {}) { params.append(key, value.toString()); } requestBody = params.toString(); + } else if ( + isFigma && + endpoint === "register" && + contentType?.includes("application/json") + ) { + // Figma: rewrite client_name and ensure scope for registration + const body = await c.req.json(); + body.client_name = "Claude Code (figma)"; + const scopes = + typeof body.scope === "string" + ? body.scope.split(/\s+/).filter(Boolean) + : []; + if (!scopes.includes("mcp:connect")) scopes.push("mcp:connect"); + body.scope = scopes.join(" "); + requestBody = JSON.stringify(body); } else { // For other content types, pass through as-is requestBody = c.req.raw.body ?? undefined; @@ -742,6 +765,19 @@ export async function createApp(options: CreateAppOptions = {}) { redirect: "manual", }); + // Figma: enhance 403 errors on register with actionable message + if (isFigma && endpoint === "register" && response.status === 403) { + return c.json( + { + error: "Figma registration failed", + error_description: connection.connection_token + ? "Figma rejected the PAT. Verify it hasn't expired and has the correct scopes." + : "Figma requires a PAT for MCP registration. Add your Figma PAT in the connection settings.", + }, + 403, + ); + } + // Copy response headers, excluding hop-by-hop and encoding headers // Note: Node.js fetch auto-decompresses, so content-encoding/content-length would be wrong const responseHeaders = new Headers(); diff --git a/apps/mesh/src/core/provider-helpers.ts b/apps/mesh/src/core/provider-helpers.ts new file mode 100644 index 0000000000..e936d5a05b --- /dev/null +++ b/apps/mesh/src/core/provider-helpers.ts @@ -0,0 +1,15 @@ +/** + * Provider-specific helpers for MCP connections. + * Detects well-known providers that need special handling in the OAuth proxy. + */ + +/** Check if a connection URL points to Figma's MCP server */ +export function isFigmaConnection(connectionUrl: string | null): boolean { + if (!connectionUrl) return false; + try { + const url = new URL(connectionUrl); + return url.hostname === "figma.com" || url.hostname.endsWith(".figma.com"); + } catch { + return false; + } +} diff --git a/apps/mesh/src/web/components/connections/create-connection-dialog.tsx b/apps/mesh/src/web/components/connections/create-connection-dialog.tsx index da4103a426..fde4272372 100644 --- a/apps/mesh/src/web/components/connections/create-connection-dialog.tsx +++ b/apps/mesh/src/web/components/connections/create-connection-dialog.tsx @@ -528,6 +528,20 @@ export function CreateConnectionDialog({ )} + {providerHint.id === "figma" && ( + <> + {" "} + ·{" "} + + Open Figma PAT settings + + + )}

)} diff --git a/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx b/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx index 4413a2b42d..bbe2ded6c7 100644 --- a/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx +++ b/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx @@ -72,6 +72,18 @@ export function ConnectionFields({ } })(); + const isFigmaMcp = (() => { + if (typeof connectionUrl !== "string" || !connectionUrl) return false; + try { + const url = new URL(connectionUrl); + return ( + url.hostname === "figma.com" || url.hostname.endsWith(".figma.com") + ); + } catch { + return false; + } + })(); + const showStdioOptions = stdioEnabled || connection.connection_type === "STDIO"; @@ -340,7 +352,11 @@ export function ConnectionFields({ render={({ field }) => ( - {isGitHubCopilotMcp ? "GitHub Personal Access Token" : "Token"} + {isGitHubCopilotMcp + ? "GitHub Personal Access Token" + : isFigmaMcp + ? "Figma Personal Access Token" + : "Token"} {/* Authentication status badge */} {hasOAuthToken ? ( @@ -395,7 +411,9 @@ export function ConnectionFields({ placeholder={ isGitHubCopilotMcp ? "Paste your GitHub PAT" - : "Enter access token..." + : isFigmaMcp + ? "Paste your Figma PAT" + : "Enter access token..." } {...field} value={field.value || ""} @@ -415,6 +433,20 @@ export function ConnectionFields({ )} + {isFigmaMcp && ( + + Create a PAT at{" "} + + figma.com/developers + + . Required for MCP OAuth registration. + + )} )} diff --git a/apps/mesh/src/web/utils/connection-form-helpers.ts b/apps/mesh/src/web/utils/connection-form-helpers.ts index be75f5a555..87b652e259 100644 --- a/apps/mesh/src/web/utils/connection-form-helpers.ts +++ b/apps/mesh/src/web/utils/connection-form-helpers.ts @@ -8,7 +8,7 @@ import type { RegistryItem } from "@/web/components/store/types"; // --------------------------------------------------------------------------- export type ConnectionProviderHint = { - id: "github" | "perplexity" | "registry"; + id: "github" | "figma" | "perplexity" | "registry"; title?: string; description?: string | null; token?: { @@ -86,6 +86,28 @@ export function inferHardcodedProviderHint(params: { }; } + // Figma MCP (hardcoded) + if (uiType === "HTTP" || uiType === "SSE" || uiType === "Websocket") { + try { + const url = new URL(normalized); + if (url.hostname === "figma.com" || url.hostname.endsWith(".figma.com")) { + return { + id: "figma", + title: "Figma", + description: "Figma MCP", + token: { + label: "Figma PAT", + placeholder: "figd_…", + helperText: + "Paste a Figma Personal Access Token — required for OAuth registration", + }, + }; + } + } catch { + // invalid URL, skip + } + } + // Perplexity MCP (hardcoded) const npxPackage = (params.npxPackage ?? "").trim(); if (uiType === "NPX" && npxPackage === "@perplexity-ai/mcp-server") {