chrometools-mcp 1.0.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 +15 -0
- package/README.md +329 -0
- package/WSL_SETUP.md +270 -0
- package/index.js +1286 -0
- package/package.json +55 -0
package/index.js
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import puppeteer from "puppeteer";
|
|
11
|
+
import Jimp from "jimp";
|
|
12
|
+
import pixelmatch from "pixelmatch";
|
|
13
|
+
|
|
14
|
+
// Figma token from environment variable (can be set in MCP config)
|
|
15
|
+
const FIGMA_TOKEN = process.env.FIGMA_TOKEN || null;
|
|
16
|
+
|
|
17
|
+
// Global browser instance (persists between requests)
|
|
18
|
+
let browserPromise = null;
|
|
19
|
+
const openPages = new Map();
|
|
20
|
+
let lastPage = null;
|
|
21
|
+
|
|
22
|
+
// Console logs storage
|
|
23
|
+
const consoleLogs = [];
|
|
24
|
+
|
|
25
|
+
// Initialize browser (singleton)
|
|
26
|
+
async function getBrowser() {
|
|
27
|
+
if (!browserPromise) {
|
|
28
|
+
browserPromise = puppeteer.launch({
|
|
29
|
+
headless: false,
|
|
30
|
+
defaultViewport: null,
|
|
31
|
+
args: [
|
|
32
|
+
'--no-sandbox',
|
|
33
|
+
'--disable-setuid-sandbox',
|
|
34
|
+
'--disable-dev-shm-usage',
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
console.error("[chrometools-mcp] Browser initialized (GUI mode)");
|
|
38
|
+
}
|
|
39
|
+
return browserPromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Get or create page for URL
|
|
43
|
+
async function getOrCreatePage(url) {
|
|
44
|
+
const browser = await getBrowser();
|
|
45
|
+
|
|
46
|
+
// Check if page for this URL already exists
|
|
47
|
+
if (openPages.has(url)) {
|
|
48
|
+
const existingPage = openPages.get(url);
|
|
49
|
+
if (!existingPage.isClosed()) {
|
|
50
|
+
lastPage = existingPage;
|
|
51
|
+
return existingPage;
|
|
52
|
+
}
|
|
53
|
+
openPages.delete(url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create new page
|
|
57
|
+
const page = await browser.newPage();
|
|
58
|
+
|
|
59
|
+
// Set up console log capture
|
|
60
|
+
const client = await page.target().createCDPSession();
|
|
61
|
+
await client.send('Runtime.enable');
|
|
62
|
+
await client.send('Log.enable');
|
|
63
|
+
|
|
64
|
+
client.on('Runtime.consoleAPICalled', (event) => {
|
|
65
|
+
const timestamp = new Date().toISOString();
|
|
66
|
+
const args = event.args.map(arg => {
|
|
67
|
+
if (arg.value !== undefined) return arg.value;
|
|
68
|
+
if (arg.description) return arg.description;
|
|
69
|
+
return String(arg);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
consoleLogs.push({
|
|
73
|
+
type: event.type, // log, warn, error, info, debug
|
|
74
|
+
timestamp,
|
|
75
|
+
message: args.join(' '),
|
|
76
|
+
stackTrace: event.stackTrace
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
client.on('Log.entryAdded', (event) => {
|
|
81
|
+
const entry = event.entry;
|
|
82
|
+
consoleLogs.push({
|
|
83
|
+
type: entry.level, // verbose, info, warning, error
|
|
84
|
+
timestamp: new Date(entry.timestamp).toISOString(),
|
|
85
|
+
message: entry.text,
|
|
86
|
+
source: entry.source,
|
|
87
|
+
url: entry.url,
|
|
88
|
+
lineNumber: entry.lineNumber
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await page.goto(url, { waitUntil: 'networkidle2' });
|
|
93
|
+
openPages.set(url, page);
|
|
94
|
+
lastPage = page;
|
|
95
|
+
|
|
96
|
+
return page;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Get last opened page (for tools that don't need URL)
|
|
100
|
+
async function getLastOpenPage() {
|
|
101
|
+
if (!lastPage || lastPage.isClosed()) {
|
|
102
|
+
throw new Error('No page is currently open. Use openBrowser first to open a page.');
|
|
103
|
+
}
|
|
104
|
+
return lastPage;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Figma API helper function
|
|
108
|
+
async function fetchFigmaAPI(endpoint, figmaToken) {
|
|
109
|
+
if (!figmaToken) {
|
|
110
|
+
throw new Error('Figma token is required. Get it from https://www.figma.com/developers/api#access-tokens');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const response = await fetch(`https://api.figma.com/v1/${endpoint}`, {
|
|
114
|
+
headers: {
|
|
115
|
+
'X-Figma-Token': figmaToken
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const error = await response.text();
|
|
121
|
+
throw new Error(`Figma API error: ${response.status} - ${error}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return response.json();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Calculate SSIM (Structural Similarity Index) for image comparison
|
|
128
|
+
function calculateSSIM(img1Data, img2Data, width, height) {
|
|
129
|
+
if (img1Data.length !== img2Data.length) {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const windowSize = 8;
|
|
134
|
+
const k1 = 0.01;
|
|
135
|
+
const k2 = 0.03;
|
|
136
|
+
const c1 = (k1 * 255) ** 2;
|
|
137
|
+
const c2 = (k2 * 255) ** 2;
|
|
138
|
+
|
|
139
|
+
let ssimSum = 0;
|
|
140
|
+
let validWindows = 0;
|
|
141
|
+
|
|
142
|
+
for (let y = 0; y <= height - windowSize; y += windowSize) {
|
|
143
|
+
for (let x = 0; x <= width - windowSize; x += windowSize) {
|
|
144
|
+
let sum1 = 0, sum2 = 0, sum1Sq = 0, sum2Sq = 0, sum12 = 0;
|
|
145
|
+
|
|
146
|
+
for (let dy = 0; dy < windowSize; dy++) {
|
|
147
|
+
for (let dx = 0; dx < windowSize; dx++) {
|
|
148
|
+
const idx = ((y + dy) * width + (x + dx)) * 4;
|
|
149
|
+
if (idx + 2 >= img1Data.length) continue;
|
|
150
|
+
|
|
151
|
+
const gray1 = (img1Data[idx] * 0.299 + img1Data[idx + 1] * 0.587 + img1Data[idx + 2] * 0.114);
|
|
152
|
+
const gray2 = (img2Data[idx] * 0.299 + img2Data[idx + 1] * 0.587 + img2Data[idx + 2] * 0.114);
|
|
153
|
+
|
|
154
|
+
sum1 += gray1;
|
|
155
|
+
sum2 += gray2;
|
|
156
|
+
sum1Sq += gray1 * gray1;
|
|
157
|
+
sum2Sq += gray2 * gray2;
|
|
158
|
+
sum12 += gray1 * gray2;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const n = windowSize * windowSize;
|
|
163
|
+
const mean1 = sum1 / n;
|
|
164
|
+
const mean2 = sum2 / n;
|
|
165
|
+
const variance1 = (sum1Sq / n) - (mean1 * mean1);
|
|
166
|
+
const variance2 = (sum2Sq / n) - (mean2 * mean2);
|
|
167
|
+
const covariance = (sum12 / n) - (mean1 * mean2);
|
|
168
|
+
|
|
169
|
+
const ssim = ((2 * mean1 * mean2 + c1) * (2 * covariance + c2)) /
|
|
170
|
+
((mean1 * mean1 + mean2 * mean2 + c1) * (variance1 + variance2 + c2));
|
|
171
|
+
|
|
172
|
+
ssimSum += ssim;
|
|
173
|
+
validWindows++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return validWindows > 0 ? ssimSum / validWindows : 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Cleanup on exit
|
|
181
|
+
process.on("SIGINT", async () => {
|
|
182
|
+
if (browserPromise) {
|
|
183
|
+
const browser = await browserPromise;
|
|
184
|
+
await browser.close();
|
|
185
|
+
}
|
|
186
|
+
process.exit(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Create MCP server
|
|
190
|
+
const server = new Server(
|
|
191
|
+
{
|
|
192
|
+
name: "chrometools-mcp",
|
|
193
|
+
version: "1.0.0",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
capabilities: {
|
|
197
|
+
tools: {},
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Tool schemas
|
|
203
|
+
const PingSchema = z.object({
|
|
204
|
+
message: z.string().optional().describe("Optional message to send"),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const OpenBrowserSchema = z.object({
|
|
208
|
+
url: z.string().describe("URL to open in the browser"),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const ClickSchema = z.object({
|
|
212
|
+
selector: z.string().describe("CSS selector for element to click"),
|
|
213
|
+
waitAfter: z.number().optional().describe("Milliseconds to wait after click (default: 1500)"),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const TypeSchema = z.object({
|
|
217
|
+
selector: z.string().describe("CSS selector for input element"),
|
|
218
|
+
text: z.string().describe("Text to type"),
|
|
219
|
+
delay: z.number().optional().describe("Delay between keystrokes in ms (default: 0)"),
|
|
220
|
+
clearFirst: z.boolean().optional().describe("Clear field before typing (default: true)"),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const GetElementSchema = z.object({
|
|
224
|
+
selector: z.string().optional().describe("CSS selector (optional, defaults to body)"),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const GetComputedCssSchema = z.object({
|
|
228
|
+
selector: z.string().optional().describe("CSS selector (optional, defaults to body)"),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const GetBoxModelSchema = z.object({
|
|
232
|
+
selector: z.string().describe("CSS selector for element"),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const ScreenshotSchema = z.object({
|
|
236
|
+
selector: z.string().describe("CSS selector for element to screenshot"),
|
|
237
|
+
padding: z.number().optional().describe("Padding around element in pixels (default: 0)"),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const ScrollToSchema = z.object({
|
|
241
|
+
selector: z.string().describe("CSS selector for element to scroll to"),
|
|
242
|
+
behavior: z.enum(['auto', 'smooth']).optional().describe("Scroll behavior (default: auto)"),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const ExecuteScriptSchema = z.object({
|
|
246
|
+
script: z.string().describe("JavaScript code to execute in page context"),
|
|
247
|
+
waitAfter: z.number().optional().describe("Milliseconds to wait after execution (default: 500)"),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Phase 2 schemas
|
|
251
|
+
const GetConsoleLogsSchema = z.object({
|
|
252
|
+
types: z.array(z.enum(['log', 'warn', 'error', 'info', 'debug', 'verbose', 'warning']))
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("Filter by log types (default: all)"),
|
|
255
|
+
clear: z.boolean().optional().describe("Clear logs after reading (default: false)"),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const HoverSchema = z.object({
|
|
259
|
+
selector: z.string().describe("CSS selector for element to hover"),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const SetStylesSchema = z.object({
|
|
263
|
+
selector: z.string().describe("CSS selector for element to modify"),
|
|
264
|
+
styles: z.array(z.object({
|
|
265
|
+
name: z.string().describe("CSS property name (e.g., 'color')"),
|
|
266
|
+
value: z.string().describe("CSS property value (e.g., 'red')")
|
|
267
|
+
})).describe("Array of CSS property name-value pairs"),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const SetViewportSchema = z.object({
|
|
271
|
+
width: z.number().min(320).max(4000).describe("Viewport width in pixels (320-4000)"),
|
|
272
|
+
height: z.number().min(200).max(3000).describe("Viewport height in pixels (200-3000)"),
|
|
273
|
+
deviceScaleFactor: z.number().min(0.5).max(3).optional().describe("Device pixel ratio (0.5-3, default: 1)"),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const GetViewportSchema = z.object({});
|
|
277
|
+
|
|
278
|
+
const NavigateToSchema = z.object({
|
|
279
|
+
url: z.string().describe("URL to navigate to"),
|
|
280
|
+
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2'])
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("Wait until event (default: networkidle2)"),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Figma tools schemas
|
|
286
|
+
const GetFigmaFrameSchema = z.object({
|
|
287
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
288
|
+
fileKey: z.string().describe("Figma file key (from URL: figma.com/file/FILE_KEY/...)"),
|
|
289
|
+
nodeId: z.string().describe("Figma node ID (frame/component ID)"),
|
|
290
|
+
scale: z.number().min(0.1).max(4).optional().describe("Export scale (0.1-4, default: 2)"),
|
|
291
|
+
format: z.enum(['png', 'jpg', 'svg']).optional().describe("Export format (default: png)")
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const CompareFigmaToElementSchema = z.object({
|
|
295
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
296
|
+
fileKey: z.string().describe("Figma file key"),
|
|
297
|
+
nodeId: z.string().describe("Figma frame/component ID"),
|
|
298
|
+
selector: z.string().describe("CSS selector for page element"),
|
|
299
|
+
threshold: z.number().min(0).max(1).optional().describe("Difference threshold (0-1, default: 0.05)"),
|
|
300
|
+
figmaScale: z.number().min(0.1).max(4).optional().describe("Figma export scale (default: 2)")
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const GetFigmaSpecsSchema = z.object({
|
|
304
|
+
figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
|
|
305
|
+
fileKey: z.string().describe("Figma file key"),
|
|
306
|
+
nodeId: z.string().describe("Figma frame/component ID")
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// List available tools
|
|
310
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
311
|
+
return {
|
|
312
|
+
tools: [
|
|
313
|
+
{
|
|
314
|
+
name: "ping",
|
|
315
|
+
description: "Simple ping-pong tool for testing. Returns 'pong' with optional message.",
|
|
316
|
+
inputSchema: {
|
|
317
|
+
type: "object",
|
|
318
|
+
properties: {
|
|
319
|
+
message: { type: "string", description: "Optional message to include in response" },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "openBrowser",
|
|
325
|
+
description: "Opens a browser window and navigates to the specified URL. Browser window remains open for further interactions. Use this as the first step before other tools.",
|
|
326
|
+
inputSchema: {
|
|
327
|
+
type: "object",
|
|
328
|
+
properties: {
|
|
329
|
+
url: { type: "string", description: "URL to navigate to (e.g., https://example.com)" },
|
|
330
|
+
},
|
|
331
|
+
required: ["url"],
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "click",
|
|
336
|
+
description: "Click on an element to trigger interactions like opening modals, navigating, or submitting forms. Waits for animations and returns a screenshot showing the result.",
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: "object",
|
|
339
|
+
properties: {
|
|
340
|
+
selector: { type: "string", description: "CSS selector for element to click" },
|
|
341
|
+
waitAfter: { type: "number", description: "Milliseconds to wait after click (default: 1500)" },
|
|
342
|
+
},
|
|
343
|
+
required: ["selector"],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "type",
|
|
348
|
+
description: "Type text into an input field, textarea, or contenteditable element. Can optionally clear the field first and control typing speed for realistic input simulation.",
|
|
349
|
+
inputSchema: {
|
|
350
|
+
type: "object",
|
|
351
|
+
properties: {
|
|
352
|
+
selector: { type: "string", description: "CSS selector for input element" },
|
|
353
|
+
text: { type: "string", description: "Text to type" },
|
|
354
|
+
delay: { type: "number", description: "Delay between keystrokes in ms (default: 0)" },
|
|
355
|
+
clearFirst: { type: "boolean", description: "Clear field before typing (default: true)" },
|
|
356
|
+
},
|
|
357
|
+
required: ["selector", "text"],
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: "getElement",
|
|
362
|
+
description: "Get the HTML markup of an element for inspection and debugging. If no selector is provided, returns the entire <body> element. Useful for understanding component structure.",
|
|
363
|
+
inputSchema: {
|
|
364
|
+
type: "object",
|
|
365
|
+
properties: {
|
|
366
|
+
selector: { type: "string", description: "CSS selector (optional, defaults to body)" },
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: "getComputedCss",
|
|
372
|
+
description: "Get all computed CSS styles applied to an element. Essential for debugging layout issues, checking responsive design, and verifying CSS properties. Returns complete computed styles.",
|
|
373
|
+
inputSchema: {
|
|
374
|
+
type: "object",
|
|
375
|
+
properties: {
|
|
376
|
+
selector: { type: "string", description: "CSS selector (optional, defaults to body)" },
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "getBoxModel",
|
|
382
|
+
description: "Get precise element dimensions, positioning, margins, padding, and borders. Returns complete box model data including content, padding, border, and margin dimensions.",
|
|
383
|
+
inputSchema: {
|
|
384
|
+
type: "object",
|
|
385
|
+
properties: {
|
|
386
|
+
selector: { type: "string", description: "CSS selector for element" },
|
|
387
|
+
},
|
|
388
|
+
required: ["selector"],
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: "screenshot",
|
|
393
|
+
description: "Capture a PNG screenshot of a specific element. Perfect for visual documentation, design reviews, and debugging. Supports optional padding to include surrounding context.",
|
|
394
|
+
inputSchema: {
|
|
395
|
+
type: "object",
|
|
396
|
+
properties: {
|
|
397
|
+
selector: { type: "string", description: "CSS selector for element to screenshot" },
|
|
398
|
+
padding: { type: "number", description: "Padding around element in pixels (default: 0)" },
|
|
399
|
+
},
|
|
400
|
+
required: ["selector"],
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: "scrollTo",
|
|
405
|
+
description: "Scroll the page to bring an element into view. Useful for testing lazy loading, sticky elements, and ensuring elements are visible. Supports smooth or instant scrolling.",
|
|
406
|
+
inputSchema: {
|
|
407
|
+
type: "object",
|
|
408
|
+
properties: {
|
|
409
|
+
selector: { type: "string", description: "CSS selector for element to scroll to" },
|
|
410
|
+
behavior: { type: "string", enum: ["auto", "smooth"], description: "Scroll behavior (default: auto)" },
|
|
411
|
+
},
|
|
412
|
+
required: ["selector"],
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: "executeScript",
|
|
417
|
+
description: "Execute arbitrary JavaScript code in the page context. Perfect for complex interactions, setting values, triggering events, or any custom page manipulation. Returns execution result and a screenshot.",
|
|
418
|
+
inputSchema: {
|
|
419
|
+
type: "object",
|
|
420
|
+
properties: {
|
|
421
|
+
script: { type: "string", description: "JavaScript code to execute" },
|
|
422
|
+
waitAfter: { type: "number", description: "Milliseconds to wait after execution (default: 500)" },
|
|
423
|
+
},
|
|
424
|
+
required: ["script"],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "getConsoleLogs",
|
|
429
|
+
description: "Retrieve all console.log, console.warn, console.error messages from the browser. Essential for debugging JavaScript errors and tracking application behavior. Logs are captured automatically from page load.",
|
|
430
|
+
inputSchema: {
|
|
431
|
+
type: "object",
|
|
432
|
+
properties: {
|
|
433
|
+
types: { type: "array", items: { type: "string", enum: ["log", "warn", "error", "info", "debug", "verbose", "warning"] }, description: "Filter by log types (default: all)" },
|
|
434
|
+
clear: { type: "boolean", description: "Clear logs after reading (default: false)" },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: "hover",
|
|
440
|
+
description: "Simulate mouse hover over an element to test hover effects, tooltips, dropdown menus, and interactive states. Essential for testing CSS :hover pseudo-classes.",
|
|
441
|
+
inputSchema: {
|
|
442
|
+
type: "object",
|
|
443
|
+
properties: {
|
|
444
|
+
selector: { type: "string", description: "CSS selector for element to hover" },
|
|
445
|
+
},
|
|
446
|
+
required: ["selector"],
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: "setStyles",
|
|
451
|
+
description: "Apply inline CSS styles to an element for live editing and prototyping. Perfect for testing design changes without modifying source code.",
|
|
452
|
+
inputSchema: {
|
|
453
|
+
type: "object",
|
|
454
|
+
properties: {
|
|
455
|
+
selector: { type: "string", description: "CSS selector for element to modify" },
|
|
456
|
+
styles: {
|
|
457
|
+
type: "array",
|
|
458
|
+
items: {
|
|
459
|
+
type: "object",
|
|
460
|
+
properties: {
|
|
461
|
+
name: { type: "string", description: "CSS property name" },
|
|
462
|
+
value: { type: "string", description: "CSS property value" },
|
|
463
|
+
},
|
|
464
|
+
required: ["name", "value"],
|
|
465
|
+
},
|
|
466
|
+
description: "Array of CSS property name-value pairs",
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
required: ["selector", "styles"],
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: "setViewport",
|
|
474
|
+
description: "Change viewport dimensions for responsive design testing. Test how your layout adapts to different screen sizes, mobile devices, tablets, and desktop resolutions.",
|
|
475
|
+
inputSchema: {
|
|
476
|
+
type: "object",
|
|
477
|
+
properties: {
|
|
478
|
+
width: { type: "number", minimum: 320, maximum: 4000, description: "Viewport width in pixels" },
|
|
479
|
+
height: { type: "number", minimum: 200, maximum: 3000, description: "Viewport height in pixels" },
|
|
480
|
+
deviceScaleFactor: { type: "number", minimum: 0.5, maximum: 3, description: "Device pixel ratio (default: 1)" },
|
|
481
|
+
},
|
|
482
|
+
required: ["width", "height"],
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
name: "getViewport",
|
|
487
|
+
description: "Get current viewport size and device pixel ratio. Essential for responsive design testing and understanding how content fits on different screen sizes.",
|
|
488
|
+
inputSchema: {
|
|
489
|
+
type: "object",
|
|
490
|
+
properties: {},
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: "navigateTo",
|
|
495
|
+
description: "Navigate the current page to a new URL. Use this when you need to move to a different page while keeping the same browser instance. Page will be reused if already open.",
|
|
496
|
+
inputSchema: {
|
|
497
|
+
type: "object",
|
|
498
|
+
properties: {
|
|
499
|
+
url: { type: "string", description: "URL to navigate to" },
|
|
500
|
+
waitUntil: { type: "string", enum: ["load", "domcontentloaded", "networkidle0", "networkidle2"], description: "Wait until event (default: networkidle2)" },
|
|
501
|
+
},
|
|
502
|
+
required: ["url"],
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: "getFigmaFrame",
|
|
507
|
+
description: "Export and download a Figma frame as PNG image for comparison. Requires Figma API token and file/node IDs from Figma URLs.",
|
|
508
|
+
inputSchema: {
|
|
509
|
+
type: "object",
|
|
510
|
+
properties: {
|
|
511
|
+
figmaToken: { type: "string", description: "Figma API token (optional if FIGMA_TOKEN env var is set)" },
|
|
512
|
+
fileKey: { type: "string", description: "Figma file key (from URL: figma.com/file/FILE_KEY/...)" },
|
|
513
|
+
nodeId: { type: "string", description: "Figma node ID (frame/component ID)" },
|
|
514
|
+
scale: { type: "number", minimum: 0.1, maximum: 4, description: "Export scale (0.1-4, default: 2)" },
|
|
515
|
+
format: { type: "string", enum: ["png", "jpg", "svg"], description: "Export format (default: png)" },
|
|
516
|
+
},
|
|
517
|
+
required: ["fileKey", "nodeId"],
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
name: "compareFigmaToElement",
|
|
522
|
+
description: "Compare Figma design directly with browser implementation. The GOLD STANDARD for design-to-code validation. Fetches Figma frame, screenshots element, performs pixel-perfect comparison with difference analysis.",
|
|
523
|
+
inputSchema: {
|
|
524
|
+
type: "object",
|
|
525
|
+
properties: {
|
|
526
|
+
figmaToken: { type: "string", description: "Figma API token (optional if FIGMA_TOKEN env var is set)" },
|
|
527
|
+
fileKey: { type: "string", description: "Figma file key" },
|
|
528
|
+
nodeId: { type: "string", description: "Figma frame/component ID" },
|
|
529
|
+
selector: { type: "string", description: "CSS selector for page element" },
|
|
530
|
+
threshold: { type: "number", minimum: 0, maximum: 1, description: "Difference threshold (0-1, default: 0.05)" },
|
|
531
|
+
figmaScale: { type: "number", minimum: 0.1, maximum: 4, description: "Figma export scale (default: 2)" },
|
|
532
|
+
},
|
|
533
|
+
required: ["fileKey", "nodeId", "selector"],
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
name: "getFigmaSpecs",
|
|
538
|
+
description: "Extract detailed design specifications from Figma including colors, fonts, dimensions, and spacing. Perfect for design-to-code comparison.",
|
|
539
|
+
inputSchema: {
|
|
540
|
+
type: "object",
|
|
541
|
+
properties: {
|
|
542
|
+
figmaToken: { type: "string", description: "Figma API token (optional if FIGMA_TOKEN env var is set)" },
|
|
543
|
+
fileKey: { type: "string", description: "Figma file key" },
|
|
544
|
+
nodeId: { type: "string", description: "Figma frame/component ID" },
|
|
545
|
+
},
|
|
546
|
+
required: ["fileKey", "nodeId"],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Handle tool calls
|
|
554
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
555
|
+
const { name, arguments: args } = request.params;
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
if (name === "ping") {
|
|
559
|
+
const validatedArgs = PingSchema.parse(args);
|
|
560
|
+
const responseMessage = validatedArgs.message
|
|
561
|
+
? `pong: ${validatedArgs.message}`
|
|
562
|
+
: "pong";
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
content: [
|
|
566
|
+
{
|
|
567
|
+
type: "text",
|
|
568
|
+
text: responseMessage,
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (name === "openBrowser") {
|
|
575
|
+
const validatedArgs = OpenBrowserSchema.parse(args);
|
|
576
|
+
const page = await getOrCreatePage(validatedArgs.url);
|
|
577
|
+
const title = await page.title();
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
content: [
|
|
581
|
+
{
|
|
582
|
+
type: "text",
|
|
583
|
+
text: `Browser opened successfully!\nURL: ${validatedArgs.url}\nPage title: ${title}\n\nBrowser remains open for interaction.`,
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (name === "click") {
|
|
590
|
+
const validatedArgs = ClickSchema.parse(args);
|
|
591
|
+
const page = await getLastOpenPage();
|
|
592
|
+
|
|
593
|
+
const element = await page.$(validatedArgs.selector);
|
|
594
|
+
if (!element) {
|
|
595
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
await element.click();
|
|
599
|
+
await new Promise(resolve => setTimeout(resolve, validatedArgs.waitAfter || 1500));
|
|
600
|
+
|
|
601
|
+
const screenshot = await page.screenshot({ encoding: 'base64', fullPage: false });
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
content: [
|
|
605
|
+
{ type: "text", text: `Clicked: ${validatedArgs.selector}` },
|
|
606
|
+
{ type: "image", data: screenshot, mimeType: "image/png" }
|
|
607
|
+
],
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (name === "type") {
|
|
612
|
+
const validatedArgs = TypeSchema.parse(args);
|
|
613
|
+
const page = await getLastOpenPage();
|
|
614
|
+
|
|
615
|
+
const element = await page.$(validatedArgs.selector);
|
|
616
|
+
if (!element) {
|
|
617
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const clearFirst = validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true;
|
|
621
|
+
if (clearFirst) {
|
|
622
|
+
await element.click({ clickCount: 3 });
|
|
623
|
+
await page.keyboard.press('Backspace');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
await element.type(validatedArgs.text, { delay: validatedArgs.delay || 0 });
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
content: [
|
|
630
|
+
{ type: "text", text: `Typed "${validatedArgs.text}" into ${validatedArgs.selector}` }
|
|
631
|
+
],
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (name === "getElement") {
|
|
636
|
+
const validatedArgs = GetElementSchema.parse(args);
|
|
637
|
+
const page = await getLastOpenPage();
|
|
638
|
+
|
|
639
|
+
const client = await page.target().createCDPSession();
|
|
640
|
+
await client.send('DOM.enable');
|
|
641
|
+
|
|
642
|
+
const { root } = await client.send('DOM.getDocument');
|
|
643
|
+
const useSelector = (validatedArgs.selector && validatedArgs.selector.trim()) ? validatedArgs.selector : 'body';
|
|
644
|
+
|
|
645
|
+
const { nodeId } = await client.send('DOM.querySelector', {
|
|
646
|
+
selector: useSelector,
|
|
647
|
+
nodeId: root.nodeId
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
if (!nodeId) {
|
|
651
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const { outerHTML } = await client.send('DOM.getOuterHTML', { nodeId });
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
content: [{ type: "text", text: outerHTML }],
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (name === "getComputedCss") {
|
|
662
|
+
const validatedArgs = GetComputedCssSchema.parse(args);
|
|
663
|
+
const page = await getLastOpenPage();
|
|
664
|
+
|
|
665
|
+
const client = await page.target().createCDPSession();
|
|
666
|
+
await client.send('DOM.enable');
|
|
667
|
+
await client.send('CSS.enable');
|
|
668
|
+
|
|
669
|
+
const { root } = await client.send('DOM.getDocument');
|
|
670
|
+
const useSelector = (validatedArgs.selector && validatedArgs.selector.trim()) ? validatedArgs.selector : 'body';
|
|
671
|
+
|
|
672
|
+
const { nodeId } = await client.send('DOM.querySelector', {
|
|
673
|
+
selector: useSelector,
|
|
674
|
+
nodeId: root.nodeId
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (!nodeId) {
|
|
678
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const { computedStyle } = await client.send('CSS.getComputedStyleForNode', { nodeId });
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
content: [{ type: "text", text: JSON.stringify(computedStyle, null, 2) }],
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (name === "getBoxModel") {
|
|
689
|
+
const validatedArgs = GetBoxModelSchema.parse(args);
|
|
690
|
+
const page = await getLastOpenPage();
|
|
691
|
+
|
|
692
|
+
const client = await page.target().createCDPSession();
|
|
693
|
+
await client.send('DOM.enable');
|
|
694
|
+
|
|
695
|
+
const { root } = await client.send('DOM.getDocument');
|
|
696
|
+
const { nodeId } = await client.send('DOM.querySelector', {
|
|
697
|
+
selector: validatedArgs.selector,
|
|
698
|
+
nodeId: root.nodeId
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
if (!nodeId) {
|
|
702
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const boxModel = await client.send('DOM.getBoxModel', { nodeId });
|
|
706
|
+
const metrics = await page.evaluate((sel) => {
|
|
707
|
+
const el = document.querySelector(sel);
|
|
708
|
+
if (!el) return null;
|
|
709
|
+
return {
|
|
710
|
+
offsetWidth: el.offsetWidth,
|
|
711
|
+
offsetHeight: el.offsetHeight,
|
|
712
|
+
scrollWidth: el.scrollWidth,
|
|
713
|
+
scrollHeight: el.scrollHeight
|
|
714
|
+
};
|
|
715
|
+
}, validatedArgs.selector);
|
|
716
|
+
|
|
717
|
+
if (!metrics) {
|
|
718
|
+
throw new Error(`Element not found (render): ${validatedArgs.selector}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
content: [{ type: "text", text: JSON.stringify({ boxModel, metrics }, null, 2) }],
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (name === "screenshot") {
|
|
727
|
+
const validatedArgs = ScreenshotSchema.parse(args);
|
|
728
|
+
const page = await getLastOpenPage();
|
|
729
|
+
|
|
730
|
+
const element = await page.$(validatedArgs.selector);
|
|
731
|
+
if (!element) {
|
|
732
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const box = await element.boundingBox();
|
|
736
|
+
if (!box) {
|
|
737
|
+
throw new Error(`Element is not visible or has no bounding box: ${validatedArgs.selector}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const padding = validatedArgs.padding || 0;
|
|
741
|
+
const clip = {
|
|
742
|
+
x: Math.max(box.x - padding, 0),
|
|
743
|
+
y: Math.max(box.y - padding, 0),
|
|
744
|
+
width: Math.max(box.width + padding * 2, 1),
|
|
745
|
+
height: Math.max(box.height + padding * 2, 1)
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const screenshot = await page.screenshot({ clip, encoding: 'base64' });
|
|
749
|
+
|
|
750
|
+
return {
|
|
751
|
+
content: [
|
|
752
|
+
{
|
|
753
|
+
type: "image",
|
|
754
|
+
data: screenshot,
|
|
755
|
+
mimeType: "image/png"
|
|
756
|
+
}
|
|
757
|
+
],
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (name === "scrollTo") {
|
|
762
|
+
const validatedArgs = ScrollToSchema.parse(args);
|
|
763
|
+
const page = await getLastOpenPage();
|
|
764
|
+
|
|
765
|
+
const element = await page.$(validatedArgs.selector);
|
|
766
|
+
if (!element) {
|
|
767
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
await element.scrollIntoView({ behavior: validatedArgs.behavior || 'auto' });
|
|
771
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
772
|
+
|
|
773
|
+
const position = await page.evaluate(() => ({
|
|
774
|
+
x: window.scrollX,
|
|
775
|
+
y: window.scrollY
|
|
776
|
+
}));
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
content: [
|
|
780
|
+
{ type: "text", text: `Scrolled to ${validatedArgs.selector} (position: ${position.x}, ${position.y})` }
|
|
781
|
+
],
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (name === "executeScript") {
|
|
786
|
+
const validatedArgs = ExecuteScriptSchema.parse(args);
|
|
787
|
+
const page = await getLastOpenPage();
|
|
788
|
+
|
|
789
|
+
const result = await page.evaluate((code) => {
|
|
790
|
+
try {
|
|
791
|
+
// eslint-disable-next-line no-eval
|
|
792
|
+
const evalResult = eval(code);
|
|
793
|
+
return { success: true, result: evalResult };
|
|
794
|
+
} catch (error) {
|
|
795
|
+
return { success: false, error: error.message };
|
|
796
|
+
}
|
|
797
|
+
}, validatedArgs.script);
|
|
798
|
+
|
|
799
|
+
await new Promise(resolve => setTimeout(resolve, validatedArgs.waitAfter || 500));
|
|
800
|
+
|
|
801
|
+
const screenshot = await page.screenshot({ encoding: 'base64', fullPage: false });
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
content: [
|
|
805
|
+
{
|
|
806
|
+
type: "text",
|
|
807
|
+
text: result.success
|
|
808
|
+
? `Script executed successfully.\nResult: ${JSON.stringify(result.result)}`
|
|
809
|
+
: `Script execution failed: ${result.error}`
|
|
810
|
+
},
|
|
811
|
+
{ type: "image", data: screenshot, mimeType: "image/png" }
|
|
812
|
+
],
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (name === "getConsoleLogs") {
|
|
817
|
+
const validatedArgs = GetConsoleLogsSchema.parse(args);
|
|
818
|
+
|
|
819
|
+
let logs = consoleLogs;
|
|
820
|
+
|
|
821
|
+
// Filter by types if specified
|
|
822
|
+
if (validatedArgs.types && validatedArgs.types.length > 0) {
|
|
823
|
+
logs = logs.filter(log => validatedArgs.types.includes(log.type));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const result = {
|
|
827
|
+
count: logs.length,
|
|
828
|
+
logs: logs.map(log => ({
|
|
829
|
+
type: log.type,
|
|
830
|
+
timestamp: log.timestamp,
|
|
831
|
+
message: log.message
|
|
832
|
+
}))
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// Clear logs if requested
|
|
836
|
+
if (validatedArgs.clear) {
|
|
837
|
+
consoleLogs.length = 0;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
content: [{
|
|
842
|
+
type: "text",
|
|
843
|
+
text: JSON.stringify(result, null, 2)
|
|
844
|
+
}],
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (name === "hover") {
|
|
849
|
+
const validatedArgs = HoverSchema.parse(args);
|
|
850
|
+
const page = await getLastOpenPage();
|
|
851
|
+
|
|
852
|
+
const element = await page.$(validatedArgs.selector);
|
|
853
|
+
if (!element) {
|
|
854
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
await element.hover();
|
|
858
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
content: [{
|
|
862
|
+
type: "text",
|
|
863
|
+
text: `Hovered over: ${validatedArgs.selector}`
|
|
864
|
+
}],
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (name === "setStyles") {
|
|
869
|
+
const validatedArgs = SetStylesSchema.parse(args);
|
|
870
|
+
const page = await getLastOpenPage();
|
|
871
|
+
|
|
872
|
+
const stylesObject = {};
|
|
873
|
+
for (const style of validatedArgs.styles) {
|
|
874
|
+
stylesObject[style.name] = style.value;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const success = await page.evaluate((sel, styles) => {
|
|
878
|
+
const el = document.querySelector(sel);
|
|
879
|
+
if (!el) return false;
|
|
880
|
+
Object.entries(styles).forEach(([key, value]) => {
|
|
881
|
+
el.style.setProperty(key, value);
|
|
882
|
+
});
|
|
883
|
+
return true;
|
|
884
|
+
}, validatedArgs.selector, stylesObject);
|
|
885
|
+
|
|
886
|
+
if (!success) {
|
|
887
|
+
throw new Error(`Element not found: ${validatedArgs.selector}`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
content: [{
|
|
892
|
+
type: "text",
|
|
893
|
+
text: `Styles applied to ${validatedArgs.selector}:\n${JSON.stringify(stylesObject, null, 2)}`
|
|
894
|
+
}],
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (name === "setViewport") {
|
|
899
|
+
const validatedArgs = SetViewportSchema.parse(args);
|
|
900
|
+
const page = await getLastOpenPage();
|
|
901
|
+
|
|
902
|
+
await page.setViewport({
|
|
903
|
+
width: validatedArgs.width,
|
|
904
|
+
height: validatedArgs.height,
|
|
905
|
+
deviceScaleFactor: validatedArgs.deviceScaleFactor || 1
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
const actual = await page.evaluate(() => ({
|
|
909
|
+
width: window.innerWidth,
|
|
910
|
+
height: window.innerHeight,
|
|
911
|
+
devicePixelRatio: window.devicePixelRatio
|
|
912
|
+
}));
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
content: [{
|
|
916
|
+
type: "text",
|
|
917
|
+
text: `Viewport set to ${validatedArgs.width}x${validatedArgs.height}\nActual: ${actual.width}x${actual.height} (DPR: ${actual.devicePixelRatio})`
|
|
918
|
+
}],
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (name === "getViewport") {
|
|
923
|
+
const page = await getLastOpenPage();
|
|
924
|
+
|
|
925
|
+
const viewport = await page.evaluate(() => ({
|
|
926
|
+
width: window.innerWidth,
|
|
927
|
+
height: window.innerHeight,
|
|
928
|
+
outerWidth: window.outerWidth,
|
|
929
|
+
outerHeight: window.outerHeight,
|
|
930
|
+
devicePixelRatio: window.devicePixelRatio
|
|
931
|
+
}));
|
|
932
|
+
|
|
933
|
+
return {
|
|
934
|
+
content: [{
|
|
935
|
+
type: "text",
|
|
936
|
+
text: JSON.stringify(viewport, null, 2)
|
|
937
|
+
}],
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (name === "navigateTo") {
|
|
942
|
+
const validatedArgs = NavigateToSchema.parse(args);
|
|
943
|
+
const page = await getOrCreatePage(validatedArgs.url);
|
|
944
|
+
|
|
945
|
+
if (validatedArgs.waitUntil) {
|
|
946
|
+
await page.goto(validatedArgs.url, { waitUntil: validatedArgs.waitUntil });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const title = await page.title();
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
content: [{
|
|
953
|
+
type: "text",
|
|
954
|
+
text: `Navigated to: ${validatedArgs.url}\nPage title: ${title}`
|
|
955
|
+
}],
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Figma tools
|
|
960
|
+
if (name === "getFigmaFrame") {
|
|
961
|
+
const validatedArgs = GetFigmaFrameSchema.parse(args);
|
|
962
|
+
const token = validatedArgs.figmaToken || FIGMA_TOKEN;
|
|
963
|
+
if (!token) {
|
|
964
|
+
throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const scale = validatedArgs.scale || 2;
|
|
968
|
+
const format = validatedArgs.format || 'png';
|
|
969
|
+
|
|
970
|
+
// Get export URL from Figma
|
|
971
|
+
const exportData = await fetchFigmaAPI(
|
|
972
|
+
`images/${validatedArgs.fileKey}?ids=${validatedArgs.nodeId}&scale=${scale}&format=${format}`,
|
|
973
|
+
token
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
if (!exportData.images || !exportData.images[validatedArgs.nodeId]) {
|
|
977
|
+
throw new Error(`Failed to export node ${validatedArgs.nodeId} from file ${validatedArgs.fileKey}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const imageUrl = exportData.images[validatedArgs.nodeId];
|
|
981
|
+
|
|
982
|
+
// Download image
|
|
983
|
+
const imageResponse = await fetch(imageUrl);
|
|
984
|
+
if (!imageResponse.ok) {
|
|
985
|
+
throw new Error(`Failed to download image: ${imageResponse.status}`);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
|
989
|
+
|
|
990
|
+
// Get frame info
|
|
991
|
+
const nodesData = await fetchFigmaAPI(`files/${validatedArgs.fileKey}/nodes?ids=${encodeURIComponent(validatedArgs.nodeId)}`, token);
|
|
992
|
+
const frameInfo = nodesData.nodes?.[validatedArgs.nodeId]?.document;
|
|
993
|
+
|
|
994
|
+
const result = {
|
|
995
|
+
figmaInfo: {
|
|
996
|
+
fileName: nodesData.name || 'Unknown',
|
|
997
|
+
frameId: validatedArgs.nodeId,
|
|
998
|
+
frameName: frameInfo?.name || 'Unknown',
|
|
999
|
+
dimensions: frameInfo ? {
|
|
1000
|
+
width: frameInfo.absoluteBoundingBox?.width,
|
|
1001
|
+
height: frameInfo.absoluteBoundingBox?.height
|
|
1002
|
+
} : null,
|
|
1003
|
+
exportSettings: {
|
|
1004
|
+
scale,
|
|
1005
|
+
format,
|
|
1006
|
+
fileSize: imageBuffer.length
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
return {
|
|
1012
|
+
content: [
|
|
1013
|
+
{ type: 'text', text: JSON.stringify(result, null, 2) },
|
|
1014
|
+
{
|
|
1015
|
+
type: 'image',
|
|
1016
|
+
data: imageBuffer.toString('base64'),
|
|
1017
|
+
mimeType: `image/${format}`
|
|
1018
|
+
}
|
|
1019
|
+
]
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (name === "compareFigmaToElement") {
|
|
1024
|
+
const validatedArgs = CompareFigmaToElementSchema.parse(args);
|
|
1025
|
+
const token = validatedArgs.figmaToken || FIGMA_TOKEN;
|
|
1026
|
+
if (!token) {
|
|
1027
|
+
throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const page = await getLastOpenPage();
|
|
1031
|
+
const figmaScale = validatedArgs.figmaScale || 2;
|
|
1032
|
+
const threshold = validatedArgs.threshold || 0.05;
|
|
1033
|
+
|
|
1034
|
+
// Get Figma image
|
|
1035
|
+
const exportData = await fetchFigmaAPI(
|
|
1036
|
+
`images/${validatedArgs.fileKey}?ids=${validatedArgs.nodeId}&scale=${figmaScale}&format=png`,
|
|
1037
|
+
token
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
if (!exportData.images || !exportData.images[validatedArgs.nodeId]) {
|
|
1041
|
+
throw new Error(`Failed to export Figma node ${validatedArgs.nodeId}`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const figmaImageUrl = exportData.images[validatedArgs.nodeId];
|
|
1045
|
+
const figmaResponse = await fetch(figmaImageUrl);
|
|
1046
|
+
const figmaBuffer = Buffer.from(await figmaResponse.arrayBuffer());
|
|
1047
|
+
|
|
1048
|
+
// Get page element screenshot
|
|
1049
|
+
const element = await page.$(validatedArgs.selector);
|
|
1050
|
+
if (!element) {
|
|
1051
|
+
throw new Error(`Selector not found: ${validatedArgs.selector}`);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const pageBuffer = await element.screenshot();
|
|
1055
|
+
|
|
1056
|
+
// Load images for comparison
|
|
1057
|
+
const [figmaImg, pageImg] = await Promise.all([
|
|
1058
|
+
Jimp.read(figmaBuffer),
|
|
1059
|
+
Jimp.read(pageBuffer)
|
|
1060
|
+
]);
|
|
1061
|
+
|
|
1062
|
+
// Resize to same dimensions (use larger dimensions)
|
|
1063
|
+
const targetWidth = Math.max(figmaImg.bitmap.width, pageImg.bitmap.width);
|
|
1064
|
+
const targetHeight = Math.max(figmaImg.bitmap.height, pageImg.bitmap.height);
|
|
1065
|
+
|
|
1066
|
+
figmaImg.resize(targetWidth, targetHeight);
|
|
1067
|
+
pageImg.resize(targetWidth, targetHeight);
|
|
1068
|
+
|
|
1069
|
+
// Compare images
|
|
1070
|
+
const figmaData = new Uint8ClampedArray(figmaImg.bitmap.data);
|
|
1071
|
+
const pageData = new Uint8ClampedArray(pageImg.bitmap.data);
|
|
1072
|
+
const diffData = new Uint8ClampedArray(targetWidth * targetHeight * 4);
|
|
1073
|
+
|
|
1074
|
+
const diffPixels = pixelmatch(figmaData, pageData, diffData, targetWidth, targetHeight, {
|
|
1075
|
+
threshold: 0.1,
|
|
1076
|
+
includeAA: false
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
const ssimValue = calculateSSIM(figmaData, pageData, targetWidth, targetHeight);
|
|
1080
|
+
const totalPixels = targetWidth * targetHeight;
|
|
1081
|
+
const differencePercent = (diffPixels / totalPixels) * 100;
|
|
1082
|
+
|
|
1083
|
+
// Analysis
|
|
1084
|
+
const analysis = {
|
|
1085
|
+
figmaVsPage: {
|
|
1086
|
+
identical: diffPixels === 0,
|
|
1087
|
+
withinThreshold: differencePercent <= (threshold * 100),
|
|
1088
|
+
pixelDifferences: diffPixels,
|
|
1089
|
+
differencePercent: Math.round(differencePercent * 100) / 100,
|
|
1090
|
+
ssim: Math.round(ssimValue * 10000) / 10000,
|
|
1091
|
+
recommendation: differencePercent < 1 ? 'Pixel-perfect match' :
|
|
1092
|
+
differencePercent < 3 ? 'Very close to design' :
|
|
1093
|
+
differencePercent < 10 ? 'Minor differences detected' :
|
|
1094
|
+
'Significant differences from design'
|
|
1095
|
+
},
|
|
1096
|
+
dimensions: {
|
|
1097
|
+
figma: { width: figmaImg.bitmap.width, height: figmaImg.bitmap.height },
|
|
1098
|
+
page: { width: pageImg.bitmap.width, height: pageImg.bitmap.height },
|
|
1099
|
+
comparison: { width: targetWidth, height: targetHeight }
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
const content = [
|
|
1104
|
+
{ type: 'text', text: JSON.stringify(analysis, null, 2) },
|
|
1105
|
+
{ type: 'image', data: figmaBuffer.toString('base64'), mimeType: 'image/png' },
|
|
1106
|
+
{ type: 'image', data: pageBuffer.toString('base64'), mimeType: 'image/png' }
|
|
1107
|
+
];
|
|
1108
|
+
|
|
1109
|
+
// Add difference map if there are differences
|
|
1110
|
+
if (diffPixels > 0) {
|
|
1111
|
+
const diffImg = new Jimp({ data: Buffer.from(diffData), width: targetWidth, height: targetHeight });
|
|
1112
|
+
const diffBuffer = await diffImg.getBufferAsync(Jimp.MIME_PNG);
|
|
1113
|
+
content.push({
|
|
1114
|
+
type: 'image',
|
|
1115
|
+
data: diffBuffer.toString('base64'),
|
|
1116
|
+
mimeType: 'image/png'
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return { content };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (name === "getFigmaSpecs") {
|
|
1124
|
+
const validatedArgs = GetFigmaSpecsSchema.parse(args);
|
|
1125
|
+
const token = validatedArgs.figmaToken || FIGMA_TOKEN;
|
|
1126
|
+
if (!token) {
|
|
1127
|
+
throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Get specific node via nodes API
|
|
1131
|
+
const nodesData = await fetchFigmaAPI(`files/${validatedArgs.fileKey}/nodes?ids=${encodeURIComponent(validatedArgs.nodeId)}`, token);
|
|
1132
|
+
|
|
1133
|
+
if (!nodesData.nodes || !nodesData.nodes[validatedArgs.nodeId]) {
|
|
1134
|
+
throw new Error(`Node ${validatedArgs.nodeId} not found in Figma file`);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const node = nodesData.nodes[validatedArgs.nodeId].document;
|
|
1138
|
+
|
|
1139
|
+
// Extract specifications
|
|
1140
|
+
const specs = {
|
|
1141
|
+
general: {
|
|
1142
|
+
name: node.name,
|
|
1143
|
+
type: node.type,
|
|
1144
|
+
visible: node.visible !== false
|
|
1145
|
+
},
|
|
1146
|
+
dimensions: node.absoluteBoundingBox ? {
|
|
1147
|
+
width: node.absoluteBoundingBox.width,
|
|
1148
|
+
height: node.absoluteBoundingBox.height,
|
|
1149
|
+
x: node.absoluteBoundingBox.x,
|
|
1150
|
+
y: node.absoluteBoundingBox.y
|
|
1151
|
+
} : null,
|
|
1152
|
+
styling: {},
|
|
1153
|
+
children: []
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
// Analyze styles
|
|
1157
|
+
if (node.fills && node.fills.length > 0) {
|
|
1158
|
+
specs.styling.fills = node.fills.map(fill => {
|
|
1159
|
+
if (fill.type === 'SOLID') {
|
|
1160
|
+
const r = Math.round(fill.color.r * 255);
|
|
1161
|
+
const g = Math.round(fill.color.g * 255);
|
|
1162
|
+
const b = Math.round(fill.color.b * 255);
|
|
1163
|
+
const a = fill.opacity || 1;
|
|
1164
|
+
return {
|
|
1165
|
+
type: fill.type,
|
|
1166
|
+
color: `rgba(${r}, ${g}, ${b}, ${a})`,
|
|
1167
|
+
hex: `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`,
|
|
1168
|
+
opacity: a
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
return fill;
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (node.strokes && node.strokes.length > 0) {
|
|
1176
|
+
specs.styling.strokes = node.strokes.map(stroke => {
|
|
1177
|
+
if (stroke.type === 'SOLID') {
|
|
1178
|
+
const r = Math.round(stroke.color.r * 255);
|
|
1179
|
+
const g = Math.round(stroke.color.g * 255);
|
|
1180
|
+
const b = Math.round(stroke.color.b * 255);
|
|
1181
|
+
const a = stroke.opacity || 1;
|
|
1182
|
+
return {
|
|
1183
|
+
type: stroke.type,
|
|
1184
|
+
color: `rgba(${r}, ${g}, ${b}, ${a})`,
|
|
1185
|
+
hex: `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`,
|
|
1186
|
+
weight: node.strokeWeight || 1
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
return stroke;
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Typography
|
|
1194
|
+
if (node.style) {
|
|
1195
|
+
specs.styling.typography = {
|
|
1196
|
+
fontFamily: node.style.fontFamily,
|
|
1197
|
+
fontSize: node.style.fontSize,
|
|
1198
|
+
fontWeight: node.style.fontWeight,
|
|
1199
|
+
lineHeight: node.style.lineHeightPx || node.style.lineHeightPercent,
|
|
1200
|
+
letterSpacing: node.style.letterSpacing,
|
|
1201
|
+
textAlign: node.style.textAlignHorizontal,
|
|
1202
|
+
textCase: node.style.textCase
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Effects (shadows, blur)
|
|
1207
|
+
if (node.effects && node.effects.length > 0) {
|
|
1208
|
+
specs.styling.effects = node.effects.map(effect => ({
|
|
1209
|
+
type: effect.type,
|
|
1210
|
+
visible: effect.visible !== false,
|
|
1211
|
+
radius: effect.radius,
|
|
1212
|
+
offset: effect.offset,
|
|
1213
|
+
color: effect.color ? {
|
|
1214
|
+
rgba: `rgba(${Math.round(effect.color.r * 255)}, ${Math.round(effect.color.g * 255)}, ${Math.round(effect.color.b * 255)}, ${effect.color.a || 1})`
|
|
1215
|
+
} : null
|
|
1216
|
+
}));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Border radius
|
|
1220
|
+
if (node.cornerRadius !== undefined) {
|
|
1221
|
+
specs.styling.borderRadius = node.cornerRadius;
|
|
1222
|
+
}
|
|
1223
|
+
if (node.rectangleCornerRadii) {
|
|
1224
|
+
specs.styling.borderRadius = {
|
|
1225
|
+
topLeft: node.rectangleCornerRadii[0],
|
|
1226
|
+
topRight: node.rectangleCornerRadii[1],
|
|
1227
|
+
bottomRight: node.rectangleCornerRadii[2],
|
|
1228
|
+
bottomLeft: node.rectangleCornerRadii[3]
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Analyze children
|
|
1233
|
+
if (node.children && node.children.length > 0) {
|
|
1234
|
+
specs.children = node.children.map(child => ({
|
|
1235
|
+
id: child.id,
|
|
1236
|
+
name: child.name,
|
|
1237
|
+
type: child.type,
|
|
1238
|
+
dimensions: child.absoluteBoundingBox,
|
|
1239
|
+
visible: child.visible !== false
|
|
1240
|
+
}));
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return {
|
|
1244
|
+
content: [
|
|
1245
|
+
{ type: 'text', text: JSON.stringify(specs, null, 2) }
|
|
1246
|
+
]
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return {
|
|
1251
|
+
content: [
|
|
1252
|
+
{
|
|
1253
|
+
type: "text",
|
|
1254
|
+
text: `Unknown tool: ${name}`,
|
|
1255
|
+
},
|
|
1256
|
+
],
|
|
1257
|
+
isError: true,
|
|
1258
|
+
};
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
return {
|
|
1261
|
+
content: [
|
|
1262
|
+
{
|
|
1263
|
+
type: "text",
|
|
1264
|
+
text: `Error: ${error.message}`,
|
|
1265
|
+
},
|
|
1266
|
+
],
|
|
1267
|
+
isError: true,
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// Start server
|
|
1273
|
+
async function main() {
|
|
1274
|
+
console.error("Starting chrometools-mcp server...");
|
|
1275
|
+
|
|
1276
|
+
const transport = new StdioServerTransport();
|
|
1277
|
+
await server.connect(transport);
|
|
1278
|
+
|
|
1279
|
+
console.error("chrometools-mcp server running on stdio");
|
|
1280
|
+
console.error("Browser will be initialized on first openBrowser call");
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
main().catch((error) => {
|
|
1284
|
+
console.error("Fatal error:", error);
|
|
1285
|
+
process.exit(1);
|
|
1286
|
+
});
|