qiksy-mcp 0.1.0 → 1.0.7

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/README.md +83 -8
  2. package/package.json +1 -1
  3. package/server.mjs +386 -12
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  An **MCP (Model Context Protocol) bridge** for the [Qiksy](../) browser extension. It lets any MCP-capable coding agent — **Claude Code, Cursor, Cline, Windsurf, Zed, VS Code** — read the live QA state (findings with CSS selectors, detected forms, failed requests with server error bodies, recorded repro steps) straight from the browser, so you can say *"fix what Qiksy found on this page"* and the agent has the full context.
4
4
 
5
- The agent can also **drive Qiksy's own surfaces**: open the panel, run the audit, pull the exploratory-tour checklist, generate the session report, and spotlight an element. What it **never** does is act on the **app under test** — no filling, clicking, submitting, or navigating the page you're testing. Qiksy senses and reports; a human (or a purpose-built driver like [Playwright MCP](#driving-the-browser-pair-with-playwright-mcp)) does the driving. This boundary is deliberate — it's what keeps a QA tool that watches *other people's* apps trustworthy.
5
+ The agent can also **drive Qiksy's own surfaces**: open the panel, run the audit, pull the exploratory-tour checklist, generate the session report, and spotlight an element.
6
+
7
+ By default it stops there — it does **not** fill, click, submit or navigate the app you're testing. Qiksy senses and reports; you drive. **Agent control** (Pro, off until you switch it on and confirm a prompt of its own) is the deliberate exception: it lets the agent act inside the session you are *already logged into*, which is the whole reason to use this rather than a cold automated browser. The default posture is unchanged — that boundary is what keeps a tool that watches *other people's* apps trustworthy — but a professional can move it knowingly.
6
8
 
7
9
  ## How it works
8
10
 
@@ -12,9 +14,23 @@ agent ⟷ (MCP over stdio, JSON-RPC) ⟷ qiksy-mcp ⟷ (WebSocket @ 127.0.0.
12
14
 
13
15
  The extension is the WebSocket **client** (a service worker can't listen on a socket); this process is the WebSocket **server**, bound to loopback only. When the agent calls a tool, this process asks the extension for the current tab's `qa-export/v1` bundle and returns it.
14
16
 
17
+ ## Two things to know before you wire it up
18
+
19
+ **The bridge as a whole is a Pro surface.** Inside the extension the free tier stays
20
+ fully usable, but the socket itself is gated: `ensureConnected()` refuses to dial
21
+ unless the licence (or the 14-day trial) is valid, so once a trial lapses *every*
22
+ tool fails — `qa_export` included — with a message saying so. Do not plan a
23
+ free-tier integration around read-only tools.
24
+
25
+ **npm carries `qiksy-mcp@1.0.6`** — the current build, versioned in lockstep with
26
+ the extension, with all sixteen tools including Agent control (`qa_click`,
27
+ `qa_type`, `qa_press`, `qa_navigate`, `qa_open_isolated`, `qa_close_tab`). Just
28
+ `npx qiksy-mcp` — no clone, no local path. (For hacking on the bridge itself, see
29
+ **Local run** at the bottom.)
30
+
15
31
  ## Tools
16
32
 
17
- **Read** — sense the page (all accept an optional `tabId` from `qa_tabs`; default = active tab):
33
+ Sixteen in total. **Read** — sense the page (all accept an optional `tabId` from `qa_tabs`; default = active tab):
18
34
 
19
35
  | Tool | What it returns |
20
36
  |---------------|-----------------|
@@ -35,9 +51,40 @@ The extension is the WebSocket **client** (a service worker can't listen on a so
35
51
 
36
52
  Because tools accept a `tabId`, an agent can read **several logins at once**: call `qa_tabs`, then `qa_export` per isolated tab to compare what admin vs. member vs. anon each see.
37
53
 
38
- ## Driving the browser: pair with Playwright MCP
54
+ **Agent control (Pro)** — these ACT on the app under test:
55
+
56
+ | Tool | What it does |
57
+ |------------|--------------|
58
+ | `qa_click` | Click an element by CSS selector. Sends the full pointer sequence (`pointerdown` → `mouseup` → `click`), so Radix/shadcn/MUI components that listen for `pointerdown` react like they would to a real click. Scrolls into view first; refuses a selector that matches nothing or has zero size instead of silently doing nothing. |
59
+ | `qa_type` | Set an input / textarea / contenteditable value using the native setter + `InputEvent`, so React and Vue controlled inputs keep it (a plain assignment is swallowed). Reports `stuck: false` when the field rejected the value — masked, read-only or controlled. |
60
+ | `qa_press` | Dispatch a key (`Enter`, `Escape`, `Tab`, …). Without a selector it goes to the focused element, like a real keyboard. |
61
+
62
+ **Chaining** — what makes a multi-page, multi-role run reliable:
63
+
64
+ | Tool | What it does |
65
+ |--------------------|--------------|
66
+ | `qa_wait_ready` | **Free.** Block until the tab finished loading AND a fresh Qiksy answers in the new document. Call it after anything that navigates — a submit, a link click, `qa_navigate` — before the next `qa_type`/`qa_click`, or the call lands on a page still being replaced. `timeoutMs` default 15000, max 60000. |
67
+ | `qa_navigate` | Point a tab at a URL and wait for that same ready signal (built in). |
68
+ | `qa_open_isolated` | Open one or MANY tabs, each with its own private session — `sessions: ["admin","manager","member","viewer"]` or `count: N` (max 12). Returns a `tabId` per login; pass it to any tool to run the whole flow as that user. **Also needs the multi-login confirmation**, because it intercepts the site's cookies — a separate acknowledgement from Agent control. |
69
+ | `qa_close_tab` | Close one tab (`tabId`) or a batch (`tabIds`) — how you tidy up after a role matrix. |
70
+
71
+ Three independent gates, re-checked on **every** call, all inside the extension where the server cannot bypass them:
72
+
73
+ 1. a **Pro** licence (or the trial) — note this is *also* what the socket itself requires, so on the bridge nothing is free once a trial lapses;
74
+ 2. the **`drive` consent**, granted separately from the bridge's own (Qiksy panel → Tools → **Agent control**) and revoked when you switch it off, so the warning is faced again next time;
75
+ 3. the bridge itself — loopback only, shared token, off by default.
76
+
77
+ Fail either of the first two and the call returns `pro-required` / `consent-required` and does nothing. **It acts for real**: it can submit, spend and delete under your live login, with no undo. Point it at test environments.
78
+
79
+ ## Driving the browser: Agent control, or pair with Playwright MCP
80
+
81
+ Qiksy is the **QA brain** (what happened, what's wrong, coverage, reports). Two ways to give an agent hands:
39
82
 
40
- Qiksy is the **QA brain** (what happened, what's wrong, coverage, reports). It does **not** click through the app for you. To let an agent also *drive* the browser, run a purpose-built driver alongside it — e.g. [Playwright MCP](https://github.com/microsoft/playwright-mcp) — and register **both** servers in your agent:
83
+ **Agent control (Pro)** drives the browser *you are already using* — the session you logged into by hand, including isolated multi-login tabs. Nothing to launch, no second browser, no re-authentication. Best when the app is behind SSO/MFA or the state is painful to reproduce from cold.
84
+
85
+ **[Playwright MCP](https://github.com/microsoft/playwright-mcp)** drives a browser *it* launches, and is the mature, dedicated tool for repeatable automation: waits, assertions, tracing, parallel contexts, CI. Best when the run must be reproducible from scratch.
86
+
87
+ They compose — Playwright can attach over CDP to the very browser Qiksy runs in, so the agent drives with one and *sees* with the other:
41
88
 
42
89
  ```
43
90
  your agent
@@ -52,7 +99,9 @@ your agent
52
99
  3. After each step it calls **qiksy-mcp** (`qa_export`, `qa_run_audit`, `qa_findings`) to see what *actually* happened — console/JS errors, failed requests **with server bodies**, a11y/markup issues, the detected form structure.
53
100
  4. The agent reconciles the story against reality, then writes test cases (using `qa_tour` for coverage) and, if History was recording, pulls a `qa_report` as the artifact.
54
101
 
55
- Why not build driving into Qiksy itself? Playwright is a mature, dedicated driver, and keeping Qiksy read-and-report keeps its trust/permissions story clean. (Embedding Playwright *inside* the extension isn't possible anyway — it's an external Node automation framework, not something an MV3 extension can host.)
102
+ **Which to reach for.** Measured on the same page and question ("what forms are here with selectors, and what is broken?"): `qa_export` answers in **~9 ms** from an already-open tab, against **~2.5 s** for Playwright from a cold start — but that gap is almost entirely browser launch. With both browsers already up it is ~9 ms vs ~28 ms for an equivalent DOM scrape: the same order. The reason to use Qiksy is not raw speed, it is that the 9 ms carries *meaning* — `kind: "first-name"`, the label, `required`, `maxLength`, plus findings, failed requests with server bodies and repro steps in the same payload. A scrape returns raw DOM and leaves the agent to work out what each field is; matching the rest needs more code and a reload to attach console listeners before load.
103
+
104
+ So: **Playwright when the run must be reproducible from cold. Agent control when the value is the live, already-authenticated session.** Reading is worth doing through Qiksy either way.
56
105
 
57
106
  ### Wiring both servers (Playwright MCP needs a CDP browser)
58
107
 
@@ -65,7 +114,7 @@ Playwright MCP drives a browser over the **Chrome DevTools Protocol** — a *nor
65
114
  # Windows: "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
66
115
  # Linux: google-chrome --remote-debugging-port=9222
67
116
  ```
68
- 2. **In the Qiksy popup** → *MCP bridge*: **On**, **Generate** a token, **Save**.
117
+ 2. **In the Qiksy popup** → *Agent bridge*: **On**, **Generate** a token (it saves as you go), then **📋 Copy agent config**.
69
118
  3. **Register both MCP servers** in your agent — one config, paste and go. Claude Code `.mcp.json` (project root):
70
119
  ```json
71
120
  {
@@ -94,11 +143,37 @@ Qiksy runs several logins on one domain at once — one per tab, each with its o
94
143
 
95
144
  ## Setup
96
145
 
97
- 1. **In the extension popup** → *MCP bridge*: toggle **On**, click **Generate** for a token, pick a port (default `7333`), **Save bridge**.
98
- 2. **Register the server in your agent** (below), using the **same token and port**. The token is passed via the `QIKSY_MCP_TOKEN` env var.
146
+ 1. **In the extension popup** → *Agent bridge*: toggle **On**, click **Generate** for a token, keep the default port `7333` (or pick your own). It **saves as you go** — there is no separate Save step.
147
+ 2. **Click 📋 Copy agent config** in the popup — it copies the whole `mcpServers` block below with **your port and token already filled in** — and paste it into your agent's MCP config file (below). No hand-editing the token or port.
148
+ 3. **Watch the status line** under the toggle. 🟡 *Waiting…* until your agent's `qiksy-mcp` is up, then 🟢 *Agent connected on port 7333*. If it stays yellow or turns red, see [Connection status & troubleshooting](#connection-status--troubleshooting).
99
149
 
100
150
  The token is the only thing guarding the bridge — localhost is reachable by any local process/page, so keep it secret and unique.
101
151
 
152
+ ### Connection status & troubleshooting
153
+
154
+ The popup shows the live link state, so you fix the right thing instead of guessing:
155
+
156
+ | Status | Means | Fix |
157
+ |--------|-------|-----|
158
+ | 🟢 **Agent connected** | Socket open on that port, token matched. | Nothing — you're live. |
159
+ | 🟡 **Waiting for the agent** | Bridge on, but nothing answering on that port. | Start your agent so `qiksy-mcp` runs, and make sure **its `--port` matches the popup's port**. The #1 snag: docs pin `7333` but the agent was launched on a different port — the numbers must match. |
160
+ | 🔴 **Token rejected** | Something answered, but the shared secret didn't match. | The popup token and the agent's `QIKSY_MCP_TOKEN` must be identical. Re-copy with **📋 Copy agent config** and repaste. |
161
+ | 🔴 **Another browser took over** | A second browser connected on the same port+token and claimed the slot. | Expected when switching browsers. To run **both at once**, give each its own port (see below). To reclaim, toggle the bridge **off then on**. |
162
+ | 🔴 **Qiksy Pro required** | Trial ended; the bridge is a Pro surface. | Activate a license key in the popup. |
163
+
164
+ ### Two browsers at once
165
+
166
+ One bridge is **one port + one token = one browser**. Pointing a second browser at the **same** port+token doesn't double up — it **takes over the slot** (last writer wins), and the first browser's status flips to *Another browser took over*. That's exactly how you **switch** between browsers. To run **both simultaneously**, give each its own port and register a second server name:
167
+
168
+ ```json
169
+ { "mcpServers": {
170
+ "qiksy": { "command": "npx", "args": ["qiksy-mcp", "--port", "7333"], "env": { "QIKSY_MCP_TOKEN": "TOKEN_A" } },
171
+ "qiksy-2": { "command": "npx", "args": ["qiksy-mcp", "--port", "7334"], "env": { "QIKSY_MCP_TOKEN": "TOKEN_B" } }
172
+ } }
173
+ ```
174
+
175
+ Set browser A's popup to `7333`/`TOKEN_A` and browser B's to `7334`/`TOKEN_B`. One agent drives one browser per bridge — a single bridge can't fan out to two browsers; different ports are independent links that never fight over the slot.
176
+
102
177
  ### Claude Code
103
178
 
104
179
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "0.1.0",
3
+ "version": "1.0.7",
4
4
  "description": "MCP bridge for the Qiksy browser extension — expose live QA findings, forms, network and session to any MCP-capable coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
package/server.mjs CHANGED
@@ -26,9 +26,91 @@ 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';
30
+ import { fileURLToPath } from 'node:url';
29
31
 
30
32
  const log = (...a) => console.error('[qiksy-mcp]', ...a);
31
33
 
34
+ // A little IT/QA humour in the STDERR log (never in tool results — the agent parses
35
+ // those). It rides the connect line and each action, to make a testing session a bit
36
+ // more fun. Set QIKSY_MCP_QUIPS=0 to keep the log strictly business (CI, demos).
37
+ const QUIPS_ON = process.env.QIKSY_MCP_QUIPS !== '0';
38
+ const QUIPS = [
39
+ 'it works on my machine ✨',
40
+ "it's not a bug, it's a feature",
41
+ 'no bugs — only undocumented features',
42
+ 'have you tried turning it off and on again?',
43
+ 'shipping it 🚀',
44
+ 'passed on the third retry 🟢',
45
+ 'flaky test? must be the network',
46
+ 'reproduced locally, naturally',
47
+ 'works 60% of the time, every time',
48
+ "trust me, I'm a QA engineer",
49
+ '0 bugs found (that we know of)',
50
+ 'definitely tested in production',
51
+ 'green tests, clear conscience',
52
+ 'clicking so you don\'t have to',
53
+ 'one does not simply skip QA',
54
+ 'red, green, refactor, repeat',
55
+ 'escalating to the rubber duck 🦆',
56
+ 'edge case #418: I\'m a teapot',
57
+ 'the build is fine, it\'s the vibes that are off',
58
+ 'QA: because prod is not a test environment',
59
+ ];
60
+ let lastQuip = -1;
61
+ function quip() {
62
+ if (!QUIPS_ON) return '';
63
+ let i;
64
+ do {
65
+ i = Math.floor(Math.random() * QUIPS.length);
66
+ } while (QUIPS.length > 1 && i === lastQuip);
67
+ lastQuip = i;
68
+ return QUIPS[i];
69
+ }
70
+
71
+ // Little ASCII faces / kaomoji to ride along with the quips — sticks, circles, meme
72
+ // faces. Same QIKSY_MCP_QUIPS gate.
73
+ const FACES = [
74
+ '¯\\_(ツ)_/¯',
75
+ '(⌐■_■)',
76
+ '( •_•)>⌐■-■',
77
+ 'ᕕ( ᐛ )ᕗ',
78
+ '(づ。◕‿‿◕。)づ',
79
+ 'ヽ(´▽`)/',
80
+ '(ノ◕ヮ◕)ノ*:・゚✧',
81
+ '◉_◉',
82
+ '(•_•)',
83
+ '༼ つ ◕_◕ ༽つ',
84
+ '~(˘▾˘~)',
85
+ '(☞゚ヮ゚)☞',
86
+ '[▰▰▰▱▱]',
87
+ 'ᕦ(ò_óˇ)ᕤ',
88
+ '(◕‿◕)',
89
+ ];
90
+ // A happier subset for the connect banner (no table-flips when things just worked).
91
+ const HAPPY = ['\\(^o^)/', 'ヽ(´▽`)/', '(ノ◕ヮ◕)ノ*:・゚✧', 'ᕕ( ᐛ )ᕗ', '(づ。◕‿‿◕。)づ', '~(˘▾˘~)'];
92
+ let lastFace = -1;
93
+ function face(happy = false) {
94
+ if (!QUIPS_ON) return '';
95
+ const pool = happy ? HAPPY : FACES;
96
+ let i;
97
+ do {
98
+ i = Math.floor(Math.random() * pool.length);
99
+ } while (pool.length > 1 && i === lastFace);
100
+ lastFace = i;
101
+ return pool[i];
102
+ }
103
+
104
+ // Read the version off package.json rather than repeating it here — a hardcoded
105
+ // copy silently rots, and this one is what every MCP client displays.
106
+ const VERSION = (() => {
107
+ try {
108
+ return JSON.parse(readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8')).version;
109
+ } catch {
110
+ return '0.0.0';
111
+ }
112
+ })();
113
+
32
114
  const TOKEN = process.env.QIKSY_MCP_TOKEN || '';
33
115
  const portArg = process.argv.indexOf('--port');
34
116
  const PORT = portArg !== -1 ? Number(process.argv[portArg + 1]) : Number(process.env.QIKSY_MCP_PORT) || 7333;
@@ -50,6 +132,9 @@ function tokenOk(got) {
50
132
 
51
133
  // ── WebSocket bridge to the extension ──────────────────────────────────────
52
134
  let client = null; // the one authenticated extension socket
135
+ // Set when the extension checked in only to report an expired licence — turns the
136
+ // generic 'not connected' into the actual reason.
137
+ let lapsed = false;
53
138
  const pending = new Map(); // id → { resolve, reject, timer }
54
139
 
55
140
  const wss = new WebSocketServer({ host: HOST, port: PORT });
@@ -81,11 +166,36 @@ wss.on('connection', (ws, req) => {
81
166
  ws.close(4001, 'auth failed');
82
167
  return;
83
168
  }
84
- authed = true;
85
169
  clearTimeout(helloTimer);
170
+ // The extension dials in ONLY to say its licence lapsed, then hangs up. It
171
+ // is not a client — remember the reason so every tool call answers with a
172
+ // sentence a human can act on, instead of the generic "not connected",
173
+ // which reads like a broken port or a bad token and sends people digging
174
+ // in the wrong place.
175
+ if (msg.lapsed) {
176
+ lapsed = true;
177
+ log('extension reports a lapsed licence — bridge stays closed');
178
+ ws.close(4002, 'lapsed');
179
+ return;
180
+ }
181
+ authed = true;
182
+ lapsed = false;
183
+ // Last-writer-wins, but EXPLICITLY. If another browser already holds the one
184
+ // client slot, close it with a distinct code (4004) instead of silently
185
+ // orphaning it — so its extension can say "another browser took over" rather
186
+ // than going dark, and (crucially) so it YIELDS instead of instantly
187
+ // reconnecting and starting a slot-stealing war. Two browsers at once = two
188
+ // bridge instances on two ports, not one shared slot.
189
+ if (client && client !== ws && client.readyState === WebSocket.OPEN) {
190
+ try {
191
+ client.close(4004, 'replaced by a newer connection');
192
+ } catch {
193
+ /* already gone */
194
+ }
195
+ }
86
196
  client = ws;
87
197
  ws.send(JSON.stringify({ t: 'welcome', v: PROTOCOL_VERSION }));
88
- log('extension connected');
198
+ log(QUIPS_ON ? `${face(true)} extension connected — ${quip()}` : 'extension connected');
89
199
  return;
90
200
  }
91
201
  if (msg.t === 'ping') {
@@ -113,7 +223,15 @@ wss.on('connection', (ws, req) => {
113
223
  function callExtension(tool, args, timeoutMs = 15_000) {
114
224
  return new Promise((resolve, reject) => {
115
225
  if (!client || client.readyState !== WebSocket.OPEN) {
116
- reject(new Error('Qiksy extension is not connected. Open Chrome with the extension and enable the MCP bridge in its popup (matching token & port).'));
226
+ reject(
227
+ new Error(
228
+ lapsed
229
+ ? 'Your Qiksy trial has ended, so the bridge is closed — I cannot read or drive the browser until it is renewed. ' +
230
+ 'Open the Qiksy popup in Chrome and add a licence key (Pro, $49/year), then this reconnects on its own. ' +
231
+ 'Everything inside the extension itself still works; only the agent link is paused.'
232
+ : 'Qiksy extension is not connected. Open Chrome with the extension and enable the MCP bridge in its popup (matching token & port).',
233
+ ),
234
+ );
117
235
  return;
118
236
  }
119
237
  const id = randomUUID();
@@ -122,6 +240,7 @@ function callExtension(tool, args, timeoutMs = 15_000) {
122
240
  reject(new Error(`Timed out after ${timeoutMs}ms waiting for "${tool}". Is a normal web page focused in Chrome?`));
123
241
  }, timeoutMs);
124
242
  pending.set(id, { resolve, reject, timer });
243
+ if (QUIPS_ON) log(`▸ ${tool} ${face()} ${quip()}`);
125
244
  client.send(JSON.stringify({ t: 'req', id, tool, args: args || {} }));
126
245
  });
127
246
  }
@@ -130,7 +249,7 @@ function callExtension(tool, args, timeoutMs = 15_000) {
130
249
  const asText = (v) => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v, null, 2) }] });
131
250
  const asError = (e) => ({ isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] });
132
251
 
133
- const server = new McpServer({ name: 'qiksy', version: '0.1.0' });
252
+ const server = new McpServer({ name: 'qiksy', version: VERSION });
134
253
 
135
254
  server.registerTool(
136
255
  'qa_tabs',
@@ -242,6 +361,26 @@ server.registerTool(
242
361
  },
243
362
  );
244
363
 
364
+ server.registerTool(
365
+ 'qa_snapshot',
366
+ {
367
+ title: 'Full page snapshot (accessibility tree)',
368
+ description:
369
+ 'A compact accessibility / interaction snapshot of the WHOLE page: every interactive control, landmark and heading with a role, an accessible name, its state, and a STABLE ref like [ref=e12]. ' +
370
+ '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". ' +
371
+ '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. ' +
372
+ '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).',
373
+ inputSchema: { tabId: tabIdArg },
374
+ },
375
+ async ({ tabId }) => {
376
+ try {
377
+ return asText(await callExtension('qa_snapshot', { tabId }, 30_000));
378
+ } catch (e) {
379
+ return asError(e);
380
+ }
381
+ },
382
+ );
383
+
245
384
  server.registerTool(
246
385
  'qa_tour',
247
386
  {
@@ -264,12 +403,36 @@ server.registerTool(
264
403
  {
265
404
  title: 'Generate session report',
266
405
  description:
267
- 'Generate Qiksy\'s self-contained HTML session report (coverage stats, per-page table, steps with screenshots, findings) from the recorded session and return the HTML. Requires History recording to have captured steps; otherwise returns a note.',
268
- inputSchema: { tabId: tabIdArg },
406
+ "Generate Qiksy's self-contained HTML report — coverage stats, per-page table, steps with screenshots, findings — and return the HTML. " +
407
+ 'Pass `spec` to make it answer the question the TICKET asked: give it the story and your verdict on each acceptance criterion, and the report leads with those, ' +
408
+ 'rendered next to the evidence Qiksy collected itself (errors, failed requests with server bodies, screenshots). ' +
409
+ 'That turns a log into a deliverable a developer or a client can read. You supply the verdicts — you are the one who read the story; Qiksy supplies the proof. ' +
410
+ 'Requires History recording to have captured steps; otherwise returns a note.',
411
+ inputSchema: {
412
+ tabId: tabIdArg,
413
+ spec: z
414
+ .object({
415
+ title: z.string().optional().describe('Report heading, e.g. the ticket key + name'),
416
+ story: z.string().optional().describe('The story / requirement text the criteria came from'),
417
+ checks: z
418
+ .array(
419
+ z.object({
420
+ id: z.string().optional().describe('Criterion id, e.g. "AC-3"'),
421
+ text: z.string().describe('What was supposed to happen'),
422
+ status: z.enum(['pass', 'fail', 'blocked']),
423
+ evidence: z.string().optional().describe('What you actually observed'),
424
+ selector: z.string().optional().describe('Where, if it is element-specific'),
425
+ }),
426
+ )
427
+ .optional(),
428
+ })
429
+ .optional()
430
+ .describe('Acceptance criteria + verdicts to lead the report with'),
431
+ },
269
432
  },
270
- async ({ tabId }) => {
433
+ async ({ tabId, spec }) => {
271
434
  try {
272
- return asText(await callExtension('qa_report', { tabId }, 45_000));
435
+ return asText(await callExtension('qa_report', { tabId, spec }, 45_000));
273
436
  } catch (e) {
274
437
  return asError(e);
275
438
  }
@@ -281,16 +444,227 @@ server.registerTool(
281
444
  {
282
445
  title: 'Spotlight an element',
283
446
  description:
284
- 'Visually highlight (dim + outline) an element on the page by CSS selector, so the human tester can see what the agent is referring to. A non-destructive overlay — it does not click, focus, or modify the element.',
447
+ 'Visually highlight (dim + outline) an element on the page — target it by a qa_snapshot ref (preferred) or a CSS selector — so the human tester can see what the agent is referring to. A non-destructive overlay — it does not click, focus, or modify the element.',
285
448
  inputSchema: {
286
449
  tabId: tabIdArg,
287
- selector: z.string().describe('CSS selector of the element to spotlight'),
450
+ selector: z.string().optional().describe('CSS selector of the element to spotlight'),
451
+ ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred over selector'),
288
452
  label: z.string().optional().describe('Optional caption shown on the highlight'),
289
453
  },
290
454
  },
291
- async ({ tabId, selector, label }) => {
455
+ async ({ tabId, selector, ref, label }) => {
456
+ try {
457
+ return asText(await callExtension('qa_spotlight', { tabId, selector, ref, label }));
458
+ } catch (e) {
459
+ return asError(e);
460
+ }
461
+ },
462
+ );
463
+
464
+ /* ── Agent control (Pro) ───────────────────────────────────────────────────
465
+ *
466
+ * Everything above this line reads. These three ACT on the app under test, and
467
+ * they are the reason the tester's own login is worth something to an agent: it
468
+ * drives the real, already-authenticated session instead of starting cold.
469
+ *
470
+ * The gate is NOT here. The extension re-checks a Pro licence and the separate
471
+ * `drive` consent on every single call and answers `pro-required` /
472
+ * `consent-required`, so nothing this server can be told to do bypasses it —
473
+ * which is also why these tools are safe to advertise unconditionally.
474
+ */
475
+ const DRIVE_NOTE =
476
+ ' Requires Qiksy Pro and the one-time “Agent control” confirmation in the panel; without both the extension answers pro-required / consent-required and does nothing.';
477
+
478
+ server.registerTool(
479
+ 'qa_click',
480
+ {
481
+ title: 'Click an element (Pro)',
482
+ description:
483
+ '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.' +
484
+ DRIVE_NOTE,
485
+ inputSchema: {
486
+ tabId: tabIdArg,
487
+ selector: z.string().optional().describe('CSS selector of the element to click'),
488
+ ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred over selector; unique where a selector is ambiguous'),
489
+ },
490
+ },
491
+ async ({ tabId, selector, ref }) => {
492
+ try {
493
+ return asText(await callExtension('qa_click', { tabId, selector, ref }));
494
+ } catch (e) {
495
+ return asError(e);
496
+ }
497
+ },
498
+ );
499
+
500
+ server.registerTool(
501
+ 'qa_type',
502
+ {
503
+ title: 'Type into a field (Pro)',
504
+ description:
505
+ 'Set the value of an input, textarea, select or contenteditable — target it by a qa_snapshot ref (preferred) or a CSS selector. Uses the native value setter plus InputEvent, so React/Vue controlled inputs actually keep the value — a plain assignment is swallowed by them. For a <select>, matches your text against the option labels. Reports back whether the value stuck, so a masked or read-only field is visible to you rather than silently ignored.' +
506
+ DRIVE_NOTE,
507
+ inputSchema: {
508
+ tabId: tabIdArg,
509
+ selector: z.string().optional().describe('CSS selector of the field'),
510
+ ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred over selector; unique where a selector is ambiguous'),
511
+ value: z.string().describe('Text to put in the field'),
512
+ },
513
+ },
514
+ async ({ tabId, selector, ref, value }) => {
515
+ try {
516
+ return asText(await callExtension('qa_type', { tabId, selector, ref, value }));
517
+ } catch (e) {
518
+ return asError(e);
519
+ }
520
+ },
521
+ );
522
+
523
+ server.registerTool(
524
+ 'qa_type_many',
525
+ {
526
+ title: 'Fill many fields at once (Pro)',
527
+ description:
528
+ '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.' +
529
+ DRIVE_NOTE,
530
+ inputSchema: {
531
+ tabId: tabIdArg,
532
+ fields: z
533
+ .array(
534
+ z.object({
535
+ selector: z.string().describe('CSS selector of the field'),
536
+ value: z.string().optional().describe('Value to set (text input / <select> / checkbox / radio)'),
537
+ action: z.enum(['type', 'click']).optional().describe('Default "type"; use "click" for buttons and toggles'),
538
+ }),
539
+ )
540
+ .describe('The fields to fill, in order'),
541
+ },
542
+ },
543
+ async ({ tabId, fields }) => {
544
+ try {
545
+ return asText(await callExtension('qa_type_many', { tabId, fields }, 45_000));
546
+ } catch (e) {
547
+ return asError(e);
548
+ }
549
+ },
550
+ );
551
+
552
+ server.registerTool(
553
+ 'qa_press',
554
+ {
555
+ title: 'Press a key (Pro)',
556
+ description:
557
+ '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.' +
558
+ DRIVE_NOTE,
559
+ inputSchema: {
560
+ tabId: tabIdArg,
561
+ key: z.string().describe('Key value, e.g. "Enter", "Escape", "Tab", "a"'),
562
+ selector: z.string().optional().describe('Optional target (CSS); defaults to the focused element'),
563
+ ref: z.string().optional().describe('Optional target (a qa_snapshot ref); defaults to the focused element'),
564
+ },
565
+ },
566
+ async ({ tabId, key, selector, ref }) => {
567
+ try {
568
+ return asText(await callExtension('qa_press', { tabId, key, selector, ref }));
569
+ } catch (e) {
570
+ return asError(e);
571
+ }
572
+ },
573
+ );
574
+
575
+ /* ── Chaining across pages and roles ───────────────────────────────────────
576
+ *
577
+ * A submit replaces the document, and with it the Qiksy instance the previous
578
+ * call was talking to. `qa_wait_ready` is the join point: it returns only once a
579
+ * FRESH Qiksy answers in the new page, which is what makes fill → submit → act on
580
+ * the next page a reliable chain instead of a race.
581
+ *
582
+ * Doing the same flow as another user is then just a different tabId:
583
+ * `qa_open_isolated` gives you a tab with its own empty session (multi-login), and
584
+ * every tool already accepts `tabId`.
585
+ */
586
+ server.registerTool(
587
+ 'qa_wait_ready',
588
+ {
589
+ title: 'Wait for the page to be ready',
590
+ description:
591
+ 'Block until the tab has finished loading AND Qiksy is live in the new document. Call this after anything that navigates — a submit, a link click, qa_navigate — before the next qa_type/qa_click, or the call lands on a page that is still being replaced. Read-only: waiting is not acting, so this needs no Pro and no consent.',
592
+ inputSchema: {
593
+ tabId: tabIdArg,
594
+ timeoutMs: z.number().optional().describe('How long to wait, default 15000, max 60000'),
595
+ },
596
+ },
597
+ async ({ tabId, timeoutMs }) => {
598
+ try {
599
+ return asText(await callExtension('qa_wait_ready', { tabId, timeoutMs }, 65_000));
600
+ } catch (e) {
601
+ return asError(e);
602
+ }
603
+ },
604
+ );
605
+
606
+ server.registerTool(
607
+ 'qa_navigate',
608
+ {
609
+ title: 'Navigate a tab (Pro)',
610
+ description:
611
+ 'Point a tab at a URL and wait until it is loaded and Qiksy is live again — the ready-wait is built in, so the next step can run straight after. Use it to jump between steps of a flow that are not reachable by clicking.' +
612
+ DRIVE_NOTE,
613
+ inputSchema: {
614
+ tabId: tabIdArg,
615
+ url: z.string().describe('Absolute http(s) URL'),
616
+ },
617
+ },
618
+ async ({ tabId, url }) => {
619
+ try {
620
+ return asText(await callExtension('qa_navigate', { tabId, url }, 40_000));
621
+ } catch (e) {
622
+ return asError(e);
623
+ }
624
+ },
625
+ );
626
+
627
+ server.registerTool(
628
+ 'qa_open_isolated',
629
+ {
630
+ title: 'Open an isolated login tab (Pro)',
631
+ description:
632
+ 'Open a tab with its OWN private session — separate cookies, localStorage, IndexedDB — so a second (or third) user can be logged in at the same time in the same browser. Returns its `tabId`; pass that to any tool to run the whole flow as that user, while the first login keeps working untouched. This is how you test a hand-off: submit as one role here, then verify it as another role there, side by side.' +
633
+ DRIVE_NOTE,
634
+ inputSchema: {
635
+ url: z.string().describe('Absolute http(s) URL to open in the isolated tab(s)'),
636
+ session: z.string().optional().describe('Name for a single session, e.g. "admin"'),
637
+ sessions: z
638
+ .array(z.string())
639
+ .optional()
640
+ .describe('Open SEVERAL at once, named: ["admin","manager","member","viewer"] — one tab each, all live side by side'),
641
+ count: z.number().int().optional().describe('Open this many (max 12); unnamed ones become user-1, user-2, …'),
642
+ },
643
+ },
644
+ async ({ url, session, sessions, count }) => {
645
+ try {
646
+ return asText(await callExtension('qa_open_isolated', { url, session, sessions, count }, 120_000));
647
+ } catch (e) {
648
+ return asError(e);
649
+ }
650
+ },
651
+ );
652
+
653
+ server.registerTool(
654
+ 'qa_close_tab',
655
+ {
656
+ title: 'Close tabs (Pro)',
657
+ description:
658
+ 'Close one tab or a list of them — how you tidy up the isolated logins when a matrix run is finished. Returns which ids closed and which were already gone.' +
659
+ DRIVE_NOTE,
660
+ inputSchema: {
661
+ tabId: z.number().int().optional().describe('A single tab to close'),
662
+ tabIds: z.array(z.number().int()).optional().describe('Several tabs to close, e.g. every isolated login from qa_tabs'),
663
+ },
664
+ },
665
+ async ({ tabId, tabIds }) => {
292
666
  try {
293
- return asText(await callExtension('qa_spotlight', { tabId, selector, label }));
667
+ return asText(await callExtension('qa_close_tab', { tabId, tabIds }, 30_000));
294
668
  } catch (e) {
295
669
  return asError(e);
296
670
  }