chrometools-mcp 1.8.2 → 2.2.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/index.js CHANGED
@@ -1,1845 +1,228 @@
1
1
  #!/usr/bin/env node
2
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
- import { writeFileSync, mkdirSync, readFileSync } from 'fs';
14
- import { dirname } from 'path';
15
- import { spawn } from 'child_process';
16
- import http from 'http';
17
- import { fileURLToPath } from 'url';
18
- import path from 'path';
19
-
20
- // Figma token from environment variable (can be set in MCP config)
21
- const FIGMA_TOKEN = process.env.FIGMA_TOKEN || null;
22
-
23
- // Get current directory for loading utils
24
- const __filename = fileURLToPath(import.meta.url);
25
- const __dirname = dirname(__filename);
26
-
27
- // Load element finder utilities
28
- const elementFinderUtils = readFileSync(path.join(__dirname, 'element-finder-utils.js'), 'utf-8');
29
-
30
- // Import hints generator
31
- import {
32
- generateNavigationHints,
33
- generateClickHints,
34
- generateFormSubmitHints,
35
- generatePageHints
36
- } from './utils/hints-generator.js';
37
-
38
- // Import Recorder modules
39
- import { injectRecorder } from './recorder/recorder-script.js';
40
- import { executeScenario } from './recorder/scenario-executor.js';
41
- import {
42
- initializeStorage,
43
- saveScenario,
44
- loadScenario,
45
- listScenarios,
46
- searchScenarios,
47
- deleteScenario
48
- } from './recorder/scenario-storage.js';
49
-
50
- // Import Project Detection
51
- import { detectProjectRoot } from './utils/project-detector.js';
52
-
53
- // Import Code Generators
54
- import { PlaywrightTypeScriptGenerator } from './utils/code-generators/playwright-typescript.js';
55
- import { PlaywrightPythonGenerator } from './utils/code-generators/playwright-python.js';
56
- import { SeleniumPythonGenerator } from './utils/code-generators/selenium-python.js';
57
- import { SeleniumJavaGenerator } from './utils/code-generators/selenium-java.js';
58
-
59
- // Global state for scenarios directory
60
- let currentScenariosDirectory = null;
61
-
62
- // Import Figma tools
63
- import {
64
- parseFigmaUrl,
65
- normalizeFigmaNodeId,
66
- fetchFigmaAPI,
67
- getFigmaFile,
68
- listFigmaPages,
69
- searchFigmaFrames,
70
- getFigmaComponents,
71
- getFigmaStyles,
72
- getFigmaColorPalette,
73
- extractTextFromNode,
74
- collectAllText
75
- } from './figma-tools.js';
76
-
77
- // Detect WSL environment
78
- const isWSL = (() => {
79
- try {
80
- const fs = require('fs');
81
- const proc_version = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
82
- return proc_version.includes('microsoft') || proc_version.includes('wsl');
83
- } catch {
84
- return false;
85
- }
86
- })();
87
-
88
- // Detect Windows environment (including WSL)
89
- const isWindows = process.platform === 'win32' || isWSL;
90
-
91
- /**
92
- * Get base directory for scenarios storage
93
- * Uses cascade strategy: explicit param → remembered → auto-detect
94
- * @param {string|null} explicitDir - Optional explicit directory path
95
- * @returns {string} - Base directory path
96
- */
97
- function getScenariosBaseDir(explicitDir = null) {
98
- // 1. If explicitly provided - remember and return
99
- if (explicitDir) {
100
- currentScenariosDirectory = path.resolve(explicitDir);
101
- console.log('[chrometools-mcp] Using explicit directory:', currentScenariosDirectory);
102
- return currentScenariosDirectory;
103
- }
104
-
105
- // 2. If already remembered - return
106
- if (currentScenariosDirectory) {
107
- console.log('[chrometools-mcp] Using remembered directory:', currentScenariosDirectory);
108
- return currentScenariosDirectory;
109
- }
110
-
111
- // 3. Auto-detect from environment
112
- const detected = detectProjectRoot();
113
- currentScenariosDirectory = detected;
114
- return detected;
115
- }
116
-
117
- // Get Chrome executable path based on platform
118
- function getChromePath() {
119
- if (process.platform === 'win32') {
120
- // Native Windows
121
- return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
122
- } else if (isWSL) {
123
- // WSL - use Windows Chrome
124
- return '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe';
125
- } else {
126
- // Linux
127
- return '/usr/bin/google-chrome';
128
- }
129
- }
130
-
131
- // Get temp directory based on platform
132
- function getTempDir() {
133
- if (process.platform === 'win32') {
134
- return process.env.TEMP || 'C:\\Windows\\Temp';
135
- } else if (isWSL) {
136
- return '/mnt/c/Windows/Temp';
137
- } else {
138
- return process.env.TMPDIR || '/tmp';
139
- }
140
- }
141
-
142
- // Global browser instance (persists between requests)
143
- let browserPromise = null;
144
- const openPages = new Map();
145
- let lastPage = null;
146
- let chromeProcess = null;
147
-
148
- // Console logs storage
149
- const consoleLogs = [];
150
-
151
- // Network requests storage
152
- const networkRequests = [];
153
-
154
- // Page analysis cache (method 4)
155
- const pageAnalysisCache = new Map();
156
-
157
- // Track pages with recorder injected
158
- const pagesWithRecorder = new WeakSet();
159
-
160
- // Debug port for Chrome remote debugging
161
- const CHROME_DEBUG_PORT = 9222;
162
-
163
- // Helper function to get WebSocket endpoint from Chrome
164
- async function getChromeWebSocketEndpoint(port = CHROME_DEBUG_PORT, maxRetries = 10) {
165
- for (let i = 0; i < maxRetries; i++) {
166
- try {
167
- const response = await new Promise((resolve, reject) => {
168
- const req = http.get(`http://localhost:${port}/json/version`, (res) => {
169
- let data = '';
170
- res.on('data', chunk => data += chunk);
171
- res.on('end', () => resolve(data));
172
- });
173
- req.on('error', reject);
174
- req.setTimeout(1000);
175
- });
176
-
177
- const info = JSON.parse(response);
178
- if (info.webSocketDebuggerUrl) {
179
- return info.webSocketDebuggerUrl;
180
- }
181
- } catch (err) {
182
- // Chrome might not be ready yet, wait and retry
183
- await new Promise(resolve => setTimeout(resolve, 500));
184
- }
185
- }
186
- throw new Error('Could not get Chrome WebSocket endpoint after multiple retries');
187
- }
188
-
189
- // Initialize browser (singleton)
190
- async function getBrowser() {
191
- // Check if we have a cached browser and if it's still connected
192
- if (browserPromise) {
193
- try {
194
- const cachedBrowser = await browserPromise;
195
- if (cachedBrowser && cachedBrowser.isConnected()) {
196
- return cachedBrowser;
197
- }
198
- // Browser disconnected, reset the promise
199
- console.error("[chrometools-mcp] Browser disconnected, will reconnect...");
200
- browserPromise = null;
201
- } catch (error) {
202
- console.error("[chrometools-mcp] Error checking cached browser:", error.message);
203
- browserPromise = null;
204
- }
205
- }
206
-
207
- if (!browserPromise) {
208
- browserPromise = (async () => {
209
- try {
210
- let browser;
211
- let endpoint;
212
-
213
- // Try to connect to existing Chrome with remote debugging
214
- try {
215
- endpoint = await getChromeWebSocketEndpoint(CHROME_DEBUG_PORT, 2);
216
- browser = await puppeteer.connect({
217
- browserWSEndpoint: endpoint,
218
- defaultViewport: null,
219
- });
220
- console.error("[chrometools-mcp] Connected to existing Chrome instance");
221
- console.error("[chrometools-mcp] WebSocket endpoint:", endpoint);
222
-
223
- // Set up disconnect handler to reset browserPromise
224
- browser.on('disconnected', () => {
225
- console.error("[chrometools-mcp] Browser disconnected");
226
- browserPromise = null;
227
- });
228
-
229
- return browser;
230
- } catch (connectError) {
231
- console.error("[chrometools-mcp] No existing Chrome found, launching new instance...");
232
- }
233
-
234
- // Launch new Chrome with remote debugging enabled
235
- const chromePath = getChromePath();
236
- const userDataDir = `${getTempDir()}/chrome-mcp-profile`;
237
-
238
- console.error("[chrometools-mcp] Chrome path:", chromePath);
239
- console.error("[chrometools-mcp] User data dir:", userDataDir);
240
-
241
- chromeProcess = spawn(chromePath, [
242
- `--remote-debugging-port=${CHROME_DEBUG_PORT}`,
243
- '--no-first-run',
244
- '--no-default-browser-check',
245
- `--user-data-dir=${userDataDir}`,
246
- ], {
247
- detached: true,
248
- stdio: 'ignore',
249
- });
250
-
251
- chromeProcess.unref(); // Allow Node to exit even if Chrome is running
252
-
253
- console.error("[chrometools-mcp] Chrome launched with remote debugging on port", CHROME_DEBUG_PORT);
254
-
255
- // Wait for Chrome to start and get the endpoint
256
- endpoint = await getChromeWebSocketEndpoint(CHROME_DEBUG_PORT, 20);
257
-
258
- // Connect to the Chrome instance
259
- browser = await puppeteer.connect({
260
- browserWSEndpoint: endpoint,
261
- defaultViewport: null,
262
- });
263
-
264
- console.error("[chrometools-mcp] Connected to Chrome instance");
265
- console.error("[chrometools-mcp] WebSocket endpoint:", endpoint);
266
-
267
- // Set up disconnect handler to reset browserPromise
268
- browser.on('disconnected', () => {
269
- console.error("[chrometools-mcp] Browser disconnected");
270
- browserPromise = null;
271
- });
272
-
273
- return browser;
274
- } catch (error) {
275
- // Check if it's a display-related error in WSL
276
- if (isWSL && (
277
- error.message.includes('DISPLAY') ||
278
- error.message.includes('connect ECONNREFUSED') ||
279
- error.message.includes('cannot open display')
280
- )) {
281
- const helpMessage = `
282
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
283
- ❌ WSL X Server Error Detected
284
-
285
- You are running in WSL environment with headless:false mode.
286
- This requires an X server to display the browser GUI.
287
-
288
- 🔧 Solution:
289
- 1. Start X server on Windows (e.g., VcXsrv, X410)
290
- 2. Set DISPLAY in your MCP config:
291
-
292
- {
293
- "mcpServers": {
294
- "chrometools": {
295
- "env": {
296
- "DISPLAY": "172.25.96.1:0"
297
- }
298
- }
299
- }
300
- }
301
-
302
- 📚 For detailed setup instructions, see:
303
- WSL_SETUP.md in chrometools-mcp package
304
-
305
- 💡 Alternative: Run in headless mode (modify index.js)
306
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
307
- `;
308
- console.error(helpMessage);
309
- throw new Error(`WSL X Server not available. ${error.message}\n\nSee above for setup instructions.`);
310
- }
311
-
312
- // Re-throw other errors as-is
313
- throw error;
314
- }
315
- })();
316
- }
317
- return browserPromise;
318
- }
319
-
320
- // Setup navigation listener for recorder auto-reinjection
321
- // Track pages with network monitoring to prevent duplicate setup
322
- const pagesWithNetworkMonitoring = new WeakSet();
323
-
324
- // Setup network monitoring with auto-reinitialization on navigation
325
- async function setupNetworkMonitoring(page) {
326
- // Prevent duplicate setup on the same page
327
- if (pagesWithNetworkMonitoring.has(page)) {
328
- return;
329
- }
330
- pagesWithNetworkMonitoring.add(page);
331
-
332
- const client = await page.target().createCDPSession();
333
- await client.send('Network.enable');
334
-
335
- client.on('Network.requestWillBeSent', (event) => {
336
- const timestamp = new Date().toISOString();
337
- networkRequests.push({
338
- requestId: event.requestId,
339
- url: event.request.url,
340
- method: event.request.method,
341
- headers: event.request.headers,
342
- postData: event.request.postData,
343
- timestamp,
344
- type: event.type, // Document, Stylesheet, Image, Media, Font, Script, XHR, Fetch, etc.
345
- initiator: event.initiator.type, // parser, script, other
346
- status: 'pending',
347
- documentURL: event.documentURL
348
- });
349
- });
350
-
351
- client.on('Network.responseReceived', (event) => {
352
- const req = networkRequests.find(r => r.requestId === event.requestId);
353
- if (req) {
354
- req.status = event.response.status;
355
- req.statusText = event.response.statusText;
356
- req.responseHeaders = event.response.headers;
357
- req.mimeType = event.response.mimeType;
358
- req.fromCache = event.response.fromDiskCache || event.response.fromServiceWorker;
359
- req.timing = event.response.timing;
360
- }
361
- });
362
-
363
- client.on('Network.loadingFinished', (event) => {
364
- const req = networkRequests.find(r => r.requestId === event.requestId);
365
- if (req && req.status === 'pending') {
366
- req.status = 'completed';
367
- }
368
- if (req) {
369
- req.encodedDataLength = event.encodedDataLength;
370
- req.finishedTimestamp = new Date().toISOString();
371
- }
372
- });
373
-
374
- client.on('Network.loadingFailed', (event) => {
375
- const req = networkRequests.find(r => r.requestId === event.requestId);
376
- if (req) {
377
- req.status = 'failed';
378
- req.errorText = event.errorText;
379
- req.canceled = event.canceled;
380
- req.finishedTimestamp = new Date().toISOString();
381
- }
382
- });
383
-
384
- // Auto-reinitialize on navigation (CDP session is reset on navigation)
385
- let lastUrl = page.url();
386
-
387
- page.on('framenavigated', async (frame) => {
388
- // Only handle main frame navigation
389
- if (frame !== page.mainFrame()) return;
390
-
391
- const currentUrl = frame.url();
392
-
393
- // Skip if URL hasn't changed
394
- if (currentUrl === lastUrl) return;
395
- lastUrl = currentUrl;
396
-
397
- // Remove from tracking set to allow re-setup
398
- pagesWithNetworkMonitoring.delete(page);
399
-
400
- // Small delay to let navigation settle
401
- setTimeout(async () => {
402
- try {
403
- await setupNetworkMonitoring(page);
404
- } catch (error) {
405
- console.error('[chrometools-mcp] Failed to reinitialize network monitoring:', error.message);
406
- }
407
- }, 100);
408
- });
409
- }
410
-
411
- async function setupRecorderAutoReinjection(page) {
412
- let reinjectionTimeout = null;
413
- let lastUrl = null;
414
-
415
- // Handle navigation events (form submits, link clicks, history API)
416
- page.on('framenavigated', async (frame) => {
417
- // Only handle main frame navigation
418
- if (frame !== page.mainFrame()) return;
419
-
420
- // Get current URL
421
- const currentUrl = frame.url();
422
-
423
- // Skip if URL hasn't changed (prevents duplicate injections on same page)
424
- if (currentUrl === lastUrl) {
425
- return;
426
- }
427
- lastUrl = currentUrl;
428
-
429
- // Clear any pending reinjection
430
- if (reinjectionTimeout) {
431
- clearTimeout(reinjectionTimeout);
432
- }
433
-
434
- // Debounce reinjection (wait 100ms for navigation to settle)
435
- reinjectionTimeout = setTimeout(async () => {
436
- // Check if this page had recorder before
437
- if (pagesWithRecorder.has(page)) {
438
- try {
439
- const baseDir = getScenariosBaseDir();
440
- await injectRecorder(page, baseDir);
441
- } catch (error) {
442
- console.error('[chrometools-mcp] Failed to re-inject recorder:', error.message);
443
- }
444
- }
445
- }, 100);
446
- });
447
-
448
- // Handle page reloads (F5, Ctrl+R) - use 'load' event
449
- page.on('load', async () => {
450
- // Check if this page had recorder before
451
- if (pagesWithRecorder.has(page)) {
452
- try {
453
- const baseDir = getScenariosBaseDir();
454
- await injectRecorder(page, baseDir);
455
- } catch (error) {
456
- console.error('[chrometools-mcp] Failed to re-inject recorder after reload:', error.message);
457
- }
458
- }
459
- });
460
- }
461
-
462
- // Get or create page for URL
463
- async function getOrCreatePage(url) {
464
- const browser = await getBrowser();
465
-
466
- // Check if page for this URL already exists
467
- if (openPages.has(url)) {
468
- const existingPage = openPages.get(url);
469
- if (!existingPage.isClosed()) {
470
- lastPage = existingPage;
471
- return existingPage;
472
- }
473
- openPages.delete(url);
474
- }
475
-
476
- // Create new page
477
- const page = await browser.newPage();
478
-
479
- // Set up console log capture
480
- const client = await page.target().createCDPSession();
481
- await client.send('Runtime.enable');
482
- await client.send('Log.enable');
483
-
484
- client.on('Runtime.consoleAPICalled', (event) => {
485
- const timestamp = new Date().toISOString();
486
- const args = event.args.map(arg => {
487
- if (arg.value !== undefined) return arg.value;
488
- if (arg.description) return arg.description;
489
- return String(arg);
490
- });
491
-
492
- consoleLogs.push({
493
- type: event.type, // log, warn, error, info, debug
494
- timestamp,
495
- message: args.join(' '),
496
- stackTrace: event.stackTrace
497
- });
498
- });
499
-
500
- client.on('Log.entryAdded', (event) => {
501
- const entry = event.entry;
502
- consoleLogs.push({
503
- type: entry.level, // verbose, info, warning, error
504
- timestamp: new Date(entry.timestamp).toISOString(),
505
- message: entry.text,
506
- source: entry.source,
507
- url: entry.url,
508
- lineNumber: entry.lineNumber
509
- });
510
- });
511
-
512
- // Setup network monitoring with auto-reinitialization on navigation
513
- await setupNetworkMonitoring(page);
514
-
515
- // Setup recorder auto-reinjection on navigation
516
- setupRecorderAutoReinjection(page);
517
-
518
- await page.goto(url, { waitUntil: 'networkidle2' });
519
- openPages.set(url, page);
520
- lastPage = page;
521
-
522
- return page;
523
- }
524
-
525
- // Get last opened page (for tools that don't need URL)
526
- async function getLastOpenPage() {
527
- if (!lastPage || lastPage.isClosed()) {
528
- throw new Error('No page is currently open. Use openBrowser first to open a page.');
529
- }
530
-
531
- // Setup recorder auto-reinjection if not already set up
532
- // Check if page already has navigation listener
533
- const listenerCount = lastPage.listenerCount('framenavigated');
534
- if (listenerCount === 0) {
535
- setupRecorderAutoReinjection(lastPage);
536
- }
537
-
538
- return lastPage;
539
- }
540
-
541
- // Helper function to normalize Figma node ID (convert URL format to API format)
542
- // Figma helper functions moved to figma-tools.js
543
-
544
- // Helper function to process screenshot with compression and scaling
545
- async function processScreenshot(screenshotBuffer, options = {}) {
546
- const {
547
- maxWidth = 1024,
548
- maxHeight = 8000, // API limit is 8000px
549
- quality = 80,
550
- format = 'auto',
551
- maxFileSize = 3 * 1024 * 1024 // 3 MB limit
552
- } = options;
553
-
554
- // Load image with Jimp
555
- const image = await Jimp.read(screenshotBuffer);
556
- const originalWidth = image.bitmap.width;
557
- const originalHeight = image.bitmap.height;
558
- const originalSize = screenshotBuffer.length;
559
-
560
- let processed = false;
561
-
562
- // Apply scaling if needed to fit within maxWidth and maxHeight
563
- if (maxWidth !== null || maxHeight !== null) {
564
- let newWidth = originalWidth;
565
- let newHeight = originalHeight;
566
-
567
- // Calculate scale factors for both dimensions
568
- let scaleWidth = 1.0;
569
- let scaleHeight = 1.0;
570
-
571
- if (maxWidth !== null && originalWidth > maxWidth) {
572
- scaleWidth = maxWidth / originalWidth;
573
- }
574
-
575
- if (maxHeight !== null && originalHeight > maxHeight) {
576
- scaleHeight = maxHeight / originalHeight;
577
- }
578
-
579
- // Use the smaller scale factor to ensure both dimensions fit
580
- const scale = Math.min(scaleWidth, scaleHeight);
581
-
582
- if (scale < 1.0) {
583
- newWidth = Math.round(originalWidth * scale);
584
- newHeight = Math.round(originalHeight * scale);
585
- image.resize(newWidth, newHeight);
586
- processed = true;
587
- }
588
- }
589
-
590
- // Determine output format
591
- let outputFormat = format;
592
- let mimeType = 'image/png';
593
-
594
- if (format === 'auto') {
595
- // Auto-select: use JPEG for large images, PNG for small
596
- const estimatedSize = image.bitmap.width * image.bitmap.height * 4;
597
- outputFormat = estimatedSize > 500000 ? 'jpeg' : 'png'; // ~500KB threshold
598
- }
599
-
600
- // Convert to buffer with appropriate format and quality
601
- let currentQuality = quality;
602
- let resultBuffer;
603
- let compressionAttempts = 0;
604
- const maxCompressionAttempts = 10;
605
-
606
- if (outputFormat === 'jpeg') {
607
- image.quality(currentQuality);
608
- resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
609
- mimeType = 'image/jpeg';
610
- processed = true;
611
-
612
- // If file exceeds maxFileSize, reduce quality iteratively
613
- while (resultBuffer.length > maxFileSize && compressionAttempts < maxCompressionAttempts) {
614
- compressionAttempts++;
615
- // Reduce quality by 10 points each iteration
616
- currentQuality = Math.max(10, currentQuality - 10);
617
- image.quality(currentQuality);
618
- resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
619
-
620
- // If quality is already at minimum and still too large, scale down the image
621
- if (currentQuality === 10 && resultBuffer.length > maxFileSize) {
622
- const scaleFactor = Math.sqrt(maxFileSize / resultBuffer.length * 0.9); // 0.9 for safety margin
623
- const newWidth = Math.round(image.bitmap.width * scaleFactor);
624
- const newHeight = Math.round(image.bitmap.height * scaleFactor);
625
- image.resize(newWidth, newHeight);
626
- image.quality(currentQuality);
627
- resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
628
- processed = true;
629
- }
630
- }
631
- } else {
632
- resultBuffer = await image.getBufferAsync(Jimp.MIME_PNG);
633
- mimeType = 'image/png';
634
-
635
- // If PNG exceeds maxFileSize, convert to JPEG and compress
636
- if (resultBuffer.length > maxFileSize) {
637
- outputFormat = 'jpeg';
638
- mimeType = 'image/jpeg';
639
- currentQuality = quality;
640
- image.quality(currentQuality);
641
- resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
642
- processed = true;
643
-
644
- // Reduce quality iteratively if still too large
645
- while (resultBuffer.length > maxFileSize && compressionAttempts < maxCompressionAttempts) {
646
- compressionAttempts++;
647
- currentQuality = Math.max(10, currentQuality - 10);
648
- image.quality(currentQuality);
649
- resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
650
-
651
- // If quality is already at minimum and still too large, scale down the image
652
- if (currentQuality === 10 && resultBuffer.length > maxFileSize) {
653
- const scaleFactor = Math.sqrt(maxFileSize / resultBuffer.length * 0.9);
654
- const newWidth = Math.round(image.bitmap.width * scaleFactor);
655
- const newHeight = Math.round(image.bitmap.height * scaleFactor);
656
- image.resize(newWidth, newHeight);
657
- image.quality(currentQuality);
658
- resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
659
- processed = true;
660
- }
661
- }
662
- }
663
- }
664
-
665
- // Return original if no processing was needed and format is PNG
666
- if (!processed && outputFormat === 'png' && resultBuffer.length <= maxFileSize) {
667
- return {
668
- buffer: screenshotBuffer,
669
- mimeType: 'image/png',
670
- metadata: {
671
- width: originalWidth,
672
- height: originalHeight,
673
- originalSize,
674
- finalSize: screenshotBuffer.length,
675
- format: 'png',
676
- compressed: false,
677
- scaled: false
678
- }
679
- };
680
- }
681
-
682
- return {
683
- buffer: resultBuffer,
684
- mimeType,
685
- metadata: {
686
- width: image.bitmap.width,
687
- height: image.bitmap.height,
688
- originalWidth,
689
- originalHeight,
690
- originalSize,
691
- finalSize: resultBuffer.length,
692
- format: outputFormat,
693
- compressed: outputFormat === 'jpeg' || compressionAttempts > 0,
694
- scaled: processed,
695
- compressionRatio: Math.round((1 - resultBuffer.length / originalSize) * 100),
696
- quality: outputFormat === 'jpeg' ? currentQuality : undefined,
697
- compressionAttempts: compressionAttempts > 0 ? compressionAttempts : undefined,
698
- autoCompressed: compressionAttempts > 0 || (outputFormat === 'jpeg' && format === 'png')
699
- }
700
- };
701
- }
702
-
703
- // Calculate SSIM (Structural Similarity Index) for image comparison
704
- function calculateSSIM(img1Data, img2Data, width, height) {
705
- if (img1Data.length !== img2Data.length) {
706
- return 0;
707
- }
708
-
709
- const windowSize = 8;
710
- const k1 = 0.01;
711
- const k2 = 0.03;
712
- const c1 = (k1 * 255) ** 2;
713
- const c2 = (k2 * 255) ** 2;
714
-
715
- let ssimSum = 0;
716
- let validWindows = 0;
717
-
718
- for (let y = 0; y <= height - windowSize; y += windowSize) {
719
- for (let x = 0; x <= width - windowSize; x += windowSize) {
720
- let sum1 = 0, sum2 = 0, sum1Sq = 0, sum2Sq = 0, sum12 = 0;
721
-
722
- for (let dy = 0; dy < windowSize; dy++) {
723
- for (let dx = 0; dx < windowSize; dx++) {
724
- const idx = ((y + dy) * width + (x + dx)) * 4;
725
- if (idx + 2 >= img1Data.length) continue;
726
-
727
- const gray1 = (img1Data[idx] * 0.299 + img1Data[idx + 1] * 0.587 + img1Data[idx + 2] * 0.114);
728
- const gray2 = (img2Data[idx] * 0.299 + img2Data[idx + 1] * 0.587 + img2Data[idx + 2] * 0.114);
729
-
730
- sum1 += gray1;
731
- sum2 += gray2;
732
- sum1Sq += gray1 * gray1;
733
- sum2Sq += gray2 * gray2;
734
- sum12 += gray1 * gray2;
735
- }
736
- }
737
-
738
- const n = windowSize * windowSize;
739
- const mean1 = sum1 / n;
740
- const mean2 = sum2 / n;
741
- const variance1 = (sum1Sq / n) - (mean1 * mean1);
742
- const variance2 = (sum2Sq / n) - (mean2 * mean2);
743
- const covariance = (sum12 / n) - (mean1 * mean2);
744
-
745
- const ssim = ((2 * mean1 * mean2 + c1) * (2 * covariance + c2)) /
746
- ((mean1 * mean1 + mean2 * mean2 + c1) * (variance1 + variance2 + c2));
747
-
748
- ssimSum += ssim;
749
- validWindows++;
750
- }
751
- }
752
-
753
- return validWindows > 0 ? ssimSum / validWindows : 0;
754
- }
755
-
756
- // Cleanup on exit
757
- process.on("SIGINT", async () => {
758
- if (browserPromise) {
759
- const browser = await browserPromise;
760
- await browser.close();
761
- }
762
- process.exit(0);
763
- });
764
-
765
- // Create MCP server
766
- const server = new Server(
767
- {
768
- name: "chrometools-mcp",
769
- version: "1.0.2",
770
- },
771
- {
772
- capabilities: {
773
- tools: {},
774
- },
775
- }
776
- );
777
-
778
- // CSS property categorization
779
- const CSS_CATEGORIES = {
780
- layout: [
781
- 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
782
- 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
783
- 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
784
- 'position', 'top', 'right', 'bottom', 'left', 'z-index',
785
- 'display', 'float', 'clear', 'overflow', 'overflow-x', 'overflow-y',
786
- 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
787
- 'justify-content', 'align-items', 'align-content', 'align-self', 'order',
788
- 'grid', 'grid-template', 'grid-template-columns', 'grid-template-rows', 'grid-gap',
789
- 'gap', 'row-gap', 'column-gap',
790
- 'box-sizing', 'visibility', 'clip', 'clip-path'
791
- ],
792
- typography: [
793
- 'font', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant',
794
- 'line-height', 'letter-spacing', 'word-spacing', 'text-align', 'text-decoration',
795
- 'text-transform', 'text-indent', 'text-overflow', 'white-space', 'word-break',
796
- 'word-wrap', 'overflow-wrap', 'hyphens', 'direction', 'unicode-bidi',
797
- 'writing-mode', 'vertical-align'
798
- ],
799
- colors: [
800
- 'color', 'background', 'background-color', 'background-image', 'background-position',
801
- 'background-size', 'background-repeat', 'background-attachment', 'background-clip',
802
- 'background-origin', 'background-blend-mode',
803
- 'border-color', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
804
- 'outline-color', 'text-decoration-color', 'caret-color', 'column-rule-color'
805
- ],
806
- visual: [
807
- 'opacity', 'transform', 'transform-origin', 'transform-style', 'perspective',
808
- 'perspective-origin', 'backface-visibility',
809
- 'transition', 'transition-property', 'transition-duration', 'transition-timing-function', 'transition-delay',
810
- 'animation', 'animation-name', 'animation-duration', 'animation-timing-function', 'animation-delay',
811
- 'animation-iteration-count', 'animation-direction', 'animation-fill-mode', 'animation-play-state',
812
- 'filter', 'backdrop-filter', 'mix-blend-mode', 'isolation',
813
- 'box-shadow', 'text-shadow',
814
- 'border', 'border-width', 'border-style', 'border-radius',
815
- 'border-top', 'border-right', 'border-bottom', 'border-left',
816
- 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
817
- 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
818
- 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius',
819
- 'outline', 'outline-width', 'outline-style', 'outline-offset',
820
- 'cursor', 'pointer-events', 'user-select'
821
- ]
822
- };
823
-
824
- // Common default CSS values to filter out
825
- const CSS_DEFAULTS = {
826
- 'display': 'inline',
827
- 'position': 'static',
828
- 'float': 'none',
829
- 'clear': 'none',
830
- 'visibility': 'visible',
831
- 'overflow': 'visible',
832
- 'overflow-x': 'visible',
833
- 'overflow-y': 'visible',
834
- 'z-index': 'auto',
835
- 'opacity': '1',
836
- 'transform': 'none',
837
- 'filter': 'none',
838
- 'backdrop-filter': 'none',
839
- 'box-shadow': 'none',
840
- 'text-shadow': 'none',
841
- 'border-style': 'none',
842
- 'border-width': '0px',
843
- 'outline-style': 'none',
844
- 'outline-width': '0px',
845
- 'margin': '0px',
846
- 'margin-top': '0px',
847
- 'margin-right': '0px',
848
- 'margin-bottom': '0px',
849
- 'margin-left': '0px',
850
- 'padding': '0px',
851
- 'padding-top': '0px',
852
- 'padding-right': '0px',
853
- 'padding-bottom': '0px',
854
- 'padding-left': '0px',
855
- 'background-image': 'none',
856
- 'transition': 'all 0s ease 0s',
857
- 'animation': 'none',
858
- 'pointer-events': 'auto',
859
- 'user-select': 'auto',
860
- 'cursor': 'auto',
861
- 'text-decoration': 'none',
862
- 'text-transform': 'none',
863
- 'font-weight': '400',
864
- 'font-style': 'normal',
865
- 'font-variant': 'normal',
866
- 'letter-spacing': 'normal',
867
- 'word-spacing': 'normal',
868
- 'text-align': 'start',
869
- 'white-space': 'normal',
870
- 'word-break': 'normal',
871
- 'overflow-wrap': 'normal',
872
- 'hyphens': 'manual'
873
- };
874
-
875
- // Filter computed CSS styles based on options
876
- function filterCssStyles(computedStyle, options = {}) {
877
- const { category, properties, includeDefaults = false } = options;
878
-
879
- let filtered = computedStyle;
880
-
881
- // Filter by specific properties (highest priority)
882
- if (properties && properties.length > 0) {
883
- filtered = filtered.filter(prop =>
884
- properties.some(p => prop.name.toLowerCase() === p.toLowerCase())
885
- );
886
- }
887
- // Filter by category
888
- else if (category && category !== 'all') {
889
- const categoryProps = CSS_CATEGORIES[category] || [];
890
- filtered = filtered.filter(prop =>
891
- categoryProps.some(p => prop.name.toLowerCase().startsWith(p.toLowerCase()))
892
- );
893
- }
894
-
895
- // Filter out default values if requested
896
- if (!includeDefaults) {
897
- filtered = filtered.filter(prop => {
898
- const defaultValue = CSS_DEFAULTS[prop.name];
899
- if (!defaultValue) return true;
900
-
901
- // Normalize values for comparison
902
- const normalizedValue = prop.value.replace(/\s+/g, ' ').trim();
903
- const normalizedDefault = defaultValue.replace(/\s+/g, ' ').trim();
904
-
905
- return normalizedValue !== normalizedDefault;
906
- });
907
- }
908
-
909
- return filtered;
910
- }
911
-
912
- // Tool schemas
913
- const PingSchema = z.object({
914
- message: z.string().optional().describe("Optional message to send"),
915
- });
916
-
917
- const OpenBrowserSchema = z.object({
918
- url: z.string().describe("URL to open in the browser"),
919
- });
920
-
921
- const ClickSchema = z.object({
922
- selector: z.string().describe("CSS selector for element to click"),
923
- waitAfter: z.number().optional().describe("Milliseconds to wait after click (default: 1500)"),
924
- screenshot: z.boolean().optional().describe("Capture screenshot after click (default: false for performance)"),
925
- timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
926
- });
927
-
928
- const TypeSchema = z.object({
929
- selector: z.string().describe("CSS selector for input element"),
930
- text: z.string().describe("Text to type"),
931
- delay: z.number().optional().describe("Delay between keystrokes in ms (default: 0)"),
932
- clearFirst: z.boolean().optional().describe("Clear field before typing (default: true)"),
933
- });
934
-
935
- const GetElementSchema = z.object({
936
- selector: z.string().optional().describe("CSS selector (optional, defaults to body)"),
937
- });
938
-
939
- const GetComputedCssSchema = z.object({
940
- selector: z.string().optional().describe("CSS selector (optional, defaults to body)"),
941
- category: z.enum(['all', 'layout', 'typography', 'colors', 'visual']).optional().describe("Filter by CSS category: 'layout' (sizing, positioning), 'typography' (fonts, text), 'colors' (color schemes), 'visual' (effects, transforms), 'all' (default)"),
942
- properties: z.array(z.string()).optional().describe("Specific CSS properties to return (e.g., ['color', 'font-size']). Overrides category filter."),
943
- includeDefaults: z.boolean().optional().describe("Include properties with default values (default: false)"),
944
- });
945
-
946
- const GetBoxModelSchema = z.object({
947
- selector: z.string().describe("CSS selector for element"),
948
- });
949
-
950
- const ScreenshotSchema = z.object({
951
- selector: z.string().describe("CSS selector for element to screenshot"),
952
- padding: z.number().optional().describe("Padding around element in pixels (default: 0)"),
953
- maxWidth: z.number().nullable().optional().describe("Maximum width in pixels, auto-scales if larger (default: 1024, set to null for original size)"),
954
- maxHeight: z.number().nullable().optional().describe("Maximum height in pixels, auto-scales if larger (default: 8000 for API limit, set to null for original size)"),
955
- quality: z.number().min(1).max(100).optional().describe("JPEG quality 1-100 (default: 80, only applies to JPEG format)"),
956
- format: z.enum(['png', 'jpeg', 'auto']).optional().describe("Image format: 'png', 'jpeg', or 'auto' (default: 'auto' - chooses based on size)"),
957
- });
958
-
959
- const SaveScreenshotSchema = z.object({
960
- selector: z.string().describe("CSS selector for element to screenshot"),
961
- filePath: z.string().describe("Absolute path where to save file"),
962
- padding: z.number().optional().describe("Padding around element in pixels (default: 0)"),
963
- maxWidth: z.number().nullable().optional().describe("Maximum width in pixels, auto-scales if larger (default: 1024, set to null for original size)"),
964
- maxHeight: z.number().nullable().optional().describe("Maximum height in pixels, auto-scales if larger (default: 8000 for API limit, set to null for original size)"),
965
- quality: z.number().min(1).max(100).optional().describe("JPEG quality 1-100 (default: 80, only applies to JPEG format)"),
966
- format: z.enum(['png', 'jpeg', 'auto']).optional().describe("Image format: 'png', 'jpeg', or 'auto' (default: 'auto' - chooses based on size)"),
967
- });
968
-
969
- const ScrollToSchema = z.object({
970
- selector: z.string().describe("CSS selector for element to scroll to"),
971
- behavior: z.enum(['auto', 'smooth']).optional().describe("Scroll behavior (default: auto)"),
972
- });
973
-
974
- const WaitForElementSchema = z.object({
975
- selector: z.string().describe("CSS selector to wait for"),
976
- timeout: z.number().optional().describe("Maximum time to wait in milliseconds (default: 5000)"),
977
- visible: z.boolean().optional().describe("Wait for element to be visible (default: true)"),
978
- });
979
-
980
- const ExecuteScriptSchema = z.object({
981
- script: z.string().describe("JavaScript code to execute in page context"),
982
- waitAfter: z.number().optional().describe("Milliseconds to wait after execution (default: 500)"),
983
- screenshot: z.boolean().optional().describe("Capture screenshot after execution (default: false for performance)"),
984
- timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
985
- });
986
-
987
- // Phase 2 schemas
988
- const GetConsoleLogsSchema = z.object({
989
- types: z.array(z.enum(['log', 'warn', 'error', 'info', 'debug', 'verbose', 'warning']))
990
- .optional()
991
- .describe("Filter by log types (default: all)"),
992
- clear: z.boolean().optional().describe("Clear logs after reading (default: false)"),
993
- });
994
-
995
- // Network tools schemas
996
- const ListNetworkRequestsSchema = z.object({
997
- types: z.array(z.enum(['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Script', 'XHR', 'Fetch', 'WebSocket', 'Other']))
998
- .optional()
999
- .default(['Fetch', 'XHR'])
1000
- .describe("Filter by request types (default: Fetch, XHR)"),
1001
- status: z.enum(['pending', 'completed', 'failed', 'all'])
1002
- .optional()
1003
- .describe("Filter by status (default: all)"),
1004
- limit: z.number().min(1).max(500).optional().default(50).describe("Maximum number of requests to return (default: 50)"),
1005
- offset: z.number().min(0).optional().default(0).describe("Number of requests to skip before returning results (default: 0)"),
1006
- clear: z.boolean().optional().describe("Clear requests after reading (default: false)"),
1007
- });
1008
-
1009
- const GetNetworkRequestSchema = z.object({
1010
- requestId: z.string().describe("Request ID to get details for"),
1011
- });
1012
-
1013
- const FilterNetworkRequestsSchema = z.object({
1014
- urlPattern: z.string().describe("URL pattern to filter by (regex or partial match)"),
1015
- types: z.array(z.enum(['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Script', 'XHR', 'Fetch', 'WebSocket', 'Other']))
1016
- .optional()
1017
- .default(['Fetch', 'XHR'])
1018
- .describe("Filter by request types (default: Fetch, XHR)"),
1019
- clear: z.boolean().optional().describe("Clear requests after reading (default: false)"),
1020
- });
1021
-
1022
- const HoverSchema = z.object({
1023
- selector: z.string().describe("CSS selector for element to hover"),
1024
- });
1025
-
1026
- const SetStylesSchema = z.object({
1027
- selector: z.string().describe("CSS selector for element to modify"),
1028
- styles: z.array(z.object({
1029
- name: z.string().describe("CSS property name (e.g., 'color')"),
1030
- value: z.string().describe("CSS property value (e.g., 'red')")
1031
- })).describe("Array of CSS property name-value pairs"),
1032
- });
1033
-
1034
- const SetViewportSchema = z.object({
1035
- width: z.number().min(320).max(4000).describe("Viewport width in pixels (320-4000)"),
1036
- height: z.number().min(200).max(3000).describe("Viewport height in pixels (200-3000)"),
1037
- deviceScaleFactor: z.number().min(0.5).max(3).optional().describe("Device pixel ratio (0.5-3, default: 1)"),
1038
- });
1039
-
1040
- const GetViewportSchema = z.object({});
1041
-
1042
- const NavigateToSchema = z.object({
1043
- url: z.string().describe("URL to navigate to"),
1044
- waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2'])
1045
- .optional()
1046
- .describe("Wait until event (default: networkidle2)"),
1047
- });
1048
-
1049
- // Figma tools schemas
1050
- const GetFigmaFrameSchema = z.object({
1051
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1052
- fileKey: z.string().describe("Figma file key (from URL: figma.com/file/FILE_KEY/...)"),
1053
- nodeId: z.string().describe("Figma node ID (frame/component ID)"),
1054
- scale: z.number().min(0.1).max(4).optional().describe("Export scale (0.1-4, default: 2)"),
1055
- format: z.enum(['png', 'jpg', 'svg']).optional().describe("Export format (default: png)")
1056
- });
1057
-
1058
- const CompareFigmaToElementSchema = z.object({
1059
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1060
- fileKey: z.string().describe("Figma file key"),
1061
- nodeId: z.string().describe("Figma frame/component ID"),
1062
- selector: z.string().describe("CSS selector for page element"),
1063
- threshold: z.number().min(0).max(1).optional().describe("Difference threshold (0-1, default: 0.05)"),
1064
- figmaScale: z.number().min(0.1).max(4).optional().describe("Figma export scale (default: 2)")
1065
- });
1066
-
1067
- const GetFigmaSpecsSchema = z.object({
1068
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1069
- fileKey: z.string().describe("Figma file key"),
1070
- nodeId: z.string().describe("Figma frame/component ID")
1071
- });
1072
-
1073
- const ParseFigmaUrlSchema = z.object({
1074
- url: z.string().describe("Full Figma URL or fileKey")
1075
- });
1076
-
1077
- const ListFigmaPagesSchema = z.object({
1078
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1079
- fileKey: z.string().describe("Figma file key or full Figma URL")
1080
- });
1081
-
1082
- const SearchFigmaFramesSchema = z.object({
1083
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1084
- fileKey: z.string().describe("Figma file key or full Figma URL"),
1085
- searchQuery: z.string().describe("Search query")
1086
- });
1087
-
1088
- const GetFigmaComponentsSchema = z.object({
1089
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1090
- fileKey: z.string().describe("Figma file key or full Figma URL")
1091
- });
1092
-
1093
- const GetFigmaStylesSchema = z.object({
1094
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1095
- fileKey: z.string().describe("Figma file key or full Figma URL")
1096
- });
1097
-
1098
- const GetFigmaColorPaletteSchema = z.object({
1099
- figmaToken: z.string().optional().describe("Figma API token (optional if FIGMA_TOKEN env var is set)"),
1100
- fileKey: z.string().describe("Figma file key or full Figma URL")
1101
- });
1102
-
1103
- // New AI optimization tools schemas
1104
- const SmartFindElementSchema = z.object({
1105
- description: z.string().describe("Natural language description of element to find (e.g., 'login button', 'email field')"),
1106
- maxResults: z.number().min(1).max(20).optional().describe("Maximum number of candidates to return (default: 5)"),
1107
- action: z.object({
1108
- type: z.enum(['click', 'type', 'scrollTo', 'screenshot', 'hover', 'setStyles']).describe("Action to perform on the best match"),
1109
- text: z.string().optional().describe("Text to type (required for 'type' action)"),
1110
- styles: z.array(z.object({
1111
- name: z.string(),
1112
- value: z.string()
1113
- })).optional().describe("Styles to apply (required for 'setStyles' action)"),
1114
- screenshot: z.boolean().optional().describe("Capture screenshot after action (default: false)"),
1115
- waitAfter: z.number().optional().describe("Wait time in ms after action"),
1116
- }).optional().describe("Optional action to perform on the best matching element"),
1117
- });
1118
-
1119
- const AnalyzePageSchema = z.object({
1120
- refresh: z.boolean().optional().describe("Force refresh of cached analysis (default: false)"),
1121
- });
1122
-
1123
- const GetAllInteractiveElementsSchema = z.object({
1124
- includeHidden: z.boolean().optional().describe("Include hidden elements (default: false)"),
1125
- });
1126
-
1127
- const FindElementsByTextSchema = z.object({
1128
- text: z.string().describe("Text to search for in elements"),
1129
- exact: z.boolean().optional().describe("Exact match only (default: false)"),
1130
- caseSensitive: z.boolean().optional().describe("Case sensitive search (default: false)"),
1131
- action: z.object({
1132
- type: z.enum(['click', 'type', 'scrollTo', 'screenshot', 'hover', 'setStyles']).describe("Action to perform on the first match"),
1133
- text: z.string().optional().describe("Text to type (required for 'type' action)"),
1134
- styles: z.array(z.object({
1135
- name: z.string(),
1136
- value: z.string()
1137
- })).optional().describe("Styles to apply (required for 'setStyles' action)"),
1138
- screenshot: z.boolean().optional().describe("Capture screenshot after action (default: false)"),
1139
- waitAfter: z.number().optional().describe("Wait time in ms after action"),
1140
- }).optional().describe("Optional action to perform on the first matching element"),
1141
- });
1142
-
1143
- // List available tools
1144
- server.setRequestHandler(ListToolsRequestSchema, async () => {
1145
- return {
1146
- tools: [
1147
- {
1148
- name: "ping",
1149
- description: "Simple ping-pong tool for testing. Returns 'pong' with optional message.",
1150
- inputSchema: {
1151
- type: "object",
1152
- properties: {
1153
- message: { type: "string", description: "Optional message to include in response" },
1154
- },
1155
- },
1156
- },
1157
- {
1158
- name: "openBrowser",
1159
- description: "Open browser and navigate to URL. Window persists for further interactions.",
1160
- inputSchema: {
1161
- type: "object",
1162
- properties: {
1163
- url: { type: "string", description: "URL to navigate to" },
1164
- },
1165
- required: ["url"],
1166
- },
1167
- },
1168
- {
1169
- name: "click",
1170
- description: "Click element. Waits for animations. Optional screenshot parameter.",
1171
- inputSchema: {
1172
- type: "object",
1173
- properties: {
1174
- selector: { type: "string", description: "CSS selector" },
1175
- waitAfter: { type: "number", description: "Wait ms (default: 1500)" },
1176
- screenshot: { type: "boolean", description: "Screenshot (default: false)" },
1177
- timeout: { type: "number", description: "Max wait ms (default: 30000)" },
1178
- },
1179
- required: ["selector"],
1180
- },
1181
- },
1182
- {
1183
- name: "type",
1184
- description: "Type text into input field. Optional clear and typing delay.",
1185
- inputSchema: {
1186
- type: "object",
1187
- properties: {
1188
- selector: { type: "string", description: "CSS selector" },
1189
- text: { type: "string", description: "Text to type" },
1190
- delay: { type: "number", description: "Keystroke delay ms (default: 0)" },
1191
- clearFirst: { type: "boolean", description: "Clear first (default: true)" },
1192
- },
1193
- required: ["selector", "text"],
1194
- },
1195
- },
1196
- {
1197
- name: "getElement",
1198
- description: "Get HTML markup of element. Prefer analyzePage for better efficiency.",
1199
- inputSchema: {
1200
- type: "object",
1201
- properties: {
1202
- selector: { type: "string", description: "CSS selector (default: body)" },
1203
- },
1204
- },
1205
- },
1206
- {
1207
- name: "getComputedCss",
1208
- description: "Get computed CSS styles for element. For layout debugging and responsive design.",
1209
- inputSchema: {
1210
- type: "object",
1211
- properties: {
1212
- selector: { type: "string", description: "CSS selector (default: body)" },
1213
- category: {
1214
- type: "string",
1215
- enum: ["all", "layout", "typography", "colors", "visual"],
1216
- description: "Filter: 'layout', 'typography', 'colors', 'visual', 'all' (default)"
1217
- },
1218
- properties: {
1219
- type: "array",
1220
- items: { type: "string" },
1221
- description: "Specific properties. Overrides category."
1222
- },
1223
- includeDefaults: {
1224
- type: "boolean",
1225
- description: "Include defaults (default: false)"
1226
- },
1227
- },
1228
- },
1229
- },
1230
- {
1231
- name: "getBoxModel",
1232
- description: "Get element box model: dimensions, positioning, margins, padding, borders.",
1233
- inputSchema: {
1234
- type: "object",
1235
- properties: {
1236
- selector: { type: "string", description: "CSS selector" },
1237
- },
1238
- required: ["selector"],
1239
- },
1240
- },
1241
- {
1242
- name: "screenshot",
1243
- description: "Capture element image (15-25k tokens). For visual comparison. Use analyzePage for form data/validation (2-5k tokens).",
1244
- inputSchema: {
1245
- type: "object",
1246
- properties: {
1247
- selector: { type: "string", description: "CSS selector" },
1248
- padding: { type: "number", description: "Padding px (default: 0)" },
1249
- maxWidth: { type: "number", description: "Max width px (default: 1024, null=original)" },
1250
- maxHeight: { type: "number", description: "Max height px (default: 8000, null=original)" },
1251
- quality: { type: "number", minimum: 1, maximum: 100, description: "JPEG quality (default: 80)" },
1252
- format: { type: "string", enum: ["png", "jpeg", "auto"], description: "Format (default: auto)" },
1253
- },
1254
- required: ["selector"],
1255
- },
1256
- },
1257
- {
1258
- name: "saveScreenshot",
1259
- description: "Save screenshot to file without returning in context. Auto-scales and compresses. Use maxWidth: null and format: 'png' for original quality.",
1260
- inputSchema: {
1261
- type: "object",
1262
- properties: {
1263
- selector: { type: "string", description: "CSS selector" },
1264
- filePath: { type: "string", description: "Save path (extension auto-adjusted)" },
1265
- padding: { type: "number", description: "Padding px (default: 0)" },
1266
- maxWidth: { type: "number", description: "Max width px (default: 1024, null=original)" },
1267
- maxHeight: { type: "number", description: "Max height px (default: 8000, null=original)" },
1268
- quality: { type: "number", minimum: 1, maximum: 100, description: "JPEG quality (default: 80)" },
1269
- format: { type: "string", enum: ["png", "jpeg", "auto"], description: "Format (default: auto)" },
1270
- },
1271
- required: ["selector", "filePath"],
1272
- },
1273
- },
1274
- {
1275
- name: "scrollTo",
1276
- description: "Scroll to element. For lazy loading and visibility testing.",
1277
- inputSchema: {
1278
- type: "object",
1279
- properties: {
1280
- selector: { type: "string", description: "CSS selector" },
1281
- behavior: { type: "string", enum: ["auto", "smooth"], description: "Behavior (default: auto)" },
1282
- },
1283
- required: ["selector"],
1284
- },
1285
- },
1286
- {
1287
- name: "waitForElement",
1288
- description: "Wait for element to appear. For dynamic content and lazy-loaded elements.",
1289
- inputSchema: {
1290
- type: "object",
1291
- properties: {
1292
- selector: { type: "string", description: "CSS selector" },
1293
- timeout: { type: "number", description: "Max wait ms (default: 5000)" },
1294
- visible: { type: "boolean", description: "Wait for visible (default: true)" },
1295
- },
1296
- required: ["selector"],
1297
- },
1298
- },
1299
- {
1300
- name: "executeScript",
1301
- description: "Execute JavaScript. Use only when specialized tools insufficient. Prefer analyzePage or findElementsByText.",
1302
- inputSchema: {
1303
- type: "object",
1304
- properties: {
1305
- script: { type: "string", description: "JavaScript code" },
1306
- waitAfter: { type: "number", description: "Wait ms (default: 500)" },
1307
- screenshot: { type: "boolean", description: "Screenshot (default: false)" },
1308
- timeout: { type: "number", description: "Max wait ms (default: 30000)" },
1309
- },
1310
- required: ["script"],
1311
- },
1312
- },
1313
- {
1314
- name: "getConsoleLogs",
1315
- description: "Get browser console messages. For debugging JS errors and tracking behavior.",
1316
- inputSchema: {
1317
- type: "object",
1318
- properties: {
1319
- types: { type: "array", items: { type: "string", enum: ["log", "warn", "error", "info", "debug", "verbose", "warning"] }, description: "Filter types (default: all)" },
1320
- clear: { type: "boolean", description: "Clear after read (default: false)" },
1321
- },
1322
- },
1323
- },
1324
- {
1325
- name: "listNetworkRequests",
1326
- description: "List network requests (method, URL, status). Use getNetworkRequest for details. Supports pagination.",
1327
- inputSchema: {
1328
- type: "object",
1329
- properties: {
1330
- types: { type: "array", items: { type: "string", enum: ["Document", "Stylesheet", "Image", "Media", "Font", "Script", "XHR", "Fetch", "WebSocket", "Other"] }, description: "Filter types (default: Fetch, XHR)" },
1331
- status: { type: "string", enum: ["pending", "completed", "failed", "all"], description: "Filter status (default: all)" },
1332
- limit: { type: "number", description: "Max requests (default: 50)" },
1333
- offset: { type: "number", description: "Skip requests (default: 0)" },
1334
- clear: { type: "boolean", description: "Clear after read (default: false)" },
1335
- },
1336
- },
1337
- },
1338
- {
1339
- name: "getNetworkRequest",
1340
- description: "Get network request details (headers, payload, response). Use requestId from listNetworkRequests.",
1341
- inputSchema: {
1342
- type: "object",
1343
- properties: {
1344
- requestId: { type: "string", description: "Request ID" },
1345
- },
1346
- required: ["requestId"],
1347
- },
1348
- },
1349
- {
1350
- name: "filterNetworkRequests",
1351
- description: "Filter network requests by URL pattern. Returns matching requests with full details.",
1352
- inputSchema: {
1353
- type: "object",
1354
- properties: {
1355
- urlPattern: { type: "string", description: "URL pattern (regex or partial)" },
1356
- types: { type: "array", items: { type: "string", enum: ["Document", "Stylesheet", "Image", "Media", "Font", "Script", "XHR", "Fetch", "WebSocket", "Other"] }, description: "Filter types (default: Fetch, XHR)" },
1357
- clear: { type: "boolean", description: "Clear after read (default: false)" },
1358
- },
1359
- required: ["urlPattern"],
1360
- },
1361
- },
1362
- {
1363
- name: "hover",
1364
- description: "Hover over element. For testing hover effects, tooltips, and CSS :hover states.",
1365
- inputSchema: {
1366
- type: "object",
1367
- properties: {
1368
- selector: { type: "string", description: "CSS selector" },
1369
- },
1370
- required: ["selector"],
1371
- },
1372
- },
1373
- {
1374
- name: "setStyles",
1375
- description: "Apply inline CSS to element. For live editing and prototyping.",
1376
- inputSchema: {
1377
- type: "object",
1378
- properties: {
1379
- selector: { type: "string", description: "CSS selector" },
1380
- styles: {
1381
- type: "array",
1382
- items: {
1383
- type: "object",
1384
- properties: {
1385
- name: { type: "string", description: "Property name" },
1386
- value: { type: "string", description: "Property value" },
1387
- },
1388
- required: ["name", "value"],
1389
- },
1390
- description: "CSS property name-value pairs",
1391
- },
1392
- },
1393
- required: ["selector", "styles"],
1394
- },
1395
- },
1396
- {
1397
- name: "setViewport",
1398
- description: "Change viewport dimensions. Test responsive layouts across screen sizes.",
1399
- inputSchema: {
1400
- type: "object",
1401
- properties: {
1402
- width: { type: "number", minimum: 320, maximum: 4000, description: "Width px" },
1403
- height: { type: "number", minimum: 200, maximum: 3000, description: "Height px" },
1404
- deviceScaleFactor: { type: "number", minimum: 0.5, maximum: 3, description: "Pixel ratio (default: 1)" },
1405
- },
1406
- required: ["width", "height"],
1407
- },
1408
- },
1409
- {
1410
- name: "getViewport",
1411
- description: "Get viewport size and pixel ratio. For responsive design testing.",
1412
- inputSchema: {
1413
- type: "object",
1414
- properties: {},
1415
- },
1416
- },
1417
- {
1418
- name: "navigateTo",
1419
- description: "Navigate to new URL. Reuses browser instance.",
1420
- inputSchema: {
1421
- type: "object",
1422
- properties: {
1423
- url: { type: "string", description: "URL to navigate to" },
1424
- waitUntil: { type: "string", enum: ["load", "domcontentloaded", "networkidle0", "networkidle2"], description: "Wait event (default: networkidle2)" },
1425
- },
1426
- required: ["url"],
1427
- },
1428
- },
1429
- {
1430
- name: "getFigmaFrame",
1431
- description: "Export Figma frame as PNG. Requires API token and file/node IDs.",
1432
- inputSchema: {
1433
- type: "object",
1434
- properties: {
1435
- figmaToken: { type: "string", description: "API token (optional)" },
1436
- fileKey: { type: "string", description: "File key" },
1437
- nodeId: { type: "string", description: "Frame/component ID" },
1438
- scale: { type: "number", minimum: 0.1, maximum: 4, description: "Scale (default: 2)" },
1439
- format: { type: "string", enum: ["png", "jpg", "svg"], description: "Format (default: png)" },
1440
- },
1441
- required: ["fileKey", "nodeId"],
1442
- },
1443
- },
1444
- {
1445
- name: "compareFigmaToElement",
1446
- description: "Compare Figma design with browser element. Pixel-perfect validation.",
1447
- inputSchema: {
1448
- type: "object",
1449
- properties: {
1450
- figmaToken: { type: "string", description: "API token (optional)" },
1451
- fileKey: { type: "string", description: "File key" },
1452
- nodeId: { type: "string", description: "Frame/component ID" },
1453
- selector: { type: "string", description: "CSS selector" },
1454
- threshold: { type: "number", minimum: 0, maximum: 1, description: "Diff threshold (default: 0.05)" },
1455
- figmaScale: { type: "number", minimum: 0.1, maximum: 4, description: "Scale (default: 2)" },
1456
- },
1457
- required: ["fileKey", "nodeId", "selector"],
1458
- },
1459
- },
1460
- {
1461
- name: "getFigmaSpecs",
1462
- description: "Extract design specs from Figma: colors, fonts, dimensions, spacing.",
1463
- inputSchema: {
1464
- type: "object",
1465
- properties: {
1466
- figmaToken: { type: "string", description: "API token (optional)" },
1467
- fileKey: { type: "string", description: "File key" },
1468
- nodeId: { type: "string", description: "Frame/component ID" },
1469
- },
1470
- required: ["fileKey", "nodeId"],
1471
- },
1472
- },
1473
- {
1474
- name: "parseFigmaUrl",
1475
- description: "Parse Figma URL to extract fileKey and nodeId.",
1476
- inputSchema: {
1477
- type: "object",
1478
- properties: {
1479
- url: { type: "string", description: "Figma URL or fileKey" },
1480
- },
1481
- required: ["url"],
1482
- },
1483
- },
1484
- {
1485
- name: "listFigmaPages",
1486
- description: "Get file structure: all pages and frames. Use first to discover file contents.",
1487
- inputSchema: {
1488
- type: "object",
1489
- properties: {
1490
- figmaToken: { type: "string", description: "API token (optional)" },
1491
- fileKey: { type: "string", description: "File key or URL" },
1492
- },
1493
- required: ["fileKey"],
1494
- },
1495
- },
1496
- {
1497
- name: "searchFigmaFrames",
1498
- description: "Search frames/components by name. Case-insensitive across all pages.",
1499
- inputSchema: {
1500
- type: "object",
1501
- properties: {
1502
- figmaToken: { type: "string", description: "API token (optional)" },
1503
- fileKey: { type: "string", description: "File key or URL" },
1504
- searchQuery: { type: "string", description: "Search query" },
1505
- },
1506
- required: ["fileKey", "searchQuery"],
1507
- },
1508
- },
1509
- {
1510
- name: "getFigmaComponents",
1511
- description: "Get all components from file (Design System). For extracting design system.",
1512
- inputSchema: {
1513
- type: "object",
1514
- properties: {
1515
- figmaToken: { type: "string", description: "API token (optional)" },
1516
- fileKey: { type: "string", description: "File key or URL" },
1517
- },
1518
- required: ["fileKey"],
1519
- },
1520
- },
1521
- {
1522
- name: "getFigmaStyles",
1523
- description: "Get all styles: color, text, effect, grid. For extracting design tokens.",
1524
- inputSchema: {
1525
- type: "object",
1526
- properties: {
1527
- figmaToken: { type: "string", description: "API token (optional)" },
1528
- fileKey: { type: "string", description: "File key or URL" },
1529
- },
1530
- required: ["fileKey"],
1531
- },
1532
- },
1533
- {
1534
- name: "getFigmaColorPalette",
1535
- description: "Extract color palette. Returns unique colors with hex, rgba, usage count.",
1536
- inputSchema: {
1537
- type: "object",
1538
- properties: {
1539
- figmaToken: { type: "string", description: "API token (optional)" },
1540
- fileKey: { type: "string", description: "File key or URL" },
1541
- },
1542
- required: ["fileKey"],
1543
- },
1544
- },
1545
- {
1546
- name: "smartFindElement",
1547
- description: "Find elements with natural language. Returns ranked candidates. Prefer analyzePage for better performance.",
1548
- inputSchema: {
1549
- type: "object",
1550
- properties: {
1551
- description: { type: "string", description: "Natural language description" },
1552
- maxResults: { type: "number", minimum: 1, maximum: 20, description: "Max candidates (default: 5)" },
1553
- action: {
1554
- type: "object",
1555
- properties: {
1556
- type: { type: "string", enum: ["click", "type", "scrollTo", "screenshot", "hover", "setStyles"], description: "Action type" },
1557
- text: { type: "string", description: "Text for 'type'" },
1558
- styles: { type: "array", items: { type: "object", properties: { name: { type: "string" }, value: { type: "string" } } }, description: "Styles for 'setStyles'" },
1559
- screenshot: { type: "boolean", description: "Screenshot (default: false)" },
1560
- waitAfter: { type: "number", description: "Wait ms" },
1561
- },
1562
- required: ["type"],
1563
- description: "Optional action on element",
1564
- },
1565
- },
1566
- required: ["description"],
1567
- },
1568
- },
1569
- {
1570
- name: "analyzePage",
1571
- description: "Get page state: forms, inputs, buttons, links with values. Use refresh:true after interactions. Cached per URL. 2-5k tokens vs screenshot 15-25k.",
1572
- inputSchema: {
1573
- type: "object",
1574
- properties: {
1575
- refresh: { type: "boolean", description: "Refresh cache (default: false)" },
1576
- },
1577
- },
1578
- },
1579
- {
1580
- name: "getAllInteractiveElements",
1581
- description: "Get all interactive elements with selectors. For understanding available actions.",
1582
- inputSchema: {
1583
- type: "object",
1584
- properties: {
1585
- includeHidden: { type: "boolean", description: "Include hidden (default: false)" },
1586
- },
1587
- },
1588
- },
1589
- {
1590
- name: "findElementsByText",
1591
- description: "Find elements by text. Returns elements with selectors. Optional actions on first match.",
1592
- inputSchema: {
1593
- type: "object",
1594
- properties: {
1595
- text: { type: "string", description: "Search text" },
1596
- exact: { type: "boolean", description: "Exact match (default: false)" },
1597
- caseSensitive: { type: "boolean", description: "Case sensitive (default: false)" },
1598
- action: {
1599
- type: "object",
1600
- properties: {
1601
- type: { type: "string", enum: ["click", "type", "scrollTo", "screenshot", "hover", "setStyles"], description: "Action type" },
1602
- text: { type: "string", description: "Text for 'type'" },
1603
- styles: { type: "array", items: { type: "object", properties: { name: { type: "string" }, value: { type: "string" } } }, description: "Styles for 'setStyles'" },
1604
- screenshot: { type: "boolean", description: "Screenshot (default: false)" },
1605
- waitAfter: { type: "number", description: "Wait ms" },
1606
- },
1607
- required: ["type"],
1608
- description: "Optional action on first match",
1609
- },
1610
- },
1611
- required: ["text"],
1612
- },
1613
- },
1614
- {
1615
- name: "enableRecorder",
1616
- description: "Inject recorder UI widget. Visual recording with start/stop/save controls.",
1617
- inputSchema: {
1618
- type: "object",
1619
- properties: {
1620
- directory: { type: "string", description: "Directory to save scenarios (optional, defaults to auto-detected project root)" },
1621
- },
1622
- },
1623
- },
1624
- {
1625
- name: "executeScenario",
1626
- description: "Execute recorded scenario by name. Runs actions with dependency resolution.",
1627
- inputSchema: {
1628
- type: "object",
1629
- properties: {
1630
- name: { type: "string", description: "Scenario name" },
1631
- parameters: { type: "object", description: "Execution parameters" },
1632
- executeDependencies: { type: "boolean", description: "Execute dependencies (default: true)" },
1633
- directory: { type: "string", description: "Directory where scenarios are stored (optional, defaults to auto-detected project root)" },
1634
- },
1635
- required: ["name"],
1636
- },
1637
- },
1638
- {
1639
- name: "listScenarios",
1640
- description: "List all scenarios with metadata.",
1641
- inputSchema: {
1642
- type: "object",
1643
- properties: {
1644
- directory: { type: "string", description: "Directory where scenarios are stored (optional, defaults to auto-detected project root)" },
1645
- },
1646
- },
1647
- },
1648
- {
1649
- name: "searchScenarios",
1650
- description: "Search scenarios by text or tags.",
1651
- inputSchema: {
1652
- type: "object",
1653
- properties: {
1654
- text: { type: "string", description: "Search text" },
1655
- tags: { type: "array", items: { type: "string" }, description: "Filter tags" },
1656
- directory: { type: "string", description: "Directory where scenarios are stored (optional, defaults to auto-detected project root)" },
1657
- },
1658
- },
1659
- },
1660
- {
1661
- name: "getScenarioInfo",
1662
- description: "Get scenario details: actions, parameters, dependencies.",
1663
- inputSchema: {
1664
- type: "object",
1665
- properties: {
1666
- name: { type: "string", description: "Scenario name" },
1667
- includeSecrets: { type: "boolean", description: "Include secrets (default: false)" },
1668
- directory: { type: "string", description: "Directory where scenarios are stored (optional, defaults to auto-detected project root)" },
1669
- },
1670
- required: ["name"],
1671
- },
1672
- },
1673
- {
1674
- name: "deleteScenario",
1675
- description: "Delete scenario and secrets.",
1676
- inputSchema: {
1677
- type: "object",
1678
- properties: {
1679
- name: { type: "string", description: "Scenario name" },
1680
- directory: { type: "string", description: "Directory where scenarios are stored (optional, defaults to auto-detected project root)" },
1681
- },
1682
- required: ["name"],
1683
- },
1684
- },
1685
- {
1686
- name: "exportScenarioAsCode",
1687
- description: "Export recorded scenario as executable test code for various frameworks. Automatically cleans unstable selectors (CSS modules, styled-components).",
1688
- inputSchema: {
1689
- type: "object",
1690
- properties: {
1691
- scenarioName: {
1692
- type: "string",
1693
- description: "Name of scenario to export"
1694
- },
1695
- language: {
1696
- type: "string",
1697
- enum: ["playwright-typescript", "playwright-python", "selenium-python", "selenium-java"],
1698
- description: "Target test framework and language"
1699
- },
1700
- cleanSelectors: {
1701
- type: "boolean",
1702
- description: "Remove unstable CSS classes (default: true)"
1703
- },
1704
- includeComments: {
1705
- type: "boolean",
1706
- description: "Include descriptive comments (default: true)"
1707
- },
1708
- directory: {
1709
- type: "string",
1710
- description: "Directory where scenarios are stored (optional, defaults to auto-detected project root)"
1711
- },
1712
- },
1713
- required: ["scenarioName", "language"],
1714
- },
1715
- },
1716
- ],
1717
- };
1718
- });
3
+ import {Server} from "@modelcontextprotocol/sdk/server/index.js";
4
+ import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import {CallToolRequestSchema, ListToolsRequestSchema,} from "@modelcontextprotocol/sdk/types.js";
6
+ import Jimp from "jimp";
7
+ import pixelmatch from "pixelmatch";
8
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'fs';
9
+ import path, {dirname} from 'path';
10
+ import {fileURLToPath} from 'url';
11
+ import {homedir} from 'os';
1719
12
 
1720
- // Helper function to execute actions on elements
1721
- async function executeElementAction(page, selector, action) {
1722
- if (!action || !action.type) {
1723
- return null;
1724
- }
13
+ // Import browser and platform utilities
14
+ import {closeBrowser} from './browser/browser-manager.js';
15
+ import {isWSL} from './utils/platform-utils.js';
16
+
17
+ // Import page management utilities
18
+ import {
19
+ consoleLogs,
20
+ getLastOpenPage,
21
+ getOrCreatePage,
22
+ networkRequests,
23
+ pageAnalysisCache,
24
+ pagesWithRecorder
25
+ } from './browser/page-manager.js';
26
+
27
+ // Import image processing utilities
28
+ import {calculateSSIM, processScreenshot} from './utils/image-processing.js';
29
+
30
+ // Import CSS utilities
31
+ import {filterCssStyles} from './utils/css-utils.js';
32
+
33
+ // Import tool schemas and definitions
34
+ import * as schemas from './server/tool-schemas.js';
35
+ import {toolDefinitions} from './server/tool-definitions.js';
36
+
37
+ // Import element actions helper
38
+ import {executeElementAction} from './utils/element-actions.js';
39
+ // Import hints generator
40
+ import {generateClickHints, generateNavigationHints} from './utils/hints-generator.js';
41
+
42
+ // Import Recorder modules
43
+ import {injectRecorder} from './recorder/recorder-script.js';
44
+ import {executeScenario} from './recorder/scenario-executor.js';
45
+ import {deleteScenario, listScenarios, loadScenario, searchScenarios} from './recorder/scenario-storage.js';
46
+ import {generatePageObject} from './recorder/page-object-generator.js';
47
+
48
+ // Import Code Generators
49
+ import {PlaywrightTypeScriptGenerator} from './utils/code-generators/playwright-typescript.js';
50
+ import {PlaywrightPythonGenerator} from './utils/code-generators/playwright-python.js';
51
+ import {SeleniumPythonGenerator} from './utils/code-generators/selenium-python.js';
52
+ import {SeleniumJavaGenerator} from './utils/code-generators/selenium-java.js';
53
+ import {FileAppender} from './utils/code-generators/file-appender.js';
54
+ // Import Figma tools
55
+ import {
56
+ collectAllText,
57
+ extractTextFromNode,
58
+ fetchFigmaAPI,
59
+ getFigmaColorPalette,
60
+ getFigmaComponents,
61
+ getFigmaStyles,
62
+ listFigmaPages,
63
+ normalizeFigmaNodeId,
64
+ parseFigmaUrl,
65
+ searchFigmaFrames
66
+ } from './figma-tools.js';
67
+
68
+ // Debug mode - only use stderr for actual errors, not debug info
69
+ // MCP uses STDIO for JSON-RPC, so console.log/error breaks the protocol
70
+ const DEBUG_MODE = process.env.CHROMETOOLS_DEBUG === 'true';
71
+ const debugLog = DEBUG_MODE ? console.error : () => {};
72
+
73
+ // Figma token from environment variable (can be set in MCP config)
74
+ const FIGMA_TOKEN = process.env.FIGMA_TOKEN || null;
75
+
76
+ // Get current directory for loading utils
77
+ const __filename = fileURLToPath(import.meta.url);
78
+ const __dirname = dirname(__filename);
79
+
80
+ // Load element finder utilities
81
+ const elementFinderUtils = readFileSync(path.join(__dirname, 'element-finder-utils.js'), 'utf-8');
1725
82
 
1726
- const element = await page.$(selector);
1727
- if (!element) {
1728
- throw new Error(`Element not found for action: ${selector}`);
83
+ // Base storage directory in user's home folder
84
+ const BASE_STORAGE_DIR = path.join(homedir(), '.config', 'chrometools-mcp');
85
+ const GLOBAL_INDEX_PATH = path.join(BASE_STORAGE_DIR, 'index.json');
86
+ const PROJECTS_DIR = path.join(BASE_STORAGE_DIR, 'projects');
87
+
88
+ /**
89
+ * Load global index from ~/.config/chrometools-mcp/index.json
90
+ * @returns {object} - Global index object
91
+ */
92
+ function loadGlobalIndex() {
93
+ try {
94
+ const data = readFileSync(GLOBAL_INDEX_PATH, 'utf-8');
95
+ return JSON.parse(data);
96
+ } catch (err) {
97
+ // Index doesn't exist yet, return empty structure
98
+ return {
99
+ version: '2.0',
100
+ projects: {}
101
+ };
1729
102
  }
103
+ }
1730
104
 
1731
- const result = {
1732
- action: action.type,
1733
- selector,
1734
- success: true,
1735
- };
105
+ /**
106
+ * Save global index to ~/.config/chrometools-mcp/index.json
107
+ * @param {object} index - Global index object
108
+ */
109
+ function saveGlobalIndex(index) {
110
+ mkdirSync(BASE_STORAGE_DIR, { recursive: true });
111
+ writeFileSync(GLOBAL_INDEX_PATH, JSON.stringify(index, null, 2), 'utf-8');
112
+ debugLog('[chrometools-mcp] Global index saved');
113
+ }
1736
114
 
1737
- switch (action.type) {
1738
- case 'click':
1739
- await element.click();
1740
- await new Promise(resolve => setTimeout(resolve, action.waitAfter || 1500));
1741
- result.message = `Clicked on ${selector}`;
115
+ /**
116
+ * Get all project IDs from global index
117
+ * @returns {string[]} - Array of project IDs
118
+ */
119
+ function getAllProjectIds() {
120
+ const index = loadGlobalIndex();
121
+ return Object.keys(index.projects || {});
122
+ }
1742
123
 
1743
- if (action.screenshot) {
1744
- const screenshot = await page.screenshot({ encoding: 'base64', fullPage: false });
1745
- result.screenshot = screenshot;
1746
- }
1747
- break;
124
+ // Cleanup on exit
125
+ process.on("SIGINT", async () => {
126
+ await closeBrowser();
127
+ process.exit(0);
128
+ });
1748
129
 
1749
- case 'type':
1750
- if (!action.text) {
1751
- throw new Error('text parameter is required for type action');
1752
- }
1753
- await element.click({ clickCount: 3 });
1754
- await page.keyboard.press('Backspace');
1755
- await element.type(action.text, { delay: 0 });
1756
- await new Promise(resolve => setTimeout(resolve, action.waitAfter || 500));
1757
- result.message = `Typed "${action.text}" into ${selector}`;
1758
-
1759
- if (action.screenshot) {
1760
- const screenshot = await page.screenshot({ encoding: 'base64', fullPage: false });
1761
- result.screenshot = screenshot;
1762
- }
1763
- break;
130
+ // Migration: Remove old project-based scenarios (v2.0) on first run with v2.1.0
131
+ // This migration runs once to clean up old scenarios and start fresh with URL-based organization
132
+ try {
133
+ const migrationFlagPath = path.join(BASE_STORAGE_DIR, '.migration-v2.1.0-done');
1764
134
 
1765
- case 'scrollTo':
1766
- await element.scrollIntoView({ behavior: 'auto' });
1767
- await new Promise(resolve => setTimeout(resolve, action.waitAfter || 300));
1768
- const position = await page.evaluate(() => ({
1769
- x: window.scrollX,
1770
- y: window.scrollY
1771
- }));
1772
- result.message = `Scrolled to ${selector}`;
1773
- result.position = position;
1774
- break;
135
+ if (!existsSync(migrationFlagPath)) {
136
+ // Check if old projects directory exists
137
+ if (existsSync(PROJECTS_DIR)) {
138
+ console.error('[chrometools-mcp] Migration v2.1.0: Removing old project-based scenarios...');
1775
139
 
1776
- case 'screenshot':
1777
- const box = await element.boundingBox();
1778
- if (!box) {
1779
- throw new Error(`Element not visible: ${selector}`);
1780
- }
1781
- const clip = {
1782
- x: Math.max(box.x, 0),
1783
- y: Math.max(box.y, 0),
1784
- width: Math.max(box.width, 1),
1785
- height: Math.max(box.height, 1)
1786
- };
1787
- const screenshot = await page.screenshot({ clip, encoding: 'base64' });
1788
- result.message = `Captured screenshot of ${selector}`;
1789
- result.screenshot = screenshot;
1790
- break;
140
+ // Remove all old scenarios
141
+ const { rmSync } = await import('fs');
142
+ rmSync(PROJECTS_DIR, { recursive: true, force: true });
1791
143
 
1792
- case 'hover':
1793
- await element.hover();
1794
- await new Promise(resolve => setTimeout(resolve, action.waitAfter || 100));
1795
- result.message = `Hovered over ${selector}`;
144
+ console.error('[chrometools-mcp] Migration v2.1.0: Old scenarios removed. Starting fresh with URL-based organization.');
145
+ }
1796
146
 
1797
- if (action.screenshot) {
1798
- const screenshot = await page.screenshot({ encoding: 'base64', fullPage: false });
1799
- result.screenshot = screenshot;
1800
- }
1801
- break;
147
+ // Remove old global index
148
+ if (existsSync(GLOBAL_INDEX_PATH)) {
149
+ const { unlinkSync } = await import('fs');
150
+ unlinkSync(GLOBAL_INDEX_PATH);
151
+ }
1802
152
 
1803
- case 'setStyles':
1804
- if (!action.styles || !Array.isArray(action.styles)) {
1805
- throw new Error('styles parameter is required for setStyles action');
1806
- }
1807
- const stylesObject = {};
1808
- for (const style of action.styles) {
1809
- stylesObject[style.name] = style.value;
1810
- }
1811
- await page.evaluate((sel, styles) => {
1812
- const el = document.querySelector(sel);
1813
- if (el) {
1814
- Object.entries(styles).forEach(([key, value]) => {
1815
- el.style.setProperty(key, value);
1816
- });
1817
- }
1818
- }, selector, stylesObject);
1819
- await new Promise(resolve => setTimeout(resolve, action.waitAfter || 100));
1820
- result.message = `Applied styles to ${selector}`;
1821
- result.styles = stylesObject;
1822
-
1823
- if (action.screenshot) {
1824
- const screenshot = await page.screenshot({ encoding: 'base64', fullPage: false });
1825
- result.screenshot = screenshot;
1826
- }
1827
- break;
153
+ // Create migration flag
154
+ mkdirSync(BASE_STORAGE_DIR, { recursive: true });
155
+ writeFileSync(migrationFlagPath, new Date().toISOString(), 'utf-8');
156
+ console.error('[chrometools-mcp] Migration v2.1.0: Complete. New scenarios will be organized by website domain.');
157
+ }
158
+ } catch (migrationError) {
159
+ console.error('[chrometools-mcp] Migration v2.1.0 failed:', migrationError.message);
160
+ // Continue anyway - migration is optional
161
+ }
1828
162
 
1829
- default:
1830
- throw new Error(`Unknown action type: ${action.type}`);
163
+ // Create MCP server
164
+ const server = new Server(
165
+ {
166
+ name: "chrometools-mcp",
167
+ version: "2.1.0",
168
+ },
169
+ {
170
+ capabilities: {
171
+ tools: {},
172
+ },
1831
173
  }
174
+ );
175
+
176
+ // List available tools
177
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
178
+ return {
179
+ tools: toolDefinitions,
180
+ };
181
+ });
182
+
183
+
1832
184
 
1833
- return result;
185
+ // Wrapper to add timeout protection for all tool calls
186
+ async function executeToolWithTimeout(toolName, toolFunction, timeoutMs = 120000) {
187
+ return Promise.race([
188
+ toolFunction(),
189
+ new Promise((_, reject) =>
190
+ setTimeout(() => reject(new Error(`Tool '${toolName}' timeout after ${timeoutMs/1000}s`)), timeoutMs)
191
+ )
192
+ ]);
1834
193
  }
1835
194
 
1836
195
  // Handle tool calls
1837
196
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1838
197
  const { name, arguments: args } = request.params;
1839
198
 
199
+ try {
200
+ // Execute with timeout wrapper (2 minutes default, 5 minutes for scenario execution)
201
+ const toolTimeout = name === 'executeScenario' ? 360000 : 120000; // 6 min for scenarios, 2 min for others
202
+
203
+ return await executeToolWithTimeout(name, async () => {
204
+ return await executeToolInternal(name, args);
205
+ }, toolTimeout);
206
+ } catch (error) {
207
+ return {
208
+ content: [{
209
+ type: 'text',
210
+ text: JSON.stringify({
211
+ success: false,
212
+ error: error.message,
213
+ tool: name
214
+ }, null, 2)
215
+ }],
216
+ isError: true,
217
+ };
218
+ }
219
+ });
220
+
221
+ // Internal tool execution function
222
+ async function executeToolInternal(name, args) {
1840
223
  try {
1841
224
  if (name === "ping") {
1842
- const validatedArgs = PingSchema.parse(args);
225
+ const validatedArgs = schemas.PingSchema.parse(args);
1843
226
  const responseMessage = validatedArgs.message
1844
227
  ? `pong: ${validatedArgs.message}`
1845
228
  : "pong";
@@ -1855,7 +238,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1855
238
  }
1856
239
 
1857
240
  if (name === "openBrowser") {
1858
- const validatedArgs = OpenBrowserSchema.parse(args);
241
+ const validatedArgs = schemas.OpenBrowserSchema.parse(args);
1859
242
  const page = await getOrCreatePage(validatedArgs.url);
1860
243
  const title = await page.title();
1861
244
 
@@ -1873,7 +256,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1873
256
  }
1874
257
 
1875
258
  if (name === "click") {
1876
- const validatedArgs = ClickSchema.parse(args);
259
+ const validatedArgs = schemas.ClickSchema.parse(args);
1877
260
  const page = await getLastOpenPage();
1878
261
  const timeout = validatedArgs.timeout || 30000;
1879
262
 
@@ -1921,7 +304,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1921
304
  }
1922
305
 
1923
306
  if (name === "type") {
1924
- const validatedArgs = TypeSchema.parse(args);
307
+ const validatedArgs = schemas.TypeSchema.parse(args);
1925
308
  const page = await getLastOpenPage();
1926
309
 
1927
310
  const element = await page.$(validatedArgs.selector);
@@ -1945,7 +328,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1945
328
  }
1946
329
 
1947
330
  if (name === "getElement") {
1948
- const validatedArgs = GetElementSchema.parse(args);
331
+ const validatedArgs = schemas.GetElementSchema.parse(args);
1949
332
  const page = await getLastOpenPage();
1950
333
 
1951
334
  const client = await page.target().createCDPSession();
@@ -1971,7 +354,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1971
354
  }
1972
355
 
1973
356
  if (name === "getComputedCss") {
1974
- const validatedArgs = GetComputedCssSchema.parse(args);
357
+ const validatedArgs = schemas.GetComputedCssSchema.parse(args);
1975
358
  const page = await getLastOpenPage();
1976
359
 
1977
360
  const client = await page.target().createCDPSession();
@@ -2018,7 +401,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2018
401
  }
2019
402
 
2020
403
  if (name === "getBoxModel") {
2021
- const validatedArgs = GetBoxModelSchema.parse(args);
404
+ const validatedArgs = schemas.GetBoxModelSchema.parse(args);
2022
405
  const page = await getLastOpenPage();
2023
406
 
2024
407
  const client = await page.target().createCDPSession();
@@ -2056,7 +439,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2056
439
  }
2057
440
 
2058
441
  if (name === "screenshot") {
2059
- const validatedArgs = ScreenshotSchema.parse(args);
442
+ const validatedArgs = schemas.ScreenshotSchema.parse(args);
2060
443
  const page = await getLastOpenPage();
2061
444
 
2062
445
  const element = await page.$(validatedArgs.selector);
@@ -2112,7 +495,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2112
495
  }
2113
496
 
2114
497
  if (name === "saveScreenshot") {
2115
- const validatedArgs = SaveScreenshotSchema.parse(args);
498
+ const validatedArgs = schemas.SaveScreenshotSchema.parse(args);
2116
499
  const page = await getLastOpenPage();
2117
500
 
2118
501
  const element = await page.$(validatedArgs.selector);
@@ -2169,7 +552,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2169
552
  }
2170
553
 
2171
554
  if (name === "scrollTo") {
2172
- const validatedArgs = ScrollToSchema.parse(args);
555
+ const validatedArgs = schemas.ScrollToSchema.parse(args);
2173
556
  const page = await getLastOpenPage();
2174
557
 
2175
558
  // Check if element exists
@@ -2202,7 +585,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2202
585
  }
2203
586
 
2204
587
  if (name === "waitForElement") {
2205
- const validatedArgs = WaitForElementSchema.parse(args);
588
+ const validatedArgs = schemas.WaitForElementSchema.parse(args);
2206
589
  const page = await getLastOpenPage();
2207
590
  const timeout = validatedArgs.timeout || 5000;
2208
591
  const waitForVisible = validatedArgs.visible !== false;
@@ -2247,7 +630,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2247
630
  }
2248
631
 
2249
632
  if (name === "executeScript") {
2250
- const validatedArgs = ExecuteScriptSchema.parse(args);
633
+ const validatedArgs = schemas.ExecuteScriptSchema.parse(args);
2251
634
  const page = await getLastOpenPage();
2252
635
  const timeout = validatedArgs.timeout || 30000;
2253
636
 
@@ -2292,7 +675,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2292
675
  }
2293
676
 
2294
677
  if (name === "getConsoleLogs") {
2295
- const validatedArgs = GetConsoleLogsSchema.parse(args);
678
+ const validatedArgs = schemas.GetConsoleLogsSchema.parse(args);
2296
679
 
2297
680
  let logs = consoleLogs;
2298
681
 
@@ -2325,7 +708,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2325
708
 
2326
709
  // Tool 1: listNetworkRequests - compact summary
2327
710
  if (name === "listNetworkRequests") {
2328
- const validatedArgs = ListNetworkRequestsSchema.parse(args);
711
+ const validatedArgs = schemas.ListNetworkRequestsSchema.parse(args);
2329
712
 
2330
713
  let requests = networkRequests;
2331
714
 
@@ -2377,7 +760,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2377
760
 
2378
761
  // Tool 2: getNetworkRequest - full details of single request
2379
762
  if (name === "getNetworkRequest") {
2380
- const validatedArgs = GetNetworkRequestSchema.parse(args);
763
+ const validatedArgs = schemas.GetNetworkRequestSchema.parse(args);
2381
764
 
2382
765
  const req = networkRequests.find(r => r.requestId === validatedArgs.requestId);
2383
766
  if (!req) {
@@ -2425,7 +808,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2425
808
 
2426
809
  // Tool 3: filterNetworkRequests - filter by URL pattern with full details
2427
810
  if (name === "filterNetworkRequests") {
2428
- const validatedArgs = FilterNetworkRequestsSchema.parse(args);
811
+ const validatedArgs = schemas.FilterNetworkRequestsSchema.parse(args);
2429
812
 
2430
813
  let requests = networkRequests;
2431
814
 
@@ -2490,7 +873,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2490
873
  }
2491
874
 
2492
875
  if (name === "hover") {
2493
- const validatedArgs = HoverSchema.parse(args);
876
+ const validatedArgs = schemas.HoverSchema.parse(args);
2494
877
  const page = await getLastOpenPage();
2495
878
 
2496
879
  const element = await page.$(validatedArgs.selector);
@@ -2510,7 +893,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2510
893
  }
2511
894
 
2512
895
  if (name === "setStyles") {
2513
- const validatedArgs = SetStylesSchema.parse(args);
896
+ const validatedArgs = schemas.SetStylesSchema.parse(args);
2514
897
  const page = await getLastOpenPage();
2515
898
 
2516
899
  const stylesObject = {};
@@ -2540,7 +923,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2540
923
  }
2541
924
 
2542
925
  if (name === "setViewport") {
2543
- const validatedArgs = SetViewportSchema.parse(args);
926
+ const validatedArgs = schemas.SetViewportSchema.parse(args);
2544
927
  const page = await getLastOpenPage();
2545
928
 
2546
929
  await page.setViewport({
@@ -2583,7 +966,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2583
966
  }
2584
967
 
2585
968
  if (name === "navigateTo") {
2586
- const validatedArgs = NavigateToSchema.parse(args);
969
+ const validatedArgs = schemas.NavigateToSchema.parse(args);
2587
970
  const page = await getLastOpenPage();
2588
971
 
2589
972
  // Navigate to the new URL (always navigate, don't use cache)
@@ -2604,7 +987,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2604
987
 
2605
988
  // Figma tools
2606
989
  if (name === "getFigmaFrame") {
2607
- const validatedArgs = GetFigmaFrameSchema.parse(args);
990
+ const validatedArgs = schemas.GetFigmaFrameSchema.parse(args);
2608
991
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
2609
992
  if (!token) {
2610
993
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -2682,7 +1065,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2682
1065
  }
2683
1066
 
2684
1067
  if (name === "compareFigmaToElement") {
2685
- const validatedArgs = CompareFigmaToElementSchema.parse(args);
1068
+ const validatedArgs = schemas.CompareFigmaToElementSchema.parse(args);
2686
1069
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
2687
1070
  if (!token) {
2688
1071
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -2813,7 +1196,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2813
1196
  }
2814
1197
 
2815
1198
  if (name === "getFigmaSpecs") {
2816
- const validatedArgs = GetFigmaSpecsSchema.parse(args);
1199
+ const validatedArgs = schemas.GetFigmaSpecsSchema.parse(args);
2817
1200
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
2818
1201
  if (!token) {
2819
1202
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -2955,7 +1338,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2955
1338
  }
2956
1339
 
2957
1340
  if (name === "parseFigmaUrl") {
2958
- const validatedArgs = ParseFigmaUrlSchema.parse(args);
1341
+ const validatedArgs = schemas.ParseFigmaUrlSchema.parse(args);
2959
1342
  const result = parseFigmaUrl(validatedArgs.url);
2960
1343
 
2961
1344
  return {
@@ -2966,7 +1349,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2966
1349
  }
2967
1350
 
2968
1351
  if (name === "listFigmaPages") {
2969
- const validatedArgs = ListFigmaPagesSchema.parse(args);
1352
+ const validatedArgs = schemas.ListFigmaPagesSchema.parse(args);
2970
1353
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
2971
1354
  if (!token) {
2972
1355
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -2986,7 +1369,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2986
1369
  }
2987
1370
 
2988
1371
  if (name === "searchFigmaFrames") {
2989
- const validatedArgs = SearchFigmaFramesSchema.parse(args);
1372
+ const validatedArgs = schemas.SearchFigmaFramesSchema.parse(args);
2990
1373
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
2991
1374
  if (!token) {
2992
1375
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -3006,7 +1389,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3006
1389
  }
3007
1390
 
3008
1391
  if (name === "getFigmaComponents") {
3009
- const validatedArgs = GetFigmaComponentsSchema.parse(args);
1392
+ const validatedArgs = schemas.GetFigmaComponentsSchema.parse(args);
3010
1393
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
3011
1394
  if (!token) {
3012
1395
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -3026,7 +1409,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3026
1409
  }
3027
1410
 
3028
1411
  if (name === "getFigmaStyles") {
3029
- const validatedArgs = GetFigmaStylesSchema.parse(args);
1412
+ const validatedArgs = schemas.GetFigmaStylesSchema.parse(args);
3030
1413
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
3031
1414
  if (!token) {
3032
1415
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -3046,7 +1429,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3046
1429
  }
3047
1430
 
3048
1431
  if (name === "getFigmaColorPalette") {
3049
- const validatedArgs = GetFigmaColorPaletteSchema.parse(args);
1432
+ const validatedArgs = schemas.GetFigmaColorPaletteSchema.parse(args);
3050
1433
  const token = validatedArgs.figmaToken || FIGMA_TOKEN;
3051
1434
  if (!token) {
3052
1435
  throw new Error('Figma token is required. Pass it as parameter or set FIGMA_TOKEN environment variable in MCP config.');
@@ -3067,7 +1450,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3067
1450
 
3068
1451
  // New AI optimization tools
3069
1452
  if (name === "smartFindElement") {
3070
- const validatedArgs = SmartFindElementSchema.parse(args);
1453
+ const validatedArgs = schemas.SmartFindElementSchema.parse(args);
3071
1454
  const page = await getLastOpenPage();
3072
1455
  const maxResults = validatedArgs.maxResults || 5;
3073
1456
 
@@ -3180,7 +1563,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3180
1563
  }
3181
1564
 
3182
1565
  if (name === "analyzePage") {
3183
- const validatedArgs = AnalyzePageSchema.parse(args);
1566
+ const validatedArgs = schemas.AnalyzePageSchema.parse(args);
3184
1567
  const page = await getLastOpenPage();
3185
1568
  const pageUrl = page.url();
3186
1569
 
@@ -3336,7 +1719,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3336
1719
  }
3337
1720
 
3338
1721
  if (name === "getAllInteractiveElements") {
3339
- const validatedArgs = GetAllInteractiveElementsSchema.parse(args);
1722
+ const validatedArgs = schemas.GetAllInteractiveElementsSchema.parse(args);
3340
1723
  const page = await getLastOpenPage();
3341
1724
 
3342
1725
  const elements = await page.evaluate((includeHidden, utilsCode) => {
@@ -3384,7 +1767,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3384
1767
  }
3385
1768
 
3386
1769
  if (name === "findElementsByText") {
3387
- const validatedArgs = FindElementsByTextSchema.parse(args);
1770
+ const validatedArgs = schemas.FindElementsByTextSchema.parse(args);
3388
1771
  const page = await getLastOpenPage();
3389
1772
 
3390
1773
  const elements = await page.evaluate((text, exact, caseSensitive, utilsCode) => {
@@ -3471,9 +1854,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3471
1854
  }
3472
1855
 
3473
1856
  if (name === "enableRecorder") {
3474
- const baseDir = getScenariosBaseDir(args.directory);
1857
+ // Project ID will be determined from URL in browser context
3475
1858
  const page = await getLastOpenPage();
3476
- const result = await injectRecorder(page, baseDir);
1859
+ const result = await injectRecorder(page);
3477
1860
 
3478
1861
  // Track this page as having recorder enabled
3479
1862
  if (result.success) {
@@ -3485,7 +1868,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3485
1868
  type: 'text',
3486
1869
  text: JSON.stringify(result.success ? {
3487
1870
  success: true,
3488
- message: "Recorder UI injected into page. Click 'Start' to begin recording. Recorder will auto-reinject on page navigation/reload."
1871
+ message: 'Recorder UI injected into page. Click \'Start\' to begin recording. Scenarios will be organized by website domain in: ~/.config/chrometools-mcp/projects/'
3489
1872
  } : {
3490
1873
  success: false,
3491
1874
  error: result.error
@@ -3495,55 +1878,109 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3495
1878
  }
3496
1879
 
3497
1880
  if (name === "executeScenario") {
3498
- // Get base directory for scenarios
3499
- const baseDir = getScenariosBaseDir(args.directory);
1881
+ // Load and validate scenario first (check for collisions)
1882
+ const scenario = await loadScenario(args.name, false, args.projectId || null);
1883
+
1884
+ if (!scenario) {
1885
+ return {
1886
+ content: [{
1887
+ type: 'text',
1888
+ text: JSON.stringify({
1889
+ success: false,
1890
+ error: `Scenario "${args.name}" not found`
1891
+ }, null, 2)
1892
+ }]
1893
+ };
1894
+ }
1895
+
1896
+ // Check for name collision
1897
+ if (scenario.collision) {
1898
+ return {
1899
+ content: [{
1900
+ type: 'text',
1901
+ text: JSON.stringify({
1902
+ success: false,
1903
+ error: scenario.message,
1904
+ availableProjectIds: scenario.availableProjectIds,
1905
+ hint: `Use: executeScenario({ name: "${args.name}", projectId: "one-of-the-above" })`
1906
+ }, null, 2)
1907
+ }]
1908
+ };
1909
+ }
3500
1910
 
3501
1911
  // Try to get existing page, or auto-open browser using scenario's entryUrl
3502
1912
  let page;
3503
1913
  try {
3504
1914
  page = await getLastOpenPage();
3505
1915
  } catch (error) {
3506
- // No page is open - load scenario and open browser at entryUrl
3507
- const scenario = await loadScenario(args.name, false, baseDir);
3508
- if (!scenario) {
1916
+ // No page is open - open browser at scenario's entry URL
1917
+ const entryUrl = scenario.metadata?.entryUrl;
1918
+ if (!entryUrl) {
3509
1919
  return {
3510
1920
  content: [{
3511
1921
  type: 'text',
3512
1922
  text: JSON.stringify({
3513
1923
  success: false,
3514
- error: `Scenario "${args.name}" not found`
1924
+ error: `Scenario "${args.name}" has no entryUrl. Cannot auto-open browser. Please open a browser first or ensure scenario has entryUrl.`
3515
1925
  }, null, 2)
3516
1926
  }]
3517
1927
  };
3518
1928
  }
3519
1929
 
3520
- const entryUrl = scenario.metadata?.entryUrl;
3521
- if (!entryUrl) {
1930
+ // Auto-open browser at scenario's entry URL with timeout
1931
+ try {
1932
+ page = await Promise.race([
1933
+ getOrCreatePage(entryUrl),
1934
+ new Promise((_, reject) =>
1935
+ setTimeout(() => reject(new Error('Browser open timeout')), 30000)
1936
+ )
1937
+ ]);
1938
+ } catch (openError) {
3522
1939
  return {
3523
1940
  content: [{
3524
1941
  type: 'text',
3525
1942
  text: JSON.stringify({
3526
1943
  success: false,
3527
- error: `Scenario "${args.name}" has no entryUrl. Cannot auto-open browser.`
1944
+ error: `Failed to open browser: ${openError.message}`
3528
1945
  }, null, 2)
3529
1946
  }]
3530
1947
  };
3531
1948
  }
3532
-
3533
- // Auto-open browser at scenario's entry URL
3534
- page = await getOrCreatePage(entryUrl);
3535
1949
  }
3536
1950
 
3537
- const options = {
3538
- baseDir: baseDir
3539
- };
1951
+ const options = {};
3540
1952
 
3541
1953
  // Pass executeDependencies option if provided
3542
1954
  if (args.executeDependencies !== undefined) {
3543
1955
  options.executeDependencies = args.executeDependencies;
3544
1956
  }
3545
1957
 
3546
- const result = await executeScenario(args.name, page, args.parameters || {}, options);
1958
+ // Pass projectId option if provided
1959
+ if (args.projectId) {
1960
+ options.projectId = args.projectId;
1961
+ }
1962
+
1963
+ // Execute scenario with timeout (5 minutes max)
1964
+ let result;
1965
+ try {
1966
+ result = await Promise.race([
1967
+ executeScenario(args.name, page, args.parameters || {}, options),
1968
+ new Promise((_, reject) =>
1969
+ setTimeout(() => reject(new Error('Scenario execution timeout (5 minutes)')), 300000)
1970
+ )
1971
+ ]);
1972
+ } catch (executeError) {
1973
+ return {
1974
+ content: [{
1975
+ type: 'text',
1976
+ text: JSON.stringify({
1977
+ success: false,
1978
+ error: `Scenario execution failed: ${executeError.message}`,
1979
+ stack: executeError.stack
1980
+ }, null, 2)
1981
+ }]
1982
+ };
1983
+ }
3547
1984
 
3548
1985
  return {
3549
1986
  content: [{
@@ -3554,8 +1991,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3554
1991
  }
3555
1992
 
3556
1993
  if (name === "listScenarios") {
3557
- const baseDir = getScenariosBaseDir(args.directory);
3558
- const scenarios = await listScenarios(baseDir);
1994
+ // Return ALL scenarios from all projects (URL-based organization)
1995
+ // Agent can filter by projectId, entryUrl, exitUrl as needed
1996
+ const scenarios = await listScenarios(null, true);
3559
1997
 
3560
1998
  return {
3561
1999
  content: [{
@@ -3566,8 +2004,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3566
2004
  }
3567
2005
 
3568
2006
  if (name === "searchScenarios") {
3569
- const baseDir = getScenariosBaseDir(args.directory);
3570
- const results = await searchScenarios(args, baseDir);
2007
+ // Return ALL matching scenarios from all projects
2008
+ // Agent can filter by projectId, entryUrl, exitUrl as needed
2009
+ const results = await searchScenarios(args, null, true);
3571
2010
 
3572
2011
  return {
3573
2012
  content: [{
@@ -3578,8 +2017,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3578
2017
  }
3579
2018
 
3580
2019
  if (name === "getScenarioInfo") {
3581
- const baseDir = getScenariosBaseDir(args.directory);
3582
- const scenario = await loadScenario(args.name, args.includeSecrets || false, baseDir);
2020
+ const scenario = await loadScenario(args.name, args.includeSecrets || false, null);
3583
2021
 
3584
2022
  return {
3585
2023
  content: [{
@@ -3590,20 +2028,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3590
2028
  }
3591
2029
 
3592
2030
  if (name === "deleteScenario") {
3593
- const baseDir = getScenariosBaseDir(args.directory);
3594
- const result = await deleteScenario(args.name, baseDir);
2031
+ const result = await deleteScenario(args.name, null);
3595
2032
 
3596
2033
  return {
3597
2034
  content: [{
3598
2035
  type: 'text',
3599
- text: JSON.stringify(result, null, 2)
2036
+ text: JSON.stringify({ success: result }, null, 2)
3600
2037
  }]
3601
2038
  };
3602
2039
  }
3603
2040
 
3604
2041
  if (name === "exportScenarioAsCode") {
3605
- const baseDir = getScenariosBaseDir(args.directory);
3606
- const scenario = await loadScenario(args.scenarioName, false, baseDir);
2042
+ const scenario = await loadScenario(args.scenarioName, false, null);
3607
2043
 
3608
2044
  if (!scenario) {
3609
2045
  return {
@@ -3649,17 +2085,221 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3649
2085
  };
3650
2086
  }
3651
2087
 
3652
- // Generate code
3653
- const code = generator.generate(scenario, options);
2088
+ // Check if append mode is requested
2089
+ if (args.appendToFile) {
2090
+ try {
2091
+ // Validate file path and extension
2092
+ FileAppender.validateFile(args.appendToFile, args.language);
2093
+
2094
+ // Read existing file content
2095
+ const existingContent = FileAppender.readFile(args.appendToFile);
2096
+
2097
+ // Check if file is empty - if so, generate full test with imports
2098
+ if (FileAppender.isEmpty(existingContent)) {
2099
+ const fullCode = generator.generate(scenario, options);
2100
+ FileAppender.writeFile(args.appendToFile, fullCode);
2101
+
2102
+ return {
2103
+ content: [{
2104
+ type: 'text',
2105
+ text: JSON.stringify({
2106
+ success: true,
2107
+ mode: 'append',
2108
+ file: args.appendToFile,
2109
+ testName: args.testName || scenario.metadata?.name,
2110
+ message: `Test written to empty file: ${args.appendToFile}`
2111
+ }, null, 2)
2112
+ }]
2113
+ };
2114
+ }
2115
+
2116
+ // Generate only test function (without imports)
2117
+ const testOnly = generator.generateTestOnly(scenario, {
2118
+ ...options,
2119
+ testName: args.testName
2120
+ });
2121
+
2122
+ // Append test to existing content
2123
+ const appendOptions = {
2124
+ insertPosition: args.insertPosition || 'end',
2125
+ referenceTestName: args.referenceTestName
2126
+ };
2127
+
2128
+ const updatedContent = generator.appendTest(existingContent, testOnly, appendOptions);
2129
+
2130
+ // Write updated content to file
2131
+ FileAppender.writeFile(args.appendToFile, updatedContent);
2132
+
2133
+ return {
2134
+ content: [{
2135
+ type: 'text',
2136
+ text: JSON.stringify({
2137
+ success: true,
2138
+ mode: 'append',
2139
+ file: args.appendToFile,
2140
+ testName: args.testName || scenario.metadata?.name,
2141
+ insertPosition: appendOptions.insertPosition,
2142
+ message: `Test '${args.testName || scenario.metadata?.name}' successfully appended to ${args.appendToFile}`
2143
+ }, null, 2)
2144
+ }]
2145
+ };
2146
+ } catch (error) {
2147
+ return {
2148
+ content: [{
2149
+ type: 'text',
2150
+ text: JSON.stringify({
2151
+ success: false,
2152
+ mode: 'append',
2153
+ file: args.appendToFile,
2154
+ error: error.message
2155
+ }, null, 2)
2156
+ }],
2157
+ isError: true
2158
+ };
2159
+ }
2160
+ }
2161
+
2162
+ // Non-append mode: Generate test code
2163
+ const testCode = generator.generate(scenario, options);
2164
+
2165
+ // If generatePageObject is requested, also generate Page Object class
2166
+ if (args.generatePageObject) {
2167
+ try {
2168
+ // Get page - need to open at scenario's entry URL
2169
+ let page;
2170
+ const entryUrl = scenario.metadata?.entryUrl;
2171
+
2172
+ if (!entryUrl) {
2173
+ return {
2174
+ content: [{
2175
+ type: 'text',
2176
+ text: JSON.stringify({
2177
+ error: 'Cannot generate Page Object: scenario has no entryUrl in metadata'
2178
+ }, null, 2)
2179
+ }],
2180
+ isError: true
2181
+ };
2182
+ }
2183
+
2184
+ // Try to get existing page or open new one
2185
+ try {
2186
+ page = await getLastOpenPage();
2187
+ // Navigate to entry URL if current page is different
2188
+ const currentUrl = page.url();
2189
+ if (currentUrl !== entryUrl) {
2190
+ await page.goto(entryUrl, { waitUntil: 'networkidle2' });
2191
+ }
2192
+ } catch (error) {
2193
+ // No page open, create new one
2194
+ page = await getOrCreatePage(entryUrl);
2195
+ }
2196
+
2197
+ // Generate Page Object
2198
+ const pageObjectOptions = {
2199
+ className: args.pageObjectClassName || null,
2200
+ framework: args.language, // Use same framework as test
2201
+ includeComments: args.includeComments !== false,
2202
+ groupElements: true
2203
+ };
2204
+
2205
+ const pageObjectResult = await generatePageObject(page, pageObjectOptions);
2206
+
2207
+ if (pageObjectResult.success) {
2208
+ // Return both test code and Page Object code
2209
+ return {
2210
+ content: [{
2211
+ type: 'text',
2212
+ text: JSON.stringify({
2213
+ success: true,
2214
+ testCode: testCode,
2215
+ pageObjectCode: pageObjectResult.code,
2216
+ pageObjectClassName: pageObjectResult.className,
2217
+ framework: args.language,
2218
+ scenarioName: args.scenarioName,
2219
+ url: pageObjectResult.url,
2220
+ elementCount: pageObjectResult.elementCount
2221
+ }, null, 2)
2222
+ }]
2223
+ };
2224
+ } else {
2225
+ // Page Object generation failed, return test code only with warning
2226
+ return {
2227
+ content: [{
2228
+ type: 'text',
2229
+ text: JSON.stringify({
2230
+ success: true,
2231
+ testCode: testCode,
2232
+ pageObjectCode: null,
2233
+ warning: 'Page Object generation failed: ' + (pageObjectResult.error || 'Unknown error')
2234
+ }, null, 2)
2235
+ }]
2236
+ };
2237
+ }
2238
+ } catch (error) {
2239
+ // Page Object generation failed, return test code only with error
2240
+ return {
2241
+ content: [{
2242
+ type: 'text',
2243
+ text: JSON.stringify({
2244
+ success: true,
2245
+ testCode: testCode,
2246
+ pageObjectCode: null,
2247
+ warning: 'Page Object generation error: ' + error.message
2248
+ }, null, 2)
2249
+ }]
2250
+ };
2251
+ }
2252
+ }
3654
2253
 
2254
+ // Default: return test code only
3655
2255
  return {
3656
2256
  content: [{
3657
2257
  type: 'text',
3658
- text: code
2258
+ text: testCode
3659
2259
  }]
3660
2260
  };
3661
2261
  }
3662
2262
 
2263
+ if (name === "generatePageObject") {
2264
+ const page = await getLastOpenPage();
2265
+
2266
+ const options = {
2267
+ className: args.className || null,
2268
+ framework: args.framework || 'playwright-typescript',
2269
+ includeComments: args.includeComments !== false,
2270
+ groupElements: args.groupElements !== false
2271
+ };
2272
+
2273
+ const result = await generatePageObject(page, options);
2274
+
2275
+ if (result.success) {
2276
+ return {
2277
+ content: [{
2278
+ type: 'text',
2279
+ text: JSON.stringify({
2280
+ success: true,
2281
+ className: result.className,
2282
+ url: result.url,
2283
+ title: result.title,
2284
+ elementCount: result.elementCount,
2285
+ framework: result.framework,
2286
+ code: result.code
2287
+ }, null, 2)
2288
+ }]
2289
+ };
2290
+ } else {
2291
+ return {
2292
+ content: [{
2293
+ type: 'text',
2294
+ text: JSON.stringify({
2295
+ error: result.error || 'Failed to generate Page Object'
2296
+ }, null, 2)
2297
+ }],
2298
+ isError: true
2299
+ };
2300
+ }
2301
+ }
2302
+
3663
2303
  return {
3664
2304
  content: [
3665
2305
  {
@@ -3670,17 +2310,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3670
2310
  isError: true,
3671
2311
  };
3672
2312
  } catch (error) {
3673
- return {
3674
- content: [
3675
- {
3676
- type: "text",
3677
- text: `Error: ${error.message}`,
3678
- },
3679
- ],
3680
- isError: true,
3681
- };
2313
+ // Re-throw to be caught by outer executeToolWithTimeout wrapper
2314
+ throw error;
3682
2315
  }
3683
- });
2316
+ }
3684
2317
 
3685
2318
  // Start server
3686
2319
  async function main() {