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.
- package/README.md +83 -8
- package/package.json +1 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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** → *
|
|
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** → *
|
|
98
|
-
2. **
|
|
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
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(
|
|
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:
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
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('
|
|
667
|
+
return asText(await callExtension('qa_close_tab', { tabId, tabIds }, 30_000));
|
|
294
668
|
} catch (e) {
|
|
295
669
|
return asError(e);
|
|
296
670
|
}
|