chrome-bridge-mcp 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Chrome Bridge
2
2
 
3
- ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-128%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
3
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-131%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
4
4
 
5
5
  **Chrome Bridge is an MCP server that connects Claude Code to your real, logged-in Chrome browser — measured 2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome" extension on a form-filling task, with ~3× the toolset and no paid plan.**
6
6
 
@@ -63,7 +63,7 @@ Claude Code calls `navigate` (returning clickable element refs), `accessibility_
63
63
 
64
64
  ## Token-Efficient Design
65
65
 
66
- - **Opt-in Surface**: 31 core tools cost ≈5.7k tokens of `tools/list`, all 60 cost ≈10.8k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 — MCP annotations on every tool and fuller descriptions added ≈1.8k, deliberately: the win here is fewer round trips, not a smaller prefix (Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on this metric alone it is now the leaner one). Measure it yourself with `npm run measure`.
66
+ - **Opt-in Surface**: 31 core tools cost ≈7.4k tokens of `tools/list`, all 60 cost ≈14.1k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 and roughly doubled in 1.10.0: MCP annotations on every tool, rewritten descriptions, and a documented `.describe()` on all 254 parameters (coverage went from 35% to 100%). The trade is deliberate and it is not free — on the form benchmark it adds ≈8% to the cache-read tokens per turn — but the measured advantage was never prefix size, it was round trips: 2.75× fewer turns. Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on prefix size alone it is the leaner one. Measure ours with `npm run measure`.
67
67
  - **Act-from-Result**: Tools attach a capped preview of interactive elements with short refs. Actions only report a `page_changed` delta when the URL or title actually changes.
68
68
  - **Optimized Media**: Screenshots are downscaled to ≤1568px. Full-page captures are sliced into readable segments.
69
69
  - **Zero-Token Escape Hatches**: The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.9.0",
4
+ "version": "1.10.0",
5
5
  "description": "Bridge your browser to Claude Code: 60 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
6
6
  "homepage_url": "https://github.com/frsorrentino/chrome-bridge",
7
7
  "minimum_chrome_version": "135",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.9.0",
4
+ "version": "1.10.0",
5
5
  "type": "module",
6
6
  "description": "MCP server with 60 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
@@ -81,7 +81,7 @@ export const MessageType = Object.freeze({
81
81
  });
82
82
 
83
83
  // Versione
84
- export const VERSION = '1.9.0';
84
+ export const VERSION = '1.10.0';
85
85
 
86
86
  // Configurazione
87
87
  export const DEFAULT_PORT = 8765;
package/server/tools.js CHANGED
@@ -185,6 +185,18 @@ export const TOOL_CAPS = {
185
185
  files: ['save_page', 'manage_downloads', 'extract_table', 'session_record'],
186
186
  };
187
187
 
188
+ // Parametri ubiqui: un solo testo, così `tab_id` non significa una cosa in un
189
+ // tool e un'altra nel gemello. test/unit/tool-parameters.test.js lo verifica.
190
+ const selectorField = (extra = '') => z.string()
191
+ .describe(('CSS selector; ">>>" pierces shadow DOM. ' + extra).trim());
192
+ const waitAfter = z.enum(['none', 'navigation', 'networkidle']).optional().default('none')
193
+ .describe('Settle before returning: navigation waits for a page load, networkidle for quiet traffic');
194
+
195
+ const tabId = z.number().optional()
196
+ .describe('Target tab; omitted = last tab navigated in this session, else the active one');
197
+ const frameId = z.number().optional()
198
+ .describe('Target iframe id from get_frames; omitted = main frame');
199
+
188
200
  const TOOL_TO_CAP = new Map();
189
201
  for (const [group, names] of Object.entries(TOOL_CAPS)) {
190
202
  for (const n of names) TOOL_TO_CAP.set(n, group);
@@ -472,8 +484,8 @@ export function registerTools(server, wsManager, caps = 'all') {
472
484
  'navigate',
473
485
  'Navigate a Chrome tab to a URL. Returns a preview of interactive elements with refs usable in click/type_text/hover.',
474
486
  {
475
- url: z.string(),
476
- tab_id: z.number().optional(),
487
+ url: z.string().describe('Absolute URL, or a path resolved against the current page'),
488
+ tab_id: tabId,
477
489
  },
478
490
  async ({ url, tab_id }) => {
479
491
  const data = await send(MessageType.NAVIGATE, { url, tab_id });
@@ -492,9 +504,11 @@ export function registerTools(server, wsManager, caps = 'all') {
492
504
  // --- screenshot ---
493
505
  server.tool(
494
506
  'screenshot',
495
- 'Screenshot a tab (PNG). Activates the tab in the background without stealing window focus, then restores the previous tab.',
507
+ 'Screenshot of the visible viewport only (PNG), at the current scroll position. Read-only. '
508
+ + 'Activates the tab in the background without stealing window focus, then restores the previous tab. '
509
+ + 'Downscaled to ≤1568px, so fine print may not survive.',
496
510
  {
497
- tab_id: z.number().optional(),
511
+ tab_id: tabId,
498
512
  },
499
513
  async ({ tab_id }) => {
500
514
  const data = await send(MessageType.SCREENSHOT, { tab_id });
@@ -522,9 +536,9 @@ export function registerTools(server, wsManager, caps = 'all') {
522
536
  'execute_js',
523
537
  'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled.',
524
538
  {
525
- code: z.string(),
526
- tab_id: z.number().optional(),
527
- frame_id: z.number().optional(),
539
+ code: z.string().describe('JS evaluated in the page; the value of the last expression is returned'),
540
+ tab_id: tabId,
541
+ frame_id: frameId,
528
542
  max_length: z.number().optional().default(20000).describe('Max output chars'),
529
543
  },
530
544
  async ({ code, tab_id, frame_id, max_length }) => {
@@ -545,12 +559,12 @@ export function registerTools(server, wsManager, caps = 'all') {
545
559
  + 'pointer sequence, so it can submit, open a dialog or navigate away — use wait_after to let that settle. '
546
560
  + 'Not idempotent, and a click that triggers a native confirm() blocks the bridge: install handle_dialogs first.',
547
561
  {
548
- selector: z.string().optional(),
562
+ selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
549
563
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
550
564
  force: z.boolean().optional().default(false).describe('Click even if occluded'),
551
- wait_after: z.enum(['none', 'navigation', 'networkidle']).optional().default('none'),
552
- tab_id: z.number().optional(),
553
- frame_id: z.number().optional(),
565
+ wait_after: waitAfter,
566
+ tab_id: tabId,
567
+ frame_id: frameId,
554
568
  },
555
569
  async ({ selector, ref, force, wait_after, tab_id, frame_id }) => {
556
570
  const target = resolveTarget(selector, ref, tab_id);
@@ -574,13 +588,13 @@ export function registerTools(server, wsManager, caps = 'all') {
574
588
  'type_text',
575
589
  'Type text into an input, by CSS selector or by ref from get_interactives.',
576
590
  {
577
- selector: z.string().optional(),
591
+ selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
578
592
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
579
- text: z.string(),
593
+ text: z.string().describe('Value to type; empty string clears the field'),
580
594
  mode: z.enum(['set', 'keys']).optional().default('set').describe('set = assign value; keys = per-char events (autocomplete/masked)'),
581
- wait_after: z.enum(['none', 'navigation', 'networkidle']).optional().default('none'),
582
- tab_id: z.number().optional(),
583
- frame_id: z.number().optional(),
595
+ wait_after: waitAfter,
596
+ tab_id: tabId,
597
+ frame_id: frameId,
584
598
  },
585
599
  async ({ selector, ref, text, mode, wait_after, tab_id, frame_id }) => {
586
600
  const target = resolveTarget(selector, ref, tab_id);
@@ -603,9 +617,9 @@ export function registerTools(server, wsManager, caps = 'all') {
603
617
  + 'HTML on a big table costs tens of thousands of tokens for data you then filter anyway — prefer '
604
618
  + 'extract_table or extract for tabular and repeated content, and get_interactives to find click targets.',
605
619
  {
606
- mode: z.enum(['text', 'html', 'accessibility']).default('text'),
607
- tab_id: z.number().optional(),
608
- frame_id: z.number().optional(),
620
+ mode: z.enum(['text', 'html', 'accessibility']).default('text').describe('text strips markup, html keeps it (costly), accessibility returns the a11y tree'),
621
+ tab_id: tabId,
622
+ frame_id: frameId,
609
623
  max_length: z.number().optional().default(50000).describe('Max output chars'),
610
624
  },
611
625
  async ({ mode, tab_id, frame_id, max_length }) => {
@@ -624,8 +638,8 @@ export function registerTools(server, wsManager, caps = 'all') {
624
638
  'get_page_info',
625
639
  'Get page metadata: meta tags, scripts, stylesheets, links, and forms',
626
640
  {
627
- tab_id: z.number().optional(),
628
- frame_id: z.number().optional(),
641
+ tab_id: tabId,
642
+ frame_id: frameId,
629
643
  },
630
644
  async ({ tab_id, frame_id }) => {
631
645
  const data = await send(MessageType.GET_PAGE_INFO, { tab_id, frame_id });
@@ -645,8 +659,8 @@ export function registerTools(server, wsManager, caps = 'all') {
645
659
  + 'Cookies come with domain, path, expiry and the httpOnly/secure flags, so this also answers whether a '
646
660
  + 'session cookie is present — write them back with set_storage, or snapshot a logged-in state with session_fixture.',
647
661
  {
648
- type: z.enum(['all', 'localStorage', 'sessionStorage', 'cookies']).default('all'),
649
- tab_id: z.number().optional(),
662
+ type: z.enum(['all', 'localStorage', 'sessionStorage', 'cookies']).default('all').describe('all returns the three together'),
663
+ tab_id: tabId,
650
664
  },
651
665
  async ({ type, tab_id }) => {
652
666
  const data = await send(MessageType.GET_STORAGE, { type, tab_id });
@@ -664,9 +678,10 @@ export function registerTools(server, wsManager, caps = 'all') {
664
678
  'get_performance',
665
679
  'Navigation timing, paint metrics, JS heap size and per-resource load times, as measured since the current '
666
680
  + 'document loaded. Read-only, no reload triggered — numbers are only meaningful once loading has settled, '
667
- + 'so on a page still fetching call wait_for(network_idle) first. For CLS/LCP/INP use web_vitals.',
681
+ + 'so on a page still fetching wait for network idle first. Covers how fast the document arrived, '
682
+ + 'not the layout-shift and interaction metrics that accumulate afterwards.',
668
683
  {
669
- tab_id: z.number().optional(),
684
+ tab_id: tabId,
670
685
  },
671
686
  async ({ tab_id }) => {
672
687
  const data = await send(MessageType.GET_PERFORMANCE, { tab_id });
@@ -684,11 +699,11 @@ export function registerTools(server, wsManager, caps = 'all') {
684
699
  'query_dom',
685
700
  'Query DOM elements by CSS selector, returning structure, attributes, bounding rect, and computed styles.',
686
701
  {
687
- selector: z.string(),
702
+ selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. Matches all, not just the first'),
688
703
  properties: z.array(z.string()).optional().describe('Computed styles to include, e.g. ["color"]'),
689
- limit: z.number().optional().default(50),
690
- tab_id: z.number().optional(),
691
- frame_id: z.number().optional(),
704
+ limit: z.number().optional().default(50).describe('Max elements returned, from the top of the match list'),
705
+ tab_id: tabId,
706
+ frame_id: frameId,
692
707
  },
693
708
  async ({ selector, properties, limit, tab_id, frame_id }) => {
694
709
  const data = await send(MessageType.QUERY_DOM, { selector, properties, limit, tab_id, frame_id });
@@ -708,13 +723,13 @@ export function registerTools(server, wsManager, caps = 'all') {
708
723
  + 'setTextContent. Nothing is persisted — the next reload restores the page as the server sends it. '
709
724
  + 'For styling many elements at once inject_css is one call instead of N.',
710
725
  {
711
- selector: z.string(),
712
- action: z.enum(['setAttribute', 'removeAttribute', 'addClass', 'removeClass', 'setStyle', 'setTextContent']),
726
+ selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. Only the first match is changed'),
727
+ action: z.enum(['setAttribute', 'removeAttribute', 'addClass', 'removeClass', 'setStyle', 'setTextContent']).describe('setStyle takes a CSS declaration in value; addClass/removeClass take className'),
713
728
  name: z.string().optional().describe('Attribute name'),
714
- value: z.string().optional(),
715
- className: z.string().optional(),
716
- tab_id: z.number().optional(),
717
- frame_id: z.number().optional(),
729
+ value: z.string().optional().describe('Attribute value, style declaration, or text, per action'),
730
+ className: z.string().optional().describe('Class to add or remove (addClass/removeClass)'),
731
+ tab_id: tabId,
732
+ frame_id: frameId,
718
733
  },
719
734
  async ({ selector, action, name, value, className, tab_id, frame_id }) => {
720
735
  const data = await send(MessageType.MODIFY_DOM, { selector, action, name, value, className, tab_id, frame_id });
@@ -733,8 +748,8 @@ export function registerTools(server, wsManager, caps = 'all') {
733
748
  'Inject a CSS rule into the page. Stays until the next navigation or reload, and re-injecting the same id '
734
749
  + 'replaces it rather than stacking. Affects only what is rendered — the stylesheet of the site is untouched.',
735
750
  {
736
- css: z.string(),
737
- tab_id: z.number().optional(),
751
+ css: z.string().describe('One or more CSS rules, as they would appear in a stylesheet'),
752
+ tab_id: tabId,
738
753
  },
739
754
  async ({ css, tab_id }) => {
740
755
  const data = await send(MessageType.INJECT_CSS, { css, tab_id });
@@ -753,10 +768,10 @@ export function registerTools(server, wsManager, caps = 'all') {
753
768
  'Read console messages captured since page load, incl. uncaught errors and unhandled rejections.',
754
769
  {
755
770
  clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
756
- level: z.enum(['all', 'log', 'warn', 'error', 'info', 'debug']).optional().default('all'),
771
+ level: z.enum(['all', 'log', 'warn', 'error', 'info', 'debug']).optional().default('all').describe('all merges every level in one chronological list'),
757
772
  limit: z.number().optional().default(50).describe('Most recent; buffer 1000'),
758
- format: z.enum(['lines', 'json']).optional().default('lines'),
759
- tab_id: z.number().optional(),
773
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps timestamps and stack traces'),
774
+ tab_id: tabId,
760
775
  },
761
776
  async ({ clear, level, limit, format, tab_id }) => {
762
777
  // limit va all'estensione: taglia in pagina e cancella (con clear) solo
@@ -788,10 +803,10 @@ export function registerTools(server, wsManager, caps = 'all') {
788
803
  'Monitor network requests. source=page: XHR/fetch hook (installed on first call); source=browser: all requests incl. static assets. format=har exports HAR 1.2.',
789
804
  {
790
805
  clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
791
- source: z.enum(['page', 'browser']).optional().default('page'),
792
- format: z.enum(['lines', 'json', 'har']).optional().default('lines'),
806
+ source: z.enum(['page', 'browser']).optional().default('page').describe('page sees XHR/fetch only; browser also sees static assets'),
807
+ format: z.enum(['lines', 'json', 'har']).optional().default('lines').describe('har exports HAR 1.2 for external tooling'),
793
808
  limit: z.number().optional().default(100).describe('Most recent; buffer 1000'),
794
- tab_id: z.number().optional(),
809
+ tab_id: tabId,
795
810
  },
796
811
  async ({ clear, source, format, limit, tab_id }) => {
797
812
  // limit va all'estensione (taglia in pagina, clear solo del restituito);
@@ -827,7 +842,7 @@ export function registerTools(server, wsManager, caps = 'all') {
827
842
  + 'Each call creates another tab: reuse a tab_id or navigate() to move an existing one instead of piling up tabs.',
828
843
  {
829
844
  url: z.string().optional().describe('URL to open (default: new tab page)'),
830
- active: z.boolean().optional().default(true),
845
+ active: z.boolean().optional().default(true).describe('false opens the tab in the background, leaving the current one focused'),
831
846
  },
832
847
  async ({ url, active }) => {
833
848
  const data = await send(MessageType.CREATE_TAB, { url, active });
@@ -846,7 +861,7 @@ export function registerTools(server, wsManager, caps = 'all') {
846
861
  'wait_for',
847
862
  'Wait for: element (selector in DOM), function (JS expression truthy; needs "Allow user scripts"), navigation (mode=spa for route changes), network_idle.',
848
863
  {
849
- condition: z.enum(['element', 'function', 'navigation', 'network_idle']),
864
+ condition: z.enum(['element', 'function', 'navigation', 'network_idle']).describe('function needs expression; element needs selector'),
850
865
  selector: z.string().optional().describe('condition=element'),
851
866
  expression: z.string().optional().describe('JS expression (condition=function)'),
852
867
  visible: z.boolean().optional().default(false).describe('Element must also be visible'),
@@ -854,8 +869,8 @@ export function registerTools(server, wsManager, caps = 'all') {
854
869
  idle_ms: z.number().optional().default(500).describe('Quiet period ms (network_idle)'),
855
870
  timeout: z.number().optional().describe('Max ms (default 10000; 15000 navigation/network_idle)'),
856
871
  interval: z.number().optional().describe('Poll ms, min 50'),
857
- tab_id: z.number().optional(),
858
- frame_id: z.number().optional(),
872
+ tab_id: tabId,
873
+ frame_id: frameId,
859
874
  },
860
875
  async ({ condition, selector, expression, visible, mode, idle_ms, timeout, interval, tab_id, frame_id }) => {
861
876
  let data;
@@ -885,18 +900,18 @@ export function registerTools(server, wsManager, caps = 'all') {
885
900
  'scroll',
886
901
  'Scroll. action=to: once to element/coordinates; action=until: repeatedly until element visible, network idle, or no new content (infinite scroll).',
887
902
  {
888
- action: z.enum(['to', 'until']).optional().default('to'),
903
+ action: z.enum(['to', 'until']).optional().default('to').describe('to jumps to a position or element; until scrolls repeatedly to load more'),
889
904
  selector: z.string().optional().describe('Target (to) or stop element (until=element)'),
890
- x: z.number().optional(),
891
- y: z.number().optional(),
892
- behavior: z.enum(['smooth', 'instant', 'auto']).optional().default('auto'),
905
+ x: z.number().optional().describe('Absolute horizontal position in px (action=to, without selector)'),
906
+ y: z.number().optional().describe('Absolute vertical position in px (action=to, without selector)'),
907
+ behavior: z.enum(['smooth', 'instant', 'auto']).optional().default('auto').describe('instant avoids waiting for smooth-scroll animations'),
893
908
  offset_y: z.number().optional().default(0).describe('px offset for fixed headers (to)'),
894
- until: z.enum(['element', 'network_idle', 'no_new_content']).optional().default('no_new_content'),
895
- max_scrolls: z.number().optional().default(20),
909
+ until: z.enum(['element', 'network_idle', 'no_new_content']).optional().default('no_new_content').describe('no_new_content stops when the page height stops growing'),
910
+ max_scrolls: z.number().optional().default(20).describe('Cap on scroll steps, so an infinite feed terminates'),
896
911
  step_px: z.number().optional().describe('px per step, default viewport height'),
897
912
  settle_ms: z.number().optional().default(400).describe('Pause ms after each step'),
898
- tab_id: z.number().optional(),
899
- frame_id: z.number().optional().describe('action=to only'),
913
+ tab_id: tabId,
914
+ frame_id: frameId,
900
915
  },
901
916
  async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, tab_id, frame_id }) => {
902
917
  const data = (action ?? 'to') === 'until'
@@ -913,17 +928,17 @@ export function registerTools(server, wsManager, caps = 'all') {
913
928
  + 'action=clear wipes every entry of that type for the origin and cannot be undone — on a site the user is '
914
929
  + 'logged into, clearing cookies logs them out. Read the current values first with get_storage.',
915
930
  {
916
- type: z.enum(['localStorage', 'sessionStorage', 'cookie']),
917
- action: z.enum(['set', 'delete', 'clear']),
931
+ type: z.enum(['localStorage', 'sessionStorage', 'cookie']).describe('cookie writes a real cookie, not a storage key'),
932
+ action: z.enum(['set', 'delete', 'clear']).describe('clear ignores key and wipes every entry of that type'),
918
933
  key: z.string().optional().describe('Required for set/delete'),
919
- value: z.string().optional(),
934
+ value: z.string().optional().describe('Required for action=set'),
920
935
  path: z.string().optional().describe('Cookie path (default /)'),
921
936
  domain: z.string().optional().describe('Cookie domain'),
922
937
  expires: z.string().optional().describe('UTC date string (cookie)'),
923
- secure: z.boolean().optional(),
924
- sameSite: z.enum(['Strict', 'Lax', 'None']).optional(),
925
- http_only: z.boolean().optional(),
926
- tab_id: z.number().optional(),
938
+ secure: z.boolean().optional().describe('Cookie sent over HTTPS only'),
939
+ sameSite: z.enum(['Strict', 'Lax', 'None']).optional().describe('None requires secure=true'),
940
+ http_only: z.boolean().optional().describe('Cookie hidden from page JS'),
941
+ tab_id: tabId,
927
942
  },
928
943
  async ({ type, action, key, value, path, domain, expires, secure, sameSite, http_only, tab_id }) => {
929
944
  const data = await send(MessageType.SET_STORAGE, { type, action, key, value, path, domain, expires, secure, sameSite, http_only, tab_id });
@@ -946,9 +961,9 @@ export function registerTools(server, wsManager, caps = 'all') {
946
961
  value: z.string(),
947
962
  })).describe('{selector, value} pairs'),
948
963
  submit_selector: z.string().optional().describe('Submit button to click after filling'),
949
- wait_after: z.enum(['none', 'navigation', 'networkidle']).optional().default('none'),
950
- tab_id: z.number().optional(),
951
- frame_id: z.number().optional(),
964
+ wait_after: waitAfter,
965
+ tab_id: tabId,
966
+ frame_id: frameId,
952
967
  },
953
968
  async ({ fields, submit_selector, wait_after, tab_id, frame_id }) => {
954
969
  const before = await tabSnapshot(tab_id);
@@ -973,7 +988,7 @@ export function registerTools(server, wsManager, caps = 'all') {
973
988
  preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
974
989
  width: z.number().optional().describe('Overrides preset'),
975
990
  height: z.number().optional().describe('Overrides preset'),
976
- tab_id: z.number().optional(),
991
+ tab_id: tabId,
977
992
  },
978
993
  async ({ preset, width, height, tab_id }) => {
979
994
  const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, tab_id });
@@ -989,10 +1004,12 @@ export function registerTools(server, wsManager, caps = 'all') {
989
1004
  // --- element_screenshot ---
990
1005
  server.tool(
991
1006
  'element_screenshot',
992
- 'Screenshot a single element (scrolled into view, cropped). PNG.',
1007
+ 'Screenshot cropped to one element (PNG), scrolled into view first. Read-only. '
1008
+ + 'The cheapest image in the set, because it carries only the box you asked for: '
1009
+ + 'a component, a chart, a table cell.',
993
1010
  {
994
- selector: z.string(),
995
- tab_id: z.number().optional(),
1011
+ selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. The element is scrolled into view first'),
1012
+ tab_id: tabId,
996
1013
  },
997
1014
  async ({ selector, tab_id }) => {
998
1015
  const data = await send(MessageType.ELEMENT_SCREENSHOT, { selector, tab_id });
@@ -1011,12 +1028,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1011
1028
  + 'max_segments are returned and the rest need segment_offset. On a long page, reading the text you actually need '
1012
1029
  + 'is cheaper by an order of magnitude — prefer read_page, extract or find_text unless the layout itself is the question.',
1013
1030
  {
1014
- max_scrolls: z.number().optional().default(20),
1031
+ max_scrolls: z.number().optional().default(20).describe('Cap on scroll steps: a taller page is captured only up to here'),
1015
1032
  delay: z.number().optional().default(500).describe('ms between captures (min 500, Chrome quota)'),
1016
1033
  stitch: z.boolean().optional().default(true).describe('false = one image per viewport'),
1017
1034
  max_segments: z.number().optional().default(3).describe('Images returned, from the top (each ≈2.7k image tokens); raise or use segment_offset for the rest'),
1018
1035
  segment_offset: z.number().optional().default(0).describe('Skip the first N segments'),
1019
- tab_id: z.number().optional(),
1036
+ tab_id: tabId,
1020
1037
  },
1021
1038
  async ({ max_scrolls, delay, stitch, max_segments, segment_offset, tab_id }) => {
1022
1039
  const data = await send(MessageType.FULL_PAGE_SCREENSHOT, { max_scrolls, delay, stitch, tab_id });
@@ -1057,12 +1074,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1057
1074
  'highlight_elements',
1058
1075
  'Add colored overlay on elements matching a CSS selector. remove=true clears all highlights.',
1059
1076
  {
1060
- selector: z.string().optional(),
1061
- color: z.string().optional().default('rgba(255,0,0,0.3)'),
1062
- border: z.string().optional().default('2px solid red'),
1077
+ selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Every match is outlined'),
1078
+ color: z.string().optional().default('rgba(255,0,0,0.3)').describe('Any CSS color for the overlay label'),
1079
+ border: z.string().optional().default('2px solid red').describe('CSS border shorthand, e.g. "2px solid red"'),
1063
1080
  label: z.boolean().optional().default(false).describe('Show tag.class (WxH) label'),
1064
- remove: z.boolean().optional().default(false),
1065
- tab_id: z.number().optional(),
1081
+ remove: z.boolean().optional().default(false).describe('Remove previously injected highlights instead of adding'),
1082
+ tab_id: tabId,
1066
1083
  },
1067
1084
  async ({ selector, color, border, label, remove, tab_id }) => {
1068
1085
  const data = await send(MessageType.HIGHLIGHT_ELEMENTS, { selector, color, border, label, remove, tab_id });
@@ -1081,8 +1098,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1081
1098
  'A11y audit: missing alt, empty links, heading hierarchy, ARIA, contrast (approximate), form labels.',
1082
1099
  {
1083
1100
  scope: z.string().optional().describe('Limit scope (CSS selector)'),
1084
- checks: z.array(z.enum(['images', 'links', 'headings', 'aria', 'contrast', 'forms', 'all'])).optional().default(['all']),
1085
- tab_id: z.number().optional(),
1101
+ checks: z.array(z.enum(['images', 'links', 'headings', 'aria', 'contrast', 'forms', 'all'])).optional().default(['all']).describe('Subset to run; fewer checks means a shorter answer'),
1102
+ tab_id: tabId,
1086
1103
  },
1087
1104
  async ({ scope, checks, tab_id }) => {
1088
1105
  const data = await send(MessageType.ACCESSIBILITY_AUDIT, { scope, checks, tab_id });
@@ -1100,12 +1117,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1100
1117
  'check_links',
1101
1118
  'Check page links for broken URLs, verified server-side (no CORS limits, real HTTP status).',
1102
1119
  {
1103
- scope: z.enum(['same-origin', 'all', 'external']).optional().default('all'),
1104
- selector: z.string().optional().default('a[href]'),
1120
+ scope: z.enum(['same-origin', 'all', 'external']).optional().default('all').describe('same-origin skips third-party links, which are the slow ones'),
1121
+ selector: z.string().optional().default('a[href]').describe('CSS selector; ">>>" pierces shadow DOM. Restricts which links are collected'),
1105
1122
  timeout: z.number().optional().default(5000).describe('Per-link ms'),
1106
- max_links: z.number().optional().default(50),
1107
- format: z.enum(['lines', 'json']).optional().default('lines'),
1108
- tab_id: z.number().optional(),
1123
+ max_links: z.number().optional().default(50).describe('Cap on links fetched: each one is a real HTTP request'),
1124
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps per-link status and timing'),
1125
+ tab_id: tabId,
1109
1126
  },
1110
1127
  async ({ scope, selector, timeout, max_links, format, tab_id }) => {
1111
1128
  const data = await send(MessageType.COLLECT_LINKS, { scope, selector, max_links, tab_id });
@@ -1136,9 +1153,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1136
1153
  + 'Read-only. Values come from the current layout, so zoom and viewport size change them: set_zoom(1) and a '
1137
1154
  + 'fixed viewport_resize make results comparable across runs.',
1138
1155
  {
1139
- selector1: z.string(),
1140
- selector2: z.string(),
1141
- tab_id: z.number().optional(),
1156
+ selector1: z.string().describe('First element; distances are measured from its box'),
1157
+ selector2: z.string().describe('Second element; ">>>" pierces shadow DOM'),
1158
+ tab_id: tabId,
1142
1159
  },
1143
1160
  async ({ selector1, selector2, tab_id }) => {
1144
1161
  const data = await send(MessageType.MEASURE_SPACING, { selector1, selector2, tab_id });
@@ -1156,15 +1173,15 @@ export function registerTools(server, wsManager, caps = 'all') {
1156
1173
  'watch_dom',
1157
1174
  'Watch DOM mutations (MutationObserver). First call installs the watcher; later calls read accumulated mutations.',
1158
1175
  {
1159
- selector: z.string().optional().default('body'),
1160
- attributes: z.boolean().optional().default(true),
1161
- childList: z.boolean().optional().default(true),
1162
- characterData: z.boolean().optional().default(false),
1163
- subtree: z.boolean().optional().default(true),
1176
+ selector: z.string().optional().default('body').describe('Subtree to observe; ">>>" pierces shadow DOM'),
1177
+ attributes: z.boolean().optional().default(true).describe('Report attribute changes'),
1178
+ childList: z.boolean().optional().default(true).describe('Report added and removed children'),
1179
+ characterData: z.boolean().optional().default(false).describe('Report text content changes'),
1180
+ subtree: z.boolean().optional().default(true).describe('Observe descendants too, not just the matched node'),
1164
1181
  clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
1165
1182
  stop: z.boolean().optional().default(false).describe('Disconnect observer'),
1166
1183
  limit: z.number().optional().default(100).describe('Most recent mutations; buffer 1000'),
1167
- tab_id: z.number().optional(),
1184
+ tab_id: tabId,
1168
1185
  },
1169
1186
  async ({ selector, attributes, childList, characterData, subtree, clear, stop, limit, tab_id }) => {
1170
1187
  const data = await send(MessageType.WATCH_DOM, { selector, attributes, childList, characterData, subtree, clear, stop, limit, tab_id });
@@ -1185,12 +1202,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1185
1202
  + 'user_agent only changes what page JS reads — the request header is a separate thing, set it with '
1186
1203
  + 'network_rules(action=modify_header, header="User-Agent"). Pair with viewport_resize to emulate a device.',
1187
1204
  {
1188
- colorScheme: z.enum(['dark', 'light', 'no-preference']).optional(),
1189
- reducedMotion: z.enum(['reduce', 'no-preference']).optional(),
1190
- printMode: z.boolean().optional().default(false),
1205
+ colorScheme: z.enum(['dark', 'light', 'no-preference']).optional().describe('Value reported to prefers-color-scheme queries'),
1206
+ reducedMotion: z.enum(['reduce', 'no-preference']).optional().describe('Value reported to prefers-reduced-motion queries'),
1207
+ printMode: z.boolean().optional().default(false).describe('Make print media queries match, without opening a print dialog'),
1191
1208
  user_agent: z.string().optional().describe('Overrides navigator.userAgent and appVersion in the page (not the HTTP header)'),
1192
1209
  reset: z.boolean().optional().default(false).describe('Remove all emulations'),
1193
- tab_id: z.number().optional(),
1210
+ tab_id: tabId,
1194
1211
  },
1195
1212
  async ({ colorScheme, reducedMotion, printMode, user_agent, reset, tab_id }) => {
1196
1213
  const data = await send(MessageType.EMULATE_MEDIA, { colorScheme, reducedMotion, printMode, user_agent, reset, tab_id });
@@ -1208,10 +1225,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1208
1225
  'hover',
1209
1226
  'Hover over an element (mouseenter/mouseover), by CSS selector or ref.',
1210
1227
  {
1211
- selector: z.string().optional(),
1228
+ selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Triggers CSS and JS hover handlers'),
1212
1229
  ref: z.string().optional().describe('From get_interactives'),
1213
- tab_id: z.number().optional(),
1214
- frame_id: z.number().optional(),
1230
+ tab_id: tabId,
1231
+ frame_id: frameId,
1215
1232
  },
1216
1233
  async ({ selector, ref, tab_id, frame_id }) => {
1217
1234
  const data = await send(MessageType.HOVER, { selector: resolveTarget(selector, ref, tab_id), tab_id, frame_id });
@@ -1233,12 +1250,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1233
1250
  {
1234
1251
  key: z.string().describe('e.g. "Enter", "Escape", "Tab", "ArrowDown"'),
1235
1252
  selector: z.string().optional().describe('Target (default: activeElement)'),
1236
- ctrl: z.boolean().optional().default(false),
1237
- shift: z.boolean().optional().default(false),
1238
- alt: z.boolean().optional().default(false),
1239
- meta: z.boolean().optional().default(false),
1240
- tab_id: z.number().optional(),
1241
- frame_id: z.number().optional(),
1253
+ ctrl: z.boolean().optional().default(false).describe('Hold Control'),
1254
+ shift: z.boolean().optional().default(false).describe('Hold Shift'),
1255
+ alt: z.boolean().optional().default(false).describe('Hold Alt'),
1256
+ meta: z.boolean().optional().default(false).describe('Hold Meta (Command/Windows)'),
1257
+ tab_id: tabId,
1258
+ frame_id: frameId,
1242
1259
  },
1243
1260
  async ({ key, selector, ctrl, shift, alt, meta, tab_id, frame_id }) => {
1244
1261
  const data = await send(MessageType.PRESS_KEY, { key, selector, ctrl, shift, alt, meta, tab_id, frame_id });
@@ -1256,7 +1273,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1256
1273
  'get_frames',
1257
1274
  'List frames (main + iframes) with frameId, parent, URL — for the frame_id parameter of DOM tools.',
1258
1275
  {
1259
- tab_id: z.number().optional(),
1276
+ tab_id: tabId,
1260
1277
  },
1261
1278
  async ({ tab_id }) => {
1262
1279
  const data = await send(MessageType.GET_FRAMES, { tab_id });
@@ -1271,9 +1288,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1271
1288
  + 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
1272
1289
  + 'reload and navigation drop injected CSS, emulations and page hooks.',
1273
1290
  {
1274
- action: z.enum(['close', 'activate', 'reload', 'back', 'forward']),
1291
+ action: z.enum(['close', 'activate', 'reload', 'back', 'forward']).describe('close cannot be undone; reload drops injected CSS and page hooks'),
1275
1292
  bypass_cache: z.boolean().optional().default(false).describe('reload only'),
1276
- tab_id: z.number().optional(),
1293
+ tab_id: tabId,
1277
1294
  },
1278
1295
  async ({ action, bypass_cache, tab_id }) => {
1279
1296
  const data = await send(MessageType.TAB_ACTION, { action, bypass_cache, tab_id });
@@ -1288,10 +1305,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1288
1305
  'upload_file',
1289
1306
  'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB).',
1290
1307
  {
1291
- selector: z.string(),
1308
+ selector: z.string().describe('The file input to fill; ">>>" pierces shadow DOM'),
1292
1309
  path: z.string().describe('Absolute path on the server machine'),
1293
1310
  mime_type: z.string().optional().describe('Default: inferred from extension'),
1294
- tab_id: z.number().optional(),
1311
+ tab_id: tabId,
1295
1312
  },
1296
1313
  async ({ selector, path, mime_type, tab_id }) => {
1297
1314
  const buf = await readFile(path);
@@ -1309,7 +1326,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1309
1326
  'dismiss_overlays',
1310
1327
  'Dismiss cookie banners/modal overlays: OneTrust, Cookiebot, Usercentrics, then generic heuristic. Idempotent.',
1311
1328
  {
1312
- tab_id: z.number().optional(),
1329
+ tab_id: tabId,
1313
1330
  },
1314
1331
  async ({ tab_id }) => {
1315
1332
  const data = await send(MessageType.DISMISS_OVERLAYS, { tab_id });
@@ -1322,9 +1339,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1322
1339
  'handle_dialogs',
1323
1340
  'Auto-accept/dismiss future JS dialogs (alert/confirm/prompt), logging them. reset restores native dialogs and returns the log.',
1324
1341
  {
1325
- action: z.enum(['accept', 'dismiss', 'reset']).optional().default('accept'),
1342
+ action: z.enum(['accept', 'dismiss', 'reset']).optional().default('accept').describe('accept/dismiss auto-answer future dialogs; reset restores native behaviour'),
1326
1343
  prompt_text: z.string().optional().describe('Returned by window.prompt on accept'),
1327
- tab_id: z.number().optional(),
1344
+ tab_id: tabId,
1328
1345
  },
1329
1346
  async ({ action, prompt_text, tab_id }) => {
1330
1347
  const data = await send(MessageType.HANDLE_DIALOGS, { action, prompt_text, tab_id });
@@ -1337,10 +1354,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1337
1354
  'find_text',
1338
1355
  'Find text on the page: parent selector, context, visibility, position per match. Attaches nearby interactive elements (with refs for click/type_text/hover) for the first visible match.',
1339
1356
  {
1340
- text: z.string(),
1341
- case_sensitive: z.boolean().optional().default(false),
1342
- max_results: z.number().optional().default(20),
1343
- tab_id: z.number().optional(),
1357
+ text: z.string().describe('Literal text to find, not a regex'),
1358
+ case_sensitive: z.boolean().optional().default(false).describe('Match case exactly'),
1359
+ max_results: z.number().optional().default(20).describe('Cap on matches returned, in document order'),
1360
+ tab_id: tabId,
1344
1361
  },
1345
1362
  async ({ text, case_sensitive, max_results, tab_id }) => {
1346
1363
  const data = await send(MessageType.FIND_TEXT, { text, case_sensitive, max_results, tab_id });
@@ -1381,16 +1398,16 @@ export function registerTools(server, wsManager, caps = 'all') {
1381
1398
  'network_rules',
1382
1399
  'Network interception, browser-wide, survives reloads until cleared: block requests, redirect URLs, set/remove request headers, or stub responses with a synthetic body (served by a local helper; from HTTPS pages the stub host must be trustworthy).',
1383
1400
  {
1384
- action: z.enum(['block', 'redirect', 'modify_header', 'stub', 'list', 'clear']),
1401
+ action: z.enum(['block', 'redirect', 'modify_header', 'stub', 'list', 'clear']).describe('list and clear inspect and drop the rules already installed'),
1385
1402
  url_filter: z.string().optional().describe('declarativeNetRequest urlFilter, e.g. "||example.com/api/*"'),
1386
- redirect_url: z.string().optional(),
1387
- header: z.string().optional(),
1403
+ redirect_url: z.string().optional().describe('Destination for action=redirect'),
1404
+ header: z.string().optional().describe('Header name for action=modify_header, e.g. "User-Agent"'),
1388
1405
  header_value: z.string().optional().describe('Omit to remove header'),
1389
1406
  header_target: z.enum(['request', 'response']).optional().default('request').describe("response = strip content-security-policy / x-frame-options, inject CORS"),
1390
1407
  body: z.string().optional().describe('Response body (action=stub)'),
1391
1408
  status: z.number().optional().default(200).describe('action=stub'),
1392
1409
  content_type: z.string().optional().default('application/json').describe('action=stub'),
1393
- resource_types: z.array(z.enum(['main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object', 'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'other'])).optional(),
1410
+ resource_types: z.array(z.enum(['main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object', 'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'other'])).optional().describe('Limit the rule to these request types; omitted = all of them'),
1394
1411
  },
1395
1412
  async ({ action, url_filter, redirect_url, header, header_value, header_target, body, status, content_type, resource_types }) => {
1396
1413
  if (action === 'stub') {
@@ -1417,11 +1434,11 @@ export function registerTools(server, wsManager, caps = 'all') {
1417
1434
  'screenshot_diff',
1418
1435
  'Visual regression: save a named baseline (viewport or element), compare later — returns changed-pixel % and red-highlighted diff image. Baselines are in-memory (lost on service worker restart).',
1419
1436
  {
1420
- action: z.enum(['baseline', 'compare', 'list', 'clear']),
1421
- name: z.string().optional().default('default'),
1437
+ action: z.enum(['baseline', 'compare', 'list', 'clear']).describe('baseline stores, compare measures against it, clear drops baselines'),
1438
+ name: z.string().optional().default('default').describe('Baseline id: reuse the same one to compare across runs'),
1422
1439
  selector: z.string().optional().describe('Capture one element (default viewport)'),
1423
1440
  threshold: z.number().optional().default(10).describe('Per-channel tolerance 0-255'),
1424
- tab_id: z.number().optional(),
1441
+ tab_id: tabId,
1425
1442
  },
1426
1443
  async ({ action, name, selector, threshold, tab_id }) => {
1427
1444
  const data = await send(MessageType.SCREENSHOT_DIFF, { action, name, selector, threshold, tab_id });
@@ -1443,9 +1460,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1443
1460
  'web_vitals',
1444
1461
  'Core Web Vitals accumulated since the current document loaded: CLS, LCP, FCP, TTFB, long tasks and an INP '
1445
1462
  + 'approximation. Read-only. Needs the page instrumentation active from before load, so it reports whether it '
1446
- + 'was hooked instead of silently returning zeros — navigate() installs it. For timing and resources use get_performance.',
1463
+ + 'was hooked instead of silently returning zeros — navigate() installs it. '
1464
+ + 'Covers user-perceived stability and responsiveness after load, not the load timings themselves.',
1447
1465
  {
1448
- tab_id: z.number().optional(),
1466
+ tab_id: tabId,
1449
1467
  },
1450
1468
  async ({ tab_id }) => {
1451
1469
  const data = await send(MessageType.WEB_VITALS, { tab_id });
@@ -1459,8 +1477,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1459
1477
  'List addEventListener registrations since page load: counts by type + recent entries.',
1460
1478
  {
1461
1479
  type: z.string().optional().describe('e.g. "click"'),
1462
- limit: z.number().optional().default(100),
1463
- tab_id: z.number().optional(),
1480
+ limit: z.number().optional().default(100).describe('Max listeners returned, from the top of the match list'),
1481
+ tab_id: tabId,
1464
1482
  },
1465
1483
  async ({ type, limit, tab_id }) => {
1466
1484
  const data = await send(MessageType.LIST_EVENT_LISTENERS, { type, limit, tab_id });
@@ -1474,7 +1492,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1474
1492
  'Monitor WebSocket connections/messages (500-char previews). Hook installs on first call; earlier connections are missed.',
1475
1493
  {
1476
1494
  clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
1477
- tab_id: z.number().optional(),
1495
+ tab_id: tabId,
1478
1496
  },
1479
1497
  async ({ clear, tab_id }) => {
1480
1498
  const data = await send(MessageType.MONITOR_WEBSOCKET, { clear, tab_id });
@@ -1487,7 +1505,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1487
1505
  'seo_audit',
1488
1506
  'SEO audit: title/description lengths, canonical, robots, h1 count, Open Graph, Twitter card, JSON-LD validity, hreflang, lang, viewport, favicon',
1489
1507
  {
1490
- tab_id: z.number().optional(),
1508
+ tab_id: tabId,
1491
1509
  },
1492
1510
  async ({ tab_id }) => {
1493
1511
  const data = await send(MessageType.SEO_AUDIT, { tab_id });
@@ -1498,16 +1516,19 @@ export function registerTools(server, wsManager, caps = 'all') {
1498
1516
  // --- extract_table ---
1499
1517
  server.tool(
1500
1518
  'extract_table',
1501
- 'Extract an HTML table as JSON (thead headers, rows as objects). Use `where` to pull only matching rows from a large table without reading it all (server-side filter). `row_count` is always the table total; `match_count` is how many rows pass the filter. index picks among multiple matches.',
1519
+ 'Read a real <table> as JSON: thead cells become keys, each row an object. Read-only. '
1520
+ + 'Only for tabular markup — `where` filters rows server-side, so one matching row out of 1500 '
1521
+ + 'costs a few hundred bytes instead of the whole document. `row_count` is the table total, '
1522
+ + '`match_count` how many rows passed the filter.',
1502
1523
  {
1503
- selector: z.string().optional().default('table'),
1504
- index: z.number().optional().default(0),
1524
+ selector: z.string().optional().default('table').describe('The table to read; ">>>" pierces shadow DOM'),
1525
+ index: z.number().optional().default(0).describe('Which table to take when the selector matches several, 0-based'),
1505
1526
  max_rows: z.number().optional().default(100).describe('Max rows returned to you (output cap).'),
1506
- where: z.record(z.string(), z.string()).optional().describe('Keep only rows where each {column: substring} matches (case-insensitive contains). Use key "any" (or when the table has no headers) to match against any cell.'),
1527
+ where: z.record(z.string(), z.string()).optional().describe('{column: substring} rows must match, case-insensitive contains. Key "any" matches any cell.'),
1507
1528
  columns: z.array(z.string()).optional().describe('Return only these columns per row.'),
1508
1529
  offset: z.number().optional().default(0).describe('Skip N rows of the (filtered) set before applying max_rows.'),
1509
1530
  scan_rows: z.number().optional().default(2000).describe('Max rows materialized in-page to scan/filter; raise for very large tables.'),
1510
- tab_id: z.number().optional(),
1531
+ tab_id: tabId,
1511
1532
  },
1512
1533
  async ({ selector, index, max_rows, where, columns, offset, scan_rows, tab_id }) => {
1513
1534
  const data = await send(MessageType.EXTRACT_TABLE, { selector, index, scan_rows, tab_id });
@@ -1521,8 +1542,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1521
1542
  'unused_css',
1522
1543
  'List CSS selectors matching nothing in the current DOM (approximate; cross-origin sheets unreadable).',
1523
1544
  {
1524
- max_selectors: z.number().optional().default(200),
1525
- tab_id: z.number().optional(),
1545
+ max_selectors: z.number().optional().default(200).describe('Cap on unused selectors reported: a large stylesheet has thousands'),
1546
+ tab_id: tabId,
1526
1547
  },
1527
1548
  async ({ max_selectors, tab_id }) => {
1528
1549
  const data = await send(MessageType.UNUSED_CSS, { max_selectors, tab_id });
@@ -1535,11 +1556,11 @@ export function registerTools(server, wsManager, caps = 'all') {
1535
1556
  'drag_and_drop',
1536
1557
  'Drag an element onto another. html5 = DragEvent+DataTransfer; pointer = pointer/mouse events (sortable libraries).',
1537
1558
  {
1538
- source_selector: z.string(),
1539
- target_selector: z.string(),
1540
- mode: z.enum(['html5', 'pointer']).optional().default('html5'),
1541
- frame_id: z.number().optional(),
1542
- tab_id: z.number().optional(),
1559
+ source_selector: z.string().describe('Element to drag; ">>>" pierces shadow DOM'),
1560
+ target_selector: z.string().describe('Drop target; ">>>" pierces shadow DOM'),
1561
+ mode: z.enum(['html5', 'pointer']).optional().default('html5').describe('Which event family to emit, since libraries listen to different ones'),
1562
+ frame_id: frameId,
1563
+ tab_id: tabId,
1543
1564
  },
1544
1565
  async ({ source_selector, target_selector, mode, frame_id, tab_id }) => {
1545
1566
  const data = await send(MessageType.DRAG_AND_DROP, { source_selector, target_selector, mode, frame_id, tab_id });
@@ -1552,9 +1573,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1552
1573
  'clipboard',
1553
1574
  'Read or write the system clipboard (text). Activates the tab first.',
1554
1575
  {
1555
- action: z.enum(['read', 'write']),
1576
+ action: z.enum(['read', 'write']).describe('write takes text; read returns the current clipboard contents'),
1556
1577
  text: z.string().optional().describe('For write'),
1557
- tab_id: z.number().optional(),
1578
+ tab_id: tabId,
1558
1579
  },
1559
1580
  async ({ action, text, tab_id }) => {
1560
1581
  const data = await send(MessageType.CLIPBOARD, { action, text, tab_id });
@@ -1567,11 +1588,11 @@ export function registerTools(server, wsManager, caps = 'all') {
1567
1588
  'set_geolocation',
1568
1589
  'Override navigator.geolocation with fixed coordinates (page-level patch). reset restores native.',
1569
1590
  {
1570
- latitude: z.number().optional(),
1571
- longitude: z.number().optional(),
1591
+ latitude: z.number().optional().describe('Decimal degrees, -90 to 90'),
1592
+ longitude: z.number().optional().describe('Decimal degrees, -180 to 180'),
1572
1593
  accuracy: z.number().optional().default(10).describe('Meters'),
1573
- reset: z.boolean().optional().default(false),
1574
- tab_id: z.number().optional(),
1594
+ reset: z.boolean().optional().default(false).describe('Restore the real position and stop overriding'),
1595
+ tab_id: tabId,
1575
1596
  },
1576
1597
  async ({ latitude, longitude, accuracy, reset, tab_id }) => {
1577
1598
  const data = await send(MessageType.SET_GEOLOCATION, { latitude, longitude, accuracy, reset, tab_id });
@@ -1584,9 +1605,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1584
1605
  'manage_downloads',
1585
1606
  'List downloads or wait for one to complete. Files land in the browser Downloads folder, not on the server.',
1586
1607
  {
1587
- action: z.enum(['list', 'wait_for_complete']),
1608
+ action: z.enum(['list', 'wait_for_complete']).describe('wait_for_complete blocks until the newest download finishes or times out'),
1588
1609
  timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
1589
- limit: z.number().optional().default(10),
1610
+ limit: z.number().optional().default(10).describe('Max download entries returned, newest first'),
1590
1611
  },
1591
1612
  async ({ action, timeout, limit }) => {
1592
1613
  const data = await send(MessageType.MANAGE_DOWNLOADS, { action, timeout, limit });
@@ -1600,7 +1621,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1600
1621
  'Save the full page (DOM, styles, images) as an MHTML archive file on the server filesystem.',
1601
1622
  {
1602
1623
  output_path: z.string().describe('Absolute file path to write (e.g. /tmp/page.mhtml)'),
1603
- tab_id: z.number().optional(),
1624
+ tab_id: tabId,
1604
1625
  },
1605
1626
  async ({ output_path, tab_id }) => {
1606
1627
  const data = await send(MessageType.SAVE_PAGE, { tab_id });
@@ -1618,7 +1639,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1618
1639
  + 'which is how you read a PDF — Chrome renders PDFs in a viewer no content script can reach, so read_page returns nothing on a PDF tab.',
1619
1640
  {
1620
1641
  url: z.string().describe('Absolute URL. Cookies are sent for its origin'),
1621
- method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']).optional().default('GET'),
1642
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']).optional().default('GET').describe('HEAD fetches headers only, without the body'),
1622
1643
  headers: z.record(z.string(), z.string()).optional().describe('Extra request headers'),
1623
1644
  body: z.string().optional().describe('Request body (POST/PUT/PATCH)'),
1624
1645
  save_to: z.string().optional().describe('Absolute path: write the response bytes here instead of returning them (PDF, images, archives)'),
@@ -1673,8 +1694,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1673
1694
  'Get or set tab zoom (0.25–5). No factor = read current; reset restores default.',
1674
1695
  {
1675
1696
  factor: z.number().optional().describe('1 = 100%'),
1676
- reset: z.boolean().optional().default(false),
1677
- tab_id: z.number().optional(),
1697
+ reset: z.boolean().optional().default(false).describe('Restore the default zoom for this origin'),
1698
+ tab_id: tabId,
1678
1699
  },
1679
1700
  async ({ factor, reset, tab_id }) => {
1680
1701
  const data = await send(MessageType.SET_ZOOM, { factor, reset, tab_id });
@@ -1687,9 +1708,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1687
1708
  'http_auth',
1688
1709
  'Set/clear credentials for HTTP Basic/Digest auth dialogs (browser-wide, in-memory only).',
1689
1710
  {
1690
- action: z.enum(['set', 'clear']),
1691
- username: z.string().optional(),
1692
- password: z.string().optional(),
1711
+ action: z.enum(['set', 'clear']).describe('set installs credentials for HTTP auth prompts; clear removes them'),
1712
+ username: z.string().optional().describe('Required for action=set'),
1713
+ password: z.string().optional().describe('Required for action=set; kept in memory, never written to disk'),
1693
1714
  },
1694
1715
  async ({ action, username, password }) => {
1695
1716
  if (action === 'set' && !username) throw new Error('username is required for action=set');
@@ -1703,7 +1724,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1703
1724
  'security_headers',
1704
1725
  'Audit security headers (CSP, HSTS, XCTO, clickjacking, Referrer/Permissions-Policy, version leaks). Captured from real navigations — reload if unavailable.',
1705
1726
  {
1706
- tab_id: z.number().optional(),
1727
+ tab_id: tabId,
1707
1728
  },
1708
1729
  async ({ tab_id }) => {
1709
1730
  const data = await send(MessageType.GET_RESPONSE_HEADERS, { tab_id });
@@ -1721,9 +1742,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1721
1742
  'session_fixture',
1722
1743
  'Save/restore localStorage+sessionStorage+cookies as a named fixture (e.g. a logged-in state). Restore requires the tab to be on the origin recorded at save.',
1723
1744
  {
1724
- action: z.enum(['save', 'restore', 'list']),
1745
+ action: z.enum(['save', 'restore', 'list']).describe('save snapshots the current origin; restore writes it back'),
1725
1746
  name: z.string().optional().describe('Required for save/restore'),
1726
- tab_id: z.number().optional(),
1747
+ tab_id: tabId,
1727
1748
  },
1728
1749
  async ({ action, name, tab_id }) => {
1729
1750
  if (action === 'list') {
@@ -1793,11 +1814,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1793
1814
  'List actionable elements (buttons, links, inputs, [role], [onclick]) with ready-to-use CSS selector, label, position, flags. Prefer this over dumping HTML to discover selectors.',
1794
1815
  {
1795
1816
  scope: z.string().optional().describe('Limit search (CSS selector)'),
1796
- limit: z.number().optional().default(100),
1797
- visible_only: z.boolean().optional().default(true),
1798
- format: z.enum(['lines', 'json']).optional().default('lines'),
1799
- frame_id: z.number().optional(),
1800
- tab_id: z.number().optional(),
1817
+ limit: z.number().optional().default(100).describe('Max elements returned; raise it on a dense page'),
1818
+ visible_only: z.boolean().optional().default(true)
1819
+ .describe('false also lists elements hidden or scrolled out of view'),
1820
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json adds full attributes per element'),
1821
+ frame_id: frameId,
1822
+ tab_id: tabId,
1801
1823
  },
1802
1824
  async ({ scope, limit, visible_only, format, frame_id, tab_id }) => {
1803
1825
  const data = await send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id });
@@ -1823,16 +1845,18 @@ export function registerTools(server, wsManager, caps = 'all') {
1823
1845
  // --- extract ---
1824
1846
  server.tool(
1825
1847
  'extract',
1826
- 'Extract repeated structured data (rows, cards, lists) in one call: item_selector matches the items, fields map names to relative selectors. Deterministic, parses the main-document HTML server-side.',
1848
+ 'Read repeated non-tabular structures — product cards, list items, search results — as one record per item: '
1849
+ + 'item_selector matches the repeating block, fields map output names to selectors relative to it. '
1850
+ + 'Read-only, deterministic, parsed server-side from the main-document HTML.',
1827
1851
  {
1828
1852
  item_selector: z.string().describe('CSS selector matching each item'),
1829
1853
  fields: z.record(z.string(), z.object({
1830
1854
  selector: z.string().optional().describe('Relative to the item (default: the item itself)'),
1831
1855
  attr: z.string().optional().describe('Attribute to read (default: text content)'),
1832
1856
  })).describe('field name → {selector, attr}'),
1833
- max_items: z.number().optional().default(50),
1834
- format: z.enum(['lines', 'json']).optional().default('lines'),
1835
- tab_id: z.number().optional(),
1857
+ max_items: z.number().optional().default(50).describe('Cap on records returned, in document order'),
1858
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps one object per record'),
1859
+ tab_id: tabId,
1836
1860
  max_length: z.number().optional().default(20000).describe('Max output chars'),
1837
1861
  },
1838
1862
  async ({ item_selector, fields, max_items, format, tab_id, max_length }) => {
@@ -1866,14 +1890,14 @@ export function registerTools(server, wsManager, caps = 'all') {
1866
1890
  'assert',
1867
1891
  'Assert a page condition, polling until timeout: element exists/visible (optionally with count or containing text), text on page, tab url/title. Patterns: substring, or "/…/" for regex. Recorded flows replay it as a test.',
1868
1892
  {
1869
- selector: z.string().optional(),
1870
- state: z.enum(['attached', 'visible']).optional().default('attached'),
1893
+ selector: z.string().optional().describe('Element the assertion is about; ">>>" pierces shadow DOM'),
1894
+ state: z.enum(['attached', 'visible']).optional().default('attached').describe('attached means present in the DOM, visible also requires a rendered box'),
1871
1895
  text: z.string().optional().describe('In the element (with selector) or anywhere on the page'),
1872
1896
  count: z.number().optional().describe('Exact match count for selector'),
1873
1897
  url: z.string().optional().describe('Tab url pattern'),
1874
1898
  title: z.string().optional().describe('Tab title pattern'),
1875
- timeout: z.number().optional().default(5000),
1876
- tab_id: z.number().optional(),
1899
+ timeout: z.number().optional().default(5000).describe('Max ms to wait for the condition before failing'),
1900
+ tab_id: tabId,
1877
1901
  },
1878
1902
  async ({ selector, state, text, count, url, title, timeout, tab_id }) => {
1879
1903
  const params = { selector, state, text, count, url, title, timeout, tab_id: tab_id ?? sessionTabId ?? undefined };
@@ -1896,7 +1920,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1896
1920
  'session_record',
1897
1921
  'Record the commands of this session as a replayable jsonl file (replay with the CLI: chrome-bridge replay --file <path>). tab_id is stripped — replays target the tab they navigate.',
1898
1922
  {
1899
- action: z.enum(['start', 'stop', 'status', 'list']),
1923
+ action: z.enum(['start', 'stop', 'status', 'list']).describe('start begins recording, stop writes the file and returns its path'),
1900
1924
  name: z.string().optional().describe('Required for start'),
1901
1925
  },
1902
1926
  async ({ action, name }) => {