figma-console-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/browser/base.d.ts +50 -0
- package/dist/browser/base.d.ts.map +1 -0
- package/dist/browser/base.js +6 -0
- package/dist/browser/base.js.map +1 -0
- package/dist/browser/local.d.ts +66 -0
- package/dist/browser/local.d.ts.map +1 -0
- package/dist/browser/local.js +223 -0
- package/dist/browser/local.js.map +1 -0
- package/dist/cloudflare/browser/base.js +5 -0
- package/dist/cloudflare/browser/cloudflare.js +156 -0
- package/dist/cloudflare/browser-manager.js +157 -0
- package/dist/cloudflare/core/config.js +161 -0
- package/dist/cloudflare/core/console-monitor.js +382 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
- package/dist/cloudflare/core/enrichment/index.js +7 -0
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
- package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
- package/dist/cloudflare/core/figma-api.js +273 -0
- package/dist/cloudflare/core/figma-desktop-connector.js +383 -0
- package/dist/cloudflare/core/figma-style-extractor.js +311 -0
- package/dist/cloudflare/core/figma-tools.js +2299 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/types/enriched.js +5 -0
- package/dist/cloudflare/core/types/index.js +4 -0
- package/dist/cloudflare/index.js +1059 -0
- package/dist/cloudflare/test-browser.js +88 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +141 -0
- package/dist/config.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +162 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/console-monitor.d.ts +81 -0
- package/dist/core/console-monitor.d.ts.map +1 -0
- package/dist/core/console-monitor.js +383 -0
- package/dist/core/console-monitor.js.map +1 -0
- package/dist/core/enrichment/enrichment-service.d.ts +52 -0
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
- package/dist/core/enrichment/enrichment-service.js +273 -0
- package/dist/core/enrichment/enrichment-service.js.map +1 -0
- package/dist/core/enrichment/index.d.ts +8 -0
- package/dist/core/enrichment/index.d.ts.map +1 -0
- package/dist/core/enrichment/index.js +8 -0
- package/dist/core/enrichment/index.js.map +1 -0
- package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
- package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
- package/dist/core/enrichment/relationship-mapper.js +352 -0
- package/dist/core/enrichment/relationship-mapper.js.map +1 -0
- package/dist/core/enrichment/style-resolver.d.ts +80 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +327 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figma-api.d.ts +137 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +274 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-desktop-connector.d.ts +52 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +384 -0
- package/dist/core/figma-desktop-connector.js.map +1 -0
- package/dist/core/figma-style-extractor.d.ts +76 -0
- package/dist/core/figma-style-extractor.d.ts.map +1 -0
- package/dist/core/figma-style-extractor.js +312 -0
- package/dist/core/figma-style-extractor.js.map +1 -0
- package/dist/core/figma-tools.d.ts +15 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +2300 -0
- package/dist/core/figma-tools.js.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/snippet-injector.d.ts +24 -0
- package/dist/core/snippet-injector.d.ts.map +1 -0
- package/dist/core/snippet-injector.js +97 -0
- package/dist/core/snippet-injector.js.map +1 -0
- package/dist/core/types/enriched.d.ts +213 -0
- package/dist/core/types/enriched.d.ts.map +1 -0
- package/dist/core/types/enriched.js +6 -0
- package/dist/core/types/enriched.js.map +1 -0
- package/dist/core/types/index.d.ts +112 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +5 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/local.d.ts +57 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +668 -0
- package/dist/local.js.map +1 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +45 -0
- package/dist/logger.js.map +1 -0
- package/dist/server.d.ts +40 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +99 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +184 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/types/index.d.ts +102 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/figma-desktop-bridge/README.md +232 -0
- package/figma-desktop-bridge/code.js +133 -0
- package/figma-desktop-bridge/manifest.json +13 -0
- package/figma-desktop-bridge/ui.html +200 -0
- package/package.json +77 -0
|
@@ -0,0 +1,1059 @@
|
|
|
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 { z } from "zod";
|
|
13
|
+
import { BrowserManager } from "./browser-manager.js";
|
|
14
|
+
import { ConsoleMonitor } from "./core/console-monitor.js";
|
|
15
|
+
import { getConfig } from "./core/config.js";
|
|
16
|
+
import { createChildLogger } from "./core/logger.js";
|
|
17
|
+
import { testBrowserRendering } from "./test-browser.js";
|
|
18
|
+
import { FigmaAPI, extractFileKey } from "./core/figma-api.js";
|
|
19
|
+
import { registerFigmaAPITools } from "./core/figma-tools.js";
|
|
20
|
+
const logger = createChildLogger({ component: "mcp-server" });
|
|
21
|
+
/**
|
|
22
|
+
* Figma Console MCP Agent
|
|
23
|
+
* Extends McpAgent to provide Figma-specific debugging tools
|
|
24
|
+
*/
|
|
25
|
+
export class FigmaConsoleMCPv3 extends McpAgent {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(...arguments);
|
|
28
|
+
this.server = new McpServer({
|
|
29
|
+
name: "Figma Console MCP",
|
|
30
|
+
version: "0.1.0",
|
|
31
|
+
});
|
|
32
|
+
this.browserManager = null;
|
|
33
|
+
this.consoleMonitor = null;
|
|
34
|
+
this.figmaAPI = null;
|
|
35
|
+
this.config = getConfig();
|
|
36
|
+
this.sessionId = null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Refresh an expired OAuth token using the refresh token
|
|
40
|
+
*/
|
|
41
|
+
async refreshOAuthToken(sessionId, refreshToken) {
|
|
42
|
+
const env = this.env;
|
|
43
|
+
if (!env.FIGMA_OAUTH_CLIENT_ID || !env.FIGMA_OAUTH_CLIENT_SECRET) {
|
|
44
|
+
throw new Error("OAuth not configured on server");
|
|
45
|
+
}
|
|
46
|
+
logger.info({ sessionId }, "Attempting to refresh OAuth token");
|
|
47
|
+
const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
|
|
48
|
+
const tokenParams = new URLSearchParams({
|
|
49
|
+
grant_type: "refresh_token",
|
|
50
|
+
refresh_token: refreshToken
|
|
51
|
+
});
|
|
52
|
+
const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
56
|
+
"Authorization": `Basic ${credentials}`
|
|
57
|
+
},
|
|
58
|
+
body: tokenParams.toString()
|
|
59
|
+
});
|
|
60
|
+
if (!tokenResponse.ok) {
|
|
61
|
+
const errorData = await tokenResponse.json().catch(() => ({}));
|
|
62
|
+
logger.error({ errorData, status: tokenResponse.status }, "Token refresh failed");
|
|
63
|
+
throw new Error(`Token refresh failed: ${JSON.stringify(errorData)}`);
|
|
64
|
+
}
|
|
65
|
+
const tokenData = await tokenResponse.json();
|
|
66
|
+
// Store refreshed token in KV
|
|
67
|
+
const tokenKey = `oauth_token:${sessionId}`;
|
|
68
|
+
const storedToken = {
|
|
69
|
+
accessToken: tokenData.access_token,
|
|
70
|
+
refreshToken: tokenData.refresh_token || refreshToken, // Use new refresh token or keep existing
|
|
71
|
+
expiresAt: Date.now() + (tokenData.expires_in * 1000)
|
|
72
|
+
};
|
|
73
|
+
await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
|
|
74
|
+
expirationTtl: tokenData.expires_in
|
|
75
|
+
});
|
|
76
|
+
logger.info({ sessionId }, "OAuth token refreshed successfully");
|
|
77
|
+
return storedToken;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate a cryptographically secure random state token for CSRF protection
|
|
81
|
+
*/
|
|
82
|
+
static generateStateToken() {
|
|
83
|
+
const array = new Uint8Array(32);
|
|
84
|
+
crypto.getRandomValues(array);
|
|
85
|
+
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Load or create persistent session ID from Durable Object storage
|
|
89
|
+
* Uses a fixed session ID for the MCP server to ensure OAuth tokens persist across reconnections
|
|
90
|
+
*/
|
|
91
|
+
async ensureSessionId() {
|
|
92
|
+
if (this.sessionId) {
|
|
93
|
+
return; // Already loaded
|
|
94
|
+
}
|
|
95
|
+
// IMPORTANT: Use a fixed session ID for all MCP connections
|
|
96
|
+
// This ensures OAuth tokens persist across MCP server reconnections
|
|
97
|
+
// Each user of this MCP server will share the same OAuth token
|
|
98
|
+
const FIXED_SESSION_ID = "figma-console-mcp-default-session";
|
|
99
|
+
// Try to load from Durable Object storage
|
|
100
|
+
// @ts-ignore - this.ctx is available in Durable Object context
|
|
101
|
+
const storage = this.ctx?.storage;
|
|
102
|
+
if (storage) {
|
|
103
|
+
try {
|
|
104
|
+
const storedSessionId = await storage.get('sessionId');
|
|
105
|
+
if (storedSessionId) {
|
|
106
|
+
this.sessionId = storedSessionId;
|
|
107
|
+
logger.info({ sessionId: this.sessionId }, "Loaded persistent session ID from storage");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Store the fixed session ID
|
|
112
|
+
this.sessionId = FIXED_SESSION_ID;
|
|
113
|
+
await storage.put('sessionId', this.sessionId);
|
|
114
|
+
logger.info({ sessionId: this.sessionId }, "Initialized fixed session ID");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
logger.warn({ error: e }, "Failed to access Durable Object storage for session ID");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Fallback: use fixed session ID directly
|
|
123
|
+
this.sessionId = FIXED_SESSION_ID;
|
|
124
|
+
logger.info({ sessionId: this.sessionId }, "Using fixed session ID (storage unavailable)");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Get session ID for this Durable Object instance
|
|
128
|
+
* Returns the session ID loaded by ensureSessionId()
|
|
129
|
+
*/
|
|
130
|
+
getSessionId() {
|
|
131
|
+
if (!this.sessionId) {
|
|
132
|
+
// This shouldn't happen if ensureSessionId() was called, but provide fallback
|
|
133
|
+
this.sessionId = FigmaConsoleMCPv3.generateStateToken();
|
|
134
|
+
logger.warn({ sessionId: this.sessionId }, "Session ID not initialized, generated ephemeral ID");
|
|
135
|
+
}
|
|
136
|
+
return this.sessionId;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get or create Figma API client with OAuth token from session
|
|
140
|
+
*/
|
|
141
|
+
async getFigmaAPI() {
|
|
142
|
+
// Ensure session ID is loaded from storage
|
|
143
|
+
await this.ensureSessionId();
|
|
144
|
+
// @ts-ignore - this.env is available in Agent/Durable Object context
|
|
145
|
+
const env = this.env;
|
|
146
|
+
// Try OAuth first (per-user authentication)
|
|
147
|
+
try {
|
|
148
|
+
const sessionId = this.getSessionId();
|
|
149
|
+
logger.info({ sessionId }, "Attempting to retrieve OAuth token from KV");
|
|
150
|
+
// Retrieve token from KV (accessible across all Durable Object instances)
|
|
151
|
+
const tokenKey = `oauth_token:${sessionId}`;
|
|
152
|
+
const tokenJson = await env.OAUTH_TOKENS.get(tokenKey);
|
|
153
|
+
if (!tokenJson) {
|
|
154
|
+
logger.warn({ sessionId, tokenKey }, "No OAuth token found in KV");
|
|
155
|
+
throw new Error("No token found");
|
|
156
|
+
}
|
|
157
|
+
let tokenData = JSON.parse(tokenJson);
|
|
158
|
+
logger.info({
|
|
159
|
+
sessionId,
|
|
160
|
+
hasToken: !!tokenData?.accessToken,
|
|
161
|
+
expiresAt: tokenData?.expiresAt,
|
|
162
|
+
isExpired: tokenData?.expiresAt ? Date.now() > tokenData.expiresAt : null
|
|
163
|
+
}, "Token retrieval result from KV");
|
|
164
|
+
if (tokenData?.accessToken) {
|
|
165
|
+
// Check if token is expired or will expire soon (within 5 minutes)
|
|
166
|
+
const isExpired = tokenData.expiresAt && Date.now() > tokenData.expiresAt;
|
|
167
|
+
const willExpireSoon = tokenData.expiresAt && Date.now() > (tokenData.expiresAt - 5 * 60 * 1000);
|
|
168
|
+
if (isExpired || willExpireSoon) {
|
|
169
|
+
if (tokenData.refreshToken) {
|
|
170
|
+
try {
|
|
171
|
+
// Attempt to refresh the token
|
|
172
|
+
tokenData = await this.refreshOAuthToken(sessionId, tokenData.refreshToken);
|
|
173
|
+
logger.info({ sessionId }, "Successfully refreshed expired/expiring token");
|
|
174
|
+
}
|
|
175
|
+
catch (refreshError) {
|
|
176
|
+
logger.error({ sessionId, refreshError }, "Failed to refresh token");
|
|
177
|
+
throw new Error("Token expired and refresh failed. Please re-authenticate.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
logger.warn({ sessionId }, "Token expired but no refresh token available");
|
|
182
|
+
throw new Error("Token expired. Please re-authenticate.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
logger.info({ sessionId }, "Using OAuth token from KV for Figma API");
|
|
186
|
+
return new FigmaAPI({ accessToken: tokenData.accessToken });
|
|
187
|
+
}
|
|
188
|
+
logger.warn({ sessionId }, "OAuth token exists in KV but missing accessToken");
|
|
189
|
+
throw new Error("Invalid token data");
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
193
|
+
const sessionId = this.getSessionId();
|
|
194
|
+
// Check if this is a "no token found" error (user hasn't authenticated yet)
|
|
195
|
+
if (errorMessage.includes("No token found")) {
|
|
196
|
+
logger.info({ sessionId }, "No OAuth token found - user needs to authenticate");
|
|
197
|
+
// No authentication available - direct user to OAuth flow
|
|
198
|
+
const authUrl = `https://figma-console-mcp.southleft.com/oauth/authorize?session_id=${sessionId}`;
|
|
199
|
+
// Only use PAT fallback if explicitly configured AND no OAuth token exists
|
|
200
|
+
if (env?.FIGMA_ACCESS_TOKEN) {
|
|
201
|
+
logger.warn("FIGMA_ACCESS_TOKEN fallback is deprecated. User should authenticate via OAuth for proper per-user authentication.");
|
|
202
|
+
return new FigmaAPI({ accessToken: env.FIGMA_ACCESS_TOKEN });
|
|
203
|
+
}
|
|
204
|
+
throw new Error(JSON.stringify({
|
|
205
|
+
error: "authentication_required",
|
|
206
|
+
message: "Please authenticate with Figma to use API features",
|
|
207
|
+
auth_url: authUrl,
|
|
208
|
+
instructions: "Your browser will open automatically to complete authentication. If it doesn't, copy the auth_url and open it manually."
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
// For other OAuth errors (expired token, refresh failed, etc.), do NOT fall back to PAT
|
|
212
|
+
logger.error({ error, sessionId }, "OAuth token retrieval failed - re-authentication required");
|
|
213
|
+
const authUrl = `https://figma-console-mcp.southleft.com/oauth/authorize?session_id=${sessionId}`;
|
|
214
|
+
throw new Error(JSON.stringify({
|
|
215
|
+
error: "oauth_error",
|
|
216
|
+
message: errorMessage,
|
|
217
|
+
auth_url: authUrl,
|
|
218
|
+
instructions: "Please re-authenticate with Figma. Your browser will open automatically."
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Initialize browser and console monitoring
|
|
224
|
+
*/
|
|
225
|
+
async ensureInitialized() {
|
|
226
|
+
try {
|
|
227
|
+
// Ensure session ID is loaded from storage first
|
|
228
|
+
await this.ensureSessionId();
|
|
229
|
+
if (!this.browserManager) {
|
|
230
|
+
logger.info("Initializing BrowserManager");
|
|
231
|
+
// Access env from Durable Object context
|
|
232
|
+
// @ts-ignore - this.env is available in Agent/Durable Object context
|
|
233
|
+
const env = this.env;
|
|
234
|
+
if (!env) {
|
|
235
|
+
throw new Error("Environment not available - this.env is undefined");
|
|
236
|
+
}
|
|
237
|
+
if (!env.BROWSER) {
|
|
238
|
+
throw new Error("BROWSER binding not found in environment. Check wrangler.jsonc configuration.");
|
|
239
|
+
}
|
|
240
|
+
logger.info("Creating BrowserManager with BROWSER binding");
|
|
241
|
+
this.browserManager = new BrowserManager(env, this.config.browser);
|
|
242
|
+
}
|
|
243
|
+
if (!this.consoleMonitor) {
|
|
244
|
+
logger.info("Initializing ConsoleMonitor");
|
|
245
|
+
this.consoleMonitor = new ConsoleMonitor(this.config.console);
|
|
246
|
+
// Start browser and begin monitoring
|
|
247
|
+
logger.info("Getting browser page");
|
|
248
|
+
const page = await this.browserManager.getPage();
|
|
249
|
+
logger.info("Starting console monitoring");
|
|
250
|
+
await this.consoleMonitor.startMonitoring(page);
|
|
251
|
+
logger.info("Browser and console monitor initialized successfully");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
logger.error({ error }, "Failed to initialize browser/monitor");
|
|
256
|
+
throw new Error(`Initialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async init() {
|
|
260
|
+
// Tool 1: Get Console Logs
|
|
261
|
+
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.", {
|
|
262
|
+
count: z.number().optional().default(100).describe("Number of recent logs to retrieve"),
|
|
263
|
+
level: z
|
|
264
|
+
.enum(["log", "info", "warn", "error", "debug", "all"])
|
|
265
|
+
.optional()
|
|
266
|
+
.default("all")
|
|
267
|
+
.describe("Filter by log level"),
|
|
268
|
+
since: z
|
|
269
|
+
.number()
|
|
270
|
+
.optional()
|
|
271
|
+
.describe("Only logs after this timestamp (Unix ms)"),
|
|
272
|
+
}, async ({ count, level, since }) => {
|
|
273
|
+
try {
|
|
274
|
+
await this.ensureInitialized();
|
|
275
|
+
if (!this.consoleMonitor) {
|
|
276
|
+
throw new Error("Console monitor not initialized");
|
|
277
|
+
}
|
|
278
|
+
const logs = this.consoleMonitor.getLogs({
|
|
279
|
+
count,
|
|
280
|
+
level,
|
|
281
|
+
since,
|
|
282
|
+
});
|
|
283
|
+
// Add AI instruction when no logs are found
|
|
284
|
+
const responseData = {
|
|
285
|
+
logs,
|
|
286
|
+
totalCount: logs.length,
|
|
287
|
+
oldestTimestamp: logs[0]?.timestamp,
|
|
288
|
+
newestTimestamp: logs[logs.length - 1]?.timestamp,
|
|
289
|
+
status: this.consoleMonitor.getStatus(),
|
|
290
|
+
};
|
|
291
|
+
// If no logs found, add helpful AI instruction
|
|
292
|
+
if (logs.length === 0) {
|
|
293
|
+
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.";
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
content: [
|
|
297
|
+
{
|
|
298
|
+
type: "text",
|
|
299
|
+
text: JSON.stringify(responseData, null, 2),
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
logger.error({ error }, "Failed to get console logs");
|
|
306
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
307
|
+
return {
|
|
308
|
+
content: [
|
|
309
|
+
{
|
|
310
|
+
type: "text",
|
|
311
|
+
text: JSON.stringify({
|
|
312
|
+
error: errorMessage,
|
|
313
|
+
message: "Failed to retrieve console logs. Make sure to call figma_navigate first to initialize the browser.",
|
|
314
|
+
hint: "Try: figma_navigate({ url: 'https://www.figma.com/design/your-file' })",
|
|
315
|
+
}, null, 2),
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
isError: true,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
// Tool 2: Take Screenshot (using Figma REST API)
|
|
323
|
+
// Note: For screenshots of specific components, use figma_get_component_image instead
|
|
324
|
+
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.", {
|
|
325
|
+
nodeId: z
|
|
326
|
+
.string()
|
|
327
|
+
.optional()
|
|
328
|
+
.describe("Optional node ID to screenshot. If not provided, uses the currently viewed page/frame from the browser URL."),
|
|
329
|
+
scale: z
|
|
330
|
+
.number()
|
|
331
|
+
.min(0.01)
|
|
332
|
+
.max(4)
|
|
333
|
+
.optional()
|
|
334
|
+
.default(2)
|
|
335
|
+
.describe("Image scale factor (0.01-4, default: 2 for high quality)"),
|
|
336
|
+
format: z
|
|
337
|
+
.enum(["png", "jpg", "svg", "pdf"])
|
|
338
|
+
.optional()
|
|
339
|
+
.default("png")
|
|
340
|
+
.describe("Image format (default: png)"),
|
|
341
|
+
}, async ({ nodeId, scale, format }) => {
|
|
342
|
+
try {
|
|
343
|
+
const api = await this.getFigmaAPI();
|
|
344
|
+
// Get current URL to extract file key and node ID if not provided
|
|
345
|
+
const currentUrl = this.browserManager?.getCurrentUrl() || null;
|
|
346
|
+
if (!currentUrl) {
|
|
347
|
+
throw new Error("No Figma file open. Either provide a nodeId parameter or call figma_navigate first to open a Figma file.");
|
|
348
|
+
}
|
|
349
|
+
const fileKey = extractFileKey(currentUrl);
|
|
350
|
+
if (!fileKey) {
|
|
351
|
+
throw new Error(`Invalid Figma URL: ${currentUrl}`);
|
|
352
|
+
}
|
|
353
|
+
// Extract node ID from URL if not provided
|
|
354
|
+
let targetNodeId = nodeId;
|
|
355
|
+
if (!targetNodeId) {
|
|
356
|
+
const urlObj = new URL(currentUrl);
|
|
357
|
+
const nodeIdParam = urlObj.searchParams.get('node-id');
|
|
358
|
+
if (nodeIdParam) {
|
|
359
|
+
// Convert 123-456 to 123:456
|
|
360
|
+
targetNodeId = nodeIdParam.replace(/-/g, ':');
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
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)");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
logger.info({ fileKey, nodeId: targetNodeId, scale, format }, "Rendering image via Figma API");
|
|
367
|
+
// Use Figma REST API to get image
|
|
368
|
+
const result = await api.getImages(fileKey, targetNodeId, {
|
|
369
|
+
scale,
|
|
370
|
+
format: format === 'jpg' ? 'jpg' : format, // normalize jpeg -> jpg
|
|
371
|
+
contents_only: true,
|
|
372
|
+
});
|
|
373
|
+
const imageUrl = result.images[targetNodeId];
|
|
374
|
+
if (!imageUrl) {
|
|
375
|
+
throw new Error(`Failed to render image for node ${targetNodeId}. The node may not exist or may not be renderable.`);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
content: [
|
|
379
|
+
{
|
|
380
|
+
type: "text",
|
|
381
|
+
text: JSON.stringify({
|
|
382
|
+
fileKey,
|
|
383
|
+
nodeId: targetNodeId,
|
|
384
|
+
imageUrl,
|
|
385
|
+
scale,
|
|
386
|
+
format,
|
|
387
|
+
expiresIn: "30 days",
|
|
388
|
+
note: "Image URL provided above. Use this URL to view or download the screenshot. URLs expire after 30 days.",
|
|
389
|
+
}, null, 2),
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
logger.error({ error }, "Failed to capture screenshot");
|
|
396
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
397
|
+
return {
|
|
398
|
+
content: [
|
|
399
|
+
{
|
|
400
|
+
type: "text",
|
|
401
|
+
text: JSON.stringify({
|
|
402
|
+
error: errorMessage,
|
|
403
|
+
message: "Failed to capture screenshot via Figma API",
|
|
404
|
+
hint: "Make sure you've called figma_navigate to open a file, or provide a valid nodeId parameter",
|
|
405
|
+
}, null, 2),
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
isError: true,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
// Tool 3: Watch Console (Real-time streaming)
|
|
413
|
+
this.server.tool("figma_watch_console", {
|
|
414
|
+
duration: z
|
|
415
|
+
.number()
|
|
416
|
+
.optional()
|
|
417
|
+
.default(30)
|
|
418
|
+
.describe("How long to watch in seconds"),
|
|
419
|
+
level: z
|
|
420
|
+
.enum(["log", "info", "warn", "error", "debug", "all"])
|
|
421
|
+
.optional()
|
|
422
|
+
.default("all")
|
|
423
|
+
.describe("Filter by log level"),
|
|
424
|
+
}, async ({ duration, level }) => {
|
|
425
|
+
await this.ensureInitialized();
|
|
426
|
+
if (!this.consoleMonitor) {
|
|
427
|
+
throw new Error("Console monitor not initialized. Call figma_navigate first.");
|
|
428
|
+
}
|
|
429
|
+
const consoleMonitor = this.consoleMonitor;
|
|
430
|
+
if (!consoleMonitor.getStatus().isMonitoring) {
|
|
431
|
+
throw new Error("Console monitoring not active. Call figma_navigate first.");
|
|
432
|
+
}
|
|
433
|
+
const startTime = Date.now();
|
|
434
|
+
const endTime = startTime + duration * 1000;
|
|
435
|
+
const startLogCount = consoleMonitor.getStatus().logCount;
|
|
436
|
+
// Wait for the specified duration while collecting logs
|
|
437
|
+
await new Promise(resolve => setTimeout(resolve, duration * 1000));
|
|
438
|
+
// Get logs captured during watch period
|
|
439
|
+
const watchedLogs = consoleMonitor.getLogs({
|
|
440
|
+
level: level === 'all' ? undefined : level,
|
|
441
|
+
since: startTime,
|
|
442
|
+
});
|
|
443
|
+
const endLogCount = consoleMonitor.getStatus().logCount;
|
|
444
|
+
const newLogsCount = endLogCount - startLogCount;
|
|
445
|
+
return {
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "text",
|
|
449
|
+
text: JSON.stringify({
|
|
450
|
+
status: "completed",
|
|
451
|
+
duration: `${duration} seconds`,
|
|
452
|
+
startTime: new Date(startTime).toISOString(),
|
|
453
|
+
endTime: new Date(endTime).toISOString(),
|
|
454
|
+
filter: level,
|
|
455
|
+
statistics: {
|
|
456
|
+
totalLogsInBuffer: endLogCount,
|
|
457
|
+
logsAddedDuringWatch: newLogsCount,
|
|
458
|
+
logsMatchingFilter: watchedLogs.length,
|
|
459
|
+
},
|
|
460
|
+
logs: watchedLogs,
|
|
461
|
+
}, null, 2),
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
// Tool 4: Reload Plugin
|
|
467
|
+
this.server.tool("figma_reload_plugin", {
|
|
468
|
+
clearConsole: z
|
|
469
|
+
.boolean()
|
|
470
|
+
.optional()
|
|
471
|
+
.default(true)
|
|
472
|
+
.describe("Clear console logs before reload"),
|
|
473
|
+
}, async ({ clearConsole: clearConsoleBefore }) => {
|
|
474
|
+
try {
|
|
475
|
+
await this.ensureInitialized();
|
|
476
|
+
if (!this.browserManager) {
|
|
477
|
+
throw new Error("Browser manager not initialized");
|
|
478
|
+
}
|
|
479
|
+
// Clear console buffer if requested
|
|
480
|
+
let clearedCount = 0;
|
|
481
|
+
if (clearConsoleBefore && this.consoleMonitor) {
|
|
482
|
+
clearedCount = this.consoleMonitor.clear();
|
|
483
|
+
}
|
|
484
|
+
// Reload the page
|
|
485
|
+
await this.browserManager.reload();
|
|
486
|
+
const currentUrl = this.browserManager.getCurrentUrl();
|
|
487
|
+
return {
|
|
488
|
+
content: [
|
|
489
|
+
{
|
|
490
|
+
type: "text",
|
|
491
|
+
text: JSON.stringify({
|
|
492
|
+
status: "reloaded",
|
|
493
|
+
timestamp: Date.now(),
|
|
494
|
+
url: currentUrl,
|
|
495
|
+
consoleCleared: clearConsoleBefore,
|
|
496
|
+
clearedCount: clearConsoleBefore ? clearedCount : 0,
|
|
497
|
+
}, null, 2),
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
logger.error({ error }, "Failed to reload plugin");
|
|
504
|
+
return {
|
|
505
|
+
content: [
|
|
506
|
+
{
|
|
507
|
+
type: "text",
|
|
508
|
+
text: JSON.stringify({
|
|
509
|
+
error: String(error),
|
|
510
|
+
message: "Failed to reload plugin",
|
|
511
|
+
}, null, 2),
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
isError: true,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
// Tool 5: Clear Console
|
|
519
|
+
this.server.tool("figma_clear_console", {}, async () => {
|
|
520
|
+
try {
|
|
521
|
+
await this.ensureInitialized();
|
|
522
|
+
if (!this.consoleMonitor) {
|
|
523
|
+
throw new Error("Console monitor not initialized");
|
|
524
|
+
}
|
|
525
|
+
const clearedCount = this.consoleMonitor.clear();
|
|
526
|
+
return {
|
|
527
|
+
content: [
|
|
528
|
+
{
|
|
529
|
+
type: "text",
|
|
530
|
+
text: JSON.stringify({
|
|
531
|
+
status: "cleared",
|
|
532
|
+
clearedCount,
|
|
533
|
+
timestamp: Date.now(),
|
|
534
|
+
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.",
|
|
535
|
+
}, null, 2),
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
logger.error({ error }, "Failed to clear console");
|
|
542
|
+
return {
|
|
543
|
+
content: [
|
|
544
|
+
{
|
|
545
|
+
type: "text",
|
|
546
|
+
text: JSON.stringify({
|
|
547
|
+
error: String(error),
|
|
548
|
+
message: "Failed to clear console buffer",
|
|
549
|
+
}, null, 2),
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
isError: true,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
// Tool 6: Navigate to Figma
|
|
557
|
+
this.server.tool("figma_navigate", {
|
|
558
|
+
url: z
|
|
559
|
+
.string()
|
|
560
|
+
.url()
|
|
561
|
+
.describe("Figma URL to navigate to (e.g., https://www.figma.com/design/abc123)"),
|
|
562
|
+
}, async ({ url }) => {
|
|
563
|
+
try {
|
|
564
|
+
await this.ensureInitialized();
|
|
565
|
+
if (!this.browserManager) {
|
|
566
|
+
throw new Error("Browser manager not initialized");
|
|
567
|
+
}
|
|
568
|
+
// Navigate to the URL
|
|
569
|
+
await this.browserManager.navigateToFigma(url);
|
|
570
|
+
// Give page time to load and start capturing logs
|
|
571
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
572
|
+
const currentUrl = this.browserManager.getCurrentUrl();
|
|
573
|
+
return {
|
|
574
|
+
content: [
|
|
575
|
+
{
|
|
576
|
+
type: "text",
|
|
577
|
+
text: JSON.stringify({
|
|
578
|
+
status: "navigated",
|
|
579
|
+
url: currentUrl,
|
|
580
|
+
timestamp: Date.now(),
|
|
581
|
+
message: "Browser navigated to Figma. Console monitoring is active.",
|
|
582
|
+
}, null, 2),
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
logger.error({ error }, "Failed to navigate to Figma");
|
|
589
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
590
|
+
return {
|
|
591
|
+
content: [
|
|
592
|
+
{
|
|
593
|
+
type: "text",
|
|
594
|
+
text: JSON.stringify({
|
|
595
|
+
error: errorMessage,
|
|
596
|
+
message: "Failed to navigate to Figma URL",
|
|
597
|
+
details: errorMessage.includes("BROWSER")
|
|
598
|
+
? "Browser Rendering API binding is missing. This is a configuration issue."
|
|
599
|
+
: "Unable to launch browser or navigate to URL.",
|
|
600
|
+
troubleshooting: [
|
|
601
|
+
"Verify the Figma URL is valid and accessible",
|
|
602
|
+
"Check that the Browser Rendering API is properly configured in wrangler.jsonc",
|
|
603
|
+
"Try again in a few moments if this is a temporary issue"
|
|
604
|
+
]
|
|
605
|
+
}, null, 2),
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
isError: true,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
// Tool 7: Get Status
|
|
613
|
+
this.server.tool("figma_get_status", {}, async () => {
|
|
614
|
+
try {
|
|
615
|
+
const browserRunning = this.browserManager?.isRunning() ?? false;
|
|
616
|
+
const monitorStatus = this.consoleMonitor?.getStatus() ?? null;
|
|
617
|
+
const currentUrl = this.browserManager?.getCurrentUrl() ?? null;
|
|
618
|
+
return {
|
|
619
|
+
content: [
|
|
620
|
+
{
|
|
621
|
+
type: "text",
|
|
622
|
+
text: JSON.stringify({
|
|
623
|
+
browser: {
|
|
624
|
+
running: browserRunning,
|
|
625
|
+
currentUrl,
|
|
626
|
+
},
|
|
627
|
+
consoleMonitor: monitorStatus,
|
|
628
|
+
initialized: this.browserManager !== null && this.consoleMonitor !== null,
|
|
629
|
+
timestamp: Date.now(),
|
|
630
|
+
}, null, 2),
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
logger.error({ error }, "Failed to get status");
|
|
637
|
+
return {
|
|
638
|
+
content: [
|
|
639
|
+
{
|
|
640
|
+
type: "text",
|
|
641
|
+
text: JSON.stringify({
|
|
642
|
+
error: String(error),
|
|
643
|
+
message: "Failed to retrieve status",
|
|
644
|
+
}, null, 2),
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
isError: true,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
// Register Figma API tools (Tools 8-14)
|
|
652
|
+
registerFigmaAPITools(this.server, async () => await this.getFigmaAPI(), () => this.browserManager?.getCurrentUrl() || null, () => this.consoleMonitor || null, () => this.browserManager || null, () => this.ensureInitialized());
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Cloudflare Workers fetch handler
|
|
657
|
+
* Routes requests to appropriate MCP endpoints
|
|
658
|
+
*/
|
|
659
|
+
export default {
|
|
660
|
+
async fetch(request, env, ctx) {
|
|
661
|
+
const url = new URL(request.url);
|
|
662
|
+
// SSE endpoint for remote MCP clients
|
|
663
|
+
if (url.pathname === "/sse" || url.pathname === "/sse/message") {
|
|
664
|
+
return FigmaConsoleMCPv3.serveSSE("/sse").fetch(request, env, ctx);
|
|
665
|
+
}
|
|
666
|
+
// HTTP endpoint for direct MCP communication
|
|
667
|
+
if (url.pathname === "/mcp") {
|
|
668
|
+
return FigmaConsoleMCPv3.serve("/mcp").fetch(request, env, ctx);
|
|
669
|
+
}
|
|
670
|
+
// OAuth authorization initiation
|
|
671
|
+
if (url.pathname === "/oauth/authorize") {
|
|
672
|
+
const sessionId = url.searchParams.get("session_id");
|
|
673
|
+
if (!sessionId) {
|
|
674
|
+
return new Response("Missing session_id parameter", { status: 400 });
|
|
675
|
+
}
|
|
676
|
+
// Check if OAuth credentials are configured
|
|
677
|
+
if (!env.FIGMA_OAUTH_CLIENT_ID) {
|
|
678
|
+
return new Response(JSON.stringify({
|
|
679
|
+
error: "OAuth not configured",
|
|
680
|
+
message: "Server administrator needs to configure FIGMA_OAUTH_CLIENT_ID",
|
|
681
|
+
docs: "https://github.com/southleft/figma-console-mcp#oauth-setup"
|
|
682
|
+
}), {
|
|
683
|
+
status: 500,
|
|
684
|
+
headers: { "Content-Type": "application/json" }
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
// Generate cryptographically secure state token for CSRF protection
|
|
688
|
+
const stateToken = FigmaConsoleMCPv3.generateStateToken();
|
|
689
|
+
// Store state token with sessionId in KV (10 minute expiration)
|
|
690
|
+
await env.OAUTH_STATE.put(stateToken, sessionId, {
|
|
691
|
+
expirationTtl: 600 // 10 minutes
|
|
692
|
+
});
|
|
693
|
+
const redirectUri = `${url.origin}/oauth/callback`;
|
|
694
|
+
const figmaAuthUrl = new URL("https://www.figma.com/oauth");
|
|
695
|
+
figmaAuthUrl.searchParams.set("client_id", env.FIGMA_OAUTH_CLIENT_ID);
|
|
696
|
+
figmaAuthUrl.searchParams.set("redirect_uri", redirectUri);
|
|
697
|
+
figmaAuthUrl.searchParams.set("scope", "file_content:read,library_content:read,file_variables:read");
|
|
698
|
+
figmaAuthUrl.searchParams.set("state", stateToken);
|
|
699
|
+
figmaAuthUrl.searchParams.set("response_type", "code");
|
|
700
|
+
return Response.redirect(figmaAuthUrl.toString(), 302);
|
|
701
|
+
}
|
|
702
|
+
// OAuth callback handler
|
|
703
|
+
if (url.pathname === "/oauth/callback") {
|
|
704
|
+
const code = url.searchParams.get("code");
|
|
705
|
+
const stateToken = url.searchParams.get("state");
|
|
706
|
+
const error = url.searchParams.get("error");
|
|
707
|
+
// Handle OAuth errors
|
|
708
|
+
if (error) {
|
|
709
|
+
return new Response(`<html><body>
|
|
710
|
+
<h1>❌ Authentication Failed</h1>
|
|
711
|
+
<p>Error: ${error}</p>
|
|
712
|
+
<p>Description: ${url.searchParams.get("error_description") || "Unknown error"}</p>
|
|
713
|
+
<p>You can close this window and try again.</p>
|
|
714
|
+
</body></html>`, {
|
|
715
|
+
status: 400,
|
|
716
|
+
headers: { "Content-Type": "text/html" }
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
if (!code || !stateToken) {
|
|
720
|
+
return new Response("Missing code or state parameter", { status: 400 });
|
|
721
|
+
}
|
|
722
|
+
// Validate state token (CSRF protection)
|
|
723
|
+
const sessionId = await env.OAUTH_STATE.get(stateToken);
|
|
724
|
+
if (!sessionId) {
|
|
725
|
+
return new Response(`<html><body>
|
|
726
|
+
<h1>❌ Invalid or Expired Request</h1>
|
|
727
|
+
<p>The authentication request has expired or is invalid.</p>
|
|
728
|
+
<p>Please try authenticating again.</p>
|
|
729
|
+
</body></html>`, {
|
|
730
|
+
status: 400,
|
|
731
|
+
headers: { "Content-Type": "text/html" }
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
// Delete state token after validation (one-time use)
|
|
735
|
+
await env.OAUTH_STATE.delete(stateToken);
|
|
736
|
+
try {
|
|
737
|
+
// Exchange authorization code for access token
|
|
738
|
+
// Use Basic auth in Authorization header (Figma's recommended method)
|
|
739
|
+
const credentials = btoa(`${env.FIGMA_OAUTH_CLIENT_ID}:${env.FIGMA_OAUTH_CLIENT_SECRET}`);
|
|
740
|
+
const tokenParams = new URLSearchParams({
|
|
741
|
+
redirect_uri: `${url.origin}/oauth/callback`,
|
|
742
|
+
code,
|
|
743
|
+
grant_type: "authorization_code"
|
|
744
|
+
});
|
|
745
|
+
const tokenResponse = await fetch("https://api.figma.com/v1/oauth/token", {
|
|
746
|
+
method: "POST",
|
|
747
|
+
headers: {
|
|
748
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
749
|
+
"Authorization": `Basic ${credentials}`
|
|
750
|
+
},
|
|
751
|
+
body: tokenParams.toString()
|
|
752
|
+
});
|
|
753
|
+
if (!tokenResponse.ok) {
|
|
754
|
+
const errorText = await tokenResponse.text();
|
|
755
|
+
let errorData;
|
|
756
|
+
try {
|
|
757
|
+
errorData = JSON.parse(errorText);
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
errorData = { error: "Unknown error", raw: errorText, status: tokenResponse.status };
|
|
761
|
+
}
|
|
762
|
+
logger.error({ errorData, status: tokenResponse.status }, "Token exchange failed");
|
|
763
|
+
throw new Error(`Token exchange failed: ${JSON.stringify(errorData)}`);
|
|
764
|
+
}
|
|
765
|
+
const tokenData = await tokenResponse.json();
|
|
766
|
+
const accessToken = tokenData.access_token;
|
|
767
|
+
const refreshToken = tokenData.refresh_token;
|
|
768
|
+
const expiresIn = tokenData.expires_in;
|
|
769
|
+
logger.info({
|
|
770
|
+
sessionId,
|
|
771
|
+
hasAccessToken: !!accessToken,
|
|
772
|
+
accessTokenPreview: accessToken ? accessToken.substring(0, 10) + "..." : null,
|
|
773
|
+
hasRefreshToken: !!refreshToken,
|
|
774
|
+
expiresIn
|
|
775
|
+
}, "Token exchange successful");
|
|
776
|
+
// IMPORTANT: Use KV storage for tokens since Durable Object storage is instance-specific
|
|
777
|
+
// Store token in Workers KV so it's accessible across all Durable Object instances
|
|
778
|
+
const tokenKey = `oauth_token:${sessionId}`;
|
|
779
|
+
const storedToken = {
|
|
780
|
+
accessToken,
|
|
781
|
+
refreshToken,
|
|
782
|
+
expiresAt: Date.now() + (expiresIn * 1000)
|
|
783
|
+
};
|
|
784
|
+
// Store in KV with 90-day expiration (matching token lifetime)
|
|
785
|
+
await env.OAUTH_TOKENS.put(tokenKey, JSON.stringify(storedToken), {
|
|
786
|
+
expirationTtl: expiresIn
|
|
787
|
+
});
|
|
788
|
+
logger.info({ sessionId, tokenKey }, "Token stored successfully in KV");
|
|
789
|
+
return new Response(`<!DOCTYPE html>
|
|
790
|
+
<html>
|
|
791
|
+
<head>
|
|
792
|
+
<meta charset="UTF-8">
|
|
793
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
794
|
+
<title>Authentication Successful</title>
|
|
795
|
+
<link rel="icon" type="image/jpeg" href="https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg">
|
|
796
|
+
<style>
|
|
797
|
+
* {
|
|
798
|
+
margin: 0;
|
|
799
|
+
padding: 0;
|
|
800
|
+
box-sizing: border-box;
|
|
801
|
+
}
|
|
802
|
+
body {
|
|
803
|
+
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
804
|
+
background: #ffffff;
|
|
805
|
+
color: #000000;
|
|
806
|
+
display: flex;
|
|
807
|
+
align-items: center;
|
|
808
|
+
justify-content: center;
|
|
809
|
+
min-height: 100vh;
|
|
810
|
+
padding: 24px;
|
|
811
|
+
}
|
|
812
|
+
.container {
|
|
813
|
+
max-width: 480px;
|
|
814
|
+
text-align: center;
|
|
815
|
+
}
|
|
816
|
+
.icon {
|
|
817
|
+
width: 64px;
|
|
818
|
+
height: 64px;
|
|
819
|
+
margin: 0 auto 24px;
|
|
820
|
+
background: #18a0fb;
|
|
821
|
+
border-radius: 50%;
|
|
822
|
+
display: flex;
|
|
823
|
+
align-items: center;
|
|
824
|
+
justify-content: center;
|
|
825
|
+
font-size: 32px;
|
|
826
|
+
color: white;
|
|
827
|
+
}
|
|
828
|
+
h1 {
|
|
829
|
+
font-size: 32px;
|
|
830
|
+
font-weight: 700;
|
|
831
|
+
margin-bottom: 16px;
|
|
832
|
+
letter-spacing: -0.02em;
|
|
833
|
+
}
|
|
834
|
+
p {
|
|
835
|
+
font-size: 16px;
|
|
836
|
+
color: #666666;
|
|
837
|
+
line-height: 1.6;
|
|
838
|
+
margin-bottom: 32px;
|
|
839
|
+
}
|
|
840
|
+
.button {
|
|
841
|
+
display: inline-block;
|
|
842
|
+
padding: 12px 24px;
|
|
843
|
+
background: #000000;
|
|
844
|
+
color: #ffffff;
|
|
845
|
+
text-decoration: none;
|
|
846
|
+
border-radius: 8px;
|
|
847
|
+
font-weight: 500;
|
|
848
|
+
font-size: 16px;
|
|
849
|
+
border: none;
|
|
850
|
+
cursor: pointer;
|
|
851
|
+
transition: background 0.2s;
|
|
852
|
+
}
|
|
853
|
+
.button:hover {
|
|
854
|
+
background: #333333;
|
|
855
|
+
}
|
|
856
|
+
.footer {
|
|
857
|
+
margin-top: 48px;
|
|
858
|
+
font-size: 14px;
|
|
859
|
+
color: #999999;
|
|
860
|
+
}
|
|
861
|
+
</style>
|
|
862
|
+
</head>
|
|
863
|
+
<body>
|
|
864
|
+
<div class="container">
|
|
865
|
+
<div class="icon">✓</div>
|
|
866
|
+
<h1>Authentication successful</h1>
|
|
867
|
+
<p>You've successfully connected Figma Console MCP to your Figma account. You can now close this window and return to Claude.</p>
|
|
868
|
+
<button class="button" onclick="window.close()">Close this window</button>
|
|
869
|
+
<div class="footer">This window will automatically close in 5 seconds</div>
|
|
870
|
+
</div>
|
|
871
|
+
<script>
|
|
872
|
+
setTimeout(() => window.close(), 5000);
|
|
873
|
+
</script>
|
|
874
|
+
</body>
|
|
875
|
+
</html>`, {
|
|
876
|
+
headers: {
|
|
877
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
logger.error({ error, sessionId }, "OAuth callback failed");
|
|
883
|
+
return new Response(`<html><body>
|
|
884
|
+
<h1>❌ Authentication Error</h1>
|
|
885
|
+
<p>Failed to complete authentication: ${error instanceof Error ? error.message : String(error)}</p>
|
|
886
|
+
<p>Please try again or contact support.</p>
|
|
887
|
+
</body></html>`, {
|
|
888
|
+
status: 500,
|
|
889
|
+
headers: { "Content-Type": "text/html" }
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// Health check endpoint
|
|
894
|
+
if (url.pathname === "/health") {
|
|
895
|
+
return new Response(JSON.stringify({
|
|
896
|
+
status: "healthy",
|
|
897
|
+
service: "Figma Console MCP",
|
|
898
|
+
version: "0.1.0",
|
|
899
|
+
endpoints: ["/sse", "/mcp", "/test-browser", "/oauth/authorize", "/oauth/callback"],
|
|
900
|
+
oauth_configured: !!env.FIGMA_OAUTH_CLIENT_ID
|
|
901
|
+
}), {
|
|
902
|
+
headers: { "Content-Type": "application/json" },
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
// Browser Rendering API test endpoint
|
|
906
|
+
if (url.pathname === "/test-browser") {
|
|
907
|
+
const results = await testBrowserRendering(env);
|
|
908
|
+
return new Response(JSON.stringify(results, null, 2), {
|
|
909
|
+
headers: { "Content-Type": "application/json" },
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
// Serve favicon
|
|
913
|
+
if (url.pathname === "/favicon.ico") {
|
|
914
|
+
// Redirect to custom Figma Console icon
|
|
915
|
+
return Response.redirect("https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg", 302);
|
|
916
|
+
}
|
|
917
|
+
// Root path - serve landing page with proper meta tags
|
|
918
|
+
if (url.pathname === "/") {
|
|
919
|
+
return new Response(`<!DOCTYPE html>
|
|
920
|
+
<html>
|
|
921
|
+
<head>
|
|
922
|
+
<meta charset="UTF-8">
|
|
923
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
924
|
+
<title>Figma Console MCP</title>
|
|
925
|
+
<link rel="icon" type="image/jpeg" href="https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg">
|
|
926
|
+
<meta name="description" content="Model Context Protocol server for Figma debugging and design system extraction">
|
|
927
|
+
<style>
|
|
928
|
+
* {
|
|
929
|
+
margin: 0;
|
|
930
|
+
padding: 0;
|
|
931
|
+
box-sizing: border-box;
|
|
932
|
+
}
|
|
933
|
+
body {
|
|
934
|
+
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
935
|
+
background: #ffffff;
|
|
936
|
+
color: #000000;
|
|
937
|
+
line-height: 1.5;
|
|
938
|
+
}
|
|
939
|
+
.header {
|
|
940
|
+
padding: 24px 48px;
|
|
941
|
+
border-bottom: 1px solid #e5e5e5;
|
|
942
|
+
}
|
|
943
|
+
.logo {
|
|
944
|
+
display: flex;
|
|
945
|
+
align-items: center;
|
|
946
|
+
gap: 12px;
|
|
947
|
+
font-size: 18px;
|
|
948
|
+
font-weight: 600;
|
|
949
|
+
}
|
|
950
|
+
.logo img {
|
|
951
|
+
width: 32px;
|
|
952
|
+
height: 32px;
|
|
953
|
+
}
|
|
954
|
+
.container {
|
|
955
|
+
max-width: 1200px;
|
|
956
|
+
margin: 0 auto;
|
|
957
|
+
padding: 80px 48px;
|
|
958
|
+
}
|
|
959
|
+
h1 {
|
|
960
|
+
font-size: 64px;
|
|
961
|
+
font-weight: 700;
|
|
962
|
+
margin-bottom: 24px;
|
|
963
|
+
letter-spacing: -0.02em;
|
|
964
|
+
line-height: 1.1;
|
|
965
|
+
}
|
|
966
|
+
.subtitle {
|
|
967
|
+
font-size: 20px;
|
|
968
|
+
color: #666666;
|
|
969
|
+
margin-bottom: 48px;
|
|
970
|
+
max-width: 600px;
|
|
971
|
+
}
|
|
972
|
+
.cta {
|
|
973
|
+
display: inline-flex;
|
|
974
|
+
align-items: center;
|
|
975
|
+
gap: 8px;
|
|
976
|
+
padding: 12px 24px;
|
|
977
|
+
background: #000000;
|
|
978
|
+
color: #ffffff;
|
|
979
|
+
text-decoration: none;
|
|
980
|
+
border-radius: 8px;
|
|
981
|
+
font-weight: 500;
|
|
982
|
+
font-size: 16px;
|
|
983
|
+
transition: background 0.2s;
|
|
984
|
+
}
|
|
985
|
+
.cta:hover {
|
|
986
|
+
background: #333333;
|
|
987
|
+
}
|
|
988
|
+
.features {
|
|
989
|
+
display: grid;
|
|
990
|
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
991
|
+
gap: 32px;
|
|
992
|
+
margin-top: 80px;
|
|
993
|
+
}
|
|
994
|
+
.feature {
|
|
995
|
+
padding: 24px;
|
|
996
|
+
border: 1px solid #e5e5e5;
|
|
997
|
+
border-radius: 8px;
|
|
998
|
+
}
|
|
999
|
+
.feature h3 {
|
|
1000
|
+
font-size: 20px;
|
|
1001
|
+
font-weight: 600;
|
|
1002
|
+
margin-bottom: 12px;
|
|
1003
|
+
}
|
|
1004
|
+
.feature p {
|
|
1005
|
+
color: #666666;
|
|
1006
|
+
font-size: 15px;
|
|
1007
|
+
}
|
|
1008
|
+
.footer {
|
|
1009
|
+
padding: 48px;
|
|
1010
|
+
text-align: center;
|
|
1011
|
+
color: #999999;
|
|
1012
|
+
font-size: 14px;
|
|
1013
|
+
border-top: 1px solid #e5e5e5;
|
|
1014
|
+
margin-top: 120px;
|
|
1015
|
+
}
|
|
1016
|
+
</style>
|
|
1017
|
+
</head>
|
|
1018
|
+
<body>
|
|
1019
|
+
<div class="header">
|
|
1020
|
+
<div class="logo">
|
|
1021
|
+
<img src="https://p198.p4.n0.cdn.zight.com/items/Qwu1Dywx/b61b7b8f-05dc-4063-8a40-53fa4f8e3e97.jpg" alt="Figma Console MCP">
|
|
1022
|
+
Figma Console MCP
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
<div class="container">
|
|
1026
|
+
<h1>Debug Figma plugins<br>with AI assistance</h1>
|
|
1027
|
+
<p class="subtitle">Model Context Protocol server that gives AI assistants real-time access to Figma console logs, design system data, and visual debugging tools.</p>
|
|
1028
|
+
<a href="https://github.com/southleft/figma-console-mcp" class="cta">
|
|
1029
|
+
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
1030
|
+
<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"/>
|
|
1031
|
+
</svg>
|
|
1032
|
+
View Documentation
|
|
1033
|
+
</a>
|
|
1034
|
+
<div class="features">
|
|
1035
|
+
<div class="feature">
|
|
1036
|
+
<h3>Real-time Console Access</h3>
|
|
1037
|
+
<p>Capture plugin console logs, errors, and stack traces as they happen</p>
|
|
1038
|
+
</div>
|
|
1039
|
+
<div class="feature">
|
|
1040
|
+
<h3>Design System Extraction</h3>
|
|
1041
|
+
<p>Pull variables, components, and styles directly from Figma files</p>
|
|
1042
|
+
</div>
|
|
1043
|
+
<div class="feature">
|
|
1044
|
+
<h3>Visual Debugging</h3>
|
|
1045
|
+
<p>Take screenshots and export component images for visual reference</p>
|
|
1046
|
+
</div>
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
<div class="footer">
|
|
1050
|
+
© 2025 Figma Console MCP · MIT License
|
|
1051
|
+
</div>
|
|
1052
|
+
</body>
|
|
1053
|
+
</html>`, {
|
|
1054
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
return new Response("Not found", { status: 404 });
|
|
1058
|
+
},
|
|
1059
|
+
};
|