@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.
Files changed (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +816 -0
  3. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
  4. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
  5. package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
  6. package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
  7. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
  8. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
  9. package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
  10. package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
  11. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
  12. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
  13. package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
  14. package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
  15. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
  16. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
  17. package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
  18. package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
  19. package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
  20. package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
  21. package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
  22. package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
  23. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
  24. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
  25. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
  26. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
  27. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
  28. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
  29. package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
  30. package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
  31. package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
  32. package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
  33. package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
  34. package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
  35. package/dist/apps/design-system-dashboard/server.d.ts +24 -0
  36. package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
  37. package/dist/apps/design-system-dashboard/server.js +160 -0
  38. package/dist/apps/design-system-dashboard/server.js.map +1 -0
  39. package/dist/apps/token-browser/server.d.ts +26 -0
  40. package/dist/apps/token-browser/server.d.ts.map +1 -0
  41. package/dist/apps/token-browser/server.js +137 -0
  42. package/dist/apps/token-browser/server.js.map +1 -0
  43. package/dist/browser/base.d.ts +58 -0
  44. package/dist/browser/base.d.ts.map +1 -0
  45. package/dist/browser/base.js +6 -0
  46. package/dist/browser/base.js.map +1 -0
  47. package/dist/browser/local.d.ts +87 -0
  48. package/dist/browser/local.d.ts.map +1 -0
  49. package/dist/browser/local.js +318 -0
  50. package/dist/browser/local.js.map +1 -0
  51. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
  52. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
  53. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
  54. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
  55. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
  56. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
  57. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
  58. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
  59. package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
  60. package/dist/cloudflare/apps/token-browser/server.js +136 -0
  61. package/dist/cloudflare/browser/base.js +5 -0
  62. package/dist/cloudflare/browser/cloudflare.js +156 -0
  63. package/dist/cloudflare/browser-manager.js +157 -0
  64. package/dist/cloudflare/core/cloud-websocket-connector.js +267 -0
  65. package/dist/cloudflare/core/cloud-websocket-relay.js +199 -0
  66. package/dist/cloudflare/core/comment-tools.js +292 -0
  67. package/dist/cloudflare/core/config.js +161 -0
  68. package/dist/cloudflare/core/console-monitor.js +427 -0
  69. package/dist/cloudflare/core/design-code-tools.js +2504 -0
  70. package/dist/cloudflare/core/design-system-manifest.js +260 -0
  71. package/dist/cloudflare/core/design-system-tools.js +863 -0
  72. package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
  73. package/dist/cloudflare/core/enrichment/index.js +7 -0
  74. package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
  75. package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
  76. package/dist/cloudflare/core/figma-api.js +409 -0
  77. package/dist/cloudflare/core/figma-connector.js +7 -0
  78. package/dist/cloudflare/core/figma-desktop-connector.js +1184 -0
  79. package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
  80. package/dist/cloudflare/core/figma-style-extractor.js +311 -0
  81. package/dist/cloudflare/core/figma-tools.js +2947 -0
  82. package/dist/cloudflare/core/logger.js +53 -0
  83. package/dist/cloudflare/core/port-discovery.js +282 -0
  84. package/dist/cloudflare/core/snippet-injector.js +96 -0
  85. package/dist/cloudflare/core/types/design-code.js +4 -0
  86. package/dist/cloudflare/core/types/enriched.js +5 -0
  87. package/dist/cloudflare/core/types/index.js +4 -0
  88. package/dist/cloudflare/core/websocket-connector.js +256 -0
  89. package/dist/cloudflare/core/websocket-server.js +646 -0
  90. package/dist/cloudflare/core/write-tools.js +2091 -0
  91. package/dist/cloudflare/index.js +2899 -0
  92. package/dist/cloudflare/test-browser.js +88 -0
  93. package/dist/core/comment-tools.d.ts +11 -0
  94. package/dist/core/comment-tools.d.ts.map +1 -0
  95. package/dist/core/comment-tools.js +293 -0
  96. package/dist/core/comment-tools.js.map +1 -0
  97. package/dist/core/config.d.ts +17 -0
  98. package/dist/core/config.d.ts.map +1 -0
  99. package/dist/core/config.js +162 -0
  100. package/dist/core/config.js.map +1 -0
  101. package/dist/core/console-monitor.d.ts +82 -0
  102. package/dist/core/console-monitor.d.ts.map +1 -0
  103. package/dist/core/console-monitor.js +428 -0
  104. package/dist/core/console-monitor.js.map +1 -0
  105. package/dist/core/design-code-tools.d.ts +127 -0
  106. package/dist/core/design-code-tools.d.ts.map +1 -0
  107. package/dist/core/design-code-tools.js +2505 -0
  108. package/dist/core/design-code-tools.js.map +1 -0
  109. package/dist/core/design-system-manifest.d.ts +272 -0
  110. package/dist/core/design-system-manifest.d.ts.map +1 -0
  111. package/dist/core/design-system-manifest.js +261 -0
  112. package/dist/core/design-system-manifest.js.map +1 -0
  113. package/dist/core/design-system-tools.d.ts +17 -0
  114. package/dist/core/design-system-tools.d.ts.map +1 -0
  115. package/dist/core/design-system-tools.js +864 -0
  116. package/dist/core/design-system-tools.js.map +1 -0
  117. package/dist/core/enrichment/enrichment-service.d.ts +52 -0
  118. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
  119. package/dist/core/enrichment/enrichment-service.js +273 -0
  120. package/dist/core/enrichment/enrichment-service.js.map +1 -0
  121. package/dist/core/enrichment/index.d.ts +8 -0
  122. package/dist/core/enrichment/index.d.ts.map +1 -0
  123. package/dist/core/enrichment/index.js +8 -0
  124. package/dist/core/enrichment/index.js.map +1 -0
  125. package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
  126. package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
  127. package/dist/core/enrichment/relationship-mapper.js +352 -0
  128. package/dist/core/enrichment/relationship-mapper.js.map +1 -0
  129. package/dist/core/enrichment/style-resolver.d.ts +80 -0
  130. package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
  131. package/dist/core/enrichment/style-resolver.js +327 -0
  132. package/dist/core/enrichment/style-resolver.js.map +1 -0
  133. package/dist/core/figma-api.d.ts +201 -0
  134. package/dist/core/figma-api.d.ts.map +1 -0
  135. package/dist/core/figma-api.js +410 -0
  136. package/dist/core/figma-api.js.map +1 -0
  137. package/dist/core/figma-connector.d.ts +48 -0
  138. package/dist/core/figma-connector.d.ts.map +1 -0
  139. package/dist/core/figma-connector.js +8 -0
  140. package/dist/core/figma-connector.js.map +1 -0
  141. package/dist/core/figma-desktop-connector.d.ts +265 -0
  142. package/dist/core/figma-desktop-connector.d.ts.map +1 -0
  143. package/dist/core/figma-desktop-connector.js +1184 -0
  144. package/dist/core/figma-desktop-connector.js.map +1 -0
  145. package/dist/core/figma-reconstruction-spec.d.ts +166 -0
  146. package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
  147. package/dist/core/figma-reconstruction-spec.js +403 -0
  148. package/dist/core/figma-reconstruction-spec.js.map +1 -0
  149. package/dist/core/figma-style-extractor.d.ts +76 -0
  150. package/dist/core/figma-style-extractor.d.ts.map +1 -0
  151. package/dist/core/figma-style-extractor.js +312 -0
  152. package/dist/core/figma-style-extractor.js.map +1 -0
  153. package/dist/core/figma-tools.d.ts +23 -0
  154. package/dist/core/figma-tools.d.ts.map +1 -0
  155. package/dist/core/figma-tools.js +2948 -0
  156. package/dist/core/figma-tools.js.map +1 -0
  157. package/dist/core/logger.d.ts +22 -0
  158. package/dist/core/logger.d.ts.map +1 -0
  159. package/dist/core/logger.js +54 -0
  160. package/dist/core/logger.js.map +1 -0
  161. package/dist/core/port-discovery.d.ts +110 -0
  162. package/dist/core/port-discovery.d.ts.map +1 -0
  163. package/dist/core/port-discovery.js +283 -0
  164. package/dist/core/port-discovery.js.map +1 -0
  165. package/dist/core/snippet-injector.d.ts +24 -0
  166. package/dist/core/snippet-injector.d.ts.map +1 -0
  167. package/dist/core/snippet-injector.js +97 -0
  168. package/dist/core/snippet-injector.js.map +1 -0
  169. package/dist/core/types/design-code.d.ts +262 -0
  170. package/dist/core/types/design-code.d.ts.map +1 -0
  171. package/dist/core/types/design-code.js +5 -0
  172. package/dist/core/types/design-code.js.map +1 -0
  173. package/dist/core/types/enriched.d.ts +213 -0
  174. package/dist/core/types/enriched.d.ts.map +1 -0
  175. package/dist/core/types/enriched.js +6 -0
  176. package/dist/core/types/enriched.js.map +1 -0
  177. package/dist/core/types/index.d.ts +112 -0
  178. package/dist/core/types/index.d.ts.map +1 -0
  179. package/dist/core/types/index.js +5 -0
  180. package/dist/core/types/index.js.map +1 -0
  181. package/dist/core/websocket-connector.d.ts +55 -0
  182. package/dist/core/websocket-connector.d.ts.map +1 -0
  183. package/dist/core/websocket-connector.js +257 -0
  184. package/dist/core/websocket-connector.js.map +1 -0
  185. package/dist/core/websocket-server.d.ts +191 -0
  186. package/dist/core/websocket-server.d.ts.map +1 -0
  187. package/dist/core/websocket-server.js +647 -0
  188. package/dist/core/websocket-server.js.map +1 -0
  189. package/dist/core/write-tools.d.ts +7 -0
  190. package/dist/core/write-tools.d.ts.map +1 -0
  191. package/dist/core/write-tools.js +2092 -0
  192. package/dist/core/write-tools.js.map +1 -0
  193. package/dist/local.d.ts +84 -0
  194. package/dist/local.d.ts.map +1 -0
  195. package/dist/local.js +5039 -0
  196. package/dist/local.js.map +1 -0
  197. package/figma-desktop-bridge/README.md +313 -0
  198. package/figma-desktop-bridge/code.js +2818 -0
  199. package/figma-desktop-bridge/manifest.json +67 -0
  200. package/figma-desktop-bridge/ui.html +1236 -0
  201. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
+ }