@mp3wizard/figma-console-mcp 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
- package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
- package/dist/apps/design-system-dashboard/server.d.ts +24 -0
- package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/server.js +160 -0
- package/dist/apps/design-system-dashboard/server.js.map +1 -0
- package/dist/apps/token-browser/server.d.ts +26 -0
- package/dist/apps/token-browser/server.d.ts.map +1 -0
- package/dist/apps/token-browser/server.js +137 -0
- package/dist/apps/token-browser/server.js.map +1 -0
- package/dist/browser/base.d.ts +58 -0
- package/dist/browser/base.d.ts.map +1 -0
- package/dist/browser/base.js +6 -0
- package/dist/browser/base.js.map +1 -0
- package/dist/browser/local.d.ts +87 -0
- package/dist/browser/local.d.ts.map +1 -0
- package/dist/browser/local.js +318 -0
- package/dist/browser/local.js.map +1 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
- package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
- package/dist/cloudflare/apps/token-browser/server.js +136 -0
- package/dist/cloudflare/browser/base.js +5 -0
- package/dist/cloudflare/browser/cloudflare.js +156 -0
- package/dist/cloudflare/browser-manager.js +157 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +267 -0
- package/dist/cloudflare/core/cloud-websocket-relay.js +199 -0
- package/dist/cloudflare/core/comment-tools.js +292 -0
- package/dist/cloudflare/core/config.js +161 -0
- package/dist/cloudflare/core/console-monitor.js +427 -0
- package/dist/cloudflare/core/design-code-tools.js +2504 -0
- package/dist/cloudflare/core/design-system-manifest.js +260 -0
- package/dist/cloudflare/core/design-system-tools.js +863 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
- package/dist/cloudflare/core/enrichment/index.js +7 -0
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
- package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
- package/dist/cloudflare/core/figma-api.js +409 -0
- package/dist/cloudflare/core/figma-connector.js +7 -0
- package/dist/cloudflare/core/figma-desktop-connector.js +1184 -0
- package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
- package/dist/cloudflare/core/figma-style-extractor.js +311 -0
- package/dist/cloudflare/core/figma-tools.js +2947 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/port-discovery.js +282 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/types/design-code.js +4 -0
- package/dist/cloudflare/core/types/enriched.js +5 -0
- package/dist/cloudflare/core/types/index.js +4 -0
- package/dist/cloudflare/core/websocket-connector.js +256 -0
- package/dist/cloudflare/core/websocket-server.js +646 -0
- package/dist/cloudflare/core/write-tools.js +2091 -0
- package/dist/cloudflare/index.js +2899 -0
- package/dist/cloudflare/test-browser.js +88 -0
- package/dist/core/comment-tools.d.ts +11 -0
- package/dist/core/comment-tools.d.ts.map +1 -0
- package/dist/core/comment-tools.js +293 -0
- package/dist/core/comment-tools.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +162 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/console-monitor.d.ts +82 -0
- package/dist/core/console-monitor.d.ts.map +1 -0
- package/dist/core/console-monitor.js +428 -0
- package/dist/core/console-monitor.js.map +1 -0
- package/dist/core/design-code-tools.d.ts +127 -0
- package/dist/core/design-code-tools.d.ts.map +1 -0
- package/dist/core/design-code-tools.js +2505 -0
- package/dist/core/design-code-tools.js.map +1 -0
- package/dist/core/design-system-manifest.d.ts +272 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -0
- package/dist/core/design-system-manifest.js +261 -0
- package/dist/core/design-system-manifest.js.map +1 -0
- package/dist/core/design-system-tools.d.ts +17 -0
- package/dist/core/design-system-tools.d.ts.map +1 -0
- package/dist/core/design-system-tools.js +864 -0
- package/dist/core/design-system-tools.js.map +1 -0
- package/dist/core/enrichment/enrichment-service.d.ts +52 -0
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
- package/dist/core/enrichment/enrichment-service.js +273 -0
- package/dist/core/enrichment/enrichment-service.js.map +1 -0
- package/dist/core/enrichment/index.d.ts +8 -0
- package/dist/core/enrichment/index.d.ts.map +1 -0
- package/dist/core/enrichment/index.js +8 -0
- package/dist/core/enrichment/index.js.map +1 -0
- package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
- package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
- package/dist/core/enrichment/relationship-mapper.js +352 -0
- package/dist/core/enrichment/relationship-mapper.js.map +1 -0
- package/dist/core/enrichment/style-resolver.d.ts +80 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +327 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figma-api.d.ts +201 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +410 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-connector.d.ts +48 -0
- package/dist/core/figma-connector.d.ts.map +1 -0
- package/dist/core/figma-connector.js +8 -0
- package/dist/core/figma-connector.js.map +1 -0
- package/dist/core/figma-desktop-connector.d.ts +265 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +1184 -0
- package/dist/core/figma-desktop-connector.js.map +1 -0
- package/dist/core/figma-reconstruction-spec.d.ts +166 -0
- package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
- package/dist/core/figma-reconstruction-spec.js +403 -0
- package/dist/core/figma-reconstruction-spec.js.map +1 -0
- package/dist/core/figma-style-extractor.d.ts +76 -0
- package/dist/core/figma-style-extractor.d.ts.map +1 -0
- package/dist/core/figma-style-extractor.js +312 -0
- package/dist/core/figma-style-extractor.js.map +1 -0
- package/dist/core/figma-tools.d.ts +23 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +2948 -0
- package/dist/core/figma-tools.js.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/port-discovery.d.ts +110 -0
- package/dist/core/port-discovery.d.ts.map +1 -0
- package/dist/core/port-discovery.js +283 -0
- package/dist/core/port-discovery.js.map +1 -0
- package/dist/core/snippet-injector.d.ts +24 -0
- package/dist/core/snippet-injector.d.ts.map +1 -0
- package/dist/core/snippet-injector.js +97 -0
- package/dist/core/snippet-injector.js.map +1 -0
- package/dist/core/types/design-code.d.ts +262 -0
- package/dist/core/types/design-code.d.ts.map +1 -0
- package/dist/core/types/design-code.js +5 -0
- package/dist/core/types/design-code.js.map +1 -0
- package/dist/core/types/enriched.d.ts +213 -0
- package/dist/core/types/enriched.d.ts.map +1 -0
- package/dist/core/types/enriched.js +6 -0
- package/dist/core/types/enriched.js.map +1 -0
- package/dist/core/types/index.d.ts +112 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +5 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/core/websocket-connector.d.ts +55 -0
- package/dist/core/websocket-connector.d.ts.map +1 -0
- package/dist/core/websocket-connector.js +257 -0
- package/dist/core/websocket-connector.js.map +1 -0
- package/dist/core/websocket-server.d.ts +191 -0
- package/dist/core/websocket-server.d.ts.map +1 -0
- package/dist/core/websocket-server.js +647 -0
- package/dist/core/websocket-server.js.map +1 -0
- package/dist/core/write-tools.d.ts +7 -0
- package/dist/core/write-tools.d.ts.map +1 -0
- package/dist/core/write-tools.js +2092 -0
- package/dist/core/write-tools.js.map +1 -0
- package/dist/local.d.ts +84 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +5039 -0
- package/dist/local.js.map +1 -0
- package/figma-desktop-bridge/README.md +313 -0
- package/figma-desktop-bridge/code.js +2818 -0
- package/figma-desktop-bridge/manifest.json +67 -0
- package/figma-desktop-bridge/ui.html +1236 -0
- package/package.json +87 -0
|
@@ -0,0 +1,2899 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Figma Console MCP Server
|
|
4
|
+
* Entry point for the MCP server that enables AI assistants to access
|
|
5
|
+
* Figma plugin console logs and screenshots.
|
|
6
|
+
*
|
|
7
|
+
* This implementation uses Cloudflare's McpAgent pattern for deployment
|
|
8
|
+
* on Cloudflare Workers with Browser Rendering API support.
|
|
9
|
+
*/
|
|
10
|
+
import { McpAgent } from "agents/mcp";
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { BrowserManager } from "./browser-manager.js";
|
|
15
|
+
import { ConsoleMonitor } from "./core/console-monitor.js";
|
|
16
|
+
import { getConfig } from "./core/config.js";
|
|
17
|
+
import { createChildLogger } from "./core/logger.js";
|
|
18
|
+
import { testBrowserRendering } from "./test-browser.js";
|
|
19
|
+
import { FigmaAPI, extractFileKey } from "./core/figma-api.js";
|
|
20
|
+
import { registerFigmaAPITools } from "./core/figma-tools.js";
|
|
21
|
+
import { registerDesignCodeTools } from "./core/design-code-tools.js";
|
|
22
|
+
import { registerCommentTools } from "./core/comment-tools.js";
|
|
23
|
+
import { registerDesignSystemTools } from "./core/design-system-tools.js";
|
|
24
|
+
import { generatePairingCode } from "./core/cloud-websocket-relay.js";
|
|
25
|
+
import { CloudWebSocketConnector } from "./core/cloud-websocket-connector.js";
|
|
26
|
+
import { registerWriteTools } from "./core/write-tools.js";
|
|
27
|
+
// Re-export PluginRelayDO so Cloudflare Workers can bind it as a Durable Object
|
|
28
|
+
export { PluginRelayDO } from "./core/cloud-websocket-relay.js";
|
|
29
|
+
// Note: MCP Apps (Token Browser, Dashboard) are only available in local mode
|
|
30
|
+
// They require Node.js file system APIs for serving HTML that don't work in Cloudflare Workers
|
|
31
|
+
const logger = createChildLogger({ component: "mcp-server" });
|
|
32
|
+
/** Escape HTML special characters to prevent XSS in server-rendered HTML responses */
|
|
33
|
+
function htmlEscape(s) {
|
|
34
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
35
|
+
}
|
|
36
|
+
/** Apply baseline HTTP security headers to any Response */
|
|
37
|
+
function withSecurityHeaders(response) {
|
|
38
|
+
const headers = new Headers(response.headers);
|
|
39
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
40
|
+
headers.set('X-Frame-Options', 'DENY');
|
|
41
|
+
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
42
|
+
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
43
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Figma Console MCP Agent
|
|
47
|
+
* Extends McpAgent to provide Figma-specific debugging tools
|
|
48
|
+
*/
|
|
49
|
+
export class FigmaConsoleMCPv3 extends McpAgent {
|
|
50
|
+
constructor() {
|
|
51
|
+
super(...arguments);
|
|
52
|
+
this.server = new McpServer({
|
|
53
|
+
name: "Figma Console MCP",
|
|
54
|
+
version: "1.13.0",
|
|
55
|
+
});
|
|
56
|
+
this.browserManager = null;
|
|
57
|
+
this.consoleMonitor = null;
|
|
58
|
+
this.figmaAPI = null;
|
|
59
|
+
this.config = getConfig();
|
|
60
|
+
this.sessionId = null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Refresh an expired OAuth token using the refresh token
|
|
64
|
+
*/
|
|
65
|
+
async refreshOAuthToken(sessionId, refreshToken) {
|
|
66
|
+
const env = this.env;
|
|
67
|
+
if (!env.FIGMA_OAUTH_CLIENT_ID || !env.FIGMA_OAUTH_CLIENT_SECRET) {
|
|
68
|
+
throw new Error("OAuth not configured on server");
|
|
69
|
+
}
|
|
70
|
+
logger.info({ sessionId }, "Attempting to refresh OAuth token");
|
|
71
|
+
const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
|
|
72
|
+
const tokenParams = new URLSearchParams({
|
|
73
|
+
grant_type: "refresh_token",
|
|
74
|
+
refresh_token: refreshToken
|
|
75
|
+
});
|
|
76
|
+
const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
80
|
+
"Authorization": `Basic ${credentials}`
|
|
81
|
+
},
|
|
82
|
+
body: tokenParams.toString()
|
|
83
|
+
});
|
|
84
|
+
if (!tokenResponse.ok) {
|
|
85
|
+
const errorData = await tokenResponse.json().catch(() => ({}));
|
|
86
|
+
logger.error({ errorData, status: tokenResponse.status }, "Token refresh failed");
|
|
87
|
+
throw new Error(`Token refresh failed: ${JSON.stringify(errorData)}`);
|
|
88
|
+
}
|
|
89
|
+
const tokenData = await tokenResponse.json();
|
|
90
|
+
// Store refreshed token in KV
|
|
91
|
+
const tokenKey = `oauth_token:${sessionId}`;
|
|
92
|
+
const storedToken = {
|
|
93
|
+
accessToken: tokenData.access_token,
|
|
94
|
+
refreshToken: tokenData.refresh_token || refreshToken, // Use new refresh token or keep existing
|
|
95
|
+
expiresAt: Date.now() + (tokenData.expires_in * 1000)
|
|
96
|
+
};
|
|
97
|
+
await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
|
|
98
|
+
expirationTtl: tokenData.expires_in
|
|
99
|
+
});
|
|
100
|
+
// Store reverse lookup for Bearer token validation on SSE endpoint
|
|
101
|
+
const bearerKey = `bearer_token:${tokenData.access_token}`;
|
|
102
|
+
await env.OAUTH_TOKENS.put(bearerKey, JSON.stringify({
|
|
103
|
+
sessionId,
|
|
104
|
+
expiresAt: storedToken.expiresAt
|
|
105
|
+
}), {
|
|
106
|
+
expirationTtl: tokenData.expires_in
|
|
107
|
+
});
|
|
108
|
+
logger.info({ sessionId }, "OAuth token refreshed successfully");
|
|
109
|
+
return storedToken;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Generate a cryptographically secure random state token for CSRF protection
|
|
113
|
+
*/
|
|
114
|
+
static generateStateToken() {
|
|
115
|
+
const array = new Uint8Array(32);
|
|
116
|
+
crypto.getRandomValues(array);
|
|
117
|
+
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Load or create persistent session ID from Durable Object storage
|
|
121
|
+
* Uses a fixed session ID for the MCP server to ensure OAuth tokens persist across reconnections
|
|
122
|
+
*/
|
|
123
|
+
async ensureSessionId() {
|
|
124
|
+
if (this.sessionId) {
|
|
125
|
+
return; // Already loaded
|
|
126
|
+
}
|
|
127
|
+
// Use a unique, per-Durable-Object session ID so that each MCP client
|
|
128
|
+
// (identified by Cloudflare's DO routing) gets its own OAuth token.
|
|
129
|
+
// @ts-ignore - this.ctx is available in Durable Object context
|
|
130
|
+
const storage = this.ctx?.storage;
|
|
131
|
+
if (storage) {
|
|
132
|
+
try {
|
|
133
|
+
const storedSessionId = await storage.get('sessionId');
|
|
134
|
+
if (storedSessionId) {
|
|
135
|
+
this.sessionId = storedSessionId;
|
|
136
|
+
logger.info({ hasSessionId: true }, "Loaded persistent session ID from storage");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// Generate a unique session ID for this Durable Object instance
|
|
141
|
+
this.sessionId = FigmaConsoleMCPv3.generateStateToken();
|
|
142
|
+
await storage.put('sessionId', this.sessionId);
|
|
143
|
+
logger.info({ hasSessionId: true }, "Initialized unique session ID");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
logger.warn({ error: e }, "Failed to access Durable Object storage for session ID");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Fallback: generate unique session ID without persistence
|
|
152
|
+
this.sessionId = FigmaConsoleMCPv3.generateStateToken();
|
|
153
|
+
logger.info({ hasSessionId: true }, "Using generated session ID (storage unavailable)");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get session ID for this Durable Object instance
|
|
157
|
+
* Returns the session ID loaded by ensureSessionId()
|
|
158
|
+
*/
|
|
159
|
+
getSessionId() {
|
|
160
|
+
if (!this.sessionId) {
|
|
161
|
+
// This shouldn't happen if ensureSessionId() was called, but provide fallback
|
|
162
|
+
this.sessionId = FigmaConsoleMCPv3.generateStateToken();
|
|
163
|
+
logger.warn({ sessionId: this.sessionId }, "Session ID not initialized, generated ephemeral ID");
|
|
164
|
+
}
|
|
165
|
+
return this.sessionId;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Get or create Figma API client with OAuth token from session
|
|
169
|
+
*/
|
|
170
|
+
async getFigmaAPI() {
|
|
171
|
+
// Ensure session ID is loaded from storage
|
|
172
|
+
await this.ensureSessionId();
|
|
173
|
+
// @ts-ignore - this.env is available in Agent/Durable Object context
|
|
174
|
+
const env = this.env;
|
|
175
|
+
// Try OAuth first (per-user authentication)
|
|
176
|
+
try {
|
|
177
|
+
const sessionId = this.getSessionId();
|
|
178
|
+
logger.info({ sessionId }, "Attempting to retrieve OAuth token from KV");
|
|
179
|
+
// Retrieve token from KV (accessible across all Durable Object instances)
|
|
180
|
+
const tokenKey = `oauth_token:${sessionId}`;
|
|
181
|
+
const tokenJson = await env.OAUTH_TOKENS.get(tokenKey);
|
|
182
|
+
if (!tokenJson) {
|
|
183
|
+
logger.warn({ sessionId, tokenKey }, "No OAuth token found in KV");
|
|
184
|
+
throw new Error("No token found");
|
|
185
|
+
}
|
|
186
|
+
let tokenData = JSON.parse(tokenJson);
|
|
187
|
+
logger.info({
|
|
188
|
+
sessionId,
|
|
189
|
+
hasToken: !!tokenData?.accessToken,
|
|
190
|
+
expiresAt: tokenData?.expiresAt,
|
|
191
|
+
isExpired: tokenData?.expiresAt ? Date.now() > tokenData.expiresAt : null
|
|
192
|
+
}, "Token retrieval result from KV");
|
|
193
|
+
if (tokenData?.accessToken) {
|
|
194
|
+
// Check if token is expired or will expire soon (within 5 minutes)
|
|
195
|
+
const isExpired = tokenData.expiresAt && Date.now() > tokenData.expiresAt;
|
|
196
|
+
const willExpireSoon = tokenData.expiresAt && Date.now() > (tokenData.expiresAt - 5 * 60 * 1000);
|
|
197
|
+
if (isExpired || willExpireSoon) {
|
|
198
|
+
if (tokenData.refreshToken) {
|
|
199
|
+
try {
|
|
200
|
+
// Attempt to refresh the token
|
|
201
|
+
tokenData = await this.refreshOAuthToken(sessionId, tokenData.refreshToken);
|
|
202
|
+
logger.info({ sessionId }, "Successfully refreshed expired/expiring token");
|
|
203
|
+
}
|
|
204
|
+
catch (refreshError) {
|
|
205
|
+
logger.error({ sessionId, refreshError }, "Failed to refresh token");
|
|
206
|
+
throw new Error("Token expired and refresh failed. Please re-authenticate.");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
logger.warn({ sessionId }, "Token expired but no refresh token available");
|
|
211
|
+
throw new Error("Token expired. Please re-authenticate.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
logger.info({ sessionId }, "Using OAuth token from KV for Figma API");
|
|
215
|
+
return new FigmaAPI({ accessToken: tokenData.accessToken });
|
|
216
|
+
}
|
|
217
|
+
logger.warn({ sessionId }, "OAuth token exists in KV but missing accessToken");
|
|
218
|
+
throw new Error("Invalid token data");
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
222
|
+
const sessionId = this.getSessionId();
|
|
223
|
+
// Check if this is a "no token found" error (user hasn't authenticated yet)
|
|
224
|
+
if (errorMessage.includes("No token found")) {
|
|
225
|
+
logger.info({ sessionId }, "No OAuth token found - user needs to authenticate");
|
|
226
|
+
// No authentication available - direct user to OAuth flow
|
|
227
|
+
const authUrl = `https://figma-console-mcp.southleft.com/oauth/authorize?session_id=${sessionId}`;
|
|
228
|
+
// Only use PAT fallback if explicitly configured AND no OAuth token exists
|
|
229
|
+
if (env?.FIGMA_ACCESS_TOKEN) {
|
|
230
|
+
logger.warn("FIGMA_ACCESS_TOKEN fallback is deprecated. User should authenticate via OAuth for proper per-user authentication.");
|
|
231
|
+
return new FigmaAPI({ accessToken: env.FIGMA_ACCESS_TOKEN });
|
|
232
|
+
}
|
|
233
|
+
throw new Error(JSON.stringify({
|
|
234
|
+
error: "authentication_required",
|
|
235
|
+
message: "Please authenticate with Figma to use API features",
|
|
236
|
+
auth_url: authUrl,
|
|
237
|
+
instructions: "Your browser will open automatically to complete authentication. If it doesn't, copy the auth_url and open it manually."
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
// For other OAuth errors (expired token, refresh failed, etc.), do NOT fall back to PAT
|
|
241
|
+
logger.error({ error, sessionId }, "OAuth token retrieval failed - re-authentication required");
|
|
242
|
+
const authUrl = `https://figma-console-mcp.southleft.com/oauth/authorize?session_id=${sessionId}`;
|
|
243
|
+
throw new Error(JSON.stringify({
|
|
244
|
+
error: "oauth_error",
|
|
245
|
+
message: errorMessage,
|
|
246
|
+
auth_url: authUrl,
|
|
247
|
+
instructions: "Please re-authenticate with Figma. Your browser will open automatically."
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Initialize browser and console monitoring
|
|
253
|
+
*/
|
|
254
|
+
async ensureInitialized() {
|
|
255
|
+
try {
|
|
256
|
+
// Ensure session ID is loaded from storage first
|
|
257
|
+
await this.ensureSessionId();
|
|
258
|
+
if (!this.browserManager) {
|
|
259
|
+
logger.info("Initializing BrowserManager");
|
|
260
|
+
// Access env from Durable Object context
|
|
261
|
+
// @ts-ignore - this.env is available in Agent/Durable Object context
|
|
262
|
+
const env = this.env;
|
|
263
|
+
if (!env) {
|
|
264
|
+
throw new Error("Environment not available - this.env is undefined");
|
|
265
|
+
}
|
|
266
|
+
if (!env.BROWSER) {
|
|
267
|
+
throw new Error("BROWSER binding not found in environment. Check wrangler.jsonc configuration.");
|
|
268
|
+
}
|
|
269
|
+
logger.info("Creating BrowserManager with BROWSER binding");
|
|
270
|
+
this.browserManager = new BrowserManager(env, this.config.browser);
|
|
271
|
+
}
|
|
272
|
+
if (!this.consoleMonitor) {
|
|
273
|
+
logger.info("Initializing ConsoleMonitor");
|
|
274
|
+
this.consoleMonitor = new ConsoleMonitor(this.config.console);
|
|
275
|
+
// Start browser and begin monitoring
|
|
276
|
+
logger.info("Getting browser page");
|
|
277
|
+
const page = await this.browserManager.getPage();
|
|
278
|
+
logger.info("Starting console monitoring");
|
|
279
|
+
await this.consoleMonitor.startMonitoring(page);
|
|
280
|
+
logger.info("Browser and console monitor initialized successfully");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
logger.error({ error }, "Failed to initialize browser/monitor");
|
|
285
|
+
throw new Error(`Initialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async init() {
|
|
289
|
+
// Tool 1: Get Console Logs
|
|
290
|
+
this.server.tool("figma_get_console_logs", "Retrieve console logs from Figma. Captures all plugin console output including [Main], [Swapper], etc. prefixes. Call figma_navigate first to initialize browser monitoring.", {
|
|
291
|
+
count: z.number().optional().default(100).describe("Number of recent logs to retrieve"),
|
|
292
|
+
level: z
|
|
293
|
+
.enum(["log", "info", "warn", "error", "debug", "all"])
|
|
294
|
+
.optional()
|
|
295
|
+
.default("all")
|
|
296
|
+
.describe("Filter by log level"),
|
|
297
|
+
since: z
|
|
298
|
+
.number()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Only logs after this timestamp (Unix ms)"),
|
|
301
|
+
}, async ({ count, level, since }) => {
|
|
302
|
+
try {
|
|
303
|
+
await this.ensureInitialized();
|
|
304
|
+
if (!this.consoleMonitor) {
|
|
305
|
+
throw new Error("Console monitor not initialized");
|
|
306
|
+
}
|
|
307
|
+
const logs = this.consoleMonitor.getLogs({
|
|
308
|
+
count,
|
|
309
|
+
level,
|
|
310
|
+
since,
|
|
311
|
+
});
|
|
312
|
+
// Add AI instruction when no logs are found
|
|
313
|
+
const responseData = {
|
|
314
|
+
logs,
|
|
315
|
+
totalCount: logs.length,
|
|
316
|
+
oldestTimestamp: logs[0]?.timestamp,
|
|
317
|
+
newestTimestamp: logs[logs.length - 1]?.timestamp,
|
|
318
|
+
status: this.consoleMonitor.getStatus(),
|
|
319
|
+
};
|
|
320
|
+
// If no logs found, add helpful AI instruction
|
|
321
|
+
if (logs.length === 0) {
|
|
322
|
+
responseData.ai_instruction = "No console logs found. This usually means the Figma plugin hasn't run since monitoring started. Please inform the user: 'No console logs found yet. Try running your Figma plugin now, then I'll check for logs again.' The MCP only captures logs AFTER monitoring starts - it cannot retrieve historical logs from before the browser connected.";
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
content: [
|
|
326
|
+
{
|
|
327
|
+
type: "text",
|
|
328
|
+
text: JSON.stringify(responseData, null, 2),
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
logger.error({ error }, "Failed to get console logs");
|
|
335
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
336
|
+
return {
|
|
337
|
+
content: [
|
|
338
|
+
{
|
|
339
|
+
type: "text",
|
|
340
|
+
text: JSON.stringify({
|
|
341
|
+
error: errorMessage,
|
|
342
|
+
message: "Failed to retrieve console logs. Make sure to call figma_navigate first to initialize the browser.",
|
|
343
|
+
hint: "Try: figma_navigate({ url: 'https://www.figma.com/design/your-file' })",
|
|
344
|
+
}, null, 2),
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
isError: true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
// Tool 2: Take Screenshot (using Figma REST API)
|
|
352
|
+
// Note: For screenshots of specific components, use figma_get_component_image instead
|
|
353
|
+
this.server.tool("figma_take_screenshot", "Export an image of the currently viewed Figma page or specific node using Figma's REST API. Returns an image URL (valid for 30 days). For specific components, use figma_get_component_image instead.", {
|
|
354
|
+
nodeId: z
|
|
355
|
+
.string()
|
|
356
|
+
.optional()
|
|
357
|
+
.describe("Optional node ID to screenshot. If not provided, uses the currently viewed page/frame from the browser URL."),
|
|
358
|
+
scale: z
|
|
359
|
+
.number()
|
|
360
|
+
.min(0.01)
|
|
361
|
+
.max(4)
|
|
362
|
+
.optional()
|
|
363
|
+
.default(2)
|
|
364
|
+
.describe("Image scale factor (0.01-4, default: 2 for high quality)"),
|
|
365
|
+
format: z
|
|
366
|
+
.enum(["png", "jpg", "svg", "pdf"])
|
|
367
|
+
.optional()
|
|
368
|
+
.default("png")
|
|
369
|
+
.describe("Image format (default: png)"),
|
|
370
|
+
}, async ({ nodeId, scale, format }) => {
|
|
371
|
+
try {
|
|
372
|
+
const api = await this.getFigmaAPI();
|
|
373
|
+
// Get current URL to extract file key and node ID if not provided
|
|
374
|
+
const currentUrl = this.browserManager?.getCurrentUrl() || null;
|
|
375
|
+
if (!currentUrl) {
|
|
376
|
+
throw new Error("No Figma file open. Either provide a nodeId parameter or call figma_navigate first to open a Figma file.");
|
|
377
|
+
}
|
|
378
|
+
const fileKey = extractFileKey(currentUrl);
|
|
379
|
+
if (!fileKey) {
|
|
380
|
+
throw new Error(`Invalid Figma URL: ${currentUrl}`);
|
|
381
|
+
}
|
|
382
|
+
// Extract node ID from URL if not provided
|
|
383
|
+
let targetNodeId = nodeId;
|
|
384
|
+
if (!targetNodeId) {
|
|
385
|
+
const urlObj = new URL(currentUrl);
|
|
386
|
+
const nodeIdParam = urlObj.searchParams.get('node-id');
|
|
387
|
+
if (nodeIdParam) {
|
|
388
|
+
// Convert 123-456 to 123:456
|
|
389
|
+
targetNodeId = nodeIdParam.replace(/-/g, ':');
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
throw new Error("No node ID found. Either provide nodeId parameter or ensure the Figma URL contains a node-id parameter (e.g., ?node-id=123-456)");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
logger.info({ fileKey, nodeId: targetNodeId, scale, format }, "Rendering image via Figma API");
|
|
396
|
+
// Use Figma REST API to get image
|
|
397
|
+
const result = await api.getImages(fileKey, targetNodeId, {
|
|
398
|
+
scale,
|
|
399
|
+
format: format === 'jpg' ? 'jpg' : format, // normalize jpeg -> jpg
|
|
400
|
+
contents_only: true,
|
|
401
|
+
});
|
|
402
|
+
const imageUrl = result.images[targetNodeId];
|
|
403
|
+
if (!imageUrl) {
|
|
404
|
+
throw new Error(`Failed to render image for node ${targetNodeId}. The node may not exist or may not be renderable.`);
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
content: [
|
|
408
|
+
{
|
|
409
|
+
type: "text",
|
|
410
|
+
text: JSON.stringify({
|
|
411
|
+
fileKey,
|
|
412
|
+
nodeId: targetNodeId,
|
|
413
|
+
imageUrl,
|
|
414
|
+
scale,
|
|
415
|
+
format,
|
|
416
|
+
expiresIn: "30 days",
|
|
417
|
+
note: "Image URL provided above. Use this URL to view or download the screenshot. URLs expire after 30 days.",
|
|
418
|
+
}, null, 2),
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
logger.error({ error }, "Failed to capture screenshot");
|
|
425
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
426
|
+
return {
|
|
427
|
+
content: [
|
|
428
|
+
{
|
|
429
|
+
type: "text",
|
|
430
|
+
text: JSON.stringify({
|
|
431
|
+
error: errorMessage,
|
|
432
|
+
message: "Failed to capture screenshot via Figma API",
|
|
433
|
+
hint: "Make sure you've called figma_navigate to open a file, or provide a valid nodeId parameter",
|
|
434
|
+
}, null, 2),
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
isError: true,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
// Tool 3: Watch Console (Real-time streaming)
|
|
442
|
+
this.server.tool("figma_watch_console", {
|
|
443
|
+
duration: z
|
|
444
|
+
.number()
|
|
445
|
+
.optional()
|
|
446
|
+
.default(30)
|
|
447
|
+
.describe("How long to watch in seconds"),
|
|
448
|
+
level: z
|
|
449
|
+
.enum(["log", "info", "warn", "error", "debug", "all"])
|
|
450
|
+
.optional()
|
|
451
|
+
.default("all")
|
|
452
|
+
.describe("Filter by log level"),
|
|
453
|
+
}, async ({ duration, level }) => {
|
|
454
|
+
await this.ensureInitialized();
|
|
455
|
+
if (!this.consoleMonitor) {
|
|
456
|
+
throw new Error("Console monitor not initialized. Call figma_navigate first.");
|
|
457
|
+
}
|
|
458
|
+
const consoleMonitor = this.consoleMonitor;
|
|
459
|
+
if (!consoleMonitor.getStatus().isMonitoring) {
|
|
460
|
+
throw new Error("Console monitoring not active. Call figma_navigate first.");
|
|
461
|
+
}
|
|
462
|
+
const startTime = Date.now();
|
|
463
|
+
const endTime = startTime + duration * 1000;
|
|
464
|
+
const startLogCount = consoleMonitor.getStatus().logCount;
|
|
465
|
+
// Wait for the specified duration while collecting logs
|
|
466
|
+
await new Promise(resolve => setTimeout(resolve, duration * 1000));
|
|
467
|
+
// Get logs captured during watch period
|
|
468
|
+
const watchedLogs = consoleMonitor.getLogs({
|
|
469
|
+
level: level === 'all' ? undefined : level,
|
|
470
|
+
since: startTime,
|
|
471
|
+
});
|
|
472
|
+
const endLogCount = consoleMonitor.getStatus().logCount;
|
|
473
|
+
const newLogsCount = endLogCount - startLogCount;
|
|
474
|
+
return {
|
|
475
|
+
content: [
|
|
476
|
+
{
|
|
477
|
+
type: "text",
|
|
478
|
+
text: JSON.stringify({
|
|
479
|
+
status: "completed",
|
|
480
|
+
duration: `${duration} seconds`,
|
|
481
|
+
startTime: new Date(startTime).toISOString(),
|
|
482
|
+
endTime: new Date(endTime).toISOString(),
|
|
483
|
+
filter: level,
|
|
484
|
+
statistics: {
|
|
485
|
+
totalLogsInBuffer: endLogCount,
|
|
486
|
+
logsAddedDuringWatch: newLogsCount,
|
|
487
|
+
logsMatchingFilter: watchedLogs.length,
|
|
488
|
+
},
|
|
489
|
+
logs: watchedLogs,
|
|
490
|
+
}, null, 2),
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
});
|
|
495
|
+
// Tool 4: Reload Plugin
|
|
496
|
+
this.server.tool("figma_reload_plugin", {
|
|
497
|
+
clearConsole: z
|
|
498
|
+
.boolean()
|
|
499
|
+
.optional()
|
|
500
|
+
.default(true)
|
|
501
|
+
.describe("Clear console logs before reload"),
|
|
502
|
+
}, async ({ clearConsole: clearConsoleBefore }) => {
|
|
503
|
+
try {
|
|
504
|
+
await this.ensureInitialized();
|
|
505
|
+
if (!this.browserManager) {
|
|
506
|
+
throw new Error("Browser manager not initialized");
|
|
507
|
+
}
|
|
508
|
+
// Clear console buffer if requested
|
|
509
|
+
let clearedCount = 0;
|
|
510
|
+
if (clearConsoleBefore && this.consoleMonitor) {
|
|
511
|
+
clearedCount = this.consoleMonitor.clear();
|
|
512
|
+
}
|
|
513
|
+
// Reload the page
|
|
514
|
+
await this.browserManager.reload();
|
|
515
|
+
const currentUrl = this.browserManager.getCurrentUrl();
|
|
516
|
+
return {
|
|
517
|
+
content: [
|
|
518
|
+
{
|
|
519
|
+
type: "text",
|
|
520
|
+
text: JSON.stringify({
|
|
521
|
+
status: "reloaded",
|
|
522
|
+
timestamp: Date.now(),
|
|
523
|
+
url: currentUrl,
|
|
524
|
+
consoleCleared: clearConsoleBefore,
|
|
525
|
+
clearedCount: clearConsoleBefore ? clearedCount : 0,
|
|
526
|
+
}, null, 2),
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
logger.error({ error }, "Failed to reload plugin");
|
|
533
|
+
return {
|
|
534
|
+
content: [
|
|
535
|
+
{
|
|
536
|
+
type: "text",
|
|
537
|
+
text: JSON.stringify({
|
|
538
|
+
error: String(error),
|
|
539
|
+
message: "Failed to reload plugin",
|
|
540
|
+
}, null, 2),
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
isError: true,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
// Tool 5: Clear Console
|
|
548
|
+
this.server.tool("figma_clear_console", {}, async () => {
|
|
549
|
+
try {
|
|
550
|
+
await this.ensureInitialized();
|
|
551
|
+
if (!this.consoleMonitor) {
|
|
552
|
+
throw new Error("Console monitor not initialized");
|
|
553
|
+
}
|
|
554
|
+
const clearedCount = this.consoleMonitor.clear();
|
|
555
|
+
return {
|
|
556
|
+
content: [
|
|
557
|
+
{
|
|
558
|
+
type: "text",
|
|
559
|
+
text: JSON.stringify({
|
|
560
|
+
status: "cleared",
|
|
561
|
+
clearedCount,
|
|
562
|
+
timestamp: Date.now(),
|
|
563
|
+
ai_instruction: "CRITICAL: Console cleared successfully, but this operation disrupts the monitoring connection. You MUST reconnect the MCP server using `/mcp reconnect figma-console` before calling figma_get_console_logs again. Best practice: Avoid clearing console - filter/parse logs instead to maintain monitoring connection.",
|
|
564
|
+
}, null, 2),
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
logger.error({ error }, "Failed to clear console");
|
|
571
|
+
return {
|
|
572
|
+
content: [
|
|
573
|
+
{
|
|
574
|
+
type: "text",
|
|
575
|
+
text: JSON.stringify({
|
|
576
|
+
error: String(error),
|
|
577
|
+
message: "Failed to clear console buffer",
|
|
578
|
+
}, null, 2),
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
isError: true,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
// Tool 6: Navigate to Figma
|
|
586
|
+
this.server.tool("figma_navigate", {
|
|
587
|
+
url: z
|
|
588
|
+
.string()
|
|
589
|
+
.url()
|
|
590
|
+
.describe("Figma URL to navigate to (e.g., https://www.figma.com/design/abc123)"),
|
|
591
|
+
}, async ({ url }) => {
|
|
592
|
+
try {
|
|
593
|
+
await this.ensureInitialized();
|
|
594
|
+
if (!this.browserManager) {
|
|
595
|
+
throw new Error("Browser manager not initialized");
|
|
596
|
+
}
|
|
597
|
+
// Navigate to the URL (may switch to existing tab in local mode)
|
|
598
|
+
const result = await this.browserManager.navigateToFigma(url);
|
|
599
|
+
if (result.action === 'switched_to_existing') {
|
|
600
|
+
// Switch console monitor to the page
|
|
601
|
+
if (this.consoleMonitor) {
|
|
602
|
+
this.consoleMonitor.stopMonitoring();
|
|
603
|
+
await this.consoleMonitor.startMonitoring(result.page);
|
|
604
|
+
}
|
|
605
|
+
const currentUrl = this.browserManager.getCurrentUrl();
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: "text",
|
|
610
|
+
text: JSON.stringify({
|
|
611
|
+
status: "switched_to_existing",
|
|
612
|
+
url: currentUrl,
|
|
613
|
+
timestamp: Date.now(),
|
|
614
|
+
message: "Switched to existing tab for this Figma file. Console monitoring is active.",
|
|
615
|
+
}, null, 2),
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// Give page time to load and start capturing logs
|
|
621
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
622
|
+
const currentUrl = this.browserManager.getCurrentUrl();
|
|
623
|
+
return {
|
|
624
|
+
content: [
|
|
625
|
+
{
|
|
626
|
+
type: "text",
|
|
627
|
+
text: JSON.stringify({
|
|
628
|
+
status: "navigated",
|
|
629
|
+
url: currentUrl,
|
|
630
|
+
timestamp: Date.now(),
|
|
631
|
+
message: "Browser navigated to Figma. Console monitoring is active.",
|
|
632
|
+
}, null, 2),
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
logger.error({ error }, "Failed to navigate to Figma");
|
|
639
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
640
|
+
return {
|
|
641
|
+
content: [
|
|
642
|
+
{
|
|
643
|
+
type: "text",
|
|
644
|
+
text: JSON.stringify({
|
|
645
|
+
error: errorMessage,
|
|
646
|
+
message: "Failed to navigate to Figma URL",
|
|
647
|
+
details: errorMessage.includes("BROWSER")
|
|
648
|
+
? "Browser Rendering API binding is missing. This is a configuration issue."
|
|
649
|
+
: "Unable to launch browser or navigate to URL.",
|
|
650
|
+
troubleshooting: [
|
|
651
|
+
"Verify the Figma URL is valid and accessible",
|
|
652
|
+
"Check that the Browser Rendering API is properly configured in wrangler.jsonc",
|
|
653
|
+
"Try again in a few moments if this is a temporary issue"
|
|
654
|
+
]
|
|
655
|
+
}, null, 2),
|
|
656
|
+
},
|
|
657
|
+
],
|
|
658
|
+
isError: true,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
// Tool 7: Get Status
|
|
663
|
+
this.server.tool("figma_get_status", {}, async () => {
|
|
664
|
+
try {
|
|
665
|
+
const browserRunning = this.browserManager?.isRunning() ?? false;
|
|
666
|
+
const monitorStatus = this.consoleMonitor?.getStatus() ?? null;
|
|
667
|
+
const currentUrl = this.browserManager?.getCurrentUrl() ?? null;
|
|
668
|
+
return {
|
|
669
|
+
content: [
|
|
670
|
+
{
|
|
671
|
+
type: "text",
|
|
672
|
+
text: JSON.stringify({
|
|
673
|
+
browser: {
|
|
674
|
+
running: browserRunning,
|
|
675
|
+
currentUrl,
|
|
676
|
+
},
|
|
677
|
+
consoleMonitor: monitorStatus,
|
|
678
|
+
initialized: this.browserManager !== null && this.consoleMonitor !== null,
|
|
679
|
+
timestamp: Date.now(),
|
|
680
|
+
}, null, 2),
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
logger.error({ error }, "Failed to get status");
|
|
687
|
+
return {
|
|
688
|
+
content: [
|
|
689
|
+
{
|
|
690
|
+
type: "text",
|
|
691
|
+
text: JSON.stringify({
|
|
692
|
+
error: String(error),
|
|
693
|
+
message: "Failed to retrieve status",
|
|
694
|
+
}, null, 2),
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
isError: true,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
// ================================================================
|
|
702
|
+
// Cloud Write Relay — Pairing Tool
|
|
703
|
+
// ================================================================
|
|
704
|
+
this.server.tool("figma_pair_plugin", "Pair the Figma Desktop Bridge plugin to this cloud session for write access. Returns a 6-character code the user enters in the plugin's Cloud Mode section.", {}, async () => {
|
|
705
|
+
try {
|
|
706
|
+
const env = this.env;
|
|
707
|
+
const code = generatePairingCode();
|
|
708
|
+
// Create a unique DO ID for this relay session
|
|
709
|
+
const relayDoId = env.PLUGIN_RELAY.newUniqueId().toString();
|
|
710
|
+
// Store pairing code → relay DO ID in KV (5-min TTL, one-time use)
|
|
711
|
+
await env.OAUTH_TOKENS.put(`pairing:${code}`, relayDoId, {
|
|
712
|
+
expirationTtl: 300,
|
|
713
|
+
});
|
|
714
|
+
// Store relay DO ID in this MCP DO's storage for session persistence
|
|
715
|
+
await this.ctx.storage.put('relayDoId', relayDoId);
|
|
716
|
+
return {
|
|
717
|
+
content: [{
|
|
718
|
+
type: "text",
|
|
719
|
+
text: JSON.stringify({
|
|
720
|
+
pairingCode: code,
|
|
721
|
+
expiresIn: "5 minutes",
|
|
722
|
+
instructions: [
|
|
723
|
+
"1. Open the Desktop Bridge plugin in Figma Desktop",
|
|
724
|
+
"2. Click the 'Cloud Mode' toggle in the plugin UI",
|
|
725
|
+
`3. Enter pairing code: ${code}`,
|
|
726
|
+
"4. Click 'Connect' — the plugin will connect to the cloud relay",
|
|
727
|
+
"5. Once paired, write tools (variables, components, nodes) work through the cloud"
|
|
728
|
+
],
|
|
729
|
+
}, null, 2),
|
|
730
|
+
}],
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
735
|
+
return {
|
|
736
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorMessage }) }],
|
|
737
|
+
isError: true,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
// ================================================================
|
|
742
|
+
// Cloud Desktop Connector factory
|
|
743
|
+
// ================================================================
|
|
744
|
+
const getCloudDesktopConnector = async () => {
|
|
745
|
+
const env = this.env;
|
|
746
|
+
const relayDoId = await this.ctx.storage.get('relayDoId');
|
|
747
|
+
if (!relayDoId) {
|
|
748
|
+
throw new Error('No cloud relay session. Call figma_pair_plugin first to pair the Desktop Bridge plugin.');
|
|
749
|
+
}
|
|
750
|
+
const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
|
|
751
|
+
const stub = env.PLUGIN_RELAY.get(doId);
|
|
752
|
+
const connector = new CloudWebSocketConnector(stub);
|
|
753
|
+
await connector.initialize();
|
|
754
|
+
return connector;
|
|
755
|
+
};
|
|
756
|
+
// Register all write/manipulation tools via shared function
|
|
757
|
+
registerWriteTools(this.server, getCloudDesktopConnector);
|
|
758
|
+
// Register Figma API tools (Tools 8-14)
|
|
759
|
+
// Pass isRemoteMode: true to suppress Desktop Bridge mentions in tool descriptions
|
|
760
|
+
registerFigmaAPITools(this.server, async () => await this.getFigmaAPI(), () => this.browserManager?.getCurrentUrl() || null, () => this.consoleMonitor || null, () => this.browserManager || null, () => this.ensureInitialized(), undefined, // variablesCache
|
|
761
|
+
{ isRemoteMode: true }, getCloudDesktopConnector);
|
|
762
|
+
// Register Design-Code Parity & Documentation tools
|
|
763
|
+
registerDesignCodeTools(this.server, async () => await this.getFigmaAPI(), () => this.browserManager?.getCurrentUrl() || null, undefined, // variablesCache
|
|
764
|
+
{ isRemoteMode: true }, getCloudDesktopConnector);
|
|
765
|
+
// Register Comment tools
|
|
766
|
+
registerCommentTools(this.server, async () => await this.getFigmaAPI(), () => this.browserManager?.getCurrentUrl() || null, { isRemoteMode: true });
|
|
767
|
+
// Register Design System Kit tool
|
|
768
|
+
registerDesignSystemTools(this.server, async () => await this.getFigmaAPI(), () => this.browserManager?.getCurrentUrl() || null, undefined, // variablesCache
|
|
769
|
+
{ isRemoteMode: true });
|
|
770
|
+
// Note: MCP Apps (Token Browser, Dashboard) are registered in local.ts only
|
|
771
|
+
// They require Node.js file system APIs that don't work in Cloudflare Workers
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Cloudflare Workers fetch handler
|
|
776
|
+
* Routes requests to appropriate MCP endpoints
|
|
777
|
+
*/
|
|
778
|
+
export default {
|
|
779
|
+
async fetch(request, env, ctx) {
|
|
780
|
+
const response = await handleRequest(request, env, ctx);
|
|
781
|
+
return withSecurityHeaders(response);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
async function handleRequest(request, env, ctx) {
|
|
785
|
+
const url = new URL(request.url);
|
|
786
|
+
// Use canonical origin for OAuth redirect URIs so they match the Figma OAuth app config
|
|
787
|
+
// regardless of whether the request comes via workers.dev or custom domain
|
|
788
|
+
const oauthOrigin = env.CANONICAL_ORIGIN || url.origin;
|
|
789
|
+
// Redirect /docs to subdomain
|
|
790
|
+
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) {
|
|
791
|
+
const newPath = url.pathname.replace(/^\/docs\/?/, "/");
|
|
792
|
+
const redirectUrl = `https://docs.figma-console-mcp.southleft.com${newPath}${url.search}`;
|
|
793
|
+
return Response.redirect(redirectUrl, 301);
|
|
794
|
+
}
|
|
795
|
+
// ================================================================
|
|
796
|
+
// Cloud Write Relay — Plugin WebSocket pairing endpoint
|
|
797
|
+
// ================================================================
|
|
798
|
+
if (url.pathname === "/ws/pair") {
|
|
799
|
+
const code = url.searchParams.get("code");
|
|
800
|
+
if (!code) {
|
|
801
|
+
return new Response(JSON.stringify({ error: "Missing pairing code" }), {
|
|
802
|
+
status: 400,
|
|
803
|
+
headers: { "Content-Type": "application/json" },
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
// Look up pairing code in KV
|
|
807
|
+
const pairingKey = `pairing:${code.toUpperCase()}`;
|
|
808
|
+
const relayDoId = await env.OAUTH_TOKENS.get(pairingKey);
|
|
809
|
+
if (!relayDoId) {
|
|
810
|
+
return new Response(JSON.stringify({ error: "Invalid or expired pairing code" }), {
|
|
811
|
+
status: 404,
|
|
812
|
+
headers: { "Content-Type": "application/json" },
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
// Delete used code (one-time use)
|
|
816
|
+
await env.OAUTH_TOKENS.delete(pairingKey);
|
|
817
|
+
// Forward WebSocket upgrade to the relay DO
|
|
818
|
+
const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
|
|
819
|
+
const stub = env.PLUGIN_RELAY.get(doId);
|
|
820
|
+
// Rewrite URL to the relay DO's /ws/connect path
|
|
821
|
+
const relayUrl = new URL(request.url);
|
|
822
|
+
relayUrl.pathname = "/ws/connect";
|
|
823
|
+
const relayRequest = new Request(relayUrl.toString(), request);
|
|
824
|
+
return stub.fetch(relayRequest);
|
|
825
|
+
}
|
|
826
|
+
// SSE endpoint for remote MCP clients
|
|
827
|
+
// Per MCP spec, we MUST validate Bearer tokens on every HTTP request
|
|
828
|
+
if (url.pathname === "/sse" || url.pathname === "/sse/message") {
|
|
829
|
+
// Validate Authorization header per MCP OAuth 2.1 spec
|
|
830
|
+
const authHeader = request.headers.get("Authorization");
|
|
831
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
832
|
+
logger.warn({ pathname: url.pathname }, "SSE request missing Authorization header - returning 401 with resource_metadata");
|
|
833
|
+
// MCP spec requires resource_metadata URL in WWW-Authenticate header (RFC9728)
|
|
834
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
835
|
+
return new Response(JSON.stringify({
|
|
836
|
+
error: "unauthorized",
|
|
837
|
+
error_description: "Authorization header with Bearer token is required"
|
|
838
|
+
}), {
|
|
839
|
+
status: 401,
|
|
840
|
+
headers: {
|
|
841
|
+
"Content-Type": "application/json",
|
|
842
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
const bearerToken = authHeader.substring(7); // Remove "Bearer " prefix
|
|
847
|
+
const bearerKey = `bearer_token:${bearerToken}`;
|
|
848
|
+
try {
|
|
849
|
+
const tokenDataJson = await env.OAUTH_TOKENS.get(bearerKey);
|
|
850
|
+
if (!tokenDataJson) {
|
|
851
|
+
logger.warn({ pathname: url.pathname }, "SSE request with invalid Bearer token");
|
|
852
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
853
|
+
return new Response(JSON.stringify({
|
|
854
|
+
error: "invalid_token",
|
|
855
|
+
error_description: "Bearer token is invalid or expired"
|
|
856
|
+
}), {
|
|
857
|
+
status: 401,
|
|
858
|
+
headers: {
|
|
859
|
+
"Content-Type": "application/json",
|
|
860
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
const tokenData = JSON.parse(tokenDataJson);
|
|
865
|
+
// Check if token is expired
|
|
866
|
+
if (tokenData.expiresAt < Date.now()) {
|
|
867
|
+
logger.warn({ pathname: url.pathname, sessionId: tokenData.sessionId }, "SSE request with expired Bearer token");
|
|
868
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
869
|
+
return new Response(JSON.stringify({
|
|
870
|
+
error: "invalid_token",
|
|
871
|
+
error_description: "Bearer token has expired"
|
|
872
|
+
}), {
|
|
873
|
+
status: 401,
|
|
874
|
+
headers: {
|
|
875
|
+
"Content-Type": "application/json",
|
|
876
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
logger.info({ pathname: url.pathname, sessionId: tokenData.sessionId }, "SSE request authenticated successfully");
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
logger.error({ error, pathname: url.pathname }, "Error validating Bearer token");
|
|
884
|
+
return new Response(JSON.stringify({
|
|
885
|
+
error: "server_error",
|
|
886
|
+
error_description: "Failed to validate authorization"
|
|
887
|
+
}), {
|
|
888
|
+
status: 500,
|
|
889
|
+
headers: { "Content-Type": "application/json" }
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
// Token is valid, proceed with SSE connection
|
|
893
|
+
return FigmaConsoleMCPv3.serveSSE("/sse").fetch(request, env, ctx);
|
|
894
|
+
}
|
|
895
|
+
// Streamable HTTP endpoint for MCP communication (current spec)
|
|
896
|
+
// Supports POST (client→server) and optional GET (server→client SSE)
|
|
897
|
+
if (url.pathname === "/mcp") {
|
|
898
|
+
// Validate Authorization header per MCP OAuth 2.1 spec
|
|
899
|
+
const authHeader = request.headers.get("Authorization");
|
|
900
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
901
|
+
logger.warn({ pathname: url.pathname }, "MCP request missing Authorization header - returning 401 with resource_metadata");
|
|
902
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
903
|
+
return new Response(JSON.stringify({
|
|
904
|
+
error: "unauthorized",
|
|
905
|
+
error_description: "Authorization header with Bearer token is required"
|
|
906
|
+
}), {
|
|
907
|
+
status: 401,
|
|
908
|
+
headers: {
|
|
909
|
+
"Content-Type": "application/json",
|
|
910
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
const bearerToken = authHeader.substring(7);
|
|
915
|
+
const bearerKey = `bearer_token:${bearerToken}`;
|
|
916
|
+
try {
|
|
917
|
+
const tokenDataJson = await env.OAUTH_TOKENS.get(bearerKey);
|
|
918
|
+
if (!tokenDataJson) {
|
|
919
|
+
logger.warn({ pathname: url.pathname }, "MCP request with invalid Bearer token");
|
|
920
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
921
|
+
return new Response(JSON.stringify({
|
|
922
|
+
error: "invalid_token",
|
|
923
|
+
error_description: "Bearer token is invalid or expired"
|
|
924
|
+
}), {
|
|
925
|
+
status: 401,
|
|
926
|
+
headers: {
|
|
927
|
+
"Content-Type": "application/json",
|
|
928
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
const tokenData = JSON.parse(tokenDataJson);
|
|
933
|
+
if (tokenData.expiresAt < Date.now()) {
|
|
934
|
+
logger.warn({ pathname: url.pathname, sessionId: tokenData.sessionId }, "MCP request with expired Bearer token");
|
|
935
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
936
|
+
return new Response(JSON.stringify({
|
|
937
|
+
error: "invalid_token",
|
|
938
|
+
error_description: "Bearer token has expired"
|
|
939
|
+
}), {
|
|
940
|
+
status: 401,
|
|
941
|
+
headers: {
|
|
942
|
+
"Content-Type": "application/json",
|
|
943
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
logger.info({ pathname: url.pathname, sessionId: tokenData.sessionId }, "MCP request authenticated successfully");
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
logger.error({ error, pathname: url.pathname }, "Error validating Bearer token for MCP endpoint");
|
|
951
|
+
return new Response(JSON.stringify({
|
|
952
|
+
error: "server_error",
|
|
953
|
+
error_description: "Failed to validate authorization"
|
|
954
|
+
}), {
|
|
955
|
+
status: 500,
|
|
956
|
+
headers: { "Content-Type": "application/json" }
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
// Token is valid — use stateless transport (no Durable Objects)
|
|
960
|
+
// The Bearer token IS the Figma access token, so we use it directly
|
|
961
|
+
const figmaAccessToken = bearerToken;
|
|
962
|
+
const statelessApi = new FigmaAPI({ accessToken: figmaAccessToken });
|
|
963
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
964
|
+
sessionIdGenerator: undefined, // Stateless — no session persistence needed
|
|
965
|
+
});
|
|
966
|
+
const statelessServer = new McpServer({
|
|
967
|
+
name: "Figma Console MCP",
|
|
968
|
+
version: "1.13.0",
|
|
969
|
+
});
|
|
970
|
+
// ================================================================
|
|
971
|
+
// Cloud Write Relay — Pairing Tool (stateless /mcp path)
|
|
972
|
+
// Uses KV keyed by bearer token instead of DO storage
|
|
973
|
+
// ================================================================
|
|
974
|
+
statelessServer.tool("figma_pair_plugin", "Pair the Figma Desktop Bridge plugin to this cloud session for write access. Returns a 6-character code the user enters in the plugin's Cloud Mode section.", {}, async () => {
|
|
975
|
+
try {
|
|
976
|
+
const code = generatePairingCode();
|
|
977
|
+
const relayDoId = env.PLUGIN_RELAY.newUniqueId().toString();
|
|
978
|
+
// Store pairing code → relay DO ID in KV (5-min TTL, one-time use)
|
|
979
|
+
await env.OAUTH_TOKENS.put(`pairing:${code}`, relayDoId, {
|
|
980
|
+
expirationTtl: 300,
|
|
981
|
+
});
|
|
982
|
+
// Store relay DO ID keyed by bearer token for session persistence
|
|
983
|
+
await env.OAUTH_TOKENS.put(`relay:${bearerToken}`, relayDoId, {
|
|
984
|
+
expirationTtl: 86400, // 24h — matches typical session length
|
|
985
|
+
});
|
|
986
|
+
return {
|
|
987
|
+
content: [{
|
|
988
|
+
type: "text",
|
|
989
|
+
text: JSON.stringify({
|
|
990
|
+
pairingCode: code,
|
|
991
|
+
expiresIn: "5 minutes",
|
|
992
|
+
instructions: [
|
|
993
|
+
"1. Open the MCP Bridge plugin in Figma Desktop",
|
|
994
|
+
"2. Click the '▶ Cloud Mode' toggle in the plugin UI",
|
|
995
|
+
`3. Enter pairing code: ${code}`,
|
|
996
|
+
"4. Click 'Connect' — the plugin will connect to the cloud relay",
|
|
997
|
+
"5. Once paired, write tools (variables, components, nodes) work through the cloud"
|
|
998
|
+
],
|
|
999
|
+
}, null, 2),
|
|
1000
|
+
}],
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
catch (error) {
|
|
1004
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1005
|
+
return {
|
|
1006
|
+
content: [{ type: "text", text: JSON.stringify({ error: errorMessage }) }],
|
|
1007
|
+
isError: true,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
// Cloud Desktop Connector factory (stateless /mcp path)
|
|
1012
|
+
const getCloudDesktopConnector = async () => {
|
|
1013
|
+
const relayDoId = await env.OAUTH_TOKENS.get(`relay:${bearerToken}`);
|
|
1014
|
+
if (!relayDoId) {
|
|
1015
|
+
throw new Error('No cloud relay session. Call figma_pair_plugin first to pair the Desktop Bridge plugin.');
|
|
1016
|
+
}
|
|
1017
|
+
const doId = env.PLUGIN_RELAY.idFromString(relayDoId);
|
|
1018
|
+
const stub = env.PLUGIN_RELAY.get(doId);
|
|
1019
|
+
const connector = new CloudWebSocketConnector(stub);
|
|
1020
|
+
await connector.initialize();
|
|
1021
|
+
return connector;
|
|
1022
|
+
};
|
|
1023
|
+
// Register all write/manipulation tools via shared function
|
|
1024
|
+
registerWriteTools(statelessServer, getCloudDesktopConnector);
|
|
1025
|
+
// Register REST API tools with the authenticated Figma API
|
|
1026
|
+
registerFigmaAPITools(statelessServer, async () => statelessApi, () => null, // No browser URL in stateless mode
|
|
1027
|
+
() => null, // No console monitor
|
|
1028
|
+
() => null, // No browser manager
|
|
1029
|
+
undefined, // No ensureInitialized
|
|
1030
|
+
new Map(), // Fresh variables cache per request
|
|
1031
|
+
{ isRemoteMode: true }, getCloudDesktopConnector);
|
|
1032
|
+
registerDesignCodeTools(statelessServer, async () => statelessApi, () => null, new Map(), // Fresh variables cache per request
|
|
1033
|
+
{ isRemoteMode: true }, getCloudDesktopConnector);
|
|
1034
|
+
registerCommentTools(statelessServer, async () => statelessApi, () => null);
|
|
1035
|
+
registerDesignSystemTools(statelessServer, async () => statelessApi, () => null, new Map(), // Fresh variables cache per request
|
|
1036
|
+
{ isRemoteMode: true });
|
|
1037
|
+
await statelessServer.connect(transport);
|
|
1038
|
+
const response = await transport.handleRequest(request);
|
|
1039
|
+
if (response) {
|
|
1040
|
+
return response;
|
|
1041
|
+
}
|
|
1042
|
+
return new Response("No response from MCP transport", { status: 500 });
|
|
1043
|
+
}
|
|
1044
|
+
// ============================================================
|
|
1045
|
+
// MCP OAuth 2.1 Spec-Compliant Endpoints
|
|
1046
|
+
// These endpoints follow the MCP Authorization specification
|
|
1047
|
+
// for compatibility with mcp-remote and Claude Code
|
|
1048
|
+
// ============================================================
|
|
1049
|
+
// Protected Resource Metadata (RFC9728)
|
|
1050
|
+
// Required by MCP spec for OAuth discovery - tells clients where to find authorization server
|
|
1051
|
+
if (url.pathname === "/.well-known/oauth-protected-resource" ||
|
|
1052
|
+
url.pathname.startsWith("/.well-known/oauth-protected-resource/")) {
|
|
1053
|
+
const metadata = {
|
|
1054
|
+
resource: url.origin,
|
|
1055
|
+
authorization_servers: [`${url.origin}/`],
|
|
1056
|
+
scopes_supported: ["file_content:read", "library_content:read", "file_variables:read"],
|
|
1057
|
+
bearer_methods_supported: ["header"],
|
|
1058
|
+
resource_signing_alg_values_supported: ["RS256"]
|
|
1059
|
+
};
|
|
1060
|
+
return new Response(JSON.stringify(metadata, null, 2), {
|
|
1061
|
+
headers: {
|
|
1062
|
+
"Content-Type": "application/json",
|
|
1063
|
+
"Cache-Control": "public, max-age=3600"
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
// OAuth 2.0 Authorization Server Metadata (RFC8414)
|
|
1068
|
+
// Required by MCP spec for client discovery
|
|
1069
|
+
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
|
1070
|
+
const metadata = {
|
|
1071
|
+
issuer: url.origin,
|
|
1072
|
+
authorization_endpoint: `${url.origin}/authorize`,
|
|
1073
|
+
token_endpoint: `${url.origin}/token`,
|
|
1074
|
+
registration_endpoint: `${url.origin}/oauth/register`,
|
|
1075
|
+
scopes_supported: ["file_content:read", "library_content:read", "file_variables:read"],
|
|
1076
|
+
response_types_supported: ["code"],
|
|
1077
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
1078
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
|
1079
|
+
code_challenge_methods_supported: ["S256"],
|
|
1080
|
+
service_documentation: "https://docs.figma-console-mcp.southleft.com",
|
|
1081
|
+
};
|
|
1082
|
+
return new Response(JSON.stringify(metadata, null, 2), {
|
|
1083
|
+
headers: {
|
|
1084
|
+
"Content-Type": "application/json",
|
|
1085
|
+
"Cache-Control": "public, max-age=3600"
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
// MCP-compliant /authorize endpoint
|
|
1090
|
+
// Handles authorization requests from MCP clients
|
|
1091
|
+
if (url.pathname === "/authorize") {
|
|
1092
|
+
const clientId = url.searchParams.get("client_id");
|
|
1093
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
1094
|
+
const state = url.searchParams.get("state");
|
|
1095
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
1096
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
1097
|
+
const scope = url.searchParams.get("scope");
|
|
1098
|
+
// For MCP clients, use the client_id as the session identifier
|
|
1099
|
+
// This allows token retrieval after the OAuth flow completes
|
|
1100
|
+
const sessionId = clientId || FigmaConsoleMCPv3.generateStateToken();
|
|
1101
|
+
// Store the MCP client's redirect_uri and state for the callback
|
|
1102
|
+
if (redirectUri && state) {
|
|
1103
|
+
// SEC-004: Validate redirect_uri against the registered client's allowed list
|
|
1104
|
+
if (clientId) {
|
|
1105
|
+
const clientJson = await env.OAUTH_STATE.get(`client:${clientId}`);
|
|
1106
|
+
if (clientJson) {
|
|
1107
|
+
const clientData = JSON.parse(clientJson);
|
|
1108
|
+
if (clientData.redirect_uris.length > 0 && !clientData.redirect_uris.includes(redirectUri)) {
|
|
1109
|
+
return new Response(JSON.stringify({ error: "invalid_request", error_description: "redirect_uri does not match registered redirect URIs" }), {
|
|
1110
|
+
status: 400, headers: { "Content-Type": "application/json" }
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const mcpAuthData = {
|
|
1116
|
+
redirectUri,
|
|
1117
|
+
state,
|
|
1118
|
+
codeChallenge,
|
|
1119
|
+
codeChallengeMethod,
|
|
1120
|
+
scope,
|
|
1121
|
+
clientId,
|
|
1122
|
+
sessionId
|
|
1123
|
+
};
|
|
1124
|
+
// Store with 10 minute expiration
|
|
1125
|
+
const mcpStateKey = `mcp_auth:${sessionId}`;
|
|
1126
|
+
await env.OAUTH_STATE.put(mcpStateKey, JSON.stringify(mcpAuthData), {
|
|
1127
|
+
expirationTtl: 600
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
// Check if OAuth credentials are configured
|
|
1131
|
+
if (!env.FIGMA_OAUTH_CLIENT_ID) {
|
|
1132
|
+
return new Response(JSON.stringify({
|
|
1133
|
+
error: "server_error",
|
|
1134
|
+
error_description: "OAuth not configured on server"
|
|
1135
|
+
}), {
|
|
1136
|
+
status: 500,
|
|
1137
|
+
headers: { "Content-Type": "application/json" }
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
// Generate CSRF protection token
|
|
1141
|
+
const stateToken = FigmaConsoleMCPv3.generateStateToken();
|
|
1142
|
+
// Store state token with sessionId (10 minute expiration)
|
|
1143
|
+
await env.OAUTH_STATE.put(stateToken, sessionId, {
|
|
1144
|
+
expirationTtl: 600
|
|
1145
|
+
});
|
|
1146
|
+
// Redirect to Figma OAuth
|
|
1147
|
+
const figmaAuthUrl = new URL("https://www.figma.com/oauth");
|
|
1148
|
+
figmaAuthUrl.searchParams.set("client_id", env.FIGMA_OAUTH_CLIENT_ID);
|
|
1149
|
+
figmaAuthUrl.searchParams.set("redirect_uri", `${oauthOrigin}/oauth/callback`);
|
|
1150
|
+
figmaAuthUrl.searchParams.set("scope", "file_content:read,library_content:read,file_variables:read");
|
|
1151
|
+
figmaAuthUrl.searchParams.set("state", stateToken);
|
|
1152
|
+
figmaAuthUrl.searchParams.set("response_type", "code");
|
|
1153
|
+
return Response.redirect(figmaAuthUrl.toString(), 302);
|
|
1154
|
+
}
|
|
1155
|
+
// MCP-compliant /token endpoint
|
|
1156
|
+
// Handles token exchange and refresh requests
|
|
1157
|
+
if (url.pathname === "/token" && request.method === "POST") {
|
|
1158
|
+
const contentType = request.headers.get("content-type") || "";
|
|
1159
|
+
let params;
|
|
1160
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
1161
|
+
params = new URLSearchParams(await request.text());
|
|
1162
|
+
}
|
|
1163
|
+
else if (contentType.includes("application/json")) {
|
|
1164
|
+
const body = await request.json();
|
|
1165
|
+
params = new URLSearchParams(body);
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
params = new URLSearchParams(await request.text());
|
|
1169
|
+
}
|
|
1170
|
+
const grantType = params.get("grant_type");
|
|
1171
|
+
const clientId = params.get("client_id");
|
|
1172
|
+
const code = params.get("code");
|
|
1173
|
+
const codeVerifier = params.get("code_verifier");
|
|
1174
|
+
const refreshToken = params.get("refresh_token");
|
|
1175
|
+
// For authorization_code grant, exchange the code for tokens
|
|
1176
|
+
if (grantType === "authorization_code" && code) {
|
|
1177
|
+
// The code here is actually our session-based token
|
|
1178
|
+
// Look up the stored token by session/client ID
|
|
1179
|
+
const sessionId = clientId || code;
|
|
1180
|
+
const tokenKey = `oauth_token:${sessionId}`;
|
|
1181
|
+
logger.info({ grantType, clientId, hasCode: !!code, sessionId }, "Token exchange request");
|
|
1182
|
+
// PKCE verification (SEC-003): if a code_challenge was stored, verify code_verifier now
|
|
1183
|
+
const mcpStateKey = `mcp_auth:${sessionId}`;
|
|
1184
|
+
const mcpAuthJson = await env.OAUTH_STATE.get(mcpStateKey);
|
|
1185
|
+
if (mcpAuthJson) {
|
|
1186
|
+
const mcpAuthData = JSON.parse(mcpAuthJson);
|
|
1187
|
+
if (mcpAuthData.codeChallenge) {
|
|
1188
|
+
if (!codeVerifier) {
|
|
1189
|
+
return new Response(JSON.stringify({ error: "invalid_request", error_description: "code_verifier required" }), {
|
|
1190
|
+
status: 400, headers: { "Content-Type": "application/json" }
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
// Verify S256: BASE64URL(SHA-256(code_verifier)) === code_challenge
|
|
1194
|
+
const encoder = new TextEncoder();
|
|
1195
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(codeVerifier));
|
|
1196
|
+
const computed = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
1197
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
1198
|
+
if (computed !== mcpAuthData.codeChallenge) {
|
|
1199
|
+
return new Response(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }), {
|
|
1200
|
+
status: 400, headers: { "Content-Type": "application/json" }
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const tokenJson = await env.OAUTH_TOKENS.get(tokenKey);
|
|
1206
|
+
logger.info({ tokenKey, hasToken: !!tokenJson }, "Token lookup result");
|
|
1207
|
+
if (tokenJson) {
|
|
1208
|
+
const tokenData = JSON.parse(tokenJson);
|
|
1209
|
+
// Return tokens in OAuth 2.0 format
|
|
1210
|
+
return new Response(JSON.stringify({
|
|
1211
|
+
access_token: tokenData.accessToken,
|
|
1212
|
+
token_type: "Bearer",
|
|
1213
|
+
expires_in: Math.max(0, Math.floor((tokenData.expiresAt - Date.now()) / 1000)),
|
|
1214
|
+
refresh_token: tokenData.refreshToken,
|
|
1215
|
+
scope: "file_content:read library_content:read file_variables:read"
|
|
1216
|
+
}), {
|
|
1217
|
+
headers: {
|
|
1218
|
+
"Content-Type": "application/json",
|
|
1219
|
+
"Cache-Control": "no-store"
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
logger.error({ sessionId, clientId, hasCode: !!code }, "Token not found for exchange");
|
|
1224
|
+
return new Response(JSON.stringify({
|
|
1225
|
+
error: "invalid_grant",
|
|
1226
|
+
error_description: "Authorization code not found or expired. Please re-authenticate."
|
|
1227
|
+
}), {
|
|
1228
|
+
status: 400,
|
|
1229
|
+
headers: { "Content-Type": "application/json" }
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
// For refresh_token grant
|
|
1233
|
+
if (grantType === "refresh_token" && refreshToken) {
|
|
1234
|
+
if (!env.FIGMA_OAUTH_CLIENT_ID || !env.FIGMA_OAUTH_CLIENT_SECRET) {
|
|
1235
|
+
return new Response(JSON.stringify({
|
|
1236
|
+
error: "server_error",
|
|
1237
|
+
error_description: "OAuth not configured"
|
|
1238
|
+
}), {
|
|
1239
|
+
status: 500,
|
|
1240
|
+
headers: { "Content-Type": "application/json" }
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
|
|
1244
|
+
const tokenParams = new URLSearchParams({
|
|
1245
|
+
grant_type: "refresh_token",
|
|
1246
|
+
refresh_token: refreshToken
|
|
1247
|
+
});
|
|
1248
|
+
const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
|
|
1249
|
+
method: "POST",
|
|
1250
|
+
headers: {
|
|
1251
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1252
|
+
"Authorization": `Basic ${credentials}`
|
|
1253
|
+
},
|
|
1254
|
+
body: tokenParams.toString()
|
|
1255
|
+
});
|
|
1256
|
+
if (!tokenResponse.ok) {
|
|
1257
|
+
return new Response(JSON.stringify({
|
|
1258
|
+
error: "invalid_grant",
|
|
1259
|
+
error_description: "Failed to refresh token"
|
|
1260
|
+
}), {
|
|
1261
|
+
status: 400,
|
|
1262
|
+
headers: { "Content-Type": "application/json" }
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
const tokenData = await tokenResponse.json();
|
|
1266
|
+
// Store the refreshed token
|
|
1267
|
+
if (clientId) {
|
|
1268
|
+
const tokenKey = `oauth_token:${clientId}`;
|
|
1269
|
+
const expiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
1270
|
+
const storedToken = {
|
|
1271
|
+
accessToken: tokenData.access_token,
|
|
1272
|
+
refreshToken: tokenData.refresh_token || refreshToken,
|
|
1273
|
+
expiresAt
|
|
1274
|
+
};
|
|
1275
|
+
await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
|
|
1276
|
+
expirationTtl: tokenData.expires_in
|
|
1277
|
+
});
|
|
1278
|
+
// Store reverse lookup for Bearer token validation on SSE endpoint
|
|
1279
|
+
const bearerKey = `bearer_token:${tokenData.access_token}`;
|
|
1280
|
+
await env.OAUTH_TOKENS.put(bearerKey, JSON.stringify({
|
|
1281
|
+
sessionId: clientId,
|
|
1282
|
+
expiresAt
|
|
1283
|
+
}), {
|
|
1284
|
+
expirationTtl: tokenData.expires_in
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
return new Response(JSON.stringify({
|
|
1288
|
+
access_token: tokenData.access_token,
|
|
1289
|
+
token_type: "Bearer",
|
|
1290
|
+
expires_in: tokenData.expires_in,
|
|
1291
|
+
refresh_token: tokenData.refresh_token || refreshToken,
|
|
1292
|
+
scope: "file_content:read library_content:read file_variables:read"
|
|
1293
|
+
}), {
|
|
1294
|
+
headers: {
|
|
1295
|
+
"Content-Type": "application/json",
|
|
1296
|
+
"Cache-Control": "no-store"
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
return new Response(JSON.stringify({
|
|
1301
|
+
error: "unsupported_grant_type",
|
|
1302
|
+
error_description: "Only authorization_code and refresh_token grants are supported"
|
|
1303
|
+
}), {
|
|
1304
|
+
status: 400,
|
|
1305
|
+
headers: { "Content-Type": "application/json" }
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
// Dynamic Client Registration (RFC7591)
|
|
1309
|
+
// Required by MCP spec for clients to register
|
|
1310
|
+
if (url.pathname === "/oauth/register" && request.method === "POST") {
|
|
1311
|
+
const body = await request.json();
|
|
1312
|
+
// Validate redirect_uris (SEC-011): reject non-HTTPS URIs and fragments
|
|
1313
|
+
const rawUris = body.redirect_uris || [];
|
|
1314
|
+
const validatedUris = [];
|
|
1315
|
+
for (const uri of rawUris) {
|
|
1316
|
+
try {
|
|
1317
|
+
const parsed = new URL(uri);
|
|
1318
|
+
if (parsed.hash) {
|
|
1319
|
+
return new Response(JSON.stringify({ error: "invalid_redirect_uri", error_description: "redirect_uri must not contain a fragment" }), {
|
|
1320
|
+
status: 400, headers: { "Content-Type": "application/json" }
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && parsed.hostname === "localhost")) {
|
|
1324
|
+
return new Response(JSON.stringify({ error: "invalid_redirect_uri", error_description: "redirect_uri must use https (or http://localhost)" }), {
|
|
1325
|
+
status: 400, headers: { "Content-Type": "application/json" }
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
validatedUris.push(uri);
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
return new Response(JSON.stringify({ error: "invalid_redirect_uri", error_description: "redirect_uri is not a valid URL" }), {
|
|
1332
|
+
status: 400, headers: { "Content-Type": "application/json" }
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
// Generate a client ID for this registration
|
|
1337
|
+
const clientId = `mcp_${FigmaConsoleMCPv3.generateStateToken().substring(0, 16)}`;
|
|
1338
|
+
// Store client registration (30 day expiration)
|
|
1339
|
+
await env.OAUTH_STATE.put(`client:${clientId}`, JSON.stringify({
|
|
1340
|
+
client_name: body.client_name || "MCP Client",
|
|
1341
|
+
redirect_uris: validatedUris,
|
|
1342
|
+
created_at: Date.now()
|
|
1343
|
+
}), {
|
|
1344
|
+
expirationTtl: 30 * 24 * 60 * 60
|
|
1345
|
+
});
|
|
1346
|
+
return new Response(JSON.stringify({
|
|
1347
|
+
client_id: clientId,
|
|
1348
|
+
client_name: body.client_name || "MCP Client",
|
|
1349
|
+
redirect_uris: body.redirect_uris || [],
|
|
1350
|
+
token_endpoint_auth_method: "none",
|
|
1351
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1352
|
+
response_types: ["code"]
|
|
1353
|
+
}), {
|
|
1354
|
+
status: 201,
|
|
1355
|
+
headers: { "Content-Type": "application/json" }
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
// ============================================================
|
|
1359
|
+
// Original Figma OAuth Endpoints (kept for backwards compatibility)
|
|
1360
|
+
// ============================================================
|
|
1361
|
+
// OAuth authorization initiation
|
|
1362
|
+
if (url.pathname === "/oauth/authorize") {
|
|
1363
|
+
const sessionId = url.searchParams.get("session_id");
|
|
1364
|
+
if (!sessionId) {
|
|
1365
|
+
return new Response("Missing session_id parameter", { status: 400 });
|
|
1366
|
+
}
|
|
1367
|
+
// Check if OAuth credentials are configured
|
|
1368
|
+
if (!env.FIGMA_OAUTH_CLIENT_ID) {
|
|
1369
|
+
return new Response(JSON.stringify({
|
|
1370
|
+
error: "OAuth not configured",
|
|
1371
|
+
message: "Server administrator needs to configure FIGMA_OAUTH_CLIENT_ID",
|
|
1372
|
+
docs: "https://github.com/southleft/figma-console-mcp#oauth-setup"
|
|
1373
|
+
}), {
|
|
1374
|
+
status: 500,
|
|
1375
|
+
headers: { "Content-Type": "application/json" }
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
// Generate cryptographically secure state token for CSRF protection
|
|
1379
|
+
const stateToken = FigmaConsoleMCPv3.generateStateToken();
|
|
1380
|
+
// Store state token with sessionId in KV (10 minute expiration)
|
|
1381
|
+
await env.OAUTH_STATE.put(stateToken, sessionId, {
|
|
1382
|
+
expirationTtl: 600 // 10 minutes
|
|
1383
|
+
});
|
|
1384
|
+
const redirectUri = `${oauthOrigin}/oauth/callback`;
|
|
1385
|
+
const figmaAuthUrl = new URL("https://www.figma.com/oauth");
|
|
1386
|
+
figmaAuthUrl.searchParams.set("client_id", env.FIGMA_OAUTH_CLIENT_ID);
|
|
1387
|
+
figmaAuthUrl.searchParams.set("redirect_uri", redirectUri);
|
|
1388
|
+
figmaAuthUrl.searchParams.set("scope", "file_content:read,library_content:read,file_variables:read");
|
|
1389
|
+
figmaAuthUrl.searchParams.set("state", stateToken);
|
|
1390
|
+
figmaAuthUrl.searchParams.set("response_type", "code");
|
|
1391
|
+
return Response.redirect(figmaAuthUrl.toString(), 302);
|
|
1392
|
+
}
|
|
1393
|
+
// OAuth callback handler
|
|
1394
|
+
if (url.pathname === "/oauth/callback") {
|
|
1395
|
+
const code = url.searchParams.get("code");
|
|
1396
|
+
const stateToken = url.searchParams.get("state");
|
|
1397
|
+
const error = url.searchParams.get("error");
|
|
1398
|
+
// Handle OAuth errors
|
|
1399
|
+
if (error) {
|
|
1400
|
+
const safeError = htmlEscape(error);
|
|
1401
|
+
const safeDesc = htmlEscape(url.searchParams.get("error_description") || "Unknown error");
|
|
1402
|
+
return new Response(`<html><head><meta charset="utf-8"></head><body>
|
|
1403
|
+
<h1>Authentication Failed</h1>
|
|
1404
|
+
<p>Error: ${safeError}</p>
|
|
1405
|
+
<p>Description: ${safeDesc}</p>
|
|
1406
|
+
<p>You can close this window and try again.</p>
|
|
1407
|
+
</body></html>`, {
|
|
1408
|
+
status: 400,
|
|
1409
|
+
headers: { "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": "default-src 'none'" }
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
if (!code || !stateToken) {
|
|
1413
|
+
return new Response("Missing code or state parameter", { status: 400 });
|
|
1414
|
+
}
|
|
1415
|
+
// Validate state token (CSRF protection)
|
|
1416
|
+
const sessionId = await env.OAUTH_STATE.get(stateToken);
|
|
1417
|
+
logger.info({ hasStateToken: !!stateToken, hasSessionId: !!sessionId }, "OAuth callback - state token lookup");
|
|
1418
|
+
if (!sessionId) {
|
|
1419
|
+
return new Response(`<html><body>
|
|
1420
|
+
<h1>Invalid or Expired Request</h1>
|
|
1421
|
+
<p>The authentication request has expired or is invalid.</p>
|
|
1422
|
+
<p>Please try authenticating again.</p>
|
|
1423
|
+
</body></html>`, {
|
|
1424
|
+
status: 400,
|
|
1425
|
+
headers: { "Content-Type": "text/html" }
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
// Delete state token after validation (one-time use)
|
|
1429
|
+
await env.OAUTH_STATE.delete(stateToken);
|
|
1430
|
+
try {
|
|
1431
|
+
// Exchange authorization code for access token
|
|
1432
|
+
// Use Basic auth in Authorization header (Figma's recommended method)
|
|
1433
|
+
const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
|
|
1434
|
+
const tokenParams = new URLSearchParams({
|
|
1435
|
+
redirect_uri: `${oauthOrigin}/oauth/callback`,
|
|
1436
|
+
code,
|
|
1437
|
+
grant_type: "authorization_code"
|
|
1438
|
+
});
|
|
1439
|
+
const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
|
|
1440
|
+
method: "POST",
|
|
1441
|
+
headers: {
|
|
1442
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1443
|
+
"Authorization": `Basic ${credentials}`
|
|
1444
|
+
},
|
|
1445
|
+
body: tokenParams.toString()
|
|
1446
|
+
});
|
|
1447
|
+
if (!tokenResponse.ok) {
|
|
1448
|
+
const errorText = await tokenResponse.text();
|
|
1449
|
+
let errorData;
|
|
1450
|
+
try {
|
|
1451
|
+
errorData = JSON.parse(errorText);
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
errorData = { error: "Unknown error", raw: errorText, status: tokenResponse.status };
|
|
1455
|
+
}
|
|
1456
|
+
logger.error({ errorData, status: tokenResponse.status }, "Token exchange failed");
|
|
1457
|
+
throw new Error(`Token exchange failed: ${JSON.stringify(errorData)}`);
|
|
1458
|
+
}
|
|
1459
|
+
const tokenData = await tokenResponse.json();
|
|
1460
|
+
const accessToken = tokenData.access_token;
|
|
1461
|
+
const refreshToken = tokenData.refresh_token;
|
|
1462
|
+
const expiresIn = tokenData.expires_in;
|
|
1463
|
+
logger.info({
|
|
1464
|
+
sessionId,
|
|
1465
|
+
hasAccessToken: !!accessToken,
|
|
1466
|
+
hasRefreshToken: !!refreshToken,
|
|
1467
|
+
expiresIn
|
|
1468
|
+
}, "Token exchange successful");
|
|
1469
|
+
// IMPORTANT: Use KV storage for tokens since Durable Object storage is instance-specific
|
|
1470
|
+
// Store token in Workers KV so it's accessible across all Durable Object instances
|
|
1471
|
+
const tokenKey = `oauth_token:${sessionId}`;
|
|
1472
|
+
const tokenExpiresAt = Date.now() + (expiresIn * 1000);
|
|
1473
|
+
const storedToken = {
|
|
1474
|
+
accessToken,
|
|
1475
|
+
refreshToken,
|
|
1476
|
+
expiresAt: tokenExpiresAt
|
|
1477
|
+
};
|
|
1478
|
+
// Store in KV with 90-day expiration (matching token lifetime)
|
|
1479
|
+
await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
|
|
1480
|
+
expirationTtl: expiresIn
|
|
1481
|
+
});
|
|
1482
|
+
// Token is stored only under the user-specific sessionId key (SEC-002)
|
|
1483
|
+
// Store reverse lookup for Bearer token validation on SSE endpoint
|
|
1484
|
+
// This allows us to validate Authorization: Bearer <token> headers
|
|
1485
|
+
const bearerKey = `bearer_token:${accessToken}`;
|
|
1486
|
+
await env.OAUTH_TOKENS.put(bearerKey, JSON.stringify({
|
|
1487
|
+
sessionId,
|
|
1488
|
+
expiresAt: tokenExpiresAt
|
|
1489
|
+
}), {
|
|
1490
|
+
expirationTtl: expiresIn
|
|
1491
|
+
});
|
|
1492
|
+
// Verify the token was stored
|
|
1493
|
+
const verifyToken = await env.OAUTH_TOKENS.get(tokenKey);
|
|
1494
|
+
logger.info({ sessionId, tokenKey, storedSuccessfully: !!verifyToken }, "Token stored in KV");
|
|
1495
|
+
// Check if this flow came from an MCP client (like mcp-remote)
|
|
1496
|
+
// If so, we need to redirect back to the client with an authorization code
|
|
1497
|
+
const mcpStateKey = `mcp_auth:${sessionId}`;
|
|
1498
|
+
const mcpAuthJson = await env.OAUTH_STATE.get(mcpStateKey);
|
|
1499
|
+
if (mcpAuthJson) {
|
|
1500
|
+
// MCP client flow - redirect back with authorization code
|
|
1501
|
+
const mcpAuthData = JSON.parse(mcpAuthJson);
|
|
1502
|
+
// Clean up the MCP auth state
|
|
1503
|
+
await env.OAUTH_STATE.delete(mcpStateKey);
|
|
1504
|
+
// Generate an authorization code for the MCP client
|
|
1505
|
+
// We use the sessionId as the code since we've already stored the token
|
|
1506
|
+
const authCode = sessionId;
|
|
1507
|
+
// Build the redirect URL back to the MCP client
|
|
1508
|
+
const redirectUrl = new URL(mcpAuthData.redirectUri);
|
|
1509
|
+
redirectUrl.searchParams.set("code", authCode);
|
|
1510
|
+
redirectUrl.searchParams.set("state", mcpAuthData.state);
|
|
1511
|
+
logger.info({
|
|
1512
|
+
sessionId,
|
|
1513
|
+
redirectUri: mcpAuthData.redirectUri,
|
|
1514
|
+
state: mcpAuthData.state
|
|
1515
|
+
}, "Redirecting back to MCP client");
|
|
1516
|
+
return Response.redirect(redirectUrl.toString(), 302);
|
|
1517
|
+
}
|
|
1518
|
+
// Direct browser flow - show success page
|
|
1519
|
+
return new Response(`<!DOCTYPE html>
|
|
1520
|
+
<html>
|
|
1521
|
+
<head>
|
|
1522
|
+
<meta charset="UTF-8">
|
|
1523
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1524
|
+
<title>Authentication Successful</title>
|
|
1525
|
+
<link rel="icon" type="image/jpeg" href="https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg">
|
|
1526
|
+
<style>
|
|
1527
|
+
* {
|
|
1528
|
+
margin: 0;
|
|
1529
|
+
padding: 0;
|
|
1530
|
+
box-sizing: border-box;
|
|
1531
|
+
}
|
|
1532
|
+
body {
|
|
1533
|
+
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
1534
|
+
background: #ffffff;
|
|
1535
|
+
color: #000000;
|
|
1536
|
+
display: flex;
|
|
1537
|
+
align-items: center;
|
|
1538
|
+
justify-content: center;
|
|
1539
|
+
min-height: 100vh;
|
|
1540
|
+
padding: 24px;
|
|
1541
|
+
}
|
|
1542
|
+
.container {
|
|
1543
|
+
max-width: 480px;
|
|
1544
|
+
text-align: center;
|
|
1545
|
+
}
|
|
1546
|
+
.icon {
|
|
1547
|
+
width: 64px;
|
|
1548
|
+
height: 64px;
|
|
1549
|
+
margin: 0 auto 24px;
|
|
1550
|
+
background: #18a0fb;
|
|
1551
|
+
border-radius: 50%;
|
|
1552
|
+
display: flex;
|
|
1553
|
+
align-items: center;
|
|
1554
|
+
justify-content: center;
|
|
1555
|
+
font-size: 32px;
|
|
1556
|
+
color: white;
|
|
1557
|
+
}
|
|
1558
|
+
h1 {
|
|
1559
|
+
font-size: 32px;
|
|
1560
|
+
font-weight: 700;
|
|
1561
|
+
margin-bottom: 16px;
|
|
1562
|
+
letter-spacing: -0.02em;
|
|
1563
|
+
}
|
|
1564
|
+
p {
|
|
1565
|
+
font-size: 16px;
|
|
1566
|
+
color: #666666;
|
|
1567
|
+
line-height: 1.6;
|
|
1568
|
+
margin-bottom: 32px;
|
|
1569
|
+
}
|
|
1570
|
+
.button {
|
|
1571
|
+
display: inline-block;
|
|
1572
|
+
padding: 12px 24px;
|
|
1573
|
+
background: #000000;
|
|
1574
|
+
color: #ffffff;
|
|
1575
|
+
text-decoration: none;
|
|
1576
|
+
border-radius: 8px;
|
|
1577
|
+
font-weight: 500;
|
|
1578
|
+
font-size: 16px;
|
|
1579
|
+
border: none;
|
|
1580
|
+
cursor: pointer;
|
|
1581
|
+
transition: background 0.2s;
|
|
1582
|
+
}
|
|
1583
|
+
.button:hover {
|
|
1584
|
+
background: #333333;
|
|
1585
|
+
}
|
|
1586
|
+
.footer {
|
|
1587
|
+
margin-top: 48px;
|
|
1588
|
+
font-size: 14px;
|
|
1589
|
+
color: #999999;
|
|
1590
|
+
}
|
|
1591
|
+
</style>
|
|
1592
|
+
</head>
|
|
1593
|
+
<body>
|
|
1594
|
+
<div class="container">
|
|
1595
|
+
<div class="icon">✓</div>
|
|
1596
|
+
<h1>Authentication successful</h1>
|
|
1597
|
+
<p>You've successfully connected Figma Console MCP to your Figma account. You can now close this window and return to Claude.</p>
|
|
1598
|
+
<button class="button" onclick="window.close()">Close this window</button>
|
|
1599
|
+
<div class="footer">This window will automatically close in 5 seconds</div>
|
|
1600
|
+
</div>
|
|
1601
|
+
<script>
|
|
1602
|
+
setTimeout(() => window.close(), 5000);
|
|
1603
|
+
</script>
|
|
1604
|
+
</body>
|
|
1605
|
+
</html>`, {
|
|
1606
|
+
headers: {
|
|
1607
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
catch (error) {
|
|
1612
|
+
logger.error({ error, sessionId }, "OAuth callback failed");
|
|
1613
|
+
return new Response(`<html><body>
|
|
1614
|
+
<h1>Authentication Error</h1>
|
|
1615
|
+
<p>Failed to complete authentication: ${error instanceof Error ? error.message : String(error)}</p>
|
|
1616
|
+
<p>Please try again or contact support.</p>
|
|
1617
|
+
</body></html>`, {
|
|
1618
|
+
status: 500,
|
|
1619
|
+
headers: { "Content-Type": "text/html" }
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
// Health check endpoint
|
|
1624
|
+
if (url.pathname === "/health") {
|
|
1625
|
+
return new Response(JSON.stringify({
|
|
1626
|
+
status: "healthy",
|
|
1627
|
+
service: "Figma Console MCP",
|
|
1628
|
+
version: "1.13.0",
|
|
1629
|
+
endpoints: {
|
|
1630
|
+
mcp: ["/sse", "/mcp"],
|
|
1631
|
+
oauth_mcp_spec: ["/.well-known/oauth-authorization-server", "/authorize", "/token", "/oauth/register"],
|
|
1632
|
+
oauth_legacy: ["/oauth/authorize", "/oauth/callback"],
|
|
1633
|
+
utility: ["/test-browser", "/health"]
|
|
1634
|
+
},
|
|
1635
|
+
oauth_configured: !!env.FIGMA_OAUTH_CLIENT_ID
|
|
1636
|
+
}), {
|
|
1637
|
+
headers: { "Content-Type": "application/json" },
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
// Browser Rendering API test endpoint
|
|
1641
|
+
if (url.pathname === "/test-browser") {
|
|
1642
|
+
const results = await testBrowserRendering(env);
|
|
1643
|
+
return new Response(JSON.stringify(results, null, 2), {
|
|
1644
|
+
headers: { "Content-Type": "application/json" },
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
// Serve favicon
|
|
1648
|
+
if (url.pathname === "/favicon.ico") {
|
|
1649
|
+
// Redirect to custom Figma Console icon
|
|
1650
|
+
return Response.redirect("https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg", 302);
|
|
1651
|
+
}
|
|
1652
|
+
// Proxy /docs to Mintlify
|
|
1653
|
+
if (/^\/docs/.test(url.pathname)) {
|
|
1654
|
+
// Try mintlify.app domain (Mintlify's standard hosting)
|
|
1655
|
+
const DOCS_URL = "southleftllc.mintlify.app";
|
|
1656
|
+
const CUSTOM_URL = "figma-console-mcp.southleft.com";
|
|
1657
|
+
const proxyUrl = new URL(request.url);
|
|
1658
|
+
proxyUrl.hostname = DOCS_URL;
|
|
1659
|
+
const proxyRequest = new Request(proxyUrl, request);
|
|
1660
|
+
proxyRequest.headers.set("Host", DOCS_URL);
|
|
1661
|
+
proxyRequest.headers.set("X-Forwarded-Host", CUSTOM_URL);
|
|
1662
|
+
proxyRequest.headers.set("X-Forwarded-Proto", "https");
|
|
1663
|
+
return await fetch(proxyRequest);
|
|
1664
|
+
}
|
|
1665
|
+
// Root path - serve landing page with editorial layout and light/dark mode
|
|
1666
|
+
if (url.pathname === "/") {
|
|
1667
|
+
return new Response(`<!DOCTYPE html>
|
|
1668
|
+
<html lang="en">
|
|
1669
|
+
<head>
|
|
1670
|
+
<meta charset="UTF-8">
|
|
1671
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1672
|
+
<title>Figma Console MCP - The Most Comprehensive MCP Server for Figma</title>
|
|
1673
|
+
<link rel="icon" type="image/svg+xml" href="https://docs.figma-console-mcp.southleft.com/favicon.svg">
|
|
1674
|
+
<meta name="description" content="Turn your Figma design system into a living API. 59+ tools give AI assistants deep access to design tokens, component specs, variables, and programmatic design creation.">
|
|
1675
|
+
|
|
1676
|
+
<!-- Open Graph -->
|
|
1677
|
+
<meta property="og:type" content="website">
|
|
1678
|
+
<meta property="og:url" content="https://figma-console-mcp.southleft.com">
|
|
1679
|
+
<meta property="og:title" content="Figma Console MCP - Turn Your Design System Into a Living API">
|
|
1680
|
+
<meta property="og:description" content="The most comprehensive MCP server for Figma. 59+ tools give AI assistants deep access to design tokens, components, variables, and programmatic design creation.">
|
|
1681
|
+
<meta property="og:image" content="https://docs.figma-console-mcp.southleft.com/images/og-image.jpg">
|
|
1682
|
+
<meta property="og:image:width" content="1200">
|
|
1683
|
+
<meta property="og:image:height" content="630">
|
|
1684
|
+
|
|
1685
|
+
<!-- Twitter -->
|
|
1686
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
1687
|
+
<meta name="twitter:title" content="Figma Console MCP - Turn Your Design System Into a Living API">
|
|
1688
|
+
<meta name="twitter:description" content="The most comprehensive MCP server for Figma. 59+ tools give AI assistants deep access to design tokens, components, variables, and programmatic design creation.">
|
|
1689
|
+
<meta name="twitter:image" content="https://docs.figma-console-mcp.southleft.com/images/og-image.jpg">
|
|
1690
|
+
|
|
1691
|
+
<meta name="theme-color" content="#0D9488">
|
|
1692
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1693
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1694
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
1695
|
+
<style>
|
|
1696
|
+
:root {
|
|
1697
|
+
--color-primary: #0D9488;
|
|
1698
|
+
--color-primary-light: #14B8A6;
|
|
1699
|
+
--color-primary-dark: #0F766E;
|
|
1700
|
+
--color-bg: #0F0F0F;
|
|
1701
|
+
--color-bg-elevated: #161616;
|
|
1702
|
+
--color-bg-card: #1A1A1A;
|
|
1703
|
+
--color-border: #2A2A2A;
|
|
1704
|
+
--color-border-hover: #3A3A3A;
|
|
1705
|
+
--color-rule: #2A2A2A;
|
|
1706
|
+
--color-text: #FAFAFA;
|
|
1707
|
+
--color-text-secondary: #A1A1A1;
|
|
1708
|
+
--color-text-tertiary: #666666;
|
|
1709
|
+
--font-mono: "JetBrains Mono", "SF Mono", Monaco, monospace;
|
|
1710
|
+
--radius-sm: 6px;
|
|
1711
|
+
--radius-md: 10px;
|
|
1712
|
+
--radius-lg: 16px;
|
|
1713
|
+
--transition: 0.2s ease;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
[data-theme="light"] {
|
|
1717
|
+
--color-bg: #FAFAFA;
|
|
1718
|
+
--color-bg-elevated: #FFFFFF;
|
|
1719
|
+
--color-bg-card: #FFFFFF;
|
|
1720
|
+
--color-border: #E5E5E5;
|
|
1721
|
+
--color-border-hover: #D4D4D4;
|
|
1722
|
+
--color-rule: #E5E5E5;
|
|
1723
|
+
--color-text: #171717;
|
|
1724
|
+
--color-text-secondary: #525252;
|
|
1725
|
+
--color-text-tertiary: #A3A3A3;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1729
|
+
|
|
1730
|
+
html {
|
|
1731
|
+
scroll-behavior: smooth;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
body {
|
|
1735
|
+
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
1736
|
+
background: var(--color-bg);
|
|
1737
|
+
color: var(--color-text);
|
|
1738
|
+
line-height: 1.6;
|
|
1739
|
+
min-height: 100vh;
|
|
1740
|
+
transition: background var(--transition), color var(--transition);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
a { color: inherit; text-decoration: none; }
|
|
1744
|
+
|
|
1745
|
+
/* Header */
|
|
1746
|
+
.header {
|
|
1747
|
+
position: sticky;
|
|
1748
|
+
top: 0;
|
|
1749
|
+
z-index: 100;
|
|
1750
|
+
padding: 16px 32px;
|
|
1751
|
+
display: flex;
|
|
1752
|
+
justify-content: space-between;
|
|
1753
|
+
align-items: center;
|
|
1754
|
+
background: var(--color-bg);
|
|
1755
|
+
border-bottom: 1px solid var(--color-rule);
|
|
1756
|
+
backdrop-filter: blur(12px);
|
|
1757
|
+
transition: background var(--transition), border-color var(--transition);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
.logo img {
|
|
1761
|
+
height: 35px;
|
|
1762
|
+
transition: opacity var(--transition);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
.logo img:hover { opacity: 0.8; }
|
|
1766
|
+
|
|
1767
|
+
.header-right {
|
|
1768
|
+
display: flex;
|
|
1769
|
+
align-items: center;
|
|
1770
|
+
gap: 24px;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
.nav {
|
|
1774
|
+
display: flex;
|
|
1775
|
+
gap: 24px;
|
|
1776
|
+
align-items: center;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
.nav a {
|
|
1780
|
+
color: var(--color-text-secondary);
|
|
1781
|
+
font-size: 14px;
|
|
1782
|
+
font-weight: 500;
|
|
1783
|
+
transition: color var(--transition);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
.nav a:hover { color: var(--color-text); }
|
|
1787
|
+
|
|
1788
|
+
.nav a.sponsor-link {
|
|
1789
|
+
color: #db61a2;
|
|
1790
|
+
display: flex;
|
|
1791
|
+
align-items: center;
|
|
1792
|
+
gap: 5px;
|
|
1793
|
+
}
|
|
1794
|
+
.nav a.sponsor-link:hover { color: #ea4aaa; }
|
|
1795
|
+
.nav a.sponsor-link svg { width: 14px; height: 14px; fill: currentColor; }
|
|
1796
|
+
|
|
1797
|
+
.theme-toggle {
|
|
1798
|
+
display: flex;
|
|
1799
|
+
align-items: center;
|
|
1800
|
+
justify-content: center;
|
|
1801
|
+
width: 36px;
|
|
1802
|
+
height: 36px;
|
|
1803
|
+
background: transparent;
|
|
1804
|
+
border: 1px solid var(--color-border);
|
|
1805
|
+
border-radius: var(--radius-sm);
|
|
1806
|
+
cursor: pointer;
|
|
1807
|
+
color: var(--color-text-secondary);
|
|
1808
|
+
transition: all var(--transition);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
.theme-toggle:hover {
|
|
1812
|
+
border-color: var(--color-border-hover);
|
|
1813
|
+
color: var(--color-text);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
.theme-toggle svg { width: 18px; height: 18px; }
|
|
1817
|
+
.theme-toggle .sun { display: none; }
|
|
1818
|
+
[data-theme="light"] .theme-toggle .moon { display: none; }
|
|
1819
|
+
[data-theme="light"] .theme-toggle .sun { display: block; }
|
|
1820
|
+
|
|
1821
|
+
/* Main layout */
|
|
1822
|
+
.main {
|
|
1823
|
+
max-width: 1280px;
|
|
1824
|
+
margin: 0 auto;
|
|
1825
|
+
padding: 64px 32px 80px;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/* Section dividers */
|
|
1829
|
+
.section-rule {
|
|
1830
|
+
border: none;
|
|
1831
|
+
border-top: 1px solid var(--color-rule);
|
|
1832
|
+
margin: 72px 0;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/* Grid layout */
|
|
1836
|
+
.grid {
|
|
1837
|
+
display: grid;
|
|
1838
|
+
grid-template-columns: repeat(12, 1fr);
|
|
1839
|
+
gap: 48px 48px;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
.grid-cell {
|
|
1843
|
+
transition: all var(--transition);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/* Rule-based separators for grid cells */
|
|
1847
|
+
.grid-cell.rule-left {
|
|
1848
|
+
padding-left: 48px;
|
|
1849
|
+
border-left: 1px solid var(--color-rule);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
.grid-cell.rule-top {
|
|
1853
|
+
padding-top: 32px;
|
|
1854
|
+
border-top: 1px solid var(--color-rule);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/* Hero section */
|
|
1858
|
+
.hero-cell {
|
|
1859
|
+
grid-column: span 7;
|
|
1860
|
+
padding-right: 48px;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
.badge {
|
|
1864
|
+
display: inline-block;
|
|
1865
|
+
width: fit-content;
|
|
1866
|
+
color: var(--color-text-secondary);
|
|
1867
|
+
font-size: 11px;
|
|
1868
|
+
font-weight: 600;
|
|
1869
|
+
text-transform: uppercase;
|
|
1870
|
+
letter-spacing: 0.5px;
|
|
1871
|
+
margin-bottom: 20px;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
h1 {
|
|
1875
|
+
font-size: 48px;
|
|
1876
|
+
font-weight: 700;
|
|
1877
|
+
letter-spacing: -0.03em;
|
|
1878
|
+
line-height: 1.1;
|
|
1879
|
+
margin-bottom: 20px;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
.highlight {
|
|
1883
|
+
color: var(--color-primary-light);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
.hero-subtitle {
|
|
1887
|
+
font-size: 18px;
|
|
1888
|
+
color: var(--color-text-secondary);
|
|
1889
|
+
line-height: 1.7;
|
|
1890
|
+
max-width: 560px;
|
|
1891
|
+
margin-bottom: 32px;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
.cta-row {
|
|
1895
|
+
display: flex;
|
|
1896
|
+
gap: 12px;
|
|
1897
|
+
flex-wrap: wrap;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
.btn {
|
|
1901
|
+
display: inline-flex;
|
|
1902
|
+
align-items: center;
|
|
1903
|
+
gap: 8px;
|
|
1904
|
+
padding: 12px 20px;
|
|
1905
|
+
border-radius: var(--radius-sm);
|
|
1906
|
+
font-weight: 500;
|
|
1907
|
+
font-size: 14px;
|
|
1908
|
+
transition: all var(--transition);
|
|
1909
|
+
border: none;
|
|
1910
|
+
cursor: pointer;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
.btn svg { width: 16px; height: 16px; }
|
|
1914
|
+
|
|
1915
|
+
.btn-primary {
|
|
1916
|
+
background: var(--color-primary);
|
|
1917
|
+
color: #FFFFFF;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
.btn-primary:hover { background: var(--color-primary-dark); }
|
|
1921
|
+
|
|
1922
|
+
.btn-secondary {
|
|
1923
|
+
background: transparent;
|
|
1924
|
+
color: var(--color-text);
|
|
1925
|
+
border: 1px solid var(--color-border);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.btn-secondary:hover {
|
|
1929
|
+
border-color: var(--color-border-hover);
|
|
1930
|
+
background: var(--color-bg-elevated);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/* Hero right - capabilities showcase */
|
|
1934
|
+
.showcase-cell {
|
|
1935
|
+
grid-column: span 5;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
.showcase-label {
|
|
1939
|
+
font-size: 11px;
|
|
1940
|
+
font-weight: 600;
|
|
1941
|
+
text-transform: uppercase;
|
|
1942
|
+
letter-spacing: 0.5px;
|
|
1943
|
+
color: var(--color-text-tertiary);
|
|
1944
|
+
margin-bottom: 16px;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
.showcase-stat {
|
|
1948
|
+
display: flex;
|
|
1949
|
+
align-items: baseline;
|
|
1950
|
+
gap: 12px;
|
|
1951
|
+
margin-bottom: 24px;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.showcase-stat .number {
|
|
1955
|
+
font-size: 56px;
|
|
1956
|
+
font-weight: 700;
|
|
1957
|
+
color: var(--color-primary-light);
|
|
1958
|
+
line-height: 1;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
.showcase-stat .label {
|
|
1962
|
+
font-size: 16px;
|
|
1963
|
+
color: var(--color-text-secondary);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
.capability-list {
|
|
1967
|
+
display: flex;
|
|
1968
|
+
flex-direction: column;
|
|
1969
|
+
gap: 12px;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
.capability-item {
|
|
1973
|
+
display: flex;
|
|
1974
|
+
align-items: center;
|
|
1975
|
+
gap: 12px;
|
|
1976
|
+
padding: 12px 16px;
|
|
1977
|
+
background: var(--color-bg-elevated);
|
|
1978
|
+
border: 1px solid var(--color-border);
|
|
1979
|
+
border-radius: var(--radius-md);
|
|
1980
|
+
font-size: 14px;
|
|
1981
|
+
color: var(--color-text-secondary);
|
|
1982
|
+
transition: all var(--transition);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
.capability-item:hover {
|
|
1986
|
+
border-color: var(--color-primary);
|
|
1987
|
+
color: var(--color-text);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
.capability-item svg {
|
|
1991
|
+
width: 18px;
|
|
1992
|
+
height: 18px;
|
|
1993
|
+
color: var(--color-primary-light);
|
|
1994
|
+
flex-shrink: 0;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/* Value proposition section */
|
|
1998
|
+
.value-cell {
|
|
1999
|
+
grid-column: span 12;
|
|
2000
|
+
text-align: center;
|
|
2001
|
+
padding: 64px 48px;
|
|
2002
|
+
position: relative;
|
|
2003
|
+
background: linear-gradient(135deg, rgba(13, 148, 136, 0.06) 0%, rgba(13, 148, 136, 0.02) 50%, transparent 100%);
|
|
2004
|
+
border-radius: var(--radius-lg);
|
|
2005
|
+
border: 1px solid rgba(13, 148, 136, 0.1);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
.value-cell::before {
|
|
2009
|
+
content: '';
|
|
2010
|
+
position: absolute;
|
|
2011
|
+
top: -1px;
|
|
2012
|
+
left: 50%;
|
|
2013
|
+
transform: translateX(-50%);
|
|
2014
|
+
width: 120px;
|
|
2015
|
+
height: 3px;
|
|
2016
|
+
background: linear-gradient(90deg, transparent, var(--color-primary-light), transparent);
|
|
2017
|
+
border-radius: 2px;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
.value-cell h2 {
|
|
2021
|
+
font-size: 36px;
|
|
2022
|
+
font-weight: 700;
|
|
2023
|
+
margin-bottom: 16px;
|
|
2024
|
+
letter-spacing: -0.03em;
|
|
2025
|
+
background: linear-gradient(135deg, var(--color-text) 0%, var(--color-primary-light) 100%);
|
|
2026
|
+
-webkit-background-clip: text;
|
|
2027
|
+
-webkit-text-fill-color: transparent;
|
|
2028
|
+
background-clip: text;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
[data-theme="light"] .value-cell h2 {
|
|
2032
|
+
background: linear-gradient(135deg, var(--color-text) 0%, var(--color-primary-dark) 100%);
|
|
2033
|
+
-webkit-background-clip: text;
|
|
2034
|
+
-webkit-text-fill-color: transparent;
|
|
2035
|
+
background-clip: text;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
.value-cell p {
|
|
2039
|
+
font-size: 18px;
|
|
2040
|
+
color: var(--color-text-secondary);
|
|
2041
|
+
max-width: 680px;
|
|
2042
|
+
margin: 0 auto;
|
|
2043
|
+
line-height: 1.7;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/* Capabilities grid */
|
|
2047
|
+
.capability-card {
|
|
2048
|
+
grid-column: span 3;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
.capability-icon {
|
|
2052
|
+
width: 44px;
|
|
2053
|
+
height: 44px;
|
|
2054
|
+
display: flex;
|
|
2055
|
+
align-items: center;
|
|
2056
|
+
justify-content: center;
|
|
2057
|
+
background: rgba(13, 148, 136, 0.12);
|
|
2058
|
+
border-radius: var(--radius-md);
|
|
2059
|
+
color: var(--color-primary-light);
|
|
2060
|
+
margin-bottom: 16px;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
.capability-icon svg { width: 22px; height: 22px; }
|
|
2064
|
+
|
|
2065
|
+
.capability-card h3 {
|
|
2066
|
+
font-size: 17px;
|
|
2067
|
+
font-weight: 600;
|
|
2068
|
+
margin-bottom: 10px;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
.capability-card p {
|
|
2072
|
+
font-size: 14px;
|
|
2073
|
+
color: var(--color-text-secondary);
|
|
2074
|
+
line-height: 1.7;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/* Prompt showcase */
|
|
2078
|
+
.prompts-cell {
|
|
2079
|
+
grid-column: span 6;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
.section-header {
|
|
2083
|
+
display: flex;
|
|
2084
|
+
align-items: center;
|
|
2085
|
+
gap: 12px;
|
|
2086
|
+
margin-bottom: 24px;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
.section-header-icon {
|
|
2090
|
+
width: 40px;
|
|
2091
|
+
height: 40px;
|
|
2092
|
+
display: flex;
|
|
2093
|
+
align-items: center;
|
|
2094
|
+
justify-content: center;
|
|
2095
|
+
background: rgba(13, 148, 136, 0.12);
|
|
2096
|
+
border-radius: var(--radius-md);
|
|
2097
|
+
color: var(--color-primary-light);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
.section-header-icon svg {
|
|
2101
|
+
width: 20px;
|
|
2102
|
+
height: 20px;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
.section-header h3 {
|
|
2106
|
+
font-size: 18px;
|
|
2107
|
+
font-weight: 600;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
.prompt-list {
|
|
2111
|
+
display: flex;
|
|
2112
|
+
flex-direction: column;
|
|
2113
|
+
gap: 12px;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
.prompt-item {
|
|
2117
|
+
display: flex;
|
|
2118
|
+
align-items: center;
|
|
2119
|
+
gap: 12px;
|
|
2120
|
+
padding: 14px 16px;
|
|
2121
|
+
background: var(--color-bg-elevated);
|
|
2122
|
+
border: 1px solid var(--color-border);
|
|
2123
|
+
border-radius: var(--radius-md);
|
|
2124
|
+
font-family: var(--font-mono);
|
|
2125
|
+
font-size: 13px;
|
|
2126
|
+
color: var(--color-text-secondary);
|
|
2127
|
+
transition: all var(--transition);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
.prompt-item:hover {
|
|
2131
|
+
border-color: var(--color-primary);
|
|
2132
|
+
color: var(--color-text);
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
.prompt-item svg {
|
|
2136
|
+
width: 16px;
|
|
2137
|
+
height: 16px;
|
|
2138
|
+
color: var(--color-primary-light);
|
|
2139
|
+
flex-shrink: 0;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
/* Audience cells */
|
|
2143
|
+
.audience-cell {
|
|
2144
|
+
grid-column: span 6;
|
|
2145
|
+
padding: 24px 0;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
.audience-header {
|
|
2149
|
+
display: flex;
|
|
2150
|
+
align-items: center;
|
|
2151
|
+
gap: 12px;
|
|
2152
|
+
margin-bottom: 28px;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
.audience-icon {
|
|
2156
|
+
width: 40px;
|
|
2157
|
+
height: 40px;
|
|
2158
|
+
display: flex;
|
|
2159
|
+
align-items: center;
|
|
2160
|
+
justify-content: center;
|
|
2161
|
+
background: rgba(13, 148, 136, 0.12);
|
|
2162
|
+
border-radius: var(--radius-md);
|
|
2163
|
+
color: var(--color-primary-light);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
.audience-icon svg { width: 20px; height: 20px; }
|
|
2167
|
+
|
|
2168
|
+
.audience-header h3 {
|
|
2169
|
+
font-size: 18px;
|
|
2170
|
+
font-weight: 600;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
.audience-list {
|
|
2174
|
+
list-style: none;
|
|
2175
|
+
display: flex;
|
|
2176
|
+
flex-direction: column;
|
|
2177
|
+
gap: 24px;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
.audience-list li {
|
|
2181
|
+
display: flex;
|
|
2182
|
+
align-items: flex-start;
|
|
2183
|
+
gap: 12px;
|
|
2184
|
+
font-size: 14px;
|
|
2185
|
+
color: var(--color-text-secondary);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
.audience-list li svg {
|
|
2189
|
+
width: 18px;
|
|
2190
|
+
height: 18px;
|
|
2191
|
+
color: var(--color-primary);
|
|
2192
|
+
flex-shrink: 0;
|
|
2193
|
+
margin-top: 1px;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/* Getting started CTA */
|
|
2197
|
+
.getting-started-cell {
|
|
2198
|
+
grid-column: span 12;
|
|
2199
|
+
display: flex;
|
|
2200
|
+
align-items: center;
|
|
2201
|
+
justify-content: space-between;
|
|
2202
|
+
padding: 40px 48px;
|
|
2203
|
+
background: var(--color-bg-elevated);
|
|
2204
|
+
border: 1px solid var(--color-border);
|
|
2205
|
+
border-radius: var(--radius-lg);
|
|
2206
|
+
margin-top: 32px;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
.getting-started-content h3 {
|
|
2210
|
+
font-size: 20px;
|
|
2211
|
+
font-weight: 600;
|
|
2212
|
+
margin-bottom: 8px;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
.getting-started-content p {
|
|
2216
|
+
font-size: 14px;
|
|
2217
|
+
color: var(--color-text-secondary);
|
|
2218
|
+
max-width: 480px;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
.getting-started-actions {
|
|
2222
|
+
display: flex;
|
|
2223
|
+
gap: 12px;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/* Blog CTA */
|
|
2227
|
+
.blog-cell {
|
|
2228
|
+
grid-column: span 12;
|
|
2229
|
+
display: flex;
|
|
2230
|
+
align-items: center;
|
|
2231
|
+
justify-content: space-between;
|
|
2232
|
+
padding: 32px 0;
|
|
2233
|
+
border-top: 1px solid var(--color-rule);
|
|
2234
|
+
margin-top: 40px;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
.blog-content {
|
|
2238
|
+
display: flex;
|
|
2239
|
+
align-items: center;
|
|
2240
|
+
gap: 16px;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
.blog-icon {
|
|
2244
|
+
width: 44px;
|
|
2245
|
+
height: 44px;
|
|
2246
|
+
display: flex;
|
|
2247
|
+
align-items: center;
|
|
2248
|
+
justify-content: center;
|
|
2249
|
+
background: rgba(13, 148, 136, 0.12);
|
|
2250
|
+
border-radius: var(--radius-md);
|
|
2251
|
+
color: var(--color-primary-light);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
.blog-icon svg { width: 22px; height: 22px; }
|
|
2255
|
+
|
|
2256
|
+
.blog-text h4 {
|
|
2257
|
+
font-size: 15px;
|
|
2258
|
+
font-weight: 600;
|
|
2259
|
+
margin-bottom: 2px;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
.blog-text p {
|
|
2263
|
+
font-size: 13px;
|
|
2264
|
+
color: var(--color-text-secondary);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
.blog-link {
|
|
2268
|
+
display: flex;
|
|
2269
|
+
align-items: center;
|
|
2270
|
+
gap: 8px;
|
|
2271
|
+
color: var(--color-primary-light);
|
|
2272
|
+
font-size: 14px;
|
|
2273
|
+
font-weight: 500;
|
|
2274
|
+
transition: gap var(--transition);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
.blog-link:hover { gap: 12px; }
|
|
2278
|
+
.blog-link svg { width: 16px; height: 16px; }
|
|
2279
|
+
|
|
2280
|
+
/* Footer */
|
|
2281
|
+
.footer {
|
|
2282
|
+
max-width: 1280px;
|
|
2283
|
+
margin: 0 auto;
|
|
2284
|
+
padding: 24px 32px;
|
|
2285
|
+
display: flex;
|
|
2286
|
+
justify-content: space-between;
|
|
2287
|
+
align-items: center;
|
|
2288
|
+
border-top: 1px solid var(--color-rule);
|
|
2289
|
+
color: var(--color-text-tertiary);
|
|
2290
|
+
font-size: 13px;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
.footer a {
|
|
2294
|
+
color: var(--color-text-secondary);
|
|
2295
|
+
transition: color var(--transition);
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
.footer a:hover { color: var(--color-text); }
|
|
2299
|
+
|
|
2300
|
+
.footer-links {
|
|
2301
|
+
display: flex;
|
|
2302
|
+
gap: 24px;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/* Mobile nav */
|
|
2306
|
+
.mobile-menu-btn {
|
|
2307
|
+
display: none;
|
|
2308
|
+
align-items: center;
|
|
2309
|
+
justify-content: center;
|
|
2310
|
+
width: 36px;
|
|
2311
|
+
height: 36px;
|
|
2312
|
+
background: transparent;
|
|
2313
|
+
border: none;
|
|
2314
|
+
cursor: pointer;
|
|
2315
|
+
color: var(--color-text);
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
.mobile-menu-btn svg { width: 24px; height: 24px; }
|
|
2319
|
+
|
|
2320
|
+
/* Mobile menu overlay */
|
|
2321
|
+
.mobile-menu {
|
|
2322
|
+
display: none;
|
|
2323
|
+
position: fixed;
|
|
2324
|
+
top: 0;
|
|
2325
|
+
left: 0;
|
|
2326
|
+
right: 0;
|
|
2327
|
+
bottom: 0;
|
|
2328
|
+
background: var(--color-bg);
|
|
2329
|
+
z-index: 1000;
|
|
2330
|
+
padding: 20px;
|
|
2331
|
+
flex-direction: column;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
.mobile-menu.active {
|
|
2335
|
+
display: flex;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
.mobile-menu-header {
|
|
2339
|
+
display: flex;
|
|
2340
|
+
justify-content: space-between;
|
|
2341
|
+
align-items: center;
|
|
2342
|
+
margin-bottom: 48px;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
.mobile-menu-close {
|
|
2346
|
+
display: flex;
|
|
2347
|
+
align-items: center;
|
|
2348
|
+
justify-content: center;
|
|
2349
|
+
width: 36px;
|
|
2350
|
+
height: 36px;
|
|
2351
|
+
background: transparent;
|
|
2352
|
+
border: none;
|
|
2353
|
+
cursor: pointer;
|
|
2354
|
+
color: var(--color-text);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
.mobile-menu-close svg { width: 24px; height: 24px; }
|
|
2358
|
+
|
|
2359
|
+
.mobile-menu-nav {
|
|
2360
|
+
display: flex;
|
|
2361
|
+
flex-direction: column;
|
|
2362
|
+
gap: 8px;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
.mobile-menu-nav a {
|
|
2366
|
+
display: block;
|
|
2367
|
+
padding: 16px 0;
|
|
2368
|
+
font-size: 18px;
|
|
2369
|
+
font-weight: 500;
|
|
2370
|
+
color: var(--color-text);
|
|
2371
|
+
border-bottom: 1px solid var(--color-border);
|
|
2372
|
+
transition: color var(--transition);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
.mobile-menu-nav a:hover {
|
|
2376
|
+
color: var(--color-primary-light);
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
body.menu-open {
|
|
2380
|
+
overflow: hidden;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/* Responsive */
|
|
2384
|
+
@media (max-width: 1024px) {
|
|
2385
|
+
.hero-cell {
|
|
2386
|
+
grid-column: span 12;
|
|
2387
|
+
padding-right: 0;
|
|
2388
|
+
padding-bottom: 40px;
|
|
2389
|
+
border-bottom: 1px solid var(--color-rule);
|
|
2390
|
+
}
|
|
2391
|
+
.showcase-cell {
|
|
2392
|
+
grid-column: span 12;
|
|
2393
|
+
padding-left: 0;
|
|
2394
|
+
padding-top: 40px;
|
|
2395
|
+
border-left: none;
|
|
2396
|
+
}
|
|
2397
|
+
.capability-card {
|
|
2398
|
+
grid-column: span 6;
|
|
2399
|
+
}
|
|
2400
|
+
.capability-card.rule-left {
|
|
2401
|
+
padding-left: 0;
|
|
2402
|
+
border-left: none;
|
|
2403
|
+
}
|
|
2404
|
+
.capability-card:nth-child(odd) {
|
|
2405
|
+
padding-left: 0;
|
|
2406
|
+
border-left: none;
|
|
2407
|
+
}
|
|
2408
|
+
.capability-card:nth-child(even) {
|
|
2409
|
+
padding-left: 32px;
|
|
2410
|
+
border-left: 1px solid var(--color-rule);
|
|
2411
|
+
}
|
|
2412
|
+
.prompts-cell { grid-column: span 12; }
|
|
2413
|
+
.audience-cell {
|
|
2414
|
+
grid-column: span 6;
|
|
2415
|
+
padding-left: 0;
|
|
2416
|
+
}
|
|
2417
|
+
.audience-cell.rule-left {
|
|
2418
|
+
padding-left: 32px;
|
|
2419
|
+
border-left: 1px solid var(--color-rule);
|
|
2420
|
+
}
|
|
2421
|
+
h1 { font-size: 40px; }
|
|
2422
|
+
.value-cell { padding: 48px 32px; }
|
|
2423
|
+
.value-cell h2 { font-size: 30px; }
|
|
2424
|
+
.section-rule { margin: 56px 0; }
|
|
2425
|
+
.getting-started-cell {
|
|
2426
|
+
flex-direction: column;
|
|
2427
|
+
gap: 24px;
|
|
2428
|
+
text-align: center;
|
|
2429
|
+
}
|
|
2430
|
+
.getting-started-content p {
|
|
2431
|
+
max-width: 100%;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
@media (max-width: 768px) {
|
|
2436
|
+
.header { padding: 12px 20px; }
|
|
2437
|
+
.nav { display: none; }
|
|
2438
|
+
.mobile-menu-btn { display: flex; }
|
|
2439
|
+
.main { padding: 32px 20px; }
|
|
2440
|
+
.grid { gap: 32px; }
|
|
2441
|
+
|
|
2442
|
+
/* Remove all vertical rules and left padding on mobile */
|
|
2443
|
+
.grid-cell.rule-left {
|
|
2444
|
+
padding-left: 0;
|
|
2445
|
+
border-left: none;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
.hero-cell {
|
|
2449
|
+
padding-right: 0;
|
|
2450
|
+
padding-bottom: 32px;
|
|
2451
|
+
}
|
|
2452
|
+
.showcase-cell {
|
|
2453
|
+
padding-top: 32px;
|
|
2454
|
+
}
|
|
2455
|
+
.capability-card {
|
|
2456
|
+
grid-column: span 12;
|
|
2457
|
+
padding: 0 !important;
|
|
2458
|
+
border: none !important;
|
|
2459
|
+
}
|
|
2460
|
+
.audience-cell {
|
|
2461
|
+
grid-column: span 12;
|
|
2462
|
+
padding: 0 !important;
|
|
2463
|
+
}
|
|
2464
|
+
.audience-cell.rule-left {
|
|
2465
|
+
padding-top: 32px !important;
|
|
2466
|
+
margin-top: 8px;
|
|
2467
|
+
border-top: 1px solid var(--color-rule);
|
|
2468
|
+
}
|
|
2469
|
+
h1 { font-size: 34px; }
|
|
2470
|
+
.hero-subtitle { font-size: 16px; }
|
|
2471
|
+
.showcase-stat .number { font-size: 48px; }
|
|
2472
|
+
.value-cell { padding: 40px 24px; }
|
|
2473
|
+
.value-cell h2 { font-size: 26px; }
|
|
2474
|
+
.value-cell p { font-size: 16px; }
|
|
2475
|
+
.section-rule { margin: 48px 0; }
|
|
2476
|
+
.blog-cell {
|
|
2477
|
+
flex-direction: column;
|
|
2478
|
+
gap: 16px;
|
|
2479
|
+
text-align: center;
|
|
2480
|
+
}
|
|
2481
|
+
.blog-content { flex-direction: column; }
|
|
2482
|
+
.footer {
|
|
2483
|
+
flex-direction: column;
|
|
2484
|
+
gap: 16px;
|
|
2485
|
+
text-align: center;
|
|
2486
|
+
}
|
|
2487
|
+
.getting-started-cell {
|
|
2488
|
+
padding: 24px;
|
|
2489
|
+
}
|
|
2490
|
+
.getting-started-actions {
|
|
2491
|
+
flex-direction: column;
|
|
2492
|
+
width: 100%;
|
|
2493
|
+
}
|
|
2494
|
+
.getting-started-actions .btn {
|
|
2495
|
+
justify-content: center;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
@media (max-width: 480px) {
|
|
2500
|
+
.cta-row { flex-direction: column; }
|
|
2501
|
+
.btn { justify-content: center; }
|
|
2502
|
+
}
|
|
2503
|
+
</style>
|
|
2504
|
+
</head>
|
|
2505
|
+
<body>
|
|
2506
|
+
<header class="header">
|
|
2507
|
+
<a href="/" class="logo">
|
|
2508
|
+
<img src="https://docs.figma-console-mcp.southleft.com/logo/light.svg" alt="Figma Console MCP" class="logo-dark">
|
|
2509
|
+
<img src="https://docs.figma-console-mcp.southleft.com/logo/dark.svg" alt="Figma Console MCP" class="logo-light" style="display: none;">
|
|
2510
|
+
</a>
|
|
2511
|
+
<div class="header-right">
|
|
2512
|
+
<nav class="nav">
|
|
2513
|
+
<a href="https://docs.figma-console-mcp.southleft.com">Documentation</a>
|
|
2514
|
+
<a href="https://github.com/southleft/figma-console-mcp">GitHub</a>
|
|
2515
|
+
<a href="https://www.npmjs.com/package/figma-console-mcp">npm</a>
|
|
2516
|
+
<a href="https://southleft.com/insights/ai/figma-console-mcp-ai-powered-design-system-management/">Blog</a>
|
|
2517
|
+
<a href="https://github.com/sponsors/southleft" class="sponsor-link"><svg viewBox="0 0 16 16"><path d="M4.25 2.5c-1.336 0-2.75 1.164-2.75 3 0 2.15 1.58 4.144 3.365 5.682A20.6 20.6 0 0 0 8 13.393a20.6 20.6 0 0 0 3.135-2.211C12.92 9.644 14.5 7.65 14.5 5.5c0-1.836-1.414-3-2.75-3-1.373 0-2.609.986-3.029 2.456a.749.749 0 0 1-1.442 0C6.859 3.486 5.623 2.5 4.25 2.5"/></svg>Sponsor</a>
|
|
2518
|
+
</nav>
|
|
2519
|
+
<button class="theme-toggle" aria-label="Toggle theme">
|
|
2520
|
+
<svg class="moon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
2521
|
+
<svg class="sun" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
|
2522
|
+
</button>
|
|
2523
|
+
<button class="mobile-menu-btn" aria-label="Menu">
|
|
2524
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
2525
|
+
</button>
|
|
2526
|
+
</div>
|
|
2527
|
+
</header>
|
|
2528
|
+
|
|
2529
|
+
<!-- Mobile Menu -->
|
|
2530
|
+
<div class="mobile-menu" id="mobileMenu">
|
|
2531
|
+
<div class="mobile-menu-header">
|
|
2532
|
+
<a href="/" class="logo">
|
|
2533
|
+
<img src="https://docs.figma-console-mcp.southleft.com/logo/light.svg" alt="Figma Console MCP" class="logo-dark">
|
|
2534
|
+
<img src="https://docs.figma-console-mcp.southleft.com/logo/dark.svg" alt="Figma Console MCP" class="logo-light" style="display: none;">
|
|
2535
|
+
</a>
|
|
2536
|
+
<button class="mobile-menu-close" aria-label="Close menu">
|
|
2537
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 18L18 6M6 6l12 12"/></svg>
|
|
2538
|
+
</button>
|
|
2539
|
+
</div>
|
|
2540
|
+
<nav class="mobile-menu-nav">
|
|
2541
|
+
<a href="https://docs.figma-console-mcp.southleft.com">Documentation</a>
|
|
2542
|
+
<a href="https://github.com/southleft/figma-console-mcp">GitHub</a>
|
|
2543
|
+
<a href="https://www.npmjs.com/package/figma-console-mcp">npm</a>
|
|
2544
|
+
<a href="https://southleft.com/insights/ai/figma-console-mcp-ai-powered-design-system-management/">Blog</a>
|
|
2545
|
+
<a href="https://github.com/sponsors/southleft" style="color: #db61a2;">♥ Sponsor</a>
|
|
2546
|
+
</nav>
|
|
2547
|
+
</div>
|
|
2548
|
+
|
|
2549
|
+
<main class="main">
|
|
2550
|
+
<div class="grid">
|
|
2551
|
+
<!-- Hero -->
|
|
2552
|
+
<div class="grid-cell hero-cell">
|
|
2553
|
+
<div class="badge">Model Context Protocol</div>
|
|
2554
|
+
<h1>Your design system, now a <span class="highlight">living API</span></h1>
|
|
2555
|
+
<p class="hero-subtitle">The most comprehensive MCP server for Figma. Give AI assistants deep access to your design tokens, components, and variables. Read, query, and even create designs programmatically through natural language.</p>
|
|
2556
|
+
<div class="cta-row">
|
|
2557
|
+
<a href="https://docs.figma-console-mcp.southleft.com" class="btn btn-primary">
|
|
2558
|
+
Read the Docs
|
|
2559
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2560
|
+
</a>
|
|
2561
|
+
<a href="https://github.com/southleft/figma-console-mcp" class="btn btn-secondary">
|
|
2562
|
+
<svg fill="currentColor" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
2563
|
+
View on GitHub
|
|
2564
|
+
</a>
|
|
2565
|
+
</div>
|
|
2566
|
+
</div>
|
|
2567
|
+
|
|
2568
|
+
<!-- Capabilities showcase -->
|
|
2569
|
+
<div class="grid-cell showcase-cell rule-left">
|
|
2570
|
+
<div class="showcase-label">What AI Can Access</div>
|
|
2571
|
+
<div class="showcase-stat">
|
|
2572
|
+
<span class="number">59+</span>
|
|
2573
|
+
<span class="label">MCP tools for Figma</span>
|
|
2574
|
+
</div>
|
|
2575
|
+
<div class="capability-list">
|
|
2576
|
+
<div class="capability-item">
|
|
2577
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/></svg>
|
|
2578
|
+
<span>Design tokens and variables</span>
|
|
2579
|
+
</div>
|
|
2580
|
+
<div class="capability-item">
|
|
2581
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
|
2582
|
+
<span>Component specs and properties</span>
|
|
2583
|
+
</div>
|
|
2584
|
+
<div class="capability-item">
|
|
2585
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
2586
|
+
<span>Programmatic design creation</span>
|
|
2587
|
+
</div>
|
|
2588
|
+
<div class="capability-item">
|
|
2589
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
2590
|
+
<span>WCAG accessibility linting</span>
|
|
2591
|
+
</div>
|
|
2592
|
+
<div class="capability-item">
|
|
2593
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"/></svg>
|
|
2594
|
+
<span>Cloud relay for web AI clients</span>
|
|
2595
|
+
</div>
|
|
2596
|
+
<div class="capability-item">
|
|
2597
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
|
2598
|
+
<span>Visual debugging and screenshots</span>
|
|
2599
|
+
</div>
|
|
2600
|
+
</div>
|
|
2601
|
+
</div>
|
|
2602
|
+
</div>
|
|
2603
|
+
|
|
2604
|
+
<hr class="section-rule">
|
|
2605
|
+
|
|
2606
|
+
<!-- Value proposition -->
|
|
2607
|
+
<div class="grid">
|
|
2608
|
+
<div class="grid-cell value-cell">
|
|
2609
|
+
<h2>Design system intelligence for AI assistants</h2>
|
|
2610
|
+
<p>Whether you're maintaining a component library, implementing designs in code, or building Figma plugins, this MCP server gives AI the deep context it needs.</p>
|
|
2611
|
+
</div>
|
|
2612
|
+
</div>
|
|
2613
|
+
|
|
2614
|
+
<hr class="section-rule">
|
|
2615
|
+
|
|
2616
|
+
<!-- Capabilities detail -->
|
|
2617
|
+
<div class="grid">
|
|
2618
|
+
<div class="grid-cell capability-card">
|
|
2619
|
+
<div class="capability-icon">
|
|
2620
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/></svg>
|
|
2621
|
+
</div>
|
|
2622
|
+
<h3>Design Tokens</h3>
|
|
2623
|
+
<p>Extract colors, typography, spacing, and effects. Export as CSS custom properties, Tailwind config, or Sass variables.</p>
|
|
2624
|
+
</div>
|
|
2625
|
+
|
|
2626
|
+
<div class="grid-cell capability-card rule-left">
|
|
2627
|
+
<div class="capability-icon">
|
|
2628
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
|
2629
|
+
</div>
|
|
2630
|
+
<h3>Component Specs</h3>
|
|
2631
|
+
<p>Get detailed layout, spacing, variants, and property data for any component in your design system.</p>
|
|
2632
|
+
</div>
|
|
2633
|
+
|
|
2634
|
+
<div class="grid-cell capability-card rule-left">
|
|
2635
|
+
<div class="capability-icon">
|
|
2636
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
2637
|
+
</div>
|
|
2638
|
+
<h3>Programmatic Design</h3>
|
|
2639
|
+
<p>Create and modify variables, build component variants, and generate design elements through natural language.</p>
|
|
2640
|
+
</div>
|
|
2641
|
+
|
|
2642
|
+
<div class="grid-cell capability-card rule-left">
|
|
2643
|
+
<div class="capability-icon">
|
|
2644
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
|
2645
|
+
</div>
|
|
2646
|
+
<h3>Visual Debugging</h3>
|
|
2647
|
+
<p>Capture screenshots, inspect node properties, and track selection changes. Let AI analyze your designs and suggest improvements.</p>
|
|
2648
|
+
</div>
|
|
2649
|
+
</div>
|
|
2650
|
+
|
|
2651
|
+
<hr class="section-rule">
|
|
2652
|
+
|
|
2653
|
+
<div class="grid">
|
|
2654
|
+
<!-- Example Prompts -->
|
|
2655
|
+
<div class="grid-cell prompts-cell">
|
|
2656
|
+
<div class="section-header">
|
|
2657
|
+
<div class="section-header-icon">
|
|
2658
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
2659
|
+
</div>
|
|
2660
|
+
<h3>What you can ask</h3>
|
|
2661
|
+
</div>
|
|
2662
|
+
<div class="prompt-list">
|
|
2663
|
+
<div class="prompt-item">
|
|
2664
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2665
|
+
<span>"Extract all color variables as Tailwind config"</span>
|
|
2666
|
+
</div>
|
|
2667
|
+
<div class="prompt-item">
|
|
2668
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2669
|
+
<span>"Get the Button component specs from my design system"</span>
|
|
2670
|
+
</div>
|
|
2671
|
+
<div class="prompt-item">
|
|
2672
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2673
|
+
<span>"Create a dark mode version of my color variables"</span>
|
|
2674
|
+
</div>
|
|
2675
|
+
<div class="prompt-item">
|
|
2676
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2677
|
+
<span>"Check my design for accessibility issues"</span>
|
|
2678
|
+
</div>
|
|
2679
|
+
<div class="prompt-item">
|
|
2680
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2681
|
+
<span>"Connect to my Figma plugin and create a card component"</span>
|
|
2682
|
+
</div>
|
|
2683
|
+
</div>
|
|
2684
|
+
</div>
|
|
2685
|
+
|
|
2686
|
+
<!-- For Designers -->
|
|
2687
|
+
<div class="grid-cell audience-cell rule-left">
|
|
2688
|
+
<div class="audience-header">
|
|
2689
|
+
<div class="audience-icon">
|
|
2690
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
|
2691
|
+
</div>
|
|
2692
|
+
<h3>For Designers</h3>
|
|
2693
|
+
</div>
|
|
2694
|
+
<ul class="audience-list">
|
|
2695
|
+
<li>
|
|
2696
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2697
|
+
<span>Generate design token documentation automatically</span>
|
|
2698
|
+
</li>
|
|
2699
|
+
<li>
|
|
2700
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2701
|
+
<span>Create component variants with AI assistance</span>
|
|
2702
|
+
</li>
|
|
2703
|
+
<li>
|
|
2704
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2705
|
+
<span>Lint designs for accessibility and quality issues</span>
|
|
2706
|
+
</li>
|
|
2707
|
+
</ul>
|
|
2708
|
+
</div>
|
|
2709
|
+
</div>
|
|
2710
|
+
|
|
2711
|
+
<div class="grid" style="margin-top: 48px;">
|
|
2712
|
+
<!-- For Engineers -->
|
|
2713
|
+
<div class="grid-cell audience-cell">
|
|
2714
|
+
<div class="audience-header">
|
|
2715
|
+
<div class="audience-icon">
|
|
2716
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
2717
|
+
</div>
|
|
2718
|
+
<h3>For Engineers</h3>
|
|
2719
|
+
</div>
|
|
2720
|
+
<ul class="audience-list">
|
|
2721
|
+
<li>
|
|
2722
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2723
|
+
<span>Extract tokens as CSS, Tailwind, or Sass variables</span>
|
|
2724
|
+
</li>
|
|
2725
|
+
<li>
|
|
2726
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2727
|
+
<span>Get accurate component specs for implementation</span>
|
|
2728
|
+
</li>
|
|
2729
|
+
<li>
|
|
2730
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2731
|
+
<span>Query your design system via MCP-enabled AI tools</span>
|
|
2732
|
+
</li>
|
|
2733
|
+
</ul>
|
|
2734
|
+
</div>
|
|
2735
|
+
|
|
2736
|
+
<!-- For Teams -->
|
|
2737
|
+
<div class="grid-cell audience-cell rule-left">
|
|
2738
|
+
<div class="audience-header">
|
|
2739
|
+
<div class="audience-icon">
|
|
2740
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
2741
|
+
</div>
|
|
2742
|
+
<h3>For Teams of All Sizes</h3>
|
|
2743
|
+
</div>
|
|
2744
|
+
<ul class="audience-list">
|
|
2745
|
+
<li>
|
|
2746
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2747
|
+
<span>Indie developers to enterprise design systems</span>
|
|
2748
|
+
</li>
|
|
2749
|
+
<li>
|
|
2750
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2751
|
+
<span>Local mode for development, remote for production</span>
|
|
2752
|
+
</li>
|
|
2753
|
+
<li>
|
|
2754
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
|
|
2755
|
+
<span>Works with Claude, Cursor, and any MCP client</span>
|
|
2756
|
+
</li>
|
|
2757
|
+
</ul>
|
|
2758
|
+
</div>
|
|
2759
|
+
</div>
|
|
2760
|
+
|
|
2761
|
+
<!-- Getting Started CTA -->
|
|
2762
|
+
<div class="grid">
|
|
2763
|
+
<div class="grid-cell getting-started-cell">
|
|
2764
|
+
<div class="getting-started-content">
|
|
2765
|
+
<h3>Ready to get started?</h3>
|
|
2766
|
+
<p>Three ways to connect: local mode for full capabilities, cloud mode for web AI clients like Claude.ai and v0, or remote mode for quick read-only access. Our docs will guide you through the right path.</p>
|
|
2767
|
+
</div>
|
|
2768
|
+
<div class="getting-started-actions">
|
|
2769
|
+
<a href="https://docs.figma-console-mcp.southleft.com/setup" class="btn btn-primary">
|
|
2770
|
+
View Setup Guide
|
|
2771
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2772
|
+
</a>
|
|
2773
|
+
<a href="https://docs.figma-console-mcp.southleft.com/tools" class="btn btn-secondary">
|
|
2774
|
+
Explore Tools
|
|
2775
|
+
</a>
|
|
2776
|
+
</div>
|
|
2777
|
+
</div>
|
|
2778
|
+
</div>
|
|
2779
|
+
|
|
2780
|
+
<div class="grid">
|
|
2781
|
+
<!-- Blog CTA -->
|
|
2782
|
+
<a href="https://southleft.com/insights/ai/figma-console-mcp-ai-powered-design-system-management/" class="grid-cell blog-cell">
|
|
2783
|
+
<div class="blog-content">
|
|
2784
|
+
<div class="blog-icon">
|
|
2785
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
|
2786
|
+
</div>
|
|
2787
|
+
<div class="blog-text">
|
|
2788
|
+
<h4>Read the announcement</h4>
|
|
2789
|
+
<p>AI-Powered Design System Management with Figma Console MCP</p>
|
|
2790
|
+
</div>
|
|
2791
|
+
</div>
|
|
2792
|
+
<span class="blog-link">
|
|
2793
|
+
Read article
|
|
2794
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
|
2795
|
+
</span>
|
|
2796
|
+
</a>
|
|
2797
|
+
</div>
|
|
2798
|
+
</main>
|
|
2799
|
+
|
|
2800
|
+
<footer class="footer">
|
|
2801
|
+
<p>MIT License. Built by <a href="https://southleft.com">Southleft</a></p>
|
|
2802
|
+
<div class="footer-links">
|
|
2803
|
+
<a href="https://docs.figma-console-mcp.southleft.com">Docs</a>
|
|
2804
|
+
<a href="https://github.com/southleft/figma-console-mcp">GitHub</a>
|
|
2805
|
+
<a href="https://www.npmjs.com/package/figma-console-mcp">npm</a>
|
|
2806
|
+
<a href="https://github.com/sponsors/southleft" style="color: #db61a2;">♥ Sponsor</a>
|
|
2807
|
+
</div>
|
|
2808
|
+
</footer>
|
|
2809
|
+
|
|
2810
|
+
<script>
|
|
2811
|
+
// Theme toggle with system preference detection
|
|
2812
|
+
(function() {
|
|
2813
|
+
const html = document.documentElement;
|
|
2814
|
+
const toggle = document.querySelector('.theme-toggle');
|
|
2815
|
+
const logosDark = document.querySelectorAll('.logo-dark');
|
|
2816
|
+
const logosLight = document.querySelectorAll('.logo-light');
|
|
2817
|
+
|
|
2818
|
+
function getSystemTheme() {
|
|
2819
|
+
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
function getStoredTheme() {
|
|
2823
|
+
return localStorage.getItem('theme');
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
function setTheme(theme) {
|
|
2827
|
+
html.setAttribute('data-theme', theme);
|
|
2828
|
+
localStorage.setItem('theme', theme);
|
|
2829
|
+
updateLogos(theme);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
function updateLogos(theme) {
|
|
2833
|
+
logosDark.forEach(logo => {
|
|
2834
|
+
logo.style.display = theme === 'light' ? 'none' : 'block';
|
|
2835
|
+
});
|
|
2836
|
+
logosLight.forEach(logo => {
|
|
2837
|
+
logo.style.display = theme === 'light' ? 'block' : 'none';
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// Initialize theme
|
|
2842
|
+
const storedTheme = getStoredTheme();
|
|
2843
|
+
const initialTheme = storedTheme || getSystemTheme();
|
|
2844
|
+
setTheme(initialTheme);
|
|
2845
|
+
|
|
2846
|
+
// Listen for system theme changes
|
|
2847
|
+
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
|
|
2848
|
+
if (!getStoredTheme()) {
|
|
2849
|
+
setTheme(e.matches ? 'light' : 'dark');
|
|
2850
|
+
}
|
|
2851
|
+
});
|
|
2852
|
+
|
|
2853
|
+
// Toggle button
|
|
2854
|
+
toggle.addEventListener('click', () => {
|
|
2855
|
+
const currentTheme = html.getAttribute('data-theme');
|
|
2856
|
+
setTheme(currentTheme === 'light' ? 'dark' : 'light');
|
|
2857
|
+
});
|
|
2858
|
+
})();
|
|
2859
|
+
|
|
2860
|
+
// Mobile menu toggle
|
|
2861
|
+
(function() {
|
|
2862
|
+
const menuBtn = document.querySelector('.mobile-menu-btn');
|
|
2863
|
+
const menu = document.getElementById('mobileMenu');
|
|
2864
|
+
const closeBtn = document.querySelector('.mobile-menu-close');
|
|
2865
|
+
const menuLinks = document.querySelectorAll('.mobile-menu-nav a');
|
|
2866
|
+
|
|
2867
|
+
function openMenu() {
|
|
2868
|
+
menu.classList.add('active');
|
|
2869
|
+
document.body.classList.add('menu-open');
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
function closeMenu() {
|
|
2873
|
+
menu.classList.remove('active');
|
|
2874
|
+
document.body.classList.remove('menu-open');
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
menuBtn.addEventListener('click', openMenu);
|
|
2878
|
+
closeBtn.addEventListener('click', closeMenu);
|
|
2879
|
+
|
|
2880
|
+
// Close menu when clicking a link
|
|
2881
|
+
menuLinks.forEach(link => {
|
|
2882
|
+
link.addEventListener('click', closeMenu);
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
// Close menu on escape key
|
|
2886
|
+
document.addEventListener('keydown', (e) => {
|
|
2887
|
+
if (e.key === 'Escape' && menu.classList.contains('active')) {
|
|
2888
|
+
closeMenu();
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
})();
|
|
2892
|
+
</script>
|
|
2893
|
+
</body>
|
|
2894
|
+
</html>`, {
|
|
2895
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
return new Response("Not found", { status: 404 });
|
|
2899
|
+
}
|