chrometools-mcp 3.2.6 → 3.3.6
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/CHANGELOG.md +212 -0
- package/README.md +14 -5
- package/angular-tools.js +9 -3
- package/bridge/bridge-client.js +62 -7
- package/bridge/bridge-service.js +80 -2
- package/browser/page-manager.js +31 -0
- package/extension/background.js +117 -0
- package/extension/content.js +3 -1
- package/extension/manifest.json +2 -1
- package/index.js +213 -45
- package/models/TextInputModel.js +56 -5
- package/models/index.js +20 -6
- package/package.json +1 -1
- package/pom/apom-tree-converter.js +19 -7
- package/server/tool-schemas.js +3 -0
- package/utils/hints-generator.js +46 -4
- package/utils/post-click-diagnostics.js +376 -0
- /package/{publish_output.txt → nul} +0 -0
package/extension/background.js
CHANGED
|
@@ -21,6 +21,10 @@ let nativePort = null;
|
|
|
21
21
|
let isConnected = false;
|
|
22
22
|
const tabsState = new Map(); // tabId -> {url, title, active, windowId}
|
|
23
23
|
|
|
24
|
+
// Network requests storage (persists across page navigations)
|
|
25
|
+
const networkRequests = new Map(); // requestId -> request info
|
|
26
|
+
const MAX_NETWORK_REQUESTS = 500;
|
|
27
|
+
|
|
24
28
|
// Recorder state (persisted in storage)
|
|
25
29
|
let recorderState = {
|
|
26
30
|
isRecording: false,
|
|
@@ -152,6 +156,24 @@ function handleBridgeMessage(message) {
|
|
|
152
156
|
sendToBridge({ type: 'pong', requestId: message.requestId });
|
|
153
157
|
break;
|
|
154
158
|
|
|
159
|
+
case 'get_network_requests':
|
|
160
|
+
// Return recent network requests
|
|
161
|
+
const requests = Array.from(networkRequests.values());
|
|
162
|
+
sendToBridge({
|
|
163
|
+
type: 'network_requests',
|
|
164
|
+
payload: { requests },
|
|
165
|
+
requestId: message.requestId
|
|
166
|
+
});
|
|
167
|
+
break;
|
|
168
|
+
|
|
169
|
+
case 'clear_network_requests':
|
|
170
|
+
networkRequests.clear();
|
|
171
|
+
sendToBridge({
|
|
172
|
+
type: 'network_requests_cleared',
|
|
173
|
+
requestId: message.requestId
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
|
|
155
177
|
default:
|
|
156
178
|
console.log('[ChromeTools] Unknown message from Bridge:', message.type);
|
|
157
179
|
}
|
|
@@ -293,6 +315,101 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
|
293
315
|
}
|
|
294
316
|
});
|
|
295
317
|
|
|
318
|
+
// ============================================
|
|
319
|
+
// Network Request Tracking (via webRequest API)
|
|
320
|
+
// Persists across page navigations!
|
|
321
|
+
// ============================================
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Clean old network requests to prevent memory leak
|
|
325
|
+
*/
|
|
326
|
+
function cleanOldNetworkRequests() {
|
|
327
|
+
if (networkRequests.size > MAX_NETWORK_REQUESTS) {
|
|
328
|
+
const entries = Array.from(networkRequests.entries());
|
|
329
|
+
const removeCount = entries.length - MAX_NETWORK_REQUESTS;
|
|
330
|
+
for (let i = 0; i < removeCount; i++) {
|
|
331
|
+
networkRequests.delete(entries[i][0]);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Track when request starts (captures POST/PUT/PATCH before navigation)
|
|
337
|
+
chrome.webRequest.onBeforeRequest.addListener(
|
|
338
|
+
(details) => {
|
|
339
|
+
// Only track mutation requests (POST, PUT, PATCH, DELETE)
|
|
340
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(details.method)) {
|
|
341
|
+
const requestInfo = {
|
|
342
|
+
requestId: details.requestId,
|
|
343
|
+
url: details.url,
|
|
344
|
+
method: details.method,
|
|
345
|
+
type: details.type, // main_frame, xmlhttprequest, etc.
|
|
346
|
+
tabId: details.tabId,
|
|
347
|
+
timestamp: Date.now(),
|
|
348
|
+
status: 'pending',
|
|
349
|
+
initiator: details.initiator || null
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
networkRequests.set(details.requestId, requestInfo);
|
|
353
|
+
cleanOldNetworkRequests();
|
|
354
|
+
|
|
355
|
+
// Send to Bridge
|
|
356
|
+
sendToBridge({
|
|
357
|
+
type: 'network_request_started',
|
|
358
|
+
payload: requestInfo
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
console.log(`[ChromeTools] Network: ${details.method} ${details.url}`);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{ urls: ['<all_urls>'] }
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Track when request completes
|
|
368
|
+
chrome.webRequest.onCompleted.addListener(
|
|
369
|
+
(details) => {
|
|
370
|
+
const request = networkRequests.get(details.requestId);
|
|
371
|
+
if (request) {
|
|
372
|
+
request.status = details.statusCode;
|
|
373
|
+
request.statusText = details.statusLine;
|
|
374
|
+
request.completedAt = Date.now();
|
|
375
|
+
|
|
376
|
+
// Send update to Bridge
|
|
377
|
+
sendToBridge({
|
|
378
|
+
type: 'network_request_completed',
|
|
379
|
+
payload: {
|
|
380
|
+
requestId: details.requestId,
|
|
381
|
+
status: details.statusCode,
|
|
382
|
+
statusText: details.statusLine
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
console.log(`[ChromeTools] Network completed: ${request.method} ${request.url} -> ${details.statusCode}`);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
{ urls: ['<all_urls>'] }
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Track request errors
|
|
393
|
+
chrome.webRequest.onErrorOccurred.addListener(
|
|
394
|
+
(details) => {
|
|
395
|
+
const request = networkRequests.get(details.requestId);
|
|
396
|
+
if (request) {
|
|
397
|
+
request.status = 'failed';
|
|
398
|
+
request.error = details.error;
|
|
399
|
+
request.completedAt = Date.now();
|
|
400
|
+
|
|
401
|
+
sendToBridge({
|
|
402
|
+
type: 'network_request_failed',
|
|
403
|
+
payload: {
|
|
404
|
+
requestId: details.requestId,
|
|
405
|
+
error: details.error
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
{ urls: ['<all_urls>'] }
|
|
411
|
+
);
|
|
412
|
+
|
|
296
413
|
// ============================================
|
|
297
414
|
// Icon Management
|
|
298
415
|
// ============================================
|
package/extension/content.js
CHANGED
|
@@ -149,8 +149,10 @@
|
|
|
149
149
|
|
|
150
150
|
return Array.from(element.classList).filter(cls => {
|
|
151
151
|
if (cls.length < 2) return false;
|
|
152
|
+
// Filter out Tailwind classes with special characters (colons, slashes, brackets)
|
|
153
|
+
if (/[:\/\[\]]/.test(cls)) return false;
|
|
152
154
|
return !unstablePatterns.some(p => p.test(cls));
|
|
153
|
-
}).slice(0, 3);
|
|
155
|
+
}).slice(0, 3).map(cls => CSS.escape(cls));
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
function getNthChildPath(element, maxDepth = 5) {
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "ChromeTools MCP",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.3.6",
|
|
5
5
|
"description": "Tab tracking and scenario recording for chrometools-mcp",
|
|
6
6
|
|
|
7
7
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxLLqg7Nu1h9ogRVgQoVMRPv8Jp7uRJugZSGUh++Niq0xm3khJefBuJ3L0dSG6xb9tkjTdgqUyg81VUgJBDVw9Bxu6iz1uL17VnEGHDZKe5wpsEpG8o6ZsTWtKRDeoxmkCGSOSDsh/ihlJe8mFaqpBYz6RBaO28R89TNobVhSobTQPB1ptyEND7W7JnsnMOiMcTo9l6j9HrIHLoHj7tO42DHNI4tEyLxI7C6R3i5dLIdwwxJMj0Hhrx4Ncmh24AzPyZypxVvpa1V7HP3sAXGBoUjLd/SEaY8j50lnaIQI3AkYv86pS9l6EZ6y3XCuW7C7W9guTTL/7ZNawYoE2bJ1HwIDAQAB",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"scripting",
|
|
13
13
|
"storage",
|
|
14
14
|
"webNavigation",
|
|
15
|
+
"webRequest",
|
|
15
16
|
"nativeMessaging"
|
|
16
17
|
],
|
|
17
18
|
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import {Server} from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
4
|
import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -54,6 +54,8 @@ import {getToolsFromGroups, getAllGroupNames} from './server/tool-groups.js';
|
|
|
54
54
|
import {executeElementAction} from './utils/element-actions.js';
|
|
55
55
|
// Import hints generator
|
|
56
56
|
import {generateClickHints, generateNavigationHints} from './utils/hints-generator.js';
|
|
57
|
+
// Import post-click diagnostics
|
|
58
|
+
import {runPostClickDiagnostics, formatDiagnosticsForAI} from './utils/post-click-diagnostics.js';
|
|
57
59
|
|
|
58
60
|
// Import Recorder modules
|
|
59
61
|
// Note: injectRecorder removed - now using Chrome Extension
|
|
@@ -313,12 +315,22 @@ async function executeToolInternal(name, args) {
|
|
|
313
315
|
|
|
314
316
|
if (name === "openBrowser") {
|
|
315
317
|
const validatedArgs = schemas.OpenBrowserSchema.parse(args);
|
|
318
|
+
|
|
319
|
+
// Capture timestamp BEFORE opening for diagnostics
|
|
320
|
+
const beforeOpenTimestamp = Date.now();
|
|
321
|
+
|
|
316
322
|
const page = await getOrCreatePage(validatedArgs.url);
|
|
317
323
|
const title = await page.title();
|
|
318
324
|
|
|
325
|
+
// Run post-navigation diagnostics (same as navigateTo)
|
|
326
|
+
const diagnostics = await runPostClickDiagnostics(page, beforeOpenTimestamp);
|
|
327
|
+
|
|
319
328
|
// Generate AI hints
|
|
320
329
|
const hints = await generateNavigationHints(page, validatedArgs.url);
|
|
321
330
|
|
|
331
|
+
// Format diagnostics for output
|
|
332
|
+
const diagnosticsText = formatDiagnosticsForAI(diagnostics);
|
|
333
|
+
|
|
322
334
|
// Check if extension is connected
|
|
323
335
|
const extensionConnected = isExtensionConnected();
|
|
324
336
|
const usedExistingChrome = isConnectedToExistingChrome();
|
|
@@ -329,22 +341,84 @@ async function executeToolInternal(name, args) {
|
|
|
329
341
|
extensionNote = `\n\n⚠️ EXTENSION NOT CONNECTED\nConnected to existing Chrome - extension needs manual installation.\n${instructions.installSteps.join('\n')}\n\nAlternative: ${instructions.alternativeFix}`;
|
|
330
342
|
}
|
|
331
343
|
|
|
344
|
+
let hintsText = '\n\n** AI HINTS **';
|
|
345
|
+
hintsText += `\nPage type: ${hints.pageType}`;
|
|
346
|
+
if (hints.availableActions.length > 0) {
|
|
347
|
+
hintsText += `\nAvailable actions: ${hints.availableActions.join(', ')}`;
|
|
348
|
+
}
|
|
349
|
+
if (hints.suggestedNext.length > 0) {
|
|
350
|
+
hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
332
353
|
return {
|
|
333
354
|
content: [
|
|
334
355
|
{
|
|
335
356
|
type: "text",
|
|
336
|
-
text: `Browser opened successfully!\nURL: ${validatedArgs.url}\nPage title: ${title}\n\nBrowser remains open for interaction
|
|
357
|
+
text: `Browser opened successfully!\nURL: ${validatedArgs.url}\nPage title: ${title}\n\nBrowser remains open for interaction.${hintsText}${diagnosticsText}${extensionNote}`,
|
|
337
358
|
},
|
|
338
359
|
],
|
|
339
360
|
};
|
|
340
361
|
}
|
|
341
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Check if identifier looks like an APOM ID (e.g., button_8, input_4, form_1)
|
|
365
|
+
*/
|
|
366
|
+
function isApomIdPattern(identifier) {
|
|
367
|
+
return /^[a-z]+_\d+$/.test(identifier);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Quick element registration - runs APOM analysis and registers elements
|
|
372
|
+
*/
|
|
373
|
+
async function quickRegisterElements(page) {
|
|
374
|
+
await page.evaluate((apomTreeConverterCode, selectorResolverCode) => {
|
|
375
|
+
// Inject utilities
|
|
376
|
+
if (typeof buildAPOMTree === 'undefined') {
|
|
377
|
+
eval(apomTreeConverterCode);
|
|
378
|
+
}
|
|
379
|
+
if (typeof registerElements === 'undefined') {
|
|
380
|
+
eval(selectorResolverCode);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Build APOM tree (interactive only for speed)
|
|
384
|
+
const apomData = buildAPOMTree(true);
|
|
385
|
+
|
|
386
|
+
// Flatten and register elements
|
|
387
|
+
const elementsArray = [];
|
|
388
|
+
function collectElements(node) {
|
|
389
|
+
if (!node) return;
|
|
390
|
+
if (node.id && node.selector) {
|
|
391
|
+
elementsArray.push({
|
|
392
|
+
id: node.id,
|
|
393
|
+
selector: node.selector,
|
|
394
|
+
metadata: { type: node.type, tag: node.tag }
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (node.children) {
|
|
398
|
+
node.children.forEach(child => collectElements(child));
|
|
399
|
+
}
|
|
400
|
+
// Also check for container keys (div_container_0, etc.)
|
|
401
|
+
Object.keys(node).forEach(key => {
|
|
402
|
+
if (Array.isArray(node[key])) {
|
|
403
|
+
node[key].forEach(child => collectElements(child));
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
collectElements(apomData.tree);
|
|
408
|
+
|
|
409
|
+
if (typeof registerElements !== 'undefined') {
|
|
410
|
+
registerElements(elementsArray);
|
|
411
|
+
}
|
|
412
|
+
}, apomTreeConverter, selectorResolver);
|
|
413
|
+
}
|
|
414
|
+
|
|
342
415
|
/**
|
|
343
416
|
* Helper: Resolve selector (ID or CSS selector)
|
|
344
417
|
* Injects selector-resolver and resolves element identifier
|
|
418
|
+
* Auto-refreshes element registry if APOM ID not found
|
|
345
419
|
*/
|
|
346
|
-
async function resolveSelector(page, identifier) {
|
|
347
|
-
|
|
420
|
+
async function resolveSelector(page, identifier, timeoutMs = 5000) {
|
|
421
|
+
const tryResolve = () => page.evaluate((id, selectorResolverCode) => {
|
|
348
422
|
// Inject selector resolver if not already loaded
|
|
349
423
|
if (typeof resolveSelector === 'undefined') {
|
|
350
424
|
eval(selectorResolverCode);
|
|
@@ -357,6 +431,20 @@ async function executeToolInternal(name, args) {
|
|
|
357
431
|
found: document.querySelector(resolved.selector) !== null
|
|
358
432
|
};
|
|
359
433
|
}, identifier, selectorResolver);
|
|
434
|
+
|
|
435
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
436
|
+
setTimeout(() => reject(new Error(`resolveSelector timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
let resolved = await Promise.race([tryResolve(), timeoutPromise]);
|
|
440
|
+
|
|
441
|
+
// Auto-refresh: if looks like APOM ID but not found, re-register elements and retry
|
|
442
|
+
if (!resolved.found && isApomIdPattern(identifier)) {
|
|
443
|
+
await quickRegisterElements(page);
|
|
444
|
+
resolved = await Promise.race([tryResolve(), timeoutPromise]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return resolved;
|
|
360
448
|
}
|
|
361
449
|
|
|
362
450
|
if (name === "click") {
|
|
@@ -380,26 +468,58 @@ async function executeToolInternal(name, args) {
|
|
|
380
468
|
throw new Error(`Element not found: ${identifier}`);
|
|
381
469
|
}
|
|
382
470
|
|
|
383
|
-
//
|
|
471
|
+
// Capture timestamp and URL BEFORE click for diagnostics
|
|
472
|
+
const beforeClickTimestamp = Date.now();
|
|
473
|
+
const urlBeforeClick = page.url();
|
|
474
|
+
|
|
475
|
+
// ALWAYS scroll to element first to ensure it's in viewport
|
|
476
|
+
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
477
|
+
|
|
478
|
+
// Click with timeout to prevent hanging on navigation
|
|
479
|
+
const clickWithTimeout = async (timeoutMs = 5000) => {
|
|
480
|
+
const clickPromise = element.click().catch(() => {
|
|
481
|
+
// If Puppeteer click fails, fallback to JS click
|
|
482
|
+
return element.evaluate(el => el.click());
|
|
483
|
+
});
|
|
484
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
485
|
+
setTimeout(() => reject(new Error('click timeout')), timeoutMs)
|
|
486
|
+
);
|
|
487
|
+
return Promise.race([clickPromise, timeoutPromise]).catch(() => {
|
|
488
|
+
// If click times out, try JS click as last resort
|
|
489
|
+
return element.evaluate(el => el.click());
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
await clickWithTimeout();
|
|
494
|
+
|
|
495
|
+
// NEW POST-CLICK PATTERN:
|
|
496
|
+
// 1. Run post-click diagnostics (waits for network requests within 200ms, max 10s timeout)
|
|
497
|
+
let diagnostics;
|
|
384
498
|
try {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
499
|
+
diagnostics = await runPostClickDiagnostics(page, beforeClickTimestamp, {
|
|
500
|
+
skipNetworkWait: validatedArgs.skipNetworkWait,
|
|
501
|
+
networkWaitTimeout: validatedArgs.networkWaitTimeout,
|
|
502
|
+
urlBeforeAction: urlBeforeClick
|
|
503
|
+
});
|
|
504
|
+
} catch (diagError) {
|
|
505
|
+
// Diagnostics may fail if page navigated - create minimal diagnostics
|
|
506
|
+
diagnostics = {
|
|
507
|
+
networkActivity: { trackedRequests: [], bridgeRequests: [], stillPending: 0, pendingRequests: [] },
|
|
508
|
+
navigation: { from: urlBeforeClick, to: page.url(), likelyFormSubmit: true },
|
|
509
|
+
errors: { consoleErrors: [], networkErrors: [], totalErrors: 0 },
|
|
510
|
+
hasErrors: false
|
|
511
|
+
};
|
|
397
512
|
}
|
|
398
|
-
await new Promise(resolve => setTimeout(resolve, validatedArgs.waitAfter || 1500));
|
|
399
513
|
|
|
400
|
-
// Generate AI hints after click
|
|
401
|
-
|
|
514
|
+
// 2. Generate AI hints after click
|
|
515
|
+
let hints;
|
|
516
|
+
try {
|
|
517
|
+
hints = await generateClickHints(page, identifier);
|
|
518
|
+
} catch (hintsError) {
|
|
519
|
+
hints = { modalOpened: false, newElements: [], suggestedNext: [] };
|
|
520
|
+
}
|
|
402
521
|
|
|
522
|
+
// 3. Format output with hints and diagnostics
|
|
403
523
|
let hintsText = '\n\n** AI HINTS **';
|
|
404
524
|
if (hints.modalOpened) hintsText += '\nModal opened - interact with it or close';
|
|
405
525
|
if (hints.newElements.length > 0) {
|
|
@@ -409,8 +529,11 @@ async function executeToolInternal(name, args) {
|
|
|
409
529
|
hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
|
|
410
530
|
}
|
|
411
531
|
|
|
532
|
+
// 4. Add diagnostics to output
|
|
533
|
+
const diagnosticsText = formatDiagnosticsForAI(diagnostics);
|
|
534
|
+
|
|
412
535
|
const content = [
|
|
413
|
-
{ type: "text", text: `Clicked: ${identifier}${hintsText}` }
|
|
536
|
+
{ type: "text", text: `Clicked: ${identifier}${hintsText}${diagnosticsText}` }
|
|
414
537
|
];
|
|
415
538
|
|
|
416
539
|
// Only add screenshot if requested
|
|
@@ -433,36 +556,60 @@ async function executeToolInternal(name, args) {
|
|
|
433
556
|
if (name === "type") {
|
|
434
557
|
const validatedArgs = schemas.TypeSchema.parse(args);
|
|
435
558
|
const page = await getLastOpenPage();
|
|
559
|
+
const timeout = validatedArgs.timeout || 30000;
|
|
436
560
|
|
|
437
|
-
//
|
|
438
|
-
const
|
|
561
|
+
// Wrap operation in timeout
|
|
562
|
+
const typeOperation = async () => {
|
|
563
|
+
// Get identifier (id or selector)
|
|
564
|
+
const identifier = validatedArgs.id || validatedArgs.selector;
|
|
439
565
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
566
|
+
// Resolve selector (supports both APOM ID and CSS selector)
|
|
567
|
+
const resolved = await resolveSelector(page, identifier);
|
|
568
|
+
if (!resolved.found) {
|
|
569
|
+
throw new Error(`Element not found: ${identifier}${resolved.isPageObjectId ? ' (APOM ID)' : ' (CSS selector)'}`);
|
|
570
|
+
}
|
|
445
571
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
572
|
+
const element = await page.$(resolved.selector);
|
|
573
|
+
if (!element) {
|
|
574
|
+
throw new Error(`Element not found: ${identifier}`);
|
|
575
|
+
}
|
|
450
576
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
delay: validatedArgs.delay !== undefined ? validatedArgs.delay : 30,
|
|
455
|
-
clearFirst: validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true,
|
|
456
|
-
};
|
|
577
|
+
// ALWAYS scroll to element first to ensure it's in viewport
|
|
578
|
+
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
579
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
457
580
|
|
|
458
|
-
|
|
459
|
-
|
|
581
|
+
// Use input model to handle the element appropriately
|
|
582
|
+
const model = await getInputModel(element, page);
|
|
583
|
+
const options = {
|
|
584
|
+
delay: validatedArgs.delay !== undefined ? validatedArgs.delay : 30,
|
|
585
|
+
clearFirst: validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true,
|
|
586
|
+
};
|
|
460
587
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
588
|
+
await model.setValue(validatedArgs.text, options);
|
|
589
|
+
const description = model.getActionDescription(validatedArgs.text, identifier);
|
|
590
|
+
|
|
591
|
+
// Capture timestamp AFTER typing finishes - requests should start within 200ms of this
|
|
592
|
+
const afterTypeTimestamp = Date.now();
|
|
593
|
+
|
|
594
|
+
// Run diagnostics with 3s timeout for type (shorter than click's 10s)
|
|
595
|
+
const diagnostics = await runPostClickDiagnostics(page, afterTypeTimestamp, {
|
|
596
|
+
networkWaitTimeout: 3000
|
|
597
|
+
});
|
|
598
|
+
const diagnosticsText = formatDiagnosticsForAI(diagnostics);
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
content: [
|
|
602
|
+
{ type: "text", text: `${description}${diagnosticsText}` }
|
|
603
|
+
],
|
|
604
|
+
};
|
|
465
605
|
};
|
|
606
|
+
|
|
607
|
+
// Execute with timeout
|
|
608
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
609
|
+
setTimeout(() => reject(new Error(`Type operation timed out after ${timeout}ms`)), timeout)
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
return Promise.race([typeOperation(), timeoutPromise]);
|
|
466
613
|
}
|
|
467
614
|
|
|
468
615
|
if (name === "getComputedCss") {
|
|
@@ -1343,6 +1490,9 @@ async function executeToolInternal(name, args) {
|
|
|
1343
1490
|
browserOpened = true;
|
|
1344
1491
|
}
|
|
1345
1492
|
|
|
1493
|
+
// Capture timestamp BEFORE navigation for diagnostics
|
|
1494
|
+
const beforeNavTimestamp = Date.now();
|
|
1495
|
+
|
|
1346
1496
|
// Navigate to the new URL (skip if we just created page with this URL)
|
|
1347
1497
|
if (!browserOpened) {
|
|
1348
1498
|
await page.goto(validatedArgs.url, { waitUntil: validatedArgs.waitUntil || 'networkidle2' });
|
|
@@ -1350,17 +1500,32 @@ async function executeToolInternal(name, args) {
|
|
|
1350
1500
|
|
|
1351
1501
|
const title = await page.title();
|
|
1352
1502
|
|
|
1503
|
+
// Run post-navigation diagnostics (same as post-click)
|
|
1504
|
+
const diagnostics = await runPostClickDiagnostics(page, beforeNavTimestamp);
|
|
1505
|
+
|
|
1353
1506
|
// Generate AI hints
|
|
1354
1507
|
const hints = await generateNavigationHints(page, validatedArgs.url);
|
|
1355
1508
|
|
|
1509
|
+
// Format diagnostics for output
|
|
1510
|
+
const diagnosticsText = formatDiagnosticsForAI(diagnostics);
|
|
1511
|
+
|
|
1356
1512
|
const message = browserOpened
|
|
1357
1513
|
? `Browser opened and navigated to: ${validatedArgs.url}`
|
|
1358
1514
|
: `Navigated to: ${validatedArgs.url}`;
|
|
1359
1515
|
|
|
1516
|
+
let hintsText = '\n\n** AI HINTS **';
|
|
1517
|
+
hintsText += `\nPage type: ${hints.pageType}`;
|
|
1518
|
+
if (hints.availableActions.length > 0) {
|
|
1519
|
+
hintsText += `\nAvailable actions: ${hints.availableActions.join(', ')}`;
|
|
1520
|
+
}
|
|
1521
|
+
if (hints.suggestedNext.length > 0) {
|
|
1522
|
+
hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1360
1525
|
return {
|
|
1361
1526
|
content: [{
|
|
1362
1527
|
type: "text",
|
|
1363
|
-
text: `${message}\nPage title: ${title}
|
|
1528
|
+
text: `${message}\nPage title: ${title}${hintsText}${diagnosticsText}`
|
|
1364
1529
|
}],
|
|
1365
1530
|
};
|
|
1366
1531
|
}
|
|
@@ -2413,6 +2578,9 @@ Start coding now.`;
|
|
|
2413
2578
|
const extensionConnected = isExtensionConnected();
|
|
2414
2579
|
const debugInfo = getWsDebugInfo();
|
|
2415
2580
|
|
|
2581
|
+
// Always log connection state for debugging
|
|
2582
|
+
console.error(`[chrometools-mcp] enableRecorder check: bridgeConnected=${debugInfo.bridgeConnected}, extensionConnected=${extensionConnected}, wsState=${debugInfo.readyState}`);
|
|
2583
|
+
|
|
2416
2584
|
if (extensionConnected) {
|
|
2417
2585
|
return {
|
|
2418
2586
|
content: [{
|
package/models/TextInputModel.js
CHANGED
|
@@ -7,25 +7,76 @@
|
|
|
7
7
|
|
|
8
8
|
import { BaseInputModel } from './BaseInputModel.js';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Wrap operation with timeout to prevent hanging
|
|
12
|
+
*/
|
|
13
|
+
async function withTimeout(operation, timeoutMs, operationName) {
|
|
14
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
15
|
+
setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
16
|
+
);
|
|
17
|
+
return Promise.race([operation(), timeoutPromise]);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export class TextInputModel extends BaseInputModel {
|
|
11
21
|
static get inputTypes() {
|
|
12
22
|
return ['text', 'email', 'tel', 'password', 'search', 'url', 'number', null];
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
* Type text into the input using keyboard simulation
|
|
26
|
+
* Type text into the input using keyboard simulation with JS fallback
|
|
17
27
|
* @param {string} value - Text to type
|
|
18
28
|
* @param {object} options - { delay, clearFirst }
|
|
19
29
|
*/
|
|
20
30
|
async setValue(value, options = {}) {
|
|
21
31
|
const { delay = 0, clearFirst = true } = options;
|
|
32
|
+
const opTimeout = 5000; // 5s timeout per operation
|
|
33
|
+
|
|
34
|
+
// Method 1: Try Puppeteer typing (works for most cases)
|
|
35
|
+
try {
|
|
36
|
+
// Focus and clear using JS (most reliable)
|
|
37
|
+
await withTimeout(
|
|
38
|
+
() => this.element.evaluate((el, shouldClear) => {
|
|
39
|
+
el.focus();
|
|
40
|
+
el.click();
|
|
41
|
+
if (shouldClear) {
|
|
42
|
+
el.select(); // Select all text
|
|
43
|
+
}
|
|
44
|
+
}, clearFirst),
|
|
45
|
+
opTimeout,
|
|
46
|
+
'focus-and-select'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Small delay to ensure focus is established
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
51
|
+
|
|
52
|
+
// Type the new value
|
|
53
|
+
const typeTimeout = Math.max(opTimeout, value.length * delay + 5000);
|
|
54
|
+
await withTimeout(
|
|
55
|
+
() => this.element.type(value, { delay }),
|
|
56
|
+
typeTimeout,
|
|
57
|
+
'type'
|
|
58
|
+
);
|
|
22
59
|
|
|
23
|
-
|
|
24
|
-
await this.element.
|
|
25
|
-
|
|
60
|
+
// Verify the value was set
|
|
61
|
+
const actualValue = await this.element.evaluate(el => el.value);
|
|
62
|
+
if (actualValue.includes(value)) {
|
|
63
|
+
return; // Success
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Fall through to JS method
|
|
26
67
|
}
|
|
27
68
|
|
|
28
|
-
|
|
69
|
+
// Method 2: Fallback to direct JS value setting
|
|
70
|
+
await withTimeout(
|
|
71
|
+
() => this.element.evaluate((el, newValue) => {
|
|
72
|
+
el.focus();
|
|
73
|
+
el.value = newValue;
|
|
74
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
75
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
76
|
+
}, value),
|
|
77
|
+
opTimeout,
|
|
78
|
+
'js-set-value'
|
|
79
|
+
);
|
|
29
80
|
}
|
|
30
81
|
|
|
31
82
|
getActionDescription(value, identifier) {
|
package/models/index.js
CHANGED
|
@@ -38,18 +38,32 @@ const MODEL_REGISTRY = [
|
|
|
38
38
|
TextInputModel, // Default fallback for text-like inputs
|
|
39
39
|
];
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Wrap operation with timeout to prevent hanging
|
|
43
|
+
*/
|
|
44
|
+
async function withTimeout(operation, timeoutMs, operationName) {
|
|
45
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
46
|
+
setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
47
|
+
);
|
|
48
|
+
return Promise.race([operation(), timeoutPromise]);
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
/**
|
|
42
52
|
* Factory class for creating appropriate input models
|
|
43
53
|
*/
|
|
44
54
|
export class InputModelFactory {
|
|
45
55
|
/**
|
|
46
|
-
* Get element info (tagName, inputType)
|
|
56
|
+
* Get element info (tagName, inputType) with timeout
|
|
47
57
|
*/
|
|
48
|
-
static async getElementInfo(element) {
|
|
49
|
-
return await
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
static async getElementInfo(element, timeoutMs = 5000) {
|
|
59
|
+
return await withTimeout(
|
|
60
|
+
() => element.evaluate(el => ({
|
|
61
|
+
tagName: el.tagName.toLowerCase(),
|
|
62
|
+
inputType: el.type?.toLowerCase() || null,
|
|
63
|
+
})),
|
|
64
|
+
timeoutMs,
|
|
65
|
+
'getElementInfo'
|
|
66
|
+
);
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrometools-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.6",
|
|
4
4
|
"description": "MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, UI framework detection (MUI, Ant Design, etc.), Page Object support, visual testing, Figma comparison. Works seamlessly in WSL, Linux, macOS, and Windows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|