qiksy-mcp 1.41.0 → 1.43.0

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 (4) hide show
  1. package/README.md +28 -1
  2. package/connect.mjs +203 -0
  3. package/package.json +2 -1
  4. package/server.mjs +106 -10
package/README.md CHANGED
@@ -22,7 +22,7 @@ unless the licence (or the 28-day trial) is valid, so once a trial lapses *every
22
22
  tool fails — `qa_export` included — with a message saying so. Do not plan a
23
23
  free-tier integration around read-only tools.
24
24
 
25
- **npm carries `qiksy-mcp@1.41.0`** — the current build, with all sixty-one tools
25
+ **npm carries `qiksy-mcp@1.43.0`** — the current build, with all sixty-two tools
26
26
  including a filterable full-page `qa_snapshot`, `qa_fill_json` (a whole wizard step —
27
27
  custom dropdowns, calendars, cascaders, trees, tags and time included — in one call),
28
28
  the `qa_pick_*` verbs for the composites, `qa_probe` for the one control that refuses,
@@ -241,6 +241,33 @@ Qiksy runs several logins on one domain at once — one per tab, each with its o
241
241
 
242
242
  ## Setup
243
243
 
244
+ ### The short way: one command
245
+
246
+ ```bash
247
+ npx qiksy-mcp connect
248
+ ```
249
+
250
+ Run it **in the project you work in**. It picks a free port, mints a token, writes the `qiksy`
251
+ server into every agent config it finds on the machine — Claude Code (`.mcp.json`), Cursor
252
+ (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`, which uses its own `servers` key), Windsurf,
253
+ Claude Desktop — and puts the token on your clipboard. Then: **popup → Agent bridge → On → paste**,
254
+ restart the agent, ask it to call `qa_status`.
255
+
256
+ `npx qiksy-mcp init` does the same **and** writes the agent guide into the project, which is what
257
+ stops your agent proposing Playwright on the first unfamiliar field. Use `init` the first time.
258
+
259
+ What it will not do: it writes a config **only where that client's own folder already exists**, so
260
+ you never get a Windsurf config for an editor you do not have; it keeps every other server already
261
+ in those files; it refuses to touch a config it cannot parse rather than overwriting it; and on a
262
+ second run it **keeps the token that is already there**, so a browser you have already paired stays
263
+ paired. On Windows the entry is written as `cmd /c npx …`, which is the form that actually starts —
264
+ a bare `npx` fails in about half the clients there.
265
+
266
+ Flags: `--dir <path>` (which project), `--port <n>`, `--token <value>`, and `--no-connect` on
267
+ `init` if you want the guide without the wiring.
268
+
269
+ ### The manual way
270
+
244
271
  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.
245
272
  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.
246
273
  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).
package/connect.mjs ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `npx qiksy-mcp connect` — THE SIX STEPS BECOME ONE.
3
+ *
4
+ * Owner, 20.09.2026: «QA ленивые, технически многие вообще не шарят — как к блютузу бы».
5
+ * Measured against the old path, a person had to: switch the bridge on, accept, press Generate,
6
+ * press Copy, FIND their agent's config file, paste JSON into it without breaking the JSON, and
7
+ * restart the agent. Two of those steps are ours and five are theirs — and the file one is where
8
+ * they stop, because «~/.cursor/mcp.json» means nothing to somebody who tests web shops for a
9
+ * living.
10
+ *
11
+ * This process is the one thing that already lives on their machine AND is allowed to write
12
+ * files, so it does the whole middle: picks a free port, mints the token, finds every agent that
13
+ * is actually installed, writes the entry into each config it found, and leaves the token on the
14
+ * clipboard so the popup is one ⌘V away. The extension cannot do any of that — no filesystem, no
15
+ * processes — which is why this belongs here and not in the popup.
16
+ *
17
+ * Three rules it does not break:
18
+ * · nothing is invented. A config is written ONLY where that client's own folder already
19
+ * exists, so nobody ends up with a Windsurf config for an editor they never installed.
20
+ * · nothing of theirs is lost. The file is read, our one entry is set, everything else is
21
+ * written back untouched — and a run on a file we cannot parse stops rather than overwrites.
22
+ * · the pairing survives. A second run keeps the token that is already in their config, or the
23
+ * browser they already paired would be locked out by a fresh secret.
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { homedir, platform as osPlatform } from 'node:os';
27
+ import { join } from 'node:path';
28
+ import { createServer } from 'node:net';
29
+ import { spawnSync } from 'node:child_process';
30
+ import { randomBytes } from 'node:crypto';
31
+
32
+ /**
33
+ * WHERE EACH AGENT KEEPS ITS SERVER LIST.
34
+ *
35
+ * `home` and `platform` are arguments rather than reads so a test can ask for the Windows answer
36
+ * on a Mac — the paths are the part that goes wrong silently, and they are unreachable otherwise.
37
+ *
38
+ * `key` differs by client and is NOT cosmetic: VS Code reads `servers`, everyone else reads
39
+ * `mcpServers`, and a file written with the wrong key is valid JSON that the agent ignores — the
40
+ * worst possible failure, because it looks installed.
41
+ *
42
+ * `needs` is the folder whose existence means the client is installed. Missing → skipped, which
43
+ * is how «wrote 4 configs» stops being a lie on a machine with one editor.
44
+ */
45
+ export function agentTargets({ home = homedir(), platform = osPlatform(), dir = process.cwd() } = {}) {
46
+ const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming');
47
+ const claudeDesktop =
48
+ platform === 'win32'
49
+ ? join(appData, 'Claude', 'claude_desktop_config.json')
50
+ : platform === 'darwin'
51
+ ? join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
52
+ : join(home, '.config', 'Claude', 'claude_desktop_config.json');
53
+
54
+ return [
55
+ /* Claude Code reads a project-scoped .mcp.json — the file belongs to the repo the person is
56
+ standing in, which is also the repo their agent is working on. */
57
+ { id: 'claude-code', label: 'Claude Code', file: join(dir, '.mcp.json'), key: 'mcpServers', needs: dir, scope: 'project' },
58
+ { id: 'cursor', label: 'Cursor', file: join(home, '.cursor', 'mcp.json'), key: 'mcpServers', needs: join(home, '.cursor'), scope: 'user' },
59
+ { id: 'vscode', label: 'VS Code', file: join(dir, '.vscode', 'mcp.json'), key: 'servers', needs: join(dir, '.vscode'), scope: 'project' },
60
+ { id: 'windsurf', label: 'Windsurf', file: join(home, '.codeium', 'windsurf', 'mcp_config.json'), key: 'mcpServers', needs: join(home, '.codeium', 'windsurf'), scope: 'user' },
61
+ { id: 'claude-desktop', label: 'Claude Desktop', file: claudeDesktop, key: 'mcpServers', needs: join(claudeDesktop, '..'), scope: 'user' },
62
+ ];
63
+ }
64
+
65
+ /**
66
+ * THE ENTRY ITSELF — and on Windows it is not the obvious one.
67
+ *
68
+ * `command: "npx"` is what every example shows and what fails on Windows for a good half of the
69
+ * clients: `npx` there is `npx.cmd`, and a spawn without a shell does not find it. The result is
70
+ * an agent that reports the server as failed to start with no explanation a tester can act on.
71
+ * So Windows gets the `cmd /c` form, which works in both the clients that need it and those that
72
+ * would have coped anyway.
73
+ */
74
+ export function serverEntry({ port, token, platform = osPlatform() }) {
75
+ const base = platform === 'win32'
76
+ ? { command: 'cmd', args: ['/c', 'npx', 'qiksy-mcp', '--port', String(port)] }
77
+ : { command: 'npx', args: ['qiksy-mcp', '--port', String(port)] };
78
+ return { ...base, env: { QIKSY_MCP_TOKEN: token } };
79
+ }
80
+
81
+ /** Read a config we are about to edit. Unreadable JSON is an error, never something to overwrite. */
82
+ export function readConfig(file) {
83
+ if (!existsSync(file)) return { json: {}, existed: false };
84
+ const raw = readFileSync(file, 'utf8').trim();
85
+ if (!raw) return { json: {}, existed: true };
86
+ try {
87
+ return { json: JSON.parse(raw), existed: true };
88
+ } catch (e) {
89
+ throw new Error(`${file} is not valid JSON (${e.message}) — fix or move it, I will not overwrite it`);
90
+ }
91
+ }
92
+
93
+ /** The token already wired into one of their configs, if any: a second run must not break the pair. */
94
+ export function tokenInPlace(targets) {
95
+ for (const t of targets) {
96
+ try {
97
+ if (!existsSync(t.file)) continue;
98
+ const { json } = readConfig(t.file);
99
+ const tok = json?.[t.key]?.qiksy?.env?.QIKSY_MCP_TOKEN;
100
+ if (typeof tok === 'string' && tok.length >= 16) return tok;
101
+ } catch {
102
+ /* a broken file is reported later by the write; it must not stop the search */
103
+ }
104
+ }
105
+ return '';
106
+ }
107
+
108
+ /** The port already wired in, for the same reason: re-running must not move the browser's target. */
109
+ export function portInPlace(targets) {
110
+ for (const t of targets) {
111
+ try {
112
+ if (!existsSync(t.file)) continue;
113
+ const { json } = readConfig(t.file);
114
+ const args = json?.[t.key]?.qiksy?.args;
115
+ const i = Array.isArray(args) ? args.indexOf('--port') : -1;
116
+ const p = i !== -1 ? Number(args[i + 1]) : NaN;
117
+ if (Number.isInteger(p) && p > 0) return p;
118
+ } catch {
119
+ /* same */
120
+ }
121
+ }
122
+ return 0;
123
+ }
124
+
125
+ /** Write our one entry, keep everything else in the file exactly as it was. */
126
+ export function writeEntry(target, entry) {
127
+ const { json, existed } = readConfig(target.file);
128
+ const next = { ...json, [target.key]: { ...(json[target.key] || {}), qiksy: entry } };
129
+ mkdirSync(join(target.file, '..'), { recursive: true });
130
+ writeFileSync(target.file, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
131
+ return existed ? 'updated' : 'created';
132
+ }
133
+
134
+ /** A port nothing is listening on, starting where the docs say and walking up a short way. */
135
+ export async function freePort(from = 7333, tries = 12) {
136
+ for (let p = from; p < from + tries; p++) {
137
+ const ok = await new Promise((res) => {
138
+ const s = createServer();
139
+ s.once('error', () => res(false));
140
+ s.once('listening', () => s.close(() => res(true)));
141
+ s.listen(p, '127.0.0.1');
142
+ });
143
+ if (ok) return p;
144
+ }
145
+ return from;
146
+ }
147
+
148
+ /**
149
+ * THE TOKEN GOES TO THE CLIPBOARD, because the last step is a paste into the popup.
150
+ * Best effort by design: a headless Linux box has no clipboard, and that is not a failure of the
151
+ * setup — the token is printed as well, and the report says which of the two happened.
152
+ */
153
+ export function toClipboard(text, platform = osPlatform()) {
154
+ const runners = platform === 'darwin'
155
+ ? [['pbcopy', []]]
156
+ : platform === 'win32'
157
+ ? [['clip', []]]
158
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
159
+ for (const [cmd, args] of runners) {
160
+ try {
161
+ const r = spawnSync(cmd, args, { input: text, shell: platform === 'win32' });
162
+ if (!r.error && r.status === 0) return cmd;
163
+ } catch {
164
+ /* try the next one */
165
+ }
166
+ }
167
+ return '';
168
+ }
169
+
170
+ export const mintToken = () => randomBytes(18).toString('base64url');
171
+
172
+ /**
173
+ * Do the whole thing and report what happened. No printing here — the caller owns the voice, and
174
+ * a function that both decides and narrates cannot be tested without reading its output.
175
+ */
176
+ export async function connectAgents({ dir = process.cwd(), home = homedir(), platform = osPlatform(), port, token } = {}) {
177
+ const targets = agentTargets({ home, platform, dir });
178
+ const installed = targets.filter((t) => existsSync(t.needs));
179
+ const keptToken = token || tokenInPlace(targets);
180
+ const keptPort = port || portInPlace(targets);
181
+ const usePort = keptPort || (await freePort());
182
+ const useToken = keptToken || mintToken();
183
+ const entry = serverEntry({ port: usePort, token: useToken, platform });
184
+
185
+ const wrote = [];
186
+ const failed = [];
187
+ for (const t of installed) {
188
+ try {
189
+ wrote.push({ ...t, action: writeEntry(t, entry) });
190
+ } catch (e) {
191
+ failed.push({ ...t, error: e.message });
192
+ }
193
+ }
194
+ return {
195
+ port: usePort,
196
+ token: useToken,
197
+ reusedToken: !!keptToken,
198
+ entry,
199
+ wrote,
200
+ failed,
201
+ skipped: targets.filter((t) => !installed.includes(t)),
202
+ };
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.41.0",
3
+ "version": "1.43.0",
4
4
  "description": "Browser MCP server for the Chrome tab you already have open — your session, your logins. Gives Claude Code, Cursor, Codex and VS Code the live page: findings, forms, failed requests with server bodies.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -35,6 +35,7 @@
35
35
  "gaps.mjs",
36
36
  "gallery.mjs",
37
37
  "svg-shot.mjs",
38
+ "connect.mjs",
38
39
  "diff.mjs",
39
40
  "README.md",
40
41
  "AGENT-GUIDE.md"
package/server.mjs CHANGED
@@ -35,6 +35,7 @@ import { project } from './project.mjs';
35
35
  import { recordGap, listGaps, gapInvite, GAPS_FILE } from './gaps.mjs';
36
36
  import { STATES, STATE_KEYS, renderGallery } from './gallery.mjs';
37
37
  import { renderSearchableSvg } from './svg-shot.mjs';
38
+ import { connectAgents, toClipboard } from './connect.mjs';
38
39
 
39
40
  const log = (...a) => console.error('[qiksy-mcp]', ...a);
40
41
 
@@ -669,7 +670,7 @@ function installDocs({ dir, out, withClaude = true }) {
669
670
  return report;
670
671
  }
671
672
 
672
- function runInit(argv) {
673
+ async function runInit(argv) {
673
674
  const flag = (name) => {
674
675
  const i = argv.indexOf(name);
675
676
  return i !== -1 ? argv[i + 1] : undefined;
@@ -695,15 +696,60 @@ function runInit(argv) {
695
696
  for (const t of report.touched) say(`✓ ${t.action} the Qiksy block in ${t.file}`);
696
697
  for (const f of report.failed) console.error(` could not write ${f.file} — ${f.error}`);
697
698
 
699
+ if (!argv.includes('--no-connect')) await runConnect(argv, { after: true });
700
+ }
701
+
702
+ /**
703
+ * THE WIRING, DONE BY THE THING THAT IS ALLOWED TO WRITE FILES.
704
+ *
705
+ * What used to be printed here was three steps in a terminal and a JSON file to edit by hand, and
706
+ * that file is where people stop (owner, 20.09.2026: «технически многие вообще не шарят»). The
707
+ * extension cannot do it — no filesystem, no processes — and the person should not have to, so
708
+ * this process does: free port, token, every installed agent's config written, token on the
709
+ * clipboard for the one paste that is left.
710
+ *
711
+ * It says what it TOUCHED and what it skipped, because a setup tool that writes into somebody's
712
+ * machine and reports only success is the kind you uninstall.
713
+ */
714
+ async function runConnect(argv, { after = false } = {}) {
715
+ const flag = (name) => {
716
+ const i = argv.indexOf(name);
717
+ return i !== -1 ? argv[i + 1] : undefined;
718
+ };
719
+ const say = (...a) => console.log(...a);
720
+ let r;
721
+ try {
722
+ r = await connectAgents({
723
+ dir: resolve(flag('--dir') || process.cwd()),
724
+ port: Number(flag('--port')) || 0,
725
+ token: flag('--token') || '',
726
+ });
727
+ } catch (e) {
728
+ console.error(`qiksy-mcp connect: ${e.message}`);
729
+ process.exit(1);
730
+ }
731
+
732
+ say(after ? '' : '');
733
+ if (!r.wrote.length) {
734
+ say('No agent config found to write into. Looked for:');
735
+ for (const s of r.skipped) say(` · ${s.label} → ${s.file}`);
736
+ say('\nOpen the project you work in and run this there, or paste the block from');
737
+ say('https://qiksy.app/qa-copilot/ into your agent yourself.');
738
+ }
739
+ for (const w of r.wrote) say(`✓ ${w.action} the qiksy server in ${w.label} → ${w.file}`);
740
+ for (const f of r.failed) console.error(` could not write ${f.label} → ${f.file}: ${f.error}`);
741
+
742
+ const clip = toClipboard(r.token);
698
743
  say(`
699
- Next, if you have not already:
700
- 1. Qiksy popup → "Agent bridge · MCP" → On → Generate a token.
701
- 2. claude mcp add --transport stdio qiksy --env QIKSY_MCP_TOKEN=<token> -- npx qiksy-mcp --port ${PORT}
702
- (or the .mcp.json / VS Code / Zed block from https://qiksy.app/qa-copilot/)
703
- 3. Restart the agent and call qa_status. It should answer with the page you are looking at.
704
-
705
- "Qiksy extension is not connected" means the popup toggle is off or the token/port differ —
706
- it never means you should reach for another browser driver.`);
744
+ Port ${r.port}${r.reusedToken ? ' · kept the token already in your config' : ' · new token'}.
745
+
746
+ One step left, in the browser:
747
+ Qiksy popup → "Agent bridge · MCP" → On → paste the token${clip ? ' (it is on your clipboard)' : ''}.${clip ? '' : `\n Token: ${r.token}`}
748
+ Then restart your agent and ask it to call qa_status.
749
+
750
+ The token never leaves this machine: it is what stops another local program from pretending to be
751
+ your browser. "Qiksy extension is not connected" means the popup toggle is off or the token and
752
+ port differ — it never means you should reach for another browser driver.`);
707
753
  }
708
754
 
709
755
  /**
@@ -763,8 +809,20 @@ function autoInstallDocs() {
763
809
  }
764
810
  }
765
811
 
812
+ /* BOTH SUBCOMMANDS END THE PROCESS, AND THE AWAIT IS THE POINT.
813
+ *
814
+ * Everything below this line boots a stdio MCP server. The first cut fired the async connect and
815
+ * carried on, so `connect` wired the configs AND announced «MCP stdio server ready» — a setup
816
+ * command turning itself into a bridge nobody asked for (seen in the first live run). Top-level
817
+ * await holds the module here until the work is done, and then the process leaves. */
766
818
  if (process.argv[2] === 'init') {
767
- runInit(process.argv.slice(3));
819
+ await runInit(process.argv.slice(3));
820
+ process.exit(0);
821
+ }
822
+ if (process.argv[2] === 'connect') {
823
+ /* The wiring on its own, for somebody who already has the guide and just wants the link up
824
+ (or is redoing it after changing editors). `init` runs both. */
825
+ await runConnect(process.argv.slice(3));
768
826
  process.exit(0);
769
827
  }
770
828
 
@@ -5808,6 +5866,44 @@ server.registerTool(
5808
5866
  },
5809
5867
  );
5810
5868
 
5869
+ /**
5870
+ * HOVER — the state every other verb here passes through and none of them stays in.
5871
+ *
5872
+ * Asked for by the owner on 17.09.2026 while testing with the bridge, and the gap was real: the
5873
+ * only verb that sent a `mouseover` was `qa_click`, which sends a `mousedown` a millisecond
5874
+ * later. So a menu that opens on hover was opened and closed by the same call, and a tooltip
5875
+ * could not be read at all. Hover is not a weaker click — it is a different state, and a large
5876
+ * part of what a tester checks lives only inside it.
5877
+ */
5878
+ server.registerTool(
5879
+ 'qa_hover',
5880
+ {
5881
+ title: 'Hover over an element and hold (Pro)',
5882
+ description:
5883
+ 'Put the pointer on an element AND LEAVE IT THERE, then report what that revealed: a menu that opens on hover, a tooltip, the actions a row only shows under the pointer, the value a chart reads out beneath it. qa_click cannot answer any of those — it presses a millisecond after it hovers, and the press closes what the hover opened. ' +
5884
+ 'It holds for `holdMs` (500 by default) before measuring, because tooltips wait — measuring at once reports "no tooltip" about one that was always going to appear. The reply carries the usual `changed` diff plus `tooltip` (role=tooltip, aria-describedby or title, whichever exists) and `expanded` when the control changed its aria-expanded. ' +
5885
+ 'ONE FACT TO READ CAREFULLY: `cssHover` says whether the browser\'s own `:hover` matched. Dispatched events run the page\'s JavaScript handlers — every menu and tooltip library — but they do not move the real cursor, so an effect written PURELY in CSS does not appear and `cssHover` comes back false. That is the tool being honest, not the page being broken. With trusted input switched on the real pointer moves, `cssHover` is true, and CSS-only hovers light up too. ' +
5886
+ 'Call it again with `off: true` to take the pointer away — "the tooltip disappears" is the other half of the check, and it needs its own call.' +
5887
+ DRIVE_NOTE,
5888
+ inputSchema: {
5889
+ tabId: tabIdArg,
5890
+ selector: z.string().optional().describe('CSS selector of the element to hover'),
5891
+ ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred over selector'),
5892
+ x: z.number().optional().describe('Without ref/selector: CSS pixels from the LEFT of the viewport. WITH one: an offset inside the element, for a chart or a split control where the middle is the wrong place.'),
5893
+ y: z.number().optional().describe('Without ref/selector: CSS pixels from the TOP of the viewport. WITH one: an offset inside the element.'),
5894
+ holdMs: z.number().optional().describe('How long to hold the pointer there before measuring (0–10000, default 500)'),
5895
+ off: z.boolean().optional().describe('Take the pointer AWAY from whatever it is on, and report what was left. No target needed.'),
5896
+ },
5897
+ },
5898
+ async ({ tabId, selector, ref, x, y, holdMs, off }) => {
5899
+ try {
5900
+ return asText(await callExtension('qa_hover', { tabId, selector, ref, x, y, holdMs, off }, 30_000));
5901
+ } catch (e) {
5902
+ return asError(e);
5903
+ }
5904
+ },
5905
+ );
5906
+
5811
5907
  server.registerTool(
5812
5908
  'qa_click',
5813
5909
  {