qiksy-mcp 1.2.0 → 1.2.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.
Files changed (3) hide show
  1. package/AGENT-GUIDE.md +12 -4
  2. package/package.json +7 -3
  3. package/server.mjs +250 -24
package/AGENT-GUIDE.md CHANGED
@@ -82,10 +82,18 @@ sentence rather than working around it.
82
82
 
83
83
  ## What it cannot do — say so, don't substitute
84
84
 
85
- - **No screenshots.** `qa_snapshot` is a tree, not pixels. Judgements about spacing,
86
- colour or alignment cannot be made from here.
87
- - **No arbitrary JavaScript.** No `evaluate`, by design (a Web Store requirement). Which
88
- means **no reading or writing localStorage** and **no measuring geometry**.
85
+ - **No screenshots.** `qa_snapshot` is a tree, not pixels. A judgement that genuinely needs
86
+ to SEE the rendering is this the right shade, does that shadow look right — cannot be
87
+ made from here.
88
+ - **No arbitrary JavaScript.** No `evaluate`, by design (a Web Store requirement). Every verb
89
+ is a closed vocabulary: you name an element, never a program.
90
+ - **Styles, geometry and web storage ARE available** — `qa_styles` (computed styles, the box,
91
+ and the CSS custom properties in scope), `qa_snapshot({geometry:true})` (every node's box in
92
+ CSS pixels), `qa_storage` (localStorage / sessionStorage, read free, write behind Agent
93
+ control). This guide used to list all three as impossible. That was a wrong inference from
94
+ the no-eval rule, and it cost real capability: an agent told a thing cannot be done does not
95
+ try it. So: no pixels, but plenty of numbers — "this button renders #3B82F6 at 15px with
96
+ 22px padding, and its box overlaps the one next to it" needs no screenshot at all.
89
97
  - **No `<iframe>` contents.** Payment forms (Stripe, 3-D Secure) live in frames the
90
98
  content script does not enter. Fill up to them, not inside them.
91
99
  - **No captcha.** Stop and hand it back.
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.2.0",
4
- "description": "MCP bridge for the Qiksy browser extension expose live QA findings, forms, network and session to any MCP-capable coding agent.",
3
+ "version": "1.2.1",
4
+ "description": "MCP bridge for the Qiksy browser extension \u2014 expose live QA findings, forms, network and session to any MCP-capable coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "qiksy-mcp": "server.mjs"
8
8
  },
9
- "files": ["server.mjs", "README.md", "AGENT-GUIDE.md"],
9
+ "files": [
10
+ "server.mjs",
11
+ "README.md",
12
+ "AGENT-GUIDE.md"
13
+ ],
10
14
  "engines": {
11
15
  "node": ">=18"
12
16
  },
package/server.mjs CHANGED
@@ -26,7 +26,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
26
26
  import { WebSocketServer, WebSocket } from 'ws';
27
27
  import { z } from 'zod';
28
28
  import { randomUUID, timingSafeEqual } from 'node:crypto';
29
- import { readFileSync } from 'node:fs';
29
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
30
+ import { basename, extname, dirname, isAbsolute, resolve } from 'node:path';
30
31
  import { fileURLToPath } from 'node:url';
31
32
 
32
33
  const log = (...a) => console.error('[qiksy-mcp]', ...a);
@@ -494,20 +495,58 @@ window or tab focus, and never steals the pointer. That is the point of the prod
494
495
  happens inside the existing (even backgrounded) tab, so prefer it over anything that would pop
495
496
  a new browser up in the user's face.
496
497
 
497
- What it gives you, and what it deliberately does not:
498
- - qa_snapshot returns an ACCESSIBILITY TREE, not pixels. There are no screenshots on this
499
- bridge, no arbitrary JavaScript execution (a Web Store requirement, not an oversight), and
500
- therefore no reading or writing localStorage and no measuring geometry. When a task truly
501
- needs pixels or eval, say that it is outside the bridge never silently substitute another
502
- browser tool.
503
- - Read tools qa_tabs, qa_status, qa_snapshot, qa_findings, qa_export always work.
504
- - Tools that act on the app under test qa_click, qa_type, qa_type_many, qa_press,
505
- qa_wait_ready, qa_navigate, qa_open_isolated, qa_close_tab require Qiksy Pro AND a
506
- one-time "Agent control" consent in the extension popup. A refusal there is the user's to
507
- clear; report it in one sentence instead of working around it.
508
- - Address a specific tab with the tabId from qa_tabs; omit it to act on the active tab. Tabs
509
- opened by qa_open_isolated are separate logins on the same site that is how you drive
510
- several accounts at once.
498
+ WHAT YOU CAN SEE (all free, none of it needs Agent control):
499
+ - qa_snapshot the whole page as an accessibility tree with stable refs. Pass geometry:true
500
+ and every node also carries its box in CSS pixels, which is how you answer "this is cut off
501
+ / these overlap / that tap target is 28px" without a screenshot.
502
+ - qa_read the RENDERED text of the page or one element. Reach for it whenever the answer is
503
+ longer than a label: qa_snapshot caps names at 80 characters, so a one-time code in a mail
504
+ tab, a full error paragraph or a generated invoice number is invisible to it.
505
+ - qa_styles computed styles, the element's box, and the CSS custom properties in scope.
506
+ "color: rgb(37,99,235)" says what is drawn; "--accent: #2563EB" says which token it came
507
+ from, and only the second one is a bug report. This is how you check a build against a design.
508
+ - qa_storage read the app's localStorage / sessionStorage. Feature flags, cached state, and
509
+ usually the session token live there.
510
+ - qa_findings / qa_export / qa_status what Qiksy itself observed: console and JS errors,
511
+ failed requests WITH their server error bodies, a11y and markup defects, and a "Speed &
512
+ stability" group fed by the browser's own measurements (layout shift with the element that
513
+ moved, slow interactions split into input delay / handler / paint, main-thread blocks with
514
+ the script responsible).
515
+
516
+ WHAT YOU CAN DO (Qiksy Pro + the one-time "Agent control" consent in the popup; a refusal
517
+ there is the user's to clear — report it in one sentence instead of working around it):
518
+ - qa_click / qa_press — and they ANSWER with what changed. After the page settles you get the
519
+ diff: what appeared, disappeared, updated. Do not follow an action with a qa_snapshot just
520
+ to find out whether it worked; \`changed: null\` means the page did not move, which is itself
521
+ the answer.
522
+ - qa_type — one field. qa_type_many — a whole form in ONE call. Prefer refs over selectors in
523
+ the batch: a form built from a repeated component gives several inputs the identical
524
+ cssPath, and selectors would then pour every value into the first match.
525
+ - qa_upload — attach a file. \`path\` for a file on the machine (THIS process reads it, so the
526
+ bytes never enter your context), \`fixture\` for a hostile one built in the page at no cost
527
+ over the wire: empty, oversize 12MB, wrong-type, corrupt, long-name, unicode-name,
528
+ svg-script, double-ext. Each returns the question it asks, as \`asks\`.
529
+ - qa_storage with op set/remove/clear, qa_navigate, qa_open_isolated, qa_close_tab.
530
+ - qa_report — the session report. ALWAYS pass \`path\`: it is a self-contained HTML document
531
+ with embedded screenshots, routinely hundreds of kilobytes, and you get back a receipt
532
+ instead of the bytes. Pass \`spec\` with the ticket's acceptance criteria and your verdict on
533
+ each, and it becomes a deliverable rather than a log.
534
+
535
+ WHAT IS GENUINELY NOT HERE: no screenshots over this bridge, and no arbitrary JavaScript
536
+ execution — the second is a Web Store requirement, not an oversight, and it is why every verb
537
+ above is a closed vocabulary: you name an element, never a program. When a task truly needs
538
+ pixels or eval, say it is outside the bridge; never silently substitute another browser tool.
539
+ (Styles, geometry and web storage USED to be listed here as impossible. They were not; that
540
+ was a wrong inference from the no-eval rule, and they have their own tools now.)
541
+
542
+ TABS: the agent session is a SET the human opts into — the "Agent drives this tab" switch in
543
+ the popup, or right-click → "Qiksy — test this tab". Attached tabs stay in the session while
544
+ the user looks at something else, so work continues in a background tab, and a tab opened BY
545
+ an attached tab joins it automatically (that is how an identity-provider sign-in stays in the
546
+ flow). qa_tabs lists the session; pass a tabId to target one. Tabs from qa_open_isolated are
547
+ separate logins on the same site — that is how you drive several accounts at once. If the set
548
+ is empty it falls back to the active tab; if a tool answers out-of-scope, ask the human to
549
+ attach the tab rather than reaching around it.
511
550
 
512
551
  Several agent windows, one browser: each MCP client starts its own copy of this server, but
513
552
  only one can own the loopback port. The first to bind it holds the extension socket; the others
@@ -642,6 +681,158 @@ server.registerTool(
642
681
  },
643
682
  );
644
683
 
684
+ server.registerTool(
685
+ 'qa_read',
686
+ {
687
+ title: 'Read the text of a page (or one element)',
688
+ description:
689
+ 'Return the RENDERED text of the page under test, or of a single element named by a qa_snapshot ref or a CSS selector. ' +
690
+ 'This is the companion to qa_snapshot: that one maps what you can ACT on (roles, names, refs) and caps every name at 80 characters, ' +
691
+ 'so anything longer — a one-time code in an email, an error paragraph, a generated invoice number — is invisible to it. ' +
692
+ 'Uses innerText, so hidden branches and a mail thread\'s collapsed quoted history stay out. Read-only: it executes nothing and returns text, capped (default 4000 chars, max 20000).',
693
+ inputSchema: {
694
+ tabId: tabIdArg,
695
+ ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred when you already have one'),
696
+ selector: z.string().optional().describe('CSS selector; omit both to read the whole page body'),
697
+ maxChars: z.number().optional().describe('Cap the returned text (default 4000, max 20000)'),
698
+ },
699
+ },
700
+ async ({ tabId, ref, selector, maxChars }, extra) => {
701
+ const bar = loader(extra, 'reading the page text');
702
+ try {
703
+ return asText(await callExtension('qa_read', { tabId, ref, selector, maxChars }, 30_000));
704
+ } catch (e) {
705
+ return asError(e);
706
+ } finally {
707
+ bar.stop();
708
+ }
709
+ },
710
+ );
711
+
712
+ /** Enough to cover what an upload field actually accepts; anything else falls back to octet-stream. */
713
+ const MIME = {
714
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
715
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
716
+ '.csv': 'text/csv', '.txt': 'text/plain', '.json': 'application/json',
717
+ '.xls': 'application/vnd.ms-excel',
718
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
719
+ '.doc': 'application/msword',
720
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
721
+ '.zip': 'application/zip',
722
+ };
723
+
724
+ server.registerTool(
725
+ 'qa_upload',
726
+ {
727
+ title: 'Attach a file to an upload field (Pro)',
728
+ description:
729
+ "Put a file into an <input type=file> — including the hidden ones behind a styled dropzone, which a click can never reach. " +
730
+ 'Three ways to choose what goes in: `fixture` for a hostile edge-case file (the upload counterpart of naughty strings — empty, oversize, wrong-type, corrupt, unicode name, unsanitised SVG…), `path` for a file on this machine, or `content` as base64. ' +
731
+ 'Pass `content` as base64 to attach a file YOU generated (a PDF fixture, a malformed CSV, an oversized image); omit it and Qiksy attaches a neutral test file matched to the field\'s `accept`. ' +
732
+ 'A content script has no filesystem access and the OS file picker is a modal an agent must never open, so bytes over this bridge are the only way a file from outside the browser gets in. ' +
733
+ 'Delivery fires both paths uploaders use — assigning `input.files` + change, and a synthetic drop on the nearest dropzone — because react-dropzone-style components listen only for the second.',
734
+ inputSchema: {
735
+ tabId: tabIdArg,
736
+ ref: z.string().optional().describe('A ref from qa_snapshot'),
737
+ selector: z.string().optional().describe("CSS selector of the file input or its dropzone; omit to use the page's first file input"),
738
+ name: z.string().optional().describe("File name, e.g. 'report.pdf'"),
739
+ mimeType: z.string().optional().describe("MIME type, e.g. 'application/pdf'"),
740
+ fixture: z
741
+ .enum(['image', 'pdf', 'csv', 'empty', 'oversize', 'wrong-type', 'corrupt', 'long-name', 'unicode-name', 'svg-script', 'double-ext'])
742
+ .optional()
743
+ .describe(
744
+ 'A HOSTILE fixture built in the page, so it costs nothing to send: empty (0 bytes) · oversize (12MB) · ' +
745
+ 'wrong-type (a PNG named .pdf — is content checked or only the extension?) · corrupt (truncated PNG) · ' +
746
+ 'long-name (255 chars) · unicode-name (RTL + emoji + CJK) · svg-script (is an uploaded SVG sanitised?) · ' +
747
+ 'double-ext (invoice.pdf.exe — does the UI show the whole name?). The answer comes back as `asks`.',
748
+ ),
749
+ path: z.string().optional().describe('Absolute path to a file ON THIS MACHINE — the server reads and encodes it for you. Use this instead of `content` for anything bigger than a few hundred bytes.'),
750
+ content: z.string().optional().describe('Base64 file content, for a file you are holding in memory. Prefer `path`.'),
751
+ },
752
+ },
753
+ async ({ tabId, ref, selector, name, mimeType, path, content, fixture }, extra) => {
754
+ const bar = loader(extra, 'attaching the file');
755
+ try {
756
+ /* READING THE FILE HAPPENS HERE, and that is the whole point of the option.
757
+ *
758
+ * This process is ordinary Node on the user's machine — it has a filesystem, which the
759
+ * content script pointedly does not. Passing 15,000 characters of base64 through a tool
760
+ * call to move a 12KB image is absurd when one side can simply open the file; a modest
761
+ * PDF or screenshot makes it worse than absurd. So the agent names a path and the bytes
762
+ * never enter the conversation at all. */
763
+ let body = content;
764
+ let fileName = name;
765
+ let type = mimeType;
766
+ if (path) {
767
+ body = readFileSync(path).toString('base64');
768
+ fileName = fileName || basename(path);
769
+ type = type || MIME[extname(path).toLowerCase()] || 'application/octet-stream';
770
+ }
771
+ return asText(await callExtension('qa_upload', { tabId, ref, selector, name: fileName, mimeType: type, content: body, fixture }, 30_000));
772
+ } catch (e) {
773
+ return asError(e);
774
+ } finally {
775
+ bar.stop();
776
+ }
777
+ },
778
+ );
779
+
780
+ server.registerTool(
781
+ 'qa_storage',
782
+ {
783
+ title: 'Read or set the page\'s web storage',
784
+ description:
785
+ "Read and write the app's own localStorage / sessionStorage — flip a feature flag, expire a token to test the re-auth path, or prove what the app actually persisted after a save. " +
786
+ "Without `op` (or with op:'list'/'get') it READS and is free like every other read on this bridge; op:'set'|'remove'|'clear' WRITES and needs Pro + the Agent control consent, because changing a stored token or flag changes what the app does just as surely as clicking does. " +
787
+ 'Closed vocabulary — it executes nothing you supply, it names a key. Listing truncates long values at 300 chars; ask for one key by name to get it whole.',
788
+ inputSchema: {
789
+ tabId: tabIdArg,
790
+ area: z.enum(['local', 'session']).optional().describe("Which store (default 'local')"),
791
+ op: z.enum(['list', 'get', 'set', 'remove', 'clear']).optional().describe("Default 'list' (or 'get' when a key is given)"),
792
+ key: z.string().optional().describe('The key to read, set or remove'),
793
+ value: z.string().optional().describe("The value, for op:'set'"),
794
+ },
795
+ },
796
+ async ({ tabId, area, op, key, value }, extra) => {
797
+ const bar = loader(extra, 'reading web storage');
798
+ try {
799
+ return asText(await callExtension('qa_storage', { tabId, area, op, key, value }, 30_000));
800
+ } catch (e) {
801
+ return asError(e);
802
+ } finally {
803
+ bar.stop();
804
+ }
805
+ },
806
+ );
807
+
808
+ server.registerTool(
809
+ 'qa_styles',
810
+ {
811
+ title: 'Computed styles and box of an element',
812
+ description:
813
+ 'The COMPUTED styles of one element — colour, type, spacing, border, shadow, layout — plus its box in CSS pixels, and the CSS custom properties (design tokens) in scope for it. ' +
814
+ 'This is how you check a built page against a design: it answers "this button renders #3B82F6 at 15px with 22px padding" without a screenshot and without measuring anything by eye. ' +
815
+ 'Target it by a qa_snapshot ref (preferred) or a CSS selector. Read-only and executes NOTHING: the property list is fixed in the extension, so you name an element, never a program. ' +
816
+ 'Pair it with a design source (a Figma file, a token JSON) to report the diff, and with qa_spotlight to show the human which element you mean.',
817
+ inputSchema: {
818
+ tabId: tabIdArg,
819
+ ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred over selector'),
820
+ selector: z.string().optional().describe('CSS selector of the element'),
821
+ tokens: z.boolean().optional().describe('Include the CSS custom properties in scope (default true)'),
822
+ },
823
+ },
824
+ async ({ tabId, ref, selector, tokens }, extra) => {
825
+ const bar = loader(extra, 'reading the computed styles');
826
+ try {
827
+ return asText(await callExtension('qa_styles', { tabId, ref, selector, tokens }, 30_000));
828
+ } catch (e) {
829
+ return asError(e);
830
+ } finally {
831
+ bar.stop();
832
+ }
833
+ },
834
+ );
835
+
645
836
  server.registerTool(
646
837
  'qa_snapshot',
647
838
  {
@@ -651,12 +842,15 @@ server.registerTool(
651
842
  'Unlike qa_export — which lists only the FORM fields Qiksy detects and can miss inputs, file uploads, date pickers, selects and buttons — this "sees everything". ' +
652
843
  'Pass a ref straight back to qa_click / qa_type / qa_spotlight: a ref is unique and unambiguous, so it works where a CSS selector is duplicated across fields. ' +
653
844
  'Built in the live, already-authenticated page in ~milliseconds — no browser launch, no CDP. Re-run it after the page changes to refresh the refs (a ref goes stale when its element is re-rendered).',
654
- inputSchema: { tabId: tabIdArg },
845
+ inputSchema: {
846
+ tabId: tabIdArg,
847
+ geometry: z.boolean().optional().describe('Add each node\'s box as "x,y,w,h" in CSS pixels — for overlap, clipping, off-screen and tap-target-size checks. Off by default: it is bulk on every node.'),
848
+ },
655
849
  },
656
- async ({ tabId }, extra) => {
850
+ async ({ tabId, geometry }, extra) => {
657
851
  const bar = loader(extra, 'reading the whole page');
658
852
  try {
659
- return asText(await callExtension('qa_snapshot', { tabId }, 30_000));
853
+ return asText(await callExtension('qa_snapshot', { tabId, geometry }, 30_000));
660
854
  } catch (e) {
661
855
  return asError(e);
662
856
  } finally {
@@ -694,6 +888,14 @@ server.registerTool(
694
888
  'Requires History recording to have captured steps; otherwise returns a note.',
695
889
  inputSchema: {
696
890
  tabId: tabIdArg,
891
+ path: z
892
+ .string()
893
+ .optional()
894
+ .describe(
895
+ 'Write the report HERE instead of returning it. An absolute path, or one relative to where the bridge process runs. ' +
896
+ 'STRONGLY PREFERRED: the report is a self-contained HTML document with embedded screenshots and routinely runs to hundreds of kilobytes — returning it inline spends your context on bytes you were going to save to a file anyway. ' +
897
+ 'You get back the path, the size and the report title.',
898
+ ),
697
899
  spec: z
698
900
  .object({
699
901
  title: z.string().optional().describe('Report heading, e.g. the ticket key + name'),
@@ -714,9 +916,29 @@ server.registerTool(
714
916
  .describe('Acceptance criteria + verdicts to lead the report with'),
715
917
  },
716
918
  },
717
- async ({ tabId, spec }) => {
919
+ async ({ tabId, path: outPath, spec }) => {
718
920
  try {
719
- return asText(await callExtension('qa_report', { tabId, spec }, 45_000));
921
+ const res = await callExtension('qa_report', { tabId, spec }, 45_000);
922
+ /* WRITING HAPPENS HERE, on the same reasoning as qa_upload's `path`, inverted.
923
+ *
924
+ * This process is ordinary Node on the user's machine and has a filesystem; the
925
+ * extension does not. A session report is a self-contained HTML document with base64
926
+ * screenshots inside it — hundreds of kilobytes — and handing that back through a tool
927
+ * result burns the agent's context on bytes whose destination is a file. So the agent
928
+ * names a path and only the receipt comes back. */
929
+ if (outPath && res && typeof res.html === 'string') {
930
+ const abs = isAbsolute(outPath) ? outPath : resolve(process.cwd(), outPath);
931
+ mkdirSync(dirname(abs), { recursive: true });
932
+ writeFileSync(abs, res.html, 'utf8');
933
+ return asText({
934
+ ok: true,
935
+ path: abs,
936
+ bytes: Buffer.byteLength(res.html, 'utf8'),
937
+ title: spec?.title ?? null,
938
+ note: 'Self-contained HTML — screenshots are embedded, so it opens anywhere and can be attached to a ticket as one file.',
939
+ });
940
+ }
941
+ return asText(res);
720
942
  } catch (e) {
721
943
  return asError(e);
722
944
  }
@@ -764,7 +986,7 @@ server.registerTool(
764
986
  {
765
987
  title: 'Click an element (Pro)',
766
988
  description:
767
- 'Click an element on the page under test, by a qa_snapshot ref (preferred) or a CSS selector. Sends the full pointer sequence (pointerdown → mouseup → click), so component libraries that listen for pointerdown — Radix, shadcn, MUI — react to it like a real click. Scrolls the element into view first, and refuses a target that matches nothing or has zero size rather than silently doing nothing.' +
989
+ 'Click an element on the page under test, by a qa_snapshot ref (preferred) or a CSS selector. Sends the full pointer sequence (pointerdown → mouseup → click), so component libraries that listen for pointerdown — Radix, shadcn, MUI — react to it like a real click. Scrolls the element into view first, and refuses a target that matches nothing or has zero size rather than silently doing nothing. Returns what the click CHANGED — what appeared, disappeared or updated once the page settled — so you do not need a follow-up qa_snapshot to find out whether it did anything. `changed: null` means the page did not move, which is itself the answer.' +
768
990
  DRIVE_NOTE,
769
991
  inputSchema: {
770
992
  tabId: tabIdArg,
@@ -809,14 +1031,18 @@ server.registerTool(
809
1031
  {
810
1032
  title: 'Fill many fields at once (Pro)',
811
1033
  description:
812
- 'Fill a WHOLE form in ONE call instead of a round-trip per field: pass an array of { selector, value, action? }. `action` defaults to "type" (which also handles <select>, checkbox and radio from the value), or "click" for buttons / toggles. Returns a per-field result list plus filled/total — far faster than one qa_type per field on a big form.' +
1034
+ 'Fill a WHOLE form in ONE call instead of a round-trip per field: pass an array of { ref | selector, value, action? }. ' +
1035
+ 'PREFER `ref` from qa_snapshot: a form built from a repeated component gives several inputs the identical cssPath, and a batch of selectors would then pour every value into the first match. ' +
1036
+ 'action defaults to type (which also handles select, checkbox and radio from the value), or click for buttons and toggles. ' +
1037
+ 'Returns a per-field result list plus filled/total — far faster than one qa_type per field on a big form.' +
813
1038
  DRIVE_NOTE,
814
1039
  inputSchema: {
815
1040
  tabId: tabIdArg,
816
1041
  fields: z
817
1042
  .array(
818
1043
  z.object({
819
- selector: z.string().describe('CSS selector of the field'),
1044
+ ref: z.string().optional().describe('A ref from qa_snapshot — REQUIRED when several fields share one cssPath, which is common on forms built from a repeated component'),
1045
+ selector: z.string().optional().describe('CSS selector of the field — use `ref` instead when the selector is not unique'),
820
1046
  value: z.string().optional().describe('Value to set (text input / <select> / checkbox / radio)'),
821
1047
  action: z.enum(['type', 'click']).optional().describe('Default "type"; use "click" for buttons and toggles'),
822
1048
  }),
@@ -838,7 +1064,7 @@ server.registerTool(
838
1064
  {
839
1065
  title: 'Press a key (Pro)',
840
1066
  description:
841
- 'Dispatch a keyboard key (keydown/keypress/keyup) — Enter to submit, Escape to close, Tab to move focus. Without a selector it goes to whatever currently has focus, like a real keyboard.' +
1067
+ 'Dispatch a keyboard key (keydown/keypress/keyup) — Enter to submit, Escape to close, Tab to move focus. Without a selector it goes to whatever currently has focus, like a real keyboard. Like qa_click, it returns what changed once the page settled.' +
842
1068
  DRIVE_NOTE,
843
1069
  inputSchema: {
844
1070
  tabId: tabIdArg,