chrometools-mcp 1.9.1 → 2.3.2

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