qiksy-mcp 1.0.6 → 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 +29 -3
  2. package/package.json +1 -1
  3. package/server.mjs +153 -16
package/README.md CHANGED
@@ -114,7 +114,7 @@ Playwright MCP drives a browser over the **Chrome DevTools Protocol** — a *nor
114
114
  # Windows: "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
115
115
  # Linux: google-chrome --remote-debugging-port=9222
116
116
  ```
117
- 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**.
118
118
  3. **Register both MCP servers** in your agent — one config, paste and go. Claude Code `.mcp.json` (project root):
119
119
  ```json
120
120
  {
@@ -143,11 +143,37 @@ Qiksy runs several logins on one domain at once — one per tab, each with its o
143
143
 
144
144
  ## Setup
145
145
 
146
- 1. **In the extension popup** → *MCP bridge*: toggle **On**, click **Generate** for a token, pick a port (default `7333`), **Save bridge**.
147
- 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).
148
149
 
149
150
  The token is the only thing guarding the bridge — localhost is reachable by any local process/page, so keep it secret and unique.
150
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
+
151
177
  ### Claude Code
152
178
 
153
179
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.0.6",
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
@@ -31,6 +31,76 @@ import { fileURLToPath } from 'node:url';
31
31
 
32
32
  const log = (...a) => console.error('[qiksy-mcp]', ...a);
33
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
+
34
104
  // Read the version off package.json rather than repeating it here — a hardcoded
35
105
  // copy silently rots, and this one is what every MCP client displays.
36
106
  const VERSION = (() => {
@@ -110,9 +180,22 @@ wss.on('connection', (ws, req) => {
110
180
  }
111
181
  authed = true;
112
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
+ }
113
196
  client = ws;
114
197
  ws.send(JSON.stringify({ t: 'welcome', v: PROTOCOL_VERSION }));
115
- log('extension connected');
198
+ log(QUIPS_ON ? `${face(true)} extension connected — ${quip()}` : 'extension connected');
116
199
  return;
117
200
  }
118
201
  if (msg.t === 'ping') {
@@ -157,6 +240,7 @@ function callExtension(tool, args, timeoutMs = 15_000) {
157
240
  reject(new Error(`Timed out after ${timeoutMs}ms waiting for "${tool}". Is a normal web page focused in Chrome?`));
158
241
  }, timeoutMs);
159
242
  pending.set(id, { resolve, reject, timer });
243
+ if (QUIPS_ON) log(`▸ ${tool} ${face()} ${quip()}`);
160
244
  client.send(JSON.stringify({ t: 'req', id, tool, args: args || {} }));
161
245
  });
162
246
  }
@@ -277,6 +361,26 @@ server.registerTool(
277
361
  },
278
362
  );
279
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
+
280
384
  server.registerTool(
281
385
  'qa_tour',
282
386
  {
@@ -340,16 +444,17 @@ server.registerTool(
340
444
  {
341
445
  title: 'Spotlight an element',
342
446
  description:
343
- '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.',
344
448
  inputSchema: {
345
449
  tabId: tabIdArg,
346
- 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'),
347
452
  label: z.string().optional().describe('Optional caption shown on the highlight'),
348
453
  },
349
454
  },
350
- async ({ tabId, selector, label }) => {
455
+ async ({ tabId, selector, ref, label }) => {
351
456
  try {
352
- return asText(await callExtension('qa_spotlight', { tabId, selector, label }));
457
+ return asText(await callExtension('qa_spotlight', { tabId, selector, ref, label }));
353
458
  } catch (e) {
354
459
  return asError(e);
355
460
  }
@@ -375,16 +480,17 @@ server.registerTool(
375
480
  {
376
481
  title: 'Click an element (Pro)',
377
482
  description:
378
- 'Click an element on the page under test by 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 selector that matches nothing or has zero size rather than silently doing nothing.' +
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.' +
379
484
  DRIVE_NOTE,
380
485
  inputSchema: {
381
486
  tabId: tabIdArg,
382
- selector: z.string().describe('CSS selector of the element to click'),
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'),
383
489
  },
384
490
  },
385
- async ({ tabId, selector }) => {
491
+ async ({ tabId, selector, ref }) => {
386
492
  try {
387
- return asText(await callExtension('qa_click', { tabId, selector }));
493
+ return asText(await callExtension('qa_click', { tabId, selector, ref }));
388
494
  } catch (e) {
389
495
  return asError(e);
390
496
  }
@@ -396,17 +502,47 @@ server.registerTool(
396
502
  {
397
503
  title: 'Type into a field (Pro)',
398
504
  description:
399
- 'Set the value of an input, textarea or contenteditable by 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. Reports back whether the value stuck, so a masked or read-only field is visible to you rather than silently ignored.' +
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.' +
400
506
  DRIVE_NOTE,
401
507
  inputSchema: {
402
508
  tabId: tabIdArg,
403
- selector: z.string().describe('CSS selector of the field'),
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'),
404
511
  value: z.string().describe('Text to put in the field'),
405
512
  },
406
513
  },
407
- async ({ tabId, selector, value }) => {
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 }) => {
408
544
  try {
409
- return asText(await callExtension('qa_type', { tabId, selector, value }));
545
+ return asText(await callExtension('qa_type_many', { tabId, fields }, 45_000));
410
546
  } catch (e) {
411
547
  return asError(e);
412
548
  }
@@ -423,12 +559,13 @@ server.registerTool(
423
559
  inputSchema: {
424
560
  tabId: tabIdArg,
425
561
  key: z.string().describe('Key value, e.g. "Enter", "Escape", "Tab", "a"'),
426
- selector: z.string().optional().describe('Optional target; defaults to the focused element'),
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'),
427
564
  },
428
565
  },
429
- async ({ tabId, key, selector }) => {
566
+ async ({ tabId, key, selector, ref }) => {
430
567
  try {
431
- return asText(await callExtension('qa_press', { tabId, key, selector }));
568
+ return asText(await callExtension('qa_press', { tabId, key, selector, ref }));
432
569
  } catch (e) {
433
570
  return asError(e);
434
571
  }