chrome-bridge-mcp 1.9.0 → 1.10.1

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-132%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.6k tokens of `tools/list`, all 60 cost ≈14.6k; 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.1",
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.1",
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.1';
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);
@@ -572,15 +586,19 @@ export function registerTools(server, wsManager, caps = 'all') {
572
586
  // --- type_text ---
573
587
  server.tool(
574
588
  'type_text',
575
- 'Type text into an input, by CSS selector or by ref from get_interactives.',
589
+ 'Put text into an input, textarea or contenteditable, by CSS selector or by a ref from get_interactives. '
590
+ + 'Replaces the whole value rather than appending, and assigns through the native setter so React and '
591
+ + 'Vue controlled inputs register the change, then fires input and change. mode=keys instead emits '
592
+ + 'keydown/input/keyup per character, which is what autocomplete and masked fields need — slower, so '
593
+ + 'reach for it only when mode=set leaves the field empty or the dropdown never opens.',
576
594
  {
577
- selector: z.string().optional(),
595
+ selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
578
596
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
579
- text: z.string(),
597
+ text: z.string().describe('Value to type; empty string clears the field'),
580
598
  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(),
599
+ wait_after: waitAfter,
600
+ tab_id: tabId,
601
+ frame_id: frameId,
584
602
  },
585
603
  async ({ selector, ref, text, mode, wait_after, tab_id, frame_id }) => {
586
604
  const target = resolveTarget(selector, ref, tab_id);
@@ -603,9 +621,9 @@ export function registerTools(server, wsManager, caps = 'all') {
603
621
  + 'HTML on a big table costs tens of thousands of tokens for data you then filter anyway — prefer '
604
622
  + 'extract_table or extract for tabular and repeated content, and get_interactives to find click targets.',
605
623
  {
606
- mode: z.enum(['text', 'html', 'accessibility']).default('text'),
607
- tab_id: z.number().optional(),
608
- frame_id: z.number().optional(),
624
+ mode: z.enum(['text', 'html', 'accessibility']).default('text').describe('text strips markup, html keeps it (costly), accessibility returns the a11y tree'),
625
+ tab_id: tabId,
626
+ frame_id: frameId,
609
627
  max_length: z.number().optional().default(50000).describe('Max output chars'),
610
628
  },
611
629
  async ({ mode, tab_id, frame_id, max_length }) => {
@@ -624,8 +642,8 @@ export function registerTools(server, wsManager, caps = 'all') {
624
642
  'get_page_info',
625
643
  'Get page metadata: meta tags, scripts, stylesheets, links, and forms',
626
644
  {
627
- tab_id: z.number().optional(),
628
- frame_id: z.number().optional(),
645
+ tab_id: tabId,
646
+ frame_id: frameId,
629
647
  },
630
648
  async ({ tab_id, frame_id }) => {
631
649
  const data = await send(MessageType.GET_PAGE_INFO, { tab_id, frame_id });
@@ -645,8 +663,8 @@ export function registerTools(server, wsManager, caps = 'all') {
645
663
  + 'Cookies come with domain, path, expiry and the httpOnly/secure flags, so this also answers whether a '
646
664
  + 'session cookie is present — write them back with set_storage, or snapshot a logged-in state with session_fixture.',
647
665
  {
648
- type: z.enum(['all', 'localStorage', 'sessionStorage', 'cookies']).default('all'),
649
- tab_id: z.number().optional(),
666
+ type: z.enum(['all', 'localStorage', 'sessionStorage', 'cookies']).default('all').describe('all returns the three together'),
667
+ tab_id: tabId,
650
668
  },
651
669
  async ({ type, tab_id }) => {
652
670
  const data = await send(MessageType.GET_STORAGE, { type, tab_id });
@@ -664,9 +682,10 @@ export function registerTools(server, wsManager, caps = 'all') {
664
682
  'get_performance',
665
683
  'Navigation timing, paint metrics, JS heap size and per-resource load times, as measured since the current '
666
684
  + '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.',
685
+ + 'so on a page still fetching wait for network idle first. Covers how fast the document arrived, '
686
+ + 'not the layout-shift and interaction metrics that accumulate afterwards.',
668
687
  {
669
- tab_id: z.number().optional(),
688
+ tab_id: tabId,
670
689
  },
671
690
  async ({ tab_id }) => {
672
691
  const data = await send(MessageType.GET_PERFORMANCE, { tab_id });
@@ -684,11 +703,11 @@ export function registerTools(server, wsManager, caps = 'all') {
684
703
  'query_dom',
685
704
  'Query DOM elements by CSS selector, returning structure, attributes, bounding rect, and computed styles.',
686
705
  {
687
- selector: z.string(),
706
+ selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. Matches all, not just the first'),
688
707
  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(),
708
+ limit: z.number().optional().default(50).describe('Max elements returned, from the top of the match list'),
709
+ tab_id: tabId,
710
+ frame_id: frameId,
692
711
  },
693
712
  async ({ selector, properties, limit, tab_id, frame_id }) => {
694
713
  const data = await send(MessageType.QUERY_DOM, { selector, properties, limit, tab_id, frame_id });
@@ -708,13 +727,13 @@ export function registerTools(server, wsManager, caps = 'all') {
708
727
  + 'setTextContent. Nothing is persisted — the next reload restores the page as the server sends it. '
709
728
  + 'For styling many elements at once inject_css is one call instead of N.',
710
729
  {
711
- selector: z.string(),
712
- action: z.enum(['setAttribute', 'removeAttribute', 'addClass', 'removeClass', 'setStyle', 'setTextContent']),
730
+ selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. Only the first match is changed'),
731
+ action: z.enum(['setAttribute', 'removeAttribute', 'addClass', 'removeClass', 'setStyle', 'setTextContent']).describe('setStyle takes a CSS declaration in value; addClass/removeClass take className'),
713
732
  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(),
733
+ value: z.string().optional().describe('Attribute value, style declaration, or text, per action'),
734
+ className: z.string().optional().describe('Class to add or remove (addClass/removeClass)'),
735
+ tab_id: tabId,
736
+ frame_id: frameId,
718
737
  },
719
738
  async ({ selector, action, name, value, className, tab_id, frame_id }) => {
720
739
  const data = await send(MessageType.MODIFY_DOM, { selector, action, name, value, className, tab_id, frame_id });
@@ -733,8 +752,8 @@ export function registerTools(server, wsManager, caps = 'all') {
733
752
  'Inject a CSS rule into the page. Stays until the next navigation or reload, and re-injecting the same id '
734
753
  + 'replaces it rather than stacking. Affects only what is rendered — the stylesheet of the site is untouched.',
735
754
  {
736
- css: z.string(),
737
- tab_id: z.number().optional(),
755
+ css: z.string().describe('One or more CSS rules, as they would appear in a stylesheet'),
756
+ tab_id: tabId,
738
757
  },
739
758
  async ({ css, tab_id }) => {
740
759
  const data = await send(MessageType.INJECT_CSS, { css, tab_id });
@@ -753,10 +772,10 @@ export function registerTools(server, wsManager, caps = 'all') {
753
772
  'Read console messages captured since page load, incl. uncaught errors and unhandled rejections.',
754
773
  {
755
774
  clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
756
- level: z.enum(['all', 'log', 'warn', 'error', 'info', 'debug']).optional().default('all'),
775
+ level: z.enum(['all', 'log', 'warn', 'error', 'info', 'debug']).optional().default('all').describe('all merges every level in one chronological list'),
757
776
  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(),
777
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps timestamps and stack traces'),
778
+ tab_id: tabId,
760
779
  },
761
780
  async ({ clear, level, limit, format, tab_id }) => {
762
781
  // limit va all'estensione: taglia in pagina e cancella (con clear) solo
@@ -788,10 +807,10 @@ export function registerTools(server, wsManager, caps = 'all') {
788
807
  '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
808
  {
790
809
  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'),
810
+ source: z.enum(['page', 'browser']).optional().default('page').describe('page sees XHR/fetch only; browser also sees static assets'),
811
+ format: z.enum(['lines', 'json', 'har']).optional().default('lines').describe('har exports HAR 1.2 for external tooling'),
793
812
  limit: z.number().optional().default(100).describe('Most recent; buffer 1000'),
794
- tab_id: z.number().optional(),
813
+ tab_id: tabId,
795
814
  },
796
815
  async ({ clear, source, format, limit, tab_id }) => {
797
816
  // limit va all'estensione (taglia in pagina, clear solo del restituito);
@@ -827,7 +846,7 @@ export function registerTools(server, wsManager, caps = 'all') {
827
846
  + 'Each call creates another tab: reuse a tab_id or navigate() to move an existing one instead of piling up tabs.',
828
847
  {
829
848
  url: z.string().optional().describe('URL to open (default: new tab page)'),
830
- active: z.boolean().optional().default(true),
849
+ active: z.boolean().optional().default(true).describe('false opens the tab in the background, leaving the current one focused'),
831
850
  },
832
851
  async ({ url, active }) => {
833
852
  const data = await send(MessageType.CREATE_TAB, { url, active });
@@ -844,9 +863,13 @@ export function registerTools(server, wsManager, caps = 'all') {
844
863
  // --- wait_for ---
845
864
  server.tool(
846
865
  'wait_for',
847
- 'Wait for: element (selector in DOM), function (JS expression truthy; needs "Allow user scripts"), navigation (mode=spa for route changes), network_idle.',
866
+ 'Block until a condition holds: element (selector in the DOM), function (JS expression turns truthy; needs '
867
+ + 'the "Allow user scripts" toggle), navigation (mode=spa for client-side route changes), network_idle. '
868
+ + 'Polls until timeout — 10s for element and function, 15s for navigation and network_idle — then '
869
+ + '**returns `found: false` with a reason instead of raising**, so a caller that ignores the result '
870
+ + 'silently proceeds as if the wait had succeeded. Read-only: waiting changes nothing on the page.',
848
871
  {
849
- condition: z.enum(['element', 'function', 'navigation', 'network_idle']),
872
+ condition: z.enum(['element', 'function', 'navigation', 'network_idle']).describe('function needs expression; element needs selector'),
850
873
  selector: z.string().optional().describe('condition=element'),
851
874
  expression: z.string().optional().describe('JS expression (condition=function)'),
852
875
  visible: z.boolean().optional().default(false).describe('Element must also be visible'),
@@ -854,8 +877,8 @@ export function registerTools(server, wsManager, caps = 'all') {
854
877
  idle_ms: z.number().optional().default(500).describe('Quiet period ms (network_idle)'),
855
878
  timeout: z.number().optional().describe('Max ms (default 10000; 15000 navigation/network_idle)'),
856
879
  interval: z.number().optional().describe('Poll ms, min 50'),
857
- tab_id: z.number().optional(),
858
- frame_id: z.number().optional(),
880
+ tab_id: tabId,
881
+ frame_id: frameId,
859
882
  },
860
883
  async ({ condition, selector, expression, visible, mode, idle_ms, timeout, interval, tab_id, frame_id }) => {
861
884
  let data;
@@ -885,18 +908,18 @@ export function registerTools(server, wsManager, caps = 'all') {
885
908
  'scroll',
886
909
  'Scroll. action=to: once to element/coordinates; action=until: repeatedly until element visible, network idle, or no new content (infinite scroll).',
887
910
  {
888
- action: z.enum(['to', 'until']).optional().default('to'),
911
+ action: z.enum(['to', 'until']).optional().default('to').describe('to jumps to a position or element; until scrolls repeatedly to load more'),
889
912
  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'),
913
+ x: z.number().optional().describe('Absolute horizontal position in px (action=to, without selector)'),
914
+ y: z.number().optional().describe('Absolute vertical position in px (action=to, without selector)'),
915
+ behavior: z.enum(['smooth', 'instant', 'auto']).optional().default('auto').describe('instant avoids waiting for smooth-scroll animations'),
893
916
  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),
917
+ 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'),
918
+ max_scrolls: z.number().optional().default(20).describe('Cap on scroll steps, so an infinite feed terminates'),
896
919
  step_px: z.number().optional().describe('px per step, default viewport height'),
897
920
  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'),
921
+ tab_id: tabId,
922
+ frame_id: frameId,
900
923
  },
901
924
  async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, tab_id, frame_id }) => {
902
925
  const data = (action ?? 'to') === 'until'
@@ -913,17 +936,17 @@ export function registerTools(server, wsManager, caps = 'all') {
913
936
  + 'action=clear wipes every entry of that type for the origin and cannot be undone — on a site the user is '
914
937
  + 'logged into, clearing cookies logs them out. Read the current values first with get_storage.',
915
938
  {
916
- type: z.enum(['localStorage', 'sessionStorage', 'cookie']),
917
- action: z.enum(['set', 'delete', 'clear']),
939
+ type: z.enum(['localStorage', 'sessionStorage', 'cookie']).describe('cookie writes a real cookie, not a storage key'),
940
+ action: z.enum(['set', 'delete', 'clear']).describe('clear ignores key and wipes every entry of that type'),
918
941
  key: z.string().optional().describe('Required for set/delete'),
919
- value: z.string().optional(),
942
+ value: z.string().optional().describe('Required for action=set'),
920
943
  path: z.string().optional().describe('Cookie path (default /)'),
921
944
  domain: z.string().optional().describe('Cookie domain'),
922
945
  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(),
946
+ secure: z.boolean().optional().describe('Cookie sent over HTTPS only'),
947
+ sameSite: z.enum(['Strict', 'Lax', 'None']).optional().describe('None requires secure=true'),
948
+ http_only: z.boolean().optional().describe('Cookie hidden from page JS'),
949
+ tab_id: tabId,
927
950
  },
928
951
  async ({ type, action, key, value, path, domain, expires, secure, sameSite, http_only, tab_id }) => {
929
952
  const data = await send(MessageType.SET_STORAGE, { type, action, key, value, path, domain, expires, secure, sameSite, http_only, tab_id });
@@ -946,9 +969,9 @@ export function registerTools(server, wsManager, caps = 'all') {
946
969
  value: z.string(),
947
970
  })).describe('{selector, value} pairs'),
948
971
  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(),
972
+ wait_after: waitAfter,
973
+ tab_id: tabId,
974
+ frame_id: frameId,
952
975
  },
953
976
  async ({ fields, submit_selector, wait_after, tab_id, frame_id }) => {
954
977
  const before = await tabSnapshot(tab_id);
@@ -968,12 +991,16 @@ export function registerTools(server, wsManager, caps = 'all') {
968
991
  // --- viewport_resize ---
969
992
  server.tool(
970
993
  'viewport_resize',
971
- 'Resize Chrome window to preset (mobile/tablet/desktop) or custom dimensions',
994
+ 'Resize the Chrome **window** to a preset (mobile 375x812, tablet 768x1024, desktop 1440x900) or to explicit '
995
+ + 'dimensions. The rendered viewport ends up smaller than what you ask for, by the height of the browser '
996
+ + 'chrome — measure it with execute_js if the exact number matters. width and height each override the '
997
+ + 'corresponding half of the preset, so preset plus width gives a custom width at the preset height. '
998
+ + 'A maximized window on ChromeOS ignores the request.',
972
999
  {
973
1000
  preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
974
1001
  width: z.number().optional().describe('Overrides preset'),
975
1002
  height: z.number().optional().describe('Overrides preset'),
976
- tab_id: z.number().optional(),
1003
+ tab_id: tabId,
977
1004
  },
978
1005
  async ({ preset, width, height, tab_id }) => {
979
1006
  const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, tab_id });
@@ -989,10 +1016,12 @@ export function registerTools(server, wsManager, caps = 'all') {
989
1016
  // --- element_screenshot ---
990
1017
  server.tool(
991
1018
  'element_screenshot',
992
- 'Screenshot a single element (scrolled into view, cropped). PNG.',
1019
+ 'Screenshot cropped to one element (PNG), scrolled into view first. Read-only. '
1020
+ + 'The cheapest image in the set, because it carries only the box you asked for: '
1021
+ + 'a component, a chart, a table cell.',
993
1022
  {
994
- selector: z.string(),
995
- tab_id: z.number().optional(),
1023
+ selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. The element is scrolled into view first'),
1024
+ tab_id: tabId,
996
1025
  },
997
1026
  async ({ selector, tab_id }) => {
998
1027
  const data = await send(MessageType.ELEMENT_SCREENSHOT, { selector, tab_id });
@@ -1011,12 +1040,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1011
1040
  + 'max_segments are returned and the rest need segment_offset. On a long page, reading the text you actually need '
1012
1041
  + 'is cheaper by an order of magnitude — prefer read_page, extract or find_text unless the layout itself is the question.',
1013
1042
  {
1014
- max_scrolls: z.number().optional().default(20),
1043
+ max_scrolls: z.number().optional().default(20).describe('Cap on scroll steps: a taller page is captured only up to here'),
1015
1044
  delay: z.number().optional().default(500).describe('ms between captures (min 500, Chrome quota)'),
1016
1045
  stitch: z.boolean().optional().default(true).describe('false = one image per viewport'),
1017
1046
  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
1047
  segment_offset: z.number().optional().default(0).describe('Skip the first N segments'),
1019
- tab_id: z.number().optional(),
1048
+ tab_id: tabId,
1020
1049
  },
1021
1050
  async ({ max_scrolls, delay, stitch, max_segments, segment_offset, tab_id }) => {
1022
1051
  const data = await send(MessageType.FULL_PAGE_SCREENSHOT, { max_scrolls, delay, stitch, tab_id });
@@ -1055,14 +1084,17 @@ export function registerTools(server, wsManager, caps = 'all') {
1055
1084
  // --- highlight_elements ---
1056
1085
  server.tool(
1057
1086
  'highlight_elements',
1058
- 'Add colored overlay on elements matching a CSS selector. remove=true clears all highlights.',
1087
+ 'Outline every element matching a selector with a coloured overlay, to see on a screenshot what a selector '
1088
+ + 'actually caught. Each call clears the overlays left by the previous one instead of stacking them, and '
1089
+ + 'remove=true clears without adding. The overlays are injected DOM nodes: a reload or a navigation drops '
1090
+ + 'them, and they sit above the page without altering its layout or its own styles.',
1059
1091
  {
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'),
1092
+ selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Every match is outlined'),
1093
+ color: z.string().optional().default('rgba(255,0,0,0.3)').describe('Any CSS color for the overlay label'),
1094
+ border: z.string().optional().default('2px solid red').describe('CSS border shorthand, e.g. "2px solid red"'),
1063
1095
  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(),
1096
+ remove: z.boolean().optional().default(false).describe('Remove previously injected highlights instead of adding'),
1097
+ tab_id: tabId,
1066
1098
  },
1067
1099
  async ({ selector, color, border, label, remove, tab_id }) => {
1068
1100
  const data = await send(MessageType.HIGHLIGHT_ELEMENTS, { selector, color, border, label, remove, tab_id });
@@ -1081,8 +1113,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1081
1113
  'A11y audit: missing alt, empty links, heading hierarchy, ARIA, contrast (approximate), form labels.',
1082
1114
  {
1083
1115
  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(),
1116
+ 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'),
1117
+ tab_id: tabId,
1086
1118
  },
1087
1119
  async ({ scope, checks, tab_id }) => {
1088
1120
  const data = await send(MessageType.ACCESSIBILITY_AUDIT, { scope, checks, tab_id });
@@ -1100,12 +1132,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1100
1132
  'check_links',
1101
1133
  'Check page links for broken URLs, verified server-side (no CORS limits, real HTTP status).',
1102
1134
  {
1103
- scope: z.enum(['same-origin', 'all', 'external']).optional().default('all'),
1104
- selector: z.string().optional().default('a[href]'),
1135
+ scope: z.enum(['same-origin', 'all', 'external']).optional().default('all').describe('same-origin skips third-party links, which are the slow ones'),
1136
+ selector: z.string().optional().default('a[href]').describe('CSS selector; ">>>" pierces shadow DOM. Restricts which links are collected'),
1105
1137
  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(),
1138
+ max_links: z.number().optional().default(50).describe('Cap on links fetched: each one is a real HTTP request'),
1139
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps per-link status and timing'),
1140
+ tab_id: tabId,
1109
1141
  },
1110
1142
  async ({ scope, selector, timeout, max_links, format, tab_id }) => {
1111
1143
  const data = await send(MessageType.COLLECT_LINKS, { scope, selector, max_links, tab_id });
@@ -1136,9 +1168,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1136
1168
  + 'Read-only. Values come from the current layout, so zoom and viewport size change them: set_zoom(1) and a '
1137
1169
  + 'fixed viewport_resize make results comparable across runs.',
1138
1170
  {
1139
- selector1: z.string(),
1140
- selector2: z.string(),
1141
- tab_id: z.number().optional(),
1171
+ selector1: z.string().describe('First element; distances are measured from its box'),
1172
+ selector2: z.string().describe('Second element; ">>>" pierces shadow DOM'),
1173
+ tab_id: tabId,
1142
1174
  },
1143
1175
  async ({ selector1, selector2, tab_id }) => {
1144
1176
  const data = await send(MessageType.MEASURE_SPACING, { selector1, selector2, tab_id });
@@ -1156,15 +1188,15 @@ export function registerTools(server, wsManager, caps = 'all') {
1156
1188
  'watch_dom',
1157
1189
  'Watch DOM mutations (MutationObserver). First call installs the watcher; later calls read accumulated mutations.',
1158
1190
  {
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),
1191
+ selector: z.string().optional().default('body').describe('Subtree to observe; ">>>" pierces shadow DOM'),
1192
+ attributes: z.boolean().optional().default(true).describe('Report attribute changes'),
1193
+ childList: z.boolean().optional().default(true).describe('Report added and removed children'),
1194
+ characterData: z.boolean().optional().default(false).describe('Report text content changes'),
1195
+ subtree: z.boolean().optional().default(true).describe('Observe descendants too, not just the matched node'),
1164
1196
  clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
1165
1197
  stop: z.boolean().optional().default(false).describe('Disconnect observer'),
1166
1198
  limit: z.number().optional().default(100).describe('Most recent mutations; buffer 1000'),
1167
- tab_id: z.number().optional(),
1199
+ tab_id: tabId,
1168
1200
  },
1169
1201
  async ({ selector, attributes, childList, characterData, subtree, clear, stop, limit, tab_id }) => {
1170
1202
  const data = await send(MessageType.WATCH_DOM, { selector, attributes, childList, characterData, subtree, clear, stop, limit, tab_id });
@@ -1185,12 +1217,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1185
1217
  + 'user_agent only changes what page JS reads — the request header is a separate thing, set it with '
1186
1218
  + 'network_rules(action=modify_header, header="User-Agent"). Pair with viewport_resize to emulate a device.',
1187
1219
  {
1188
- colorScheme: z.enum(['dark', 'light', 'no-preference']).optional(),
1189
- reducedMotion: z.enum(['reduce', 'no-preference']).optional(),
1190
- printMode: z.boolean().optional().default(false),
1220
+ colorScheme: z.enum(['dark', 'light', 'no-preference']).optional().describe('Value reported to prefers-color-scheme queries'),
1221
+ reducedMotion: z.enum(['reduce', 'no-preference']).optional().describe('Value reported to prefers-reduced-motion queries'),
1222
+ printMode: z.boolean().optional().default(false).describe('Make print media queries match, without opening a print dialog'),
1191
1223
  user_agent: z.string().optional().describe('Overrides navigator.userAgent and appVersion in the page (not the HTTP header)'),
1192
1224
  reset: z.boolean().optional().default(false).describe('Remove all emulations'),
1193
- tab_id: z.number().optional(),
1225
+ tab_id: tabId,
1194
1226
  },
1195
1227
  async ({ colorScheme, reducedMotion, printMode, user_agent, reset, tab_id }) => {
1196
1228
  const data = await send(MessageType.EMULATE_MEDIA, { colorScheme, reducedMotion, printMode, user_agent, reset, tab_id });
@@ -1208,10 +1240,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1208
1240
  'hover',
1209
1241
  'Hover over an element (mouseenter/mouseover), by CSS selector or ref.',
1210
1242
  {
1211
- selector: z.string().optional(),
1243
+ selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Triggers CSS and JS hover handlers'),
1212
1244
  ref: z.string().optional().describe('From get_interactives'),
1213
- tab_id: z.number().optional(),
1214
- frame_id: z.number().optional(),
1245
+ tab_id: tabId,
1246
+ frame_id: frameId,
1215
1247
  },
1216
1248
  async ({ selector, ref, tab_id, frame_id }) => {
1217
1249
  const data = await send(MessageType.HOVER, { selector: resolveTarget(selector, ref, tab_id), tab_id, frame_id });
@@ -1233,12 +1265,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1233
1265
  {
1234
1266
  key: z.string().describe('e.g. "Enter", "Escape", "Tab", "ArrowDown"'),
1235
1267
  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(),
1268
+ ctrl: z.boolean().optional().default(false).describe('Hold Control'),
1269
+ shift: z.boolean().optional().default(false).describe('Hold Shift'),
1270
+ alt: z.boolean().optional().default(false).describe('Hold Alt'),
1271
+ meta: z.boolean().optional().default(false).describe('Hold Meta (Command/Windows)'),
1272
+ tab_id: tabId,
1273
+ frame_id: frameId,
1242
1274
  },
1243
1275
  async ({ key, selector, ctrl, shift, alt, meta, tab_id, frame_id }) => {
1244
1276
  const data = await send(MessageType.PRESS_KEY, { key, selector, ctrl, shift, alt, meta, tab_id, frame_id });
@@ -1256,7 +1288,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1256
1288
  'get_frames',
1257
1289
  'List frames (main + iframes) with frameId, parent, URL — for the frame_id parameter of DOM tools.',
1258
1290
  {
1259
- tab_id: z.number().optional(),
1291
+ tab_id: tabId,
1260
1292
  },
1261
1293
  async ({ tab_id }) => {
1262
1294
  const data = await send(MessageType.GET_FRAMES, { tab_id });
@@ -1271,9 +1303,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1271
1303
  + 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
1272
1304
  + 'reload and navigation drop injected CSS, emulations and page hooks.',
1273
1305
  {
1274
- action: z.enum(['close', 'activate', 'reload', 'back', 'forward']),
1306
+ action: z.enum(['close', 'activate', 'reload', 'back', 'forward']).describe('close cannot be undone; reload drops injected CSS and page hooks'),
1275
1307
  bypass_cache: z.boolean().optional().default(false).describe('reload only'),
1276
- tab_id: z.number().optional(),
1308
+ tab_id: tabId,
1277
1309
  },
1278
1310
  async ({ action, bypass_cache, tab_id }) => {
1279
1311
  const data = await send(MessageType.TAB_ACTION, { action, bypass_cache, tab_id });
@@ -1288,10 +1320,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1288
1320
  'upload_file',
1289
1321
  'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB).',
1290
1322
  {
1291
- selector: z.string(),
1323
+ selector: z.string().describe('The file input to fill; ">>>" pierces shadow DOM'),
1292
1324
  path: z.string().describe('Absolute path on the server machine'),
1293
1325
  mime_type: z.string().optional().describe('Default: inferred from extension'),
1294
- tab_id: z.number().optional(),
1326
+ tab_id: tabId,
1295
1327
  },
1296
1328
  async ({ selector, path, mime_type, tab_id }) => {
1297
1329
  const buf = await readFile(path);
@@ -1309,7 +1341,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1309
1341
  'dismiss_overlays',
1310
1342
  'Dismiss cookie banners/modal overlays: OneTrust, Cookiebot, Usercentrics, then generic heuristic. Idempotent.',
1311
1343
  {
1312
- tab_id: z.number().optional(),
1344
+ tab_id: tabId,
1313
1345
  },
1314
1346
  async ({ tab_id }) => {
1315
1347
  const data = await send(MessageType.DISMISS_OVERLAYS, { tab_id });
@@ -1322,9 +1354,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1322
1354
  'handle_dialogs',
1323
1355
  'Auto-accept/dismiss future JS dialogs (alert/confirm/prompt), logging them. reset restores native dialogs and returns the log.',
1324
1356
  {
1325
- action: z.enum(['accept', 'dismiss', 'reset']).optional().default('accept'),
1357
+ action: z.enum(['accept', 'dismiss', 'reset']).optional().default('accept').describe('accept/dismiss auto-answer future dialogs; reset restores native behaviour'),
1326
1358
  prompt_text: z.string().optional().describe('Returned by window.prompt on accept'),
1327
- tab_id: z.number().optional(),
1359
+ tab_id: tabId,
1328
1360
  },
1329
1361
  async ({ action, prompt_text, tab_id }) => {
1330
1362
  const data = await send(MessageType.HANDLE_DIALOGS, { action, prompt_text, tab_id });
@@ -1337,10 +1369,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1337
1369
  'find_text',
1338
1370
  '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
1371
  {
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(),
1372
+ text: z.string().describe('Literal text to find, not a regex'),
1373
+ case_sensitive: z.boolean().optional().default(false).describe('Match case exactly'),
1374
+ max_results: z.number().optional().default(20).describe('Cap on matches returned, in document order'),
1375
+ tab_id: tabId,
1344
1376
  },
1345
1377
  async ({ text, case_sensitive, max_results, tab_id }) => {
1346
1378
  const data = await send(MessageType.FIND_TEXT, { text, case_sensitive, max_results, tab_id });
@@ -1381,16 +1413,16 @@ export function registerTools(server, wsManager, caps = 'all') {
1381
1413
  'network_rules',
1382
1414
  '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
1415
  {
1384
- action: z.enum(['block', 'redirect', 'modify_header', 'stub', 'list', 'clear']),
1416
+ action: z.enum(['block', 'redirect', 'modify_header', 'stub', 'list', 'clear']).describe('list and clear inspect and drop the rules already installed'),
1385
1417
  url_filter: z.string().optional().describe('declarativeNetRequest urlFilter, e.g. "||example.com/api/*"'),
1386
- redirect_url: z.string().optional(),
1387
- header: z.string().optional(),
1418
+ redirect_url: z.string().optional().describe('Destination for action=redirect'),
1419
+ header: z.string().optional().describe('Header name for action=modify_header, e.g. "User-Agent"'),
1388
1420
  header_value: z.string().optional().describe('Omit to remove header'),
1389
1421
  header_target: z.enum(['request', 'response']).optional().default('request').describe("response = strip content-security-policy / x-frame-options, inject CORS"),
1390
1422
  body: z.string().optional().describe('Response body (action=stub)'),
1391
1423
  status: z.number().optional().default(200).describe('action=stub'),
1392
1424
  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(),
1425
+ 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
1426
  },
1395
1427
  async ({ action, url_filter, redirect_url, header, header_value, header_target, body, status, content_type, resource_types }) => {
1396
1428
  if (action === 'stub') {
@@ -1417,11 +1449,11 @@ export function registerTools(server, wsManager, caps = 'all') {
1417
1449
  'screenshot_diff',
1418
1450
  '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
1451
  {
1420
- action: z.enum(['baseline', 'compare', 'list', 'clear']),
1421
- name: z.string().optional().default('default'),
1452
+ action: z.enum(['baseline', 'compare', 'list', 'clear']).describe('baseline stores, compare measures against it, clear drops baselines'),
1453
+ name: z.string().optional().default('default').describe('Baseline id: reuse the same one to compare across runs'),
1422
1454
  selector: z.string().optional().describe('Capture one element (default viewport)'),
1423
1455
  threshold: z.number().optional().default(10).describe('Per-channel tolerance 0-255'),
1424
- tab_id: z.number().optional(),
1456
+ tab_id: tabId,
1425
1457
  },
1426
1458
  async ({ action, name, selector, threshold, tab_id }) => {
1427
1459
  const data = await send(MessageType.SCREENSHOT_DIFF, { action, name, selector, threshold, tab_id });
@@ -1443,9 +1475,10 @@ export function registerTools(server, wsManager, caps = 'all') {
1443
1475
  'web_vitals',
1444
1476
  'Core Web Vitals accumulated since the current document loaded: CLS, LCP, FCP, TTFB, long tasks and an INP '
1445
1477
  + '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.',
1478
+ + 'was hooked instead of silently returning zeros — navigate() installs it. '
1479
+ + 'Covers user-perceived stability and responsiveness after load, not the load timings themselves.',
1447
1480
  {
1448
- tab_id: z.number().optional(),
1481
+ tab_id: tabId,
1449
1482
  },
1450
1483
  async ({ tab_id }) => {
1451
1484
  const data = await send(MessageType.WEB_VITALS, { tab_id });
@@ -1459,8 +1492,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1459
1492
  'List addEventListener registrations since page load: counts by type + recent entries.',
1460
1493
  {
1461
1494
  type: z.string().optional().describe('e.g. "click"'),
1462
- limit: z.number().optional().default(100),
1463
- tab_id: z.number().optional(),
1495
+ limit: z.number().optional().default(100).describe('Max listeners returned, from the top of the match list'),
1496
+ tab_id: tabId,
1464
1497
  },
1465
1498
  async ({ type, limit, tab_id }) => {
1466
1499
  const data = await send(MessageType.LIST_EVENT_LISTENERS, { type, limit, tab_id });
@@ -1474,7 +1507,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1474
1507
  'Monitor WebSocket connections/messages (500-char previews). Hook installs on first call; earlier connections are missed.',
1475
1508
  {
1476
1509
  clear: z.boolean().optional().default(false).describe('Clear buffer after read'),
1477
- tab_id: z.number().optional(),
1510
+ tab_id: tabId,
1478
1511
  },
1479
1512
  async ({ clear, tab_id }) => {
1480
1513
  const data = await send(MessageType.MONITOR_WEBSOCKET, { clear, tab_id });
@@ -1487,7 +1520,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1487
1520
  'seo_audit',
1488
1521
  'SEO audit: title/description lengths, canonical, robots, h1 count, Open Graph, Twitter card, JSON-LD validity, hreflang, lang, viewport, favicon',
1489
1522
  {
1490
- tab_id: z.number().optional(),
1523
+ tab_id: tabId,
1491
1524
  },
1492
1525
  async ({ tab_id }) => {
1493
1526
  const data = await send(MessageType.SEO_AUDIT, { tab_id });
@@ -1498,16 +1531,19 @@ export function registerTools(server, wsManager, caps = 'all') {
1498
1531
  // --- extract_table ---
1499
1532
  server.tool(
1500
1533
  '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.',
1534
+ 'Read a real <table> as JSON: thead cells become keys, each row an object. Read-only. '
1535
+ + 'Only for tabular markup — `where` filters rows server-side, so one matching row out of 1500 '
1536
+ + 'costs a few hundred bytes instead of the whole document. `row_count` is the table total, '
1537
+ + '`match_count` how many rows passed the filter.',
1502
1538
  {
1503
- selector: z.string().optional().default('table'),
1504
- index: z.number().optional().default(0),
1539
+ selector: z.string().optional().default('table').describe('The table to read; ">>>" pierces shadow DOM'),
1540
+ index: z.number().optional().default(0).describe('Which table to take when the selector matches several, 0-based'),
1505
1541
  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.'),
1542
+ where: z.record(z.string(), z.string()).optional().describe('{column: substring} rows must match, case-insensitive contains. Key "any" matches any cell.'),
1507
1543
  columns: z.array(z.string()).optional().describe('Return only these columns per row.'),
1508
1544
  offset: z.number().optional().default(0).describe('Skip N rows of the (filtered) set before applying max_rows.'),
1509
1545
  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(),
1546
+ tab_id: tabId,
1511
1547
  },
1512
1548
  async ({ selector, index, max_rows, where, columns, offset, scan_rows, tab_id }) => {
1513
1549
  const data = await send(MessageType.EXTRACT_TABLE, { selector, index, scan_rows, tab_id });
@@ -1521,8 +1557,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1521
1557
  'unused_css',
1522
1558
  'List CSS selectors matching nothing in the current DOM (approximate; cross-origin sheets unreadable).',
1523
1559
  {
1524
- max_selectors: z.number().optional().default(200),
1525
- tab_id: z.number().optional(),
1560
+ max_selectors: z.number().optional().default(200).describe('Cap on unused selectors reported: a large stylesheet has thousands'),
1561
+ tab_id: tabId,
1526
1562
  },
1527
1563
  async ({ max_selectors, tab_id }) => {
1528
1564
  const data = await send(MessageType.UNUSED_CSS, { max_selectors, tab_id });
@@ -1535,11 +1571,11 @@ export function registerTools(server, wsManager, caps = 'all') {
1535
1571
  'drag_and_drop',
1536
1572
  'Drag an element onto another. html5 = DragEvent+DataTransfer; pointer = pointer/mouse events (sortable libraries).',
1537
1573
  {
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(),
1574
+ source_selector: z.string().describe('Element to drag; ">>>" pierces shadow DOM'),
1575
+ target_selector: z.string().describe('Drop target; ">>>" pierces shadow DOM'),
1576
+ mode: z.enum(['html5', 'pointer']).optional().default('html5').describe('Which event family to emit, since libraries listen to different ones'),
1577
+ frame_id: frameId,
1578
+ tab_id: tabId,
1543
1579
  },
1544
1580
  async ({ source_selector, target_selector, mode, frame_id, tab_id }) => {
1545
1581
  const data = await send(MessageType.DRAG_AND_DROP, { source_selector, target_selector, mode, frame_id, tab_id });
@@ -1552,9 +1588,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1552
1588
  'clipboard',
1553
1589
  'Read or write the system clipboard (text). Activates the tab first.',
1554
1590
  {
1555
- action: z.enum(['read', 'write']),
1591
+ action: z.enum(['read', 'write']).describe('write takes text; read returns the current clipboard contents'),
1556
1592
  text: z.string().optional().describe('For write'),
1557
- tab_id: z.number().optional(),
1593
+ tab_id: tabId,
1558
1594
  },
1559
1595
  async ({ action, text, tab_id }) => {
1560
1596
  const data = await send(MessageType.CLIPBOARD, { action, text, tab_id });
@@ -1567,11 +1603,11 @@ export function registerTools(server, wsManager, caps = 'all') {
1567
1603
  'set_geolocation',
1568
1604
  'Override navigator.geolocation with fixed coordinates (page-level patch). reset restores native.',
1569
1605
  {
1570
- latitude: z.number().optional(),
1571
- longitude: z.number().optional(),
1606
+ latitude: z.number().optional().describe('Decimal degrees, -90 to 90'),
1607
+ longitude: z.number().optional().describe('Decimal degrees, -180 to 180'),
1572
1608
  accuracy: z.number().optional().default(10).describe('Meters'),
1573
- reset: z.boolean().optional().default(false),
1574
- tab_id: z.number().optional(),
1609
+ reset: z.boolean().optional().default(false).describe('Restore the real position and stop overriding'),
1610
+ tab_id: tabId,
1575
1611
  },
1576
1612
  async ({ latitude, longitude, accuracy, reset, tab_id }) => {
1577
1613
  const data = await send(MessageType.SET_GEOLOCATION, { latitude, longitude, accuracy, reset, tab_id });
@@ -1584,9 +1620,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1584
1620
  'manage_downloads',
1585
1621
  'List downloads or wait for one to complete. Files land in the browser Downloads folder, not on the server.',
1586
1622
  {
1587
- action: z.enum(['list', 'wait_for_complete']),
1623
+ action: z.enum(['list', 'wait_for_complete']).describe('wait_for_complete blocks until the newest download finishes or times out'),
1588
1624
  timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
1589
- limit: z.number().optional().default(10),
1625
+ limit: z.number().optional().default(10).describe('Max download entries returned, newest first'),
1590
1626
  },
1591
1627
  async ({ action, timeout, limit }) => {
1592
1628
  const data = await send(MessageType.MANAGE_DOWNLOADS, { action, timeout, limit });
@@ -1600,7 +1636,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1600
1636
  'Save the full page (DOM, styles, images) as an MHTML archive file on the server filesystem.',
1601
1637
  {
1602
1638
  output_path: z.string().describe('Absolute file path to write (e.g. /tmp/page.mhtml)'),
1603
- tab_id: z.number().optional(),
1639
+ tab_id: tabId,
1604
1640
  },
1605
1641
  async ({ output_path, tab_id }) => {
1606
1642
  const data = await send(MessageType.SAVE_PAGE, { tab_id });
@@ -1618,7 +1654,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1618
1654
  + '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
1655
  {
1620
1656
  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'),
1657
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']).optional().default('GET').describe('HEAD fetches headers only, without the body'),
1622
1658
  headers: z.record(z.string(), z.string()).optional().describe('Extra request headers'),
1623
1659
  body: z.string().optional().describe('Request body (POST/PUT/PATCH)'),
1624
1660
  save_to: z.string().optional().describe('Absolute path: write the response bytes here instead of returning them (PDF, images, archives)'),
@@ -1673,8 +1709,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1673
1709
  'Get or set tab zoom (0.25–5). No factor = read current; reset restores default.',
1674
1710
  {
1675
1711
  factor: z.number().optional().describe('1 = 100%'),
1676
- reset: z.boolean().optional().default(false),
1677
- tab_id: z.number().optional(),
1712
+ reset: z.boolean().optional().default(false).describe('Restore the default zoom for this origin'),
1713
+ tab_id: tabId,
1678
1714
  },
1679
1715
  async ({ factor, reset, tab_id }) => {
1680
1716
  const data = await send(MessageType.SET_ZOOM, { factor, reset, tab_id });
@@ -1687,9 +1723,9 @@ export function registerTools(server, wsManager, caps = 'all') {
1687
1723
  'http_auth',
1688
1724
  'Set/clear credentials for HTTP Basic/Digest auth dialogs (browser-wide, in-memory only).',
1689
1725
  {
1690
- action: z.enum(['set', 'clear']),
1691
- username: z.string().optional(),
1692
- password: z.string().optional(),
1726
+ action: z.enum(['set', 'clear']).describe('set installs credentials for HTTP auth prompts; clear removes them'),
1727
+ username: z.string().optional().describe('Required for action=set'),
1728
+ password: z.string().optional().describe('Required for action=set; kept in memory, never written to disk'),
1693
1729
  },
1694
1730
  async ({ action, username, password }) => {
1695
1731
  if (action === 'set' && !username) throw new Error('username is required for action=set');
@@ -1703,7 +1739,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1703
1739
  'security_headers',
1704
1740
  'Audit security headers (CSP, HSTS, XCTO, clickjacking, Referrer/Permissions-Policy, version leaks). Captured from real navigations — reload if unavailable.',
1705
1741
  {
1706
- tab_id: z.number().optional(),
1742
+ tab_id: tabId,
1707
1743
  },
1708
1744
  async ({ tab_id }) => {
1709
1745
  const data = await send(MessageType.GET_RESPONSE_HEADERS, { tab_id });
@@ -1719,11 +1755,15 @@ export function registerTools(server, wsManager, caps = 'all') {
1719
1755
  // --- session_fixture ---
1720
1756
  server.tool(
1721
1757
  'session_fixture',
1722
- '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.',
1758
+ 'Snapshot localStorage, sessionStorage and cookies of the current origin into a named fixture on the server, '
1759
+ + 'restore one, or list what has been saved. A logged-in state is the usual reason. save overwrites a '
1760
+ + 'fixture of the same name without asking; restore writes entries on top of what is already there '
1761
+ + 'rather than clearing first, and refuses outright when the tab sits on a different origin than the '
1762
+ + 'one recorded — cookies would otherwise attach to the wrong site. name is required except for list.',
1723
1763
  {
1724
- action: z.enum(['save', 'restore', 'list']),
1764
+ action: z.enum(['save', 'restore', 'list']).describe('save snapshots the current origin; restore writes it back'),
1725
1765
  name: z.string().optional().describe('Required for save/restore'),
1726
- tab_id: z.number().optional(),
1766
+ tab_id: tabId,
1727
1767
  },
1728
1768
  async ({ action, name, tab_id }) => {
1729
1769
  if (action === 'list') {
@@ -1793,11 +1833,12 @@ export function registerTools(server, wsManager, caps = 'all') {
1793
1833
  '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
1834
  {
1795
1835
  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(),
1836
+ limit: z.number().optional().default(100).describe('Max elements returned; raise it on a dense page'),
1837
+ visible_only: z.boolean().optional().default(true)
1838
+ .describe('false also lists elements hidden or scrolled out of view'),
1839
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json adds full attributes per element'),
1840
+ frame_id: frameId,
1841
+ tab_id: tabId,
1801
1842
  },
1802
1843
  async ({ scope, limit, visible_only, format, frame_id, tab_id }) => {
1803
1844
  const data = await send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id });
@@ -1823,16 +1864,18 @@ export function registerTools(server, wsManager, caps = 'all') {
1823
1864
  // --- extract ---
1824
1865
  server.tool(
1825
1866
  '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.',
1867
+ 'Read repeated non-tabular structures — product cards, list items, search results — as one record per item: '
1868
+ + 'item_selector matches the repeating block, fields map output names to selectors relative to it. '
1869
+ + 'Read-only, deterministic, parsed server-side from the main-document HTML.',
1827
1870
  {
1828
1871
  item_selector: z.string().describe('CSS selector matching each item'),
1829
1872
  fields: z.record(z.string(), z.object({
1830
1873
  selector: z.string().optional().describe('Relative to the item (default: the item itself)'),
1831
1874
  attr: z.string().optional().describe('Attribute to read (default: text content)'),
1832
1875
  })).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(),
1876
+ max_items: z.number().optional().default(50).describe('Cap on records returned, in document order'),
1877
+ format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps one object per record'),
1878
+ tab_id: tabId,
1836
1879
  max_length: z.number().optional().default(20000).describe('Max output chars'),
1837
1880
  },
1838
1881
  async ({ item_selector, fields, max_items, format, tab_id, max_length }) => {
@@ -1866,14 +1909,14 @@ export function registerTools(server, wsManager, caps = 'all') {
1866
1909
  'assert',
1867
1910
  '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
1911
  {
1869
- selector: z.string().optional(),
1870
- state: z.enum(['attached', 'visible']).optional().default('attached'),
1912
+ selector: z.string().optional().describe('Element the assertion is about; ">>>" pierces shadow DOM'),
1913
+ state: z.enum(['attached', 'visible']).optional().default('attached').describe('attached means present in the DOM, visible also requires a rendered box'),
1871
1914
  text: z.string().optional().describe('In the element (with selector) or anywhere on the page'),
1872
1915
  count: z.number().optional().describe('Exact match count for selector'),
1873
1916
  url: z.string().optional().describe('Tab url pattern'),
1874
1917
  title: z.string().optional().describe('Tab title pattern'),
1875
- timeout: z.number().optional().default(5000),
1876
- tab_id: z.number().optional(),
1918
+ timeout: z.number().optional().default(5000).describe('Max ms to wait for the condition before failing'),
1919
+ tab_id: tabId,
1877
1920
  },
1878
1921
  async ({ selector, state, text, count, url, title, timeout, tab_id }) => {
1879
1922
  const params = { selector, state, text, count, url, title, timeout, tab_id: tab_id ?? sessionTabId ?? undefined };
@@ -1896,7 +1939,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1896
1939
  'session_record',
1897
1940
  '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
1941
  {
1899
- action: z.enum(['start', 'stop', 'status', 'list']),
1942
+ action: z.enum(['start', 'stop', 'status', 'list']).describe('start begins recording, stop writes the file and returns its path'),
1900
1943
  name: z.string().optional().describe('Required for start'),
1901
1944
  },
1902
1945
  async ({ action, name }) => {