chrometools-mcp 3.5.4 → 3.5.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.
@@ -101,8 +101,14 @@ function cleanOldNetworkRequests() {
101
101
  // Page analysis cache
102
102
  export const pageAnalysisCache = new Map();
103
103
 
104
+ /** Get the CDP client used for network monitoring on a page (for Network.getResponseBody) */
105
+ export function getNetworkCDPClient(page) {
106
+ return networkCDPClients.get(page) || null;
107
+ }
108
+
104
109
  // Track pages with network monitoring to prevent duplicate setup
105
110
  const pagesWithNetworkMonitoring = new WeakSet();
111
+ const networkCDPClients = new WeakMap();
106
112
 
107
113
  /**
108
114
  * Setup network monitoring with auto-reinitialization on navigation
@@ -117,6 +123,7 @@ export async function setupNetworkMonitoring(page) {
117
123
 
118
124
  const client = await page.target().createCDPSession();
119
125
  await client.send('Network.enable');
126
+ networkCDPClients.set(page, client);
120
127
 
121
128
  client.on('Network.requestWillBeSent', (event) => {
122
129
  const timestamp = new Date().toISOString();
package/index.js CHANGED
@@ -36,7 +36,8 @@ import {
36
36
  getAndClearNewTabEvents,
37
37
  getAllPages,
38
38
  switchToPage,
39
- connectToTabByUrl
39
+ connectToTabByUrl,
40
+ getNetworkCDPClient
40
41
  } from './browser/page-manager.js';
41
42
 
42
43
  // Import image processing utilities
@@ -402,8 +403,13 @@ async function executeToolInternal(name, args) {
402
403
  * Quick element registration - runs APOM analysis and registers elements
403
404
  */
404
405
  async function quickRegisterElements(page) {
405
- await page.evaluate((apomTreeConverterCode, selectorResolverCode) => {
406
- // Inject utilities
406
+ await page.evaluate((apomTreeConverterCode, selectorResolverCode, modelsCode) => {
407
+ // Inject utilities. Models must be loaded BEFORE buildAPOMTree because
408
+ // initializeModelRegistry() inside it references window.ModelRegistry. After a
409
+ // navigation the browser window is wiped, so we always re-eval if missing.
410
+ if (typeof window.ModelRegistry === 'undefined') {
411
+ eval(modelsCode);
412
+ }
407
413
  if (typeof buildAPOMTree === 'undefined') {
408
414
  eval(apomTreeConverterCode);
409
415
  }
@@ -440,7 +446,7 @@ async function executeToolInternal(name, args) {
440
446
  if (typeof registerElements !== 'undefined') {
441
447
  registerElements(elementsArray);
442
448
  }
443
- }, apomTreeConverter, selectorResolver);
449
+ }, apomTreeConverter, selectorResolver, elementModelBase + '\n' + elementModels + '\n' + modelRegistry);
444
450
  }
445
451
 
446
452
  /**
@@ -469,15 +475,56 @@ async function executeToolInternal(name, args) {
469
475
 
470
476
  let resolved = await Promise.race([tryResolve(), timeoutPromise]);
471
477
 
472
- // Auto-refresh: if looks like APOM ID but not found, re-register elements and retry
478
+ // Auto-refresh: if looks like APOM ID but not found, re-register elements and retry.
479
+ // quickRegisterElements re-injects ModelRegistry — if that itself fails (e.g. ReferenceError
480
+ // from a partially-loaded browser context), surface a clear "call analyzePage" message
481
+ // instead of leaking the raw browser error.
473
482
  if (!resolved.found && isApomIdPattern(identifier)) {
474
- await quickRegisterElements(page);
483
+ try {
484
+ await quickRegisterElements(page);
485
+ } catch (e) {
486
+ if (/ModelRegistry is not defined/.test(String(e?.message || e))) {
487
+ throw new Error(
488
+ `APOM registry stale after navigation/reload. Call analyzePage() to refresh element ids before reusing "${identifier}".`
489
+ );
490
+ }
491
+ throw e;
492
+ }
475
493
  resolved = await Promise.race([tryResolve(), timeoutPromise]);
476
494
  }
477
495
 
478
496
  return resolved;
479
497
  }
480
498
 
499
+ /**
500
+ * Snapshot of all APOM ids on the page → { id: { tag, type, text } }
501
+ * Used by click({ autoAnalyzeAfter: true }) to compute pre/post deltas.
502
+ */
503
+ async function getApomSnapshot(page) {
504
+ return await page.evaluate((apomCode, modelsCode) => {
505
+ if (typeof window.ModelRegistry === 'undefined') eval(modelsCode);
506
+ if (typeof buildAPOMTree === 'undefined') eval(apomCode);
507
+ const apom = buildAPOMTree(true);
508
+ const map = {};
509
+ function walk(node) {
510
+ if (!node || typeof node !== 'object') return;
511
+ if (node.id) {
512
+ map[node.id] = {
513
+ tag: node.tag,
514
+ type: node.type,
515
+ text: (node.metadata && node.metadata.text ? String(node.metadata.text).substring(0, 60) : null)
516
+ };
517
+ }
518
+ if (Array.isArray(node.children)) node.children.forEach(walk);
519
+ for (const k of Object.keys(node)) {
520
+ if (Array.isArray(node[k])) node[k].forEach(walk);
521
+ }
522
+ }
523
+ walk(apom.tree);
524
+ return map;
525
+ }, apomTreeConverter, elementModelBase + '\n' + elementModels + '\n' + modelRegistry);
526
+ }
527
+
481
528
  if (name === "click") {
482
529
  const validatedArgs = schemas.ClickSchema.parse(args);
483
530
  const page = await getLastOpenPage();
@@ -499,13 +546,61 @@ async function executeToolInternal(name, args) {
499
546
  throw new Error(`Element not found: ${identifier}`);
500
547
  }
501
548
 
549
+ // Pre-click APOM snapshot (only if delta is requested) — captures every id
550
+ // currently in the tree so we can diff the post-click state.
551
+ let preSnap = null;
552
+ if (validatedArgs.autoAnalyzeAfter) {
553
+ preSnap = await getApomSnapshot(page);
554
+ }
555
+
502
556
  // Use shared click action handler
503
- return await executeClickAction(page, element, {
557
+ const clickResult = await executeClickAction(page, element, {
504
558
  identifier,
505
559
  screenshot: validatedArgs.screenshot,
506
560
  skipNetworkWait: validatedArgs.skipNetworkWait,
507
- networkWaitTimeout: validatedArgs.networkWaitTimeout
561
+ networkWaitTimeout: validatedArgs.networkWaitTimeout,
562
+ waitForSelector: validatedArgs.waitForSelector,
563
+ waitTimeoutMs: validatedArgs.waitTimeoutMs
508
564
  });
565
+
566
+ // Post-click APOM delta: re-register new ids so callers can immediately
567
+ // use them in follow-up click/type calls without an extra analyzePage call.
568
+ if (validatedArgs.autoAnalyzeAfter && preSnap) {
569
+ try {
570
+ await quickRegisterElements(page);
571
+ const postSnap = await getApomSnapshot(page);
572
+ const added = Object.keys(postSnap).filter(id => !preSnap[id]);
573
+ const removed = Object.keys(preSnap).filter(id => !postSnap[id]);
574
+
575
+ let deltaText = '\n\n** APOM DELTA **';
576
+ if (added.length === 0 && removed.length === 0) {
577
+ deltaText += '\nNo APOM changes detected after click';
578
+ } else {
579
+ if (added.length > 0) {
580
+ const sample = added.slice(0, 15).map(id => {
581
+ const m = postSnap[id] || {};
582
+ const lab = m.text ? `"${m.text}"` : (m.type || m.tag || '?');
583
+ return `${id}:${lab}`;
584
+ });
585
+ deltaText += `\n+${added.length} appeared: ${sample.join(', ')}${added.length > 15 ? `, ... (${added.length - 15} more)` : ''}`;
586
+ }
587
+ if (removed.length > 0) {
588
+ deltaText += `\n-${removed.length} disappeared`;
589
+ }
590
+ }
591
+
592
+ if (clickResult && Array.isArray(clickResult.content) && clickResult.content[0]) {
593
+ clickResult.content[0].text += deltaText;
594
+ }
595
+ } catch (e) {
596
+ // Delta is best-effort — never let it fail the actual click result
597
+ if (clickResult && Array.isArray(clickResult.content) && clickResult.content[0]) {
598
+ clickResult.content[0].text += `\n\n** APOM DELTA ** unavailable (${e.message})`;
599
+ }
600
+ }
601
+ }
602
+
603
+ return clickResult;
509
604
  };
510
605
 
511
606
  // Execute with timeout
@@ -785,6 +880,24 @@ async function executeToolInternal(name, args) {
785
880
  // Get identifier (id or selector)
786
881
  const identifier = validatedArgs.id || validatedArgs.selector;
787
882
 
883
+ // No identifier → full viewport screenshot. Mirrors processSceenshot pipeline used
884
+ // by element screenshots so the output (JPEG, scaled, base64) stays consistent.
885
+ if (!identifier) {
886
+ const buffer = await page.screenshot({ encoding: 'binary', fullPage: false });
887
+ const processed = await processScreenshot(buffer, {
888
+ maxWidth: validatedArgs.maxWidth !== undefined ? validatedArgs.maxWidth : 1024,
889
+ maxHeight: validatedArgs.maxHeight !== undefined ? validatedArgs.maxHeight : 8000,
890
+ quality: validatedArgs.quality || 40,
891
+ format: validatedArgs.format || 'jpeg',
892
+ });
893
+ return {
894
+ content: [
895
+ { type: 'text', text: 'Viewport screenshot' },
896
+ { type: 'image', data: processed.buffer.toString('base64'), mimeType: processed.mimeType }
897
+ ]
898
+ };
899
+ }
900
+
788
901
  // Resolve selector (supports both APOM ID and CSS selector)
789
902
  const resolved = await resolveSelector(page, identifier);
790
903
  if (!resolved.found) {
@@ -939,17 +1052,34 @@ async function executeToolInternal(name, args) {
939
1052
  const page = await getLastOpenPage();
940
1053
  const timeout = validatedArgs.timeout || 30000;
941
1054
 
1055
+ // Auto-wrap top-level `return` into an async IIFE: `eval('return X')` is an
1056
+ // Illegal return statement in script context, so users had to wrap manually.
1057
+ // Heuristic: rewrite only when the snippet starts with `return ...` (most common
1058
+ // case from QA reports). Skip when the snippet declares a function — that signals
1059
+ // an explicit user-defined scope and an implicit-return result is likely intended.
1060
+ let scriptToRun = validatedArgs.script;
1061
+ const trimmedHead = scriptToRun.replace(/^\s+/, '');
1062
+ if (/^return[\s;]/.test(trimmedHead) && !/\bfunction\s*[\w*(]/.test(scriptToRun)) {
1063
+ scriptToRun = `(async () => { ${scriptToRun} })()`;
1064
+ }
1065
+
942
1066
  // Wrap operation in timeout
943
1067
  const executeOperation = async () => {
944
- const result = await page.evaluate((code) => {
1068
+ // Use page.evaluate with async support — if eval returns a Promise,
1069
+ // await it so async IIFEs work correctly instead of returning {}
1070
+ const result = await page.evaluate(async (code) => {
945
1071
  try {
946
1072
  // eslint-disable-next-line no-eval
947
- const evalResult = eval(code);
1073
+ let evalResult = eval(code);
1074
+ // Await promises (async IIFE, fetch, etc.)
1075
+ if (evalResult && typeof evalResult === 'object' && typeof evalResult.then === 'function') {
1076
+ evalResult = await evalResult;
1077
+ }
948
1078
  return { success: true, result: evalResult };
949
1079
  } catch (error) {
950
1080
  return { success: false, error: error.message };
951
1081
  }
952
- }, validatedArgs.script);
1082
+ }, scriptToRun);
953
1083
 
954
1084
  await new Promise(resolve => setTimeout(resolve, validatedArgs.waitAfter || 500));
955
1085
 
@@ -1083,6 +1213,31 @@ async function executeToolInternal(name, args) {
1083
1213
  }
1084
1214
  };
1085
1215
 
1216
+ // Retrieve response body via CDP using the same session that captured the request
1217
+ let responseBody = undefined;
1218
+ if (req.finishedTimestamp && !req.errorText) {
1219
+ try {
1220
+ const page = await getLastOpenPage();
1221
+ const client = getNetworkCDPClient(page);
1222
+ if (client) {
1223
+ const { body, base64Encoded } = await client.send('Network.getResponseBody', { requestId: req.requestId });
1224
+ if (base64Encoded) {
1225
+ responseBody = `[base64 encoded, ${body.length} chars]`;
1226
+ } else {
1227
+ // Minify JSON responses, truncate large bodies
1228
+ const maxBodySize = 50000;
1229
+ responseBody = body.length > maxBodySize
1230
+ ? minifyJson(body.substring(0, maxBodySize)) + `... [truncated, total ${body.length} chars]`
1231
+ : minifyJson(body);
1232
+ }
1233
+ } else {
1234
+ responseBody = '[unavailable: no network CDP session]';
1235
+ }
1236
+ } catch (e) {
1237
+ responseBody = `[unavailable: ${e.message}]`;
1238
+ }
1239
+ }
1240
+
1086
1241
  const result = {
1087
1242
  requestId: req.requestId,
1088
1243
  url: req.url,
@@ -1098,6 +1253,7 @@ async function executeToolInternal(name, args) {
1098
1253
  headers: req.headers,
1099
1254
  postData: req.postData ? minifyJson(req.postData) : undefined,
1100
1255
  responseHeaders: req.responseHeaders,
1256
+ responseBody,
1101
1257
  mimeType: req.mimeType,
1102
1258
  errorText: req.errorText,
1103
1259
  canceled: req.canceled,
@@ -2361,8 +2517,34 @@ Start coding now.`;
2361
2517
  const page = await getLastOpenPage();
2362
2518
  const pageUrl = page.url();
2363
2519
 
2520
+ // Check for non-HTML pages (JSON, plain text, XML) — return raw content instead of empty tree
2521
+ const contentType = await page.evaluate(() => document.contentType || '');
2522
+ if (contentType && !contentType.includes('html')) {
2523
+ const rawContent = await page.evaluate(() => {
2524
+ // For JSON/text pages, browser wraps content in <pre> inside <body>
2525
+ const pre = document.querySelector('pre');
2526
+ const text = pre ? pre.textContent : document.body?.innerText || '';
2527
+ // Truncate large content
2528
+ const maxSize = 50000;
2529
+ return text.length > maxSize ? text.substring(0, maxSize) + `\n... [truncated, total ${text.length} chars]` : text;
2530
+ });
2531
+
2532
+ return {
2533
+ content: [{
2534
+ type: 'text',
2535
+ text: JSON.stringify({
2536
+ pageId: `page_${Buffer.from(pageUrl).toString('base64').substring(0, 20)}_${Date.now()}`,
2537
+ url: pageUrl,
2538
+ contentType,
2539
+ rawContent,
2540
+ metadata: { totalElements: 0, interactiveCount: 0, nonHtmlPage: true }
2541
+ })
2542
+ }]
2543
+ };
2544
+ }
2545
+
2364
2546
  // APOM Tree format (default) - v2 with tree structure and positioning
2365
- const apomResult = await page.evaluate(async (apomTreeConverterCode, selectorResolverCode, modelsCode, shouldRegister, includeAll, viewportOnly) => {
2547
+ const apomResult = await page.evaluate(async (apomTreeConverterCode, selectorResolverCode, modelsCode, shouldRegister, includeAll, viewportOnly, portalOpts) => {
2366
2548
  // Inject utilities if not already loaded
2367
2549
  if (typeof buildAPOMTree === 'undefined') {
2368
2550
  eval(apomTreeConverterCode);
@@ -2399,7 +2581,7 @@ Start coding now.`;
2399
2581
 
2400
2582
  // Build APOM tree
2401
2583
  // interactiveOnly = !includeAll (if includeAll is true, we want ALL elements)
2402
- const apomData = buildAPOMTree(!includeAll, viewportOnly);
2584
+ const apomData = buildAPOMTree(!includeAll, viewportOnly, portalOpts);
2403
2585
 
2404
2586
  // Register elements in selector resolver if requested
2405
2587
  if (shouldRegister) {
@@ -2432,7 +2614,10 @@ Start coding now.`;
2432
2614
  }
2433
2615
 
2434
2616
  return apomData;
2435
- }, apomTreeConverter, selectorResolver, elementModelBase + '\n' + elementModels + '\n' + modelRegistry, validatedArgs.registerElements !== false, validatedArgs.includeAll || false, validatedArgs.viewportOnly || false);
2617
+ }, apomTreeConverter, selectorResolver, elementModelBase + '\n' + elementModels + '\n' + modelRegistry, validatedArgs.registerElements !== false, validatedArgs.includeAll || false, validatedArgs.viewportOnly || false, {
2618
+ include: validatedArgs.includePortals !== false,
2619
+ selectors: validatedArgs.portalSelectors || undefined
2620
+ });
2436
2621
 
2437
2622
  // Handle diff mode
2438
2623
  if (validatedArgs.diff) {