qiksy-mcp 1.46.0 → 1.47.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.
- package/connect.mjs +71 -2
- package/package.json +1 -1
- package/server.mjs +53 -8
package/connect.mjs
CHANGED
|
@@ -122,6 +122,68 @@ export function portInPlace(targets) {
|
|
|
122
122
|
return 0;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* THE PAIRING, FOR A COMMAND THAT HAS NO ENVIRONMENT — i.e. the finish hook.
|
|
127
|
+
*
|
|
128
|
+
* The server proper is started BY the agent, which hands it the token in the entry's `env` block.
|
|
129
|
+
* A Stop hook is not started that way: it is a bare command in a bare shell, so QIKSY_MCP_TOKEN is
|
|
130
|
+
* empty, the hub closes the unauthenticated join — correctly — and the hook then fails in the one
|
|
131
|
+
* way nobody notices: silently, once per run, into a log no one reads. Measured on a real setup
|
|
132
|
+
* (21.09.2026): `npx qiksy-mcp done` answered «the bridge closed the connection» and the browser
|
|
133
|
+
* never heard a thing, while the same command with the token in front of it rang through.
|
|
134
|
+
*
|
|
135
|
+
* So the hook reads the same pairing the agent itself uses. `~/.claude.json` is in the search
|
|
136
|
+
* because that is where `claude mcp add` puts an entry — top level, or under projects/<cwd> — and
|
|
137
|
+
* it is READ ONLY here: connect writes `.mcp.json` instead, and a command that fires on every stop
|
|
138
|
+
* must never rewrite the file that holds every project's servers.
|
|
139
|
+
*/
|
|
140
|
+
export function pairingInPlace({ dir = process.cwd(), home = homedir(), platform = osPlatform() } = {}) {
|
|
141
|
+
const targets = agentTargets({ dir, home, platform });
|
|
142
|
+
const found = { token: tokenInPlace(targets), port: portInPlace(targets) };
|
|
143
|
+
if (found.token && found.port) return found;
|
|
144
|
+
|
|
145
|
+
for (const entry of claudeJsonEntries(join(home, '.claude.json'), dir)) {
|
|
146
|
+
const tok = entry?.env?.QIKSY_MCP_TOKEN;
|
|
147
|
+
if (!found.token && typeof tok === 'string' && tok.length >= 16) found.token = tok;
|
|
148
|
+
if (!found.port) {
|
|
149
|
+
const args = Array.isArray(entry?.args) ? entry.args : [];
|
|
150
|
+
const i = args.indexOf('--port');
|
|
151
|
+
const p = i !== -1 ? Number(args[i + 1]) : NaN;
|
|
152
|
+
if (Number.isInteger(p) && p > 0) found.port = p;
|
|
153
|
+
}
|
|
154
|
+
if (found.token && found.port) break;
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every qiksy entry in `~/.claude.json`, THE ONE FOR THIS FOLDER FIRST.
|
|
161
|
+
*
|
|
162
|
+
* Order is the whole point: the token is the same everywhere (the popup has one field), but the
|
|
163
|
+
* PORT is per project, and a hook that picks a neighbouring project's port rings a bridge that
|
|
164
|
+
* belongs to somebody else's run. Deepest matching project path wins, then the global entry, then
|
|
165
|
+
* whatever else is there — the last one so a folder that was never connected still finds the
|
|
166
|
+
* pairing instead of going silent.
|
|
167
|
+
*/
|
|
168
|
+
function claudeJsonEntries(file, dir) {
|
|
169
|
+
let json;
|
|
170
|
+
try {
|
|
171
|
+
json = JSON.parse(readFileSync(file, 'utf8'));
|
|
172
|
+
} catch {
|
|
173
|
+
return []; // missing or hand-broken: not our file to fix, and not a reason to fail
|
|
174
|
+
}
|
|
175
|
+
const projects = json?.projects && typeof json.projects === 'object' ? json.projects : {};
|
|
176
|
+
const mine = Object.keys(projects)
|
|
177
|
+
.filter((p) => dir === p || dir.startsWith(`${p}/`))
|
|
178
|
+
.sort((a, b) => b.length - a.length);
|
|
179
|
+
const rest = Object.keys(projects).filter((p) => !mine.includes(p));
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const p of mine) if (projects[p]?.mcpServers?.qiksy) out.push(projects[p].mcpServers.qiksy);
|
|
182
|
+
if (json?.mcpServers?.qiksy) out.push(json.mcpServers.qiksy);
|
|
183
|
+
for (const p of rest) if (projects[p]?.mcpServers?.qiksy) out.push(projects[p].mcpServers.qiksy);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
125
187
|
/** Write our one entry, keep everything else in the file exactly as it was. */
|
|
126
188
|
export function writeEntry(target, entry) {
|
|
127
189
|
const { json, existed } = readConfig(target.file);
|
|
@@ -213,8 +275,15 @@ export const mintToken = () => randomBytes(18).toString('base64url');
|
|
|
213
275
|
export async function connectAgents({ dir = process.cwd(), home = homedir(), platform = osPlatform(), port, token, hook = true } = {}) {
|
|
214
276
|
const targets = agentTargets({ home, platform, dir });
|
|
215
277
|
const installed = targets.filter((t) => existsSync(t.needs));
|
|
216
|
-
|
|
217
|
-
|
|
278
|
+
/* THE SEARCH FOR AN EXISTING PAIRING IS WIDER THAN THE LIST WE WRITE INTO, and that is the whole
|
|
279
|
+
point of it. `claude mcp add` puts the entry in ~/.claude.json, which is not a file connect
|
|
280
|
+
writes; looking only at its own targets, a re-run found nothing, minted a FRESH token and wrote
|
|
281
|
+
it everywhere — leaving the browser paired with a secret nobody holds any more. It costs the
|
|
282
|
+
person a «Token rejected» on the next restart and a trip to the popup, for a command they ran
|
|
283
|
+
to fix something else. Seen live on the owner's own machine, 21.09.2026. */
|
|
284
|
+
const inPlace = pairingInPlace({ dir, home, platform });
|
|
285
|
+
const keptToken = token || inPlace.token;
|
|
286
|
+
const keptPort = port || inPlace.port;
|
|
218
287
|
const usePort = keptPort || (await freePort());
|
|
219
288
|
const useToken = keptToken || mintToken();
|
|
220
289
|
const entry = serverEntry({ port: usePort, token: useToken, platform });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.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",
|
package/server.mjs
CHANGED
|
@@ -35,7 +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
|
+
import { connectAgents, toClipboard, pairingInPlace } from './connect.mjs';
|
|
39
39
|
|
|
40
40
|
const log = (...a) => console.error('[qiksy-mcp]', ...a);
|
|
41
41
|
|
|
@@ -855,17 +855,26 @@ if (process.argv[2] === 'done') {
|
|
|
855
855
|
* loudly because the browser is closed would be a hook people remove.
|
|
856
856
|
*/
|
|
857
857
|
const bad = process.argv.includes('--problem');
|
|
858
|
-
|
|
858
|
+
|
|
859
|
+
/* ONE ATTEMPT WITH ONE PAIRING. Resolves to the sentence to print, plus `refused` when the hub
|
|
860
|
+
shut the door on us — which is the only failure worth trying a second key on. */
|
|
861
|
+
const ring = (token, port) => new Promise((res) => {
|
|
859
862
|
let ws;
|
|
860
|
-
|
|
863
|
+
let answered = false;
|
|
864
|
+
const give = (said, refused = false) => {
|
|
865
|
+
if (answered) return;
|
|
866
|
+
answered = true;
|
|
867
|
+
try { ws?.close(); } catch { /* already gone */ }
|
|
868
|
+
res({ said, refused });
|
|
869
|
+
};
|
|
861
870
|
const timer = setTimeout(() => give('no answer from the bridge'), 6000);
|
|
862
871
|
try {
|
|
863
|
-
ws = new WebSocket(`ws://${HOST}:${
|
|
872
|
+
ws = new WebSocket(`ws://${HOST}:${port}`);
|
|
864
873
|
} catch {
|
|
865
874
|
clearTimeout(timer);
|
|
866
|
-
return
|
|
875
|
+
return give('could not reach the bridge');
|
|
867
876
|
}
|
|
868
|
-
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', v: PROTOCOL_VERSION, token
|
|
877
|
+
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', v: PROTOCOL_VERSION, token, pid: process.pid })));
|
|
869
878
|
ws.on('message', (raw) => {
|
|
870
879
|
let m;
|
|
871
880
|
try { m = JSON.parse(raw.toString()); } catch { return; }
|
|
@@ -873,9 +882,45 @@ if (process.argv[2] === 'done') {
|
|
|
873
882
|
if (m.t === 'res') { clearTimeout(timer); give(m.result?.soundNote || m.error || 'done'); }
|
|
874
883
|
});
|
|
875
884
|
ws.on('error', () => { clearTimeout(timer); give('no bridge on this port'); });
|
|
876
|
-
|
|
885
|
+
/* Closed before an answer = the token was refused. The hub says nothing more, by design: it
|
|
886
|
+
is an authentication gate, and a gate that explains itself is a gate that helps a stranger. */
|
|
887
|
+
ws.on('close', () => { clearTimeout(timer); give('the bridge closed the connection — the token it holds is a different one', true); });
|
|
877
888
|
});
|
|
878
|
-
|
|
889
|
+
|
|
890
|
+
/* TWO PLACES A TOKEN CAN COME FROM, AND THE ENVIRONMENT IS NOT THE TRUSTWORTHY ONE.
|
|
891
|
+
*
|
|
892
|
+
* A hook runs in a bare shell: no `env` block from the agent, so usually nothing — and sometimes
|
|
893
|
+
* WORSE than nothing, because an old QIKSY_MCP_TOKEN left in a .zshenv from a previous setup is
|
|
894
|
+
* exported into every shell on the machine and looks exactly like configuration. Found on the
|
|
895
|
+
* owner's own machine 21.09.2026: the shell carried a token three generations old, the hub
|
|
896
|
+
* refused it, and the command reported «the bridge closed the connection» — which reads as a
|
|
897
|
+
* browser problem, so the search starts in the popup, which was never wrong.
|
|
898
|
+
*
|
|
899
|
+
* So the environment is tried FIRST (an explicit export must still win when it is right), and a
|
|
900
|
+
* refusal falls through to the pairing the agent itself is using. Two attempts, never more. */
|
|
901
|
+
const tries = [];
|
|
902
|
+
if (TOKEN) tries.push({ token: TOKEN, port: PORT, from: 'the environment' });
|
|
903
|
+
const p = pairingInPlace({ dir: process.cwd() });
|
|
904
|
+
if (p.token && p.token !== TOKEN) {
|
|
905
|
+
const own = portArg === -1 && !process.env.QIKSY_MCP_PORT && p.port ? p.port : PORT;
|
|
906
|
+
tries.push({ token: p.token, port: own, from: 'your agent config' });
|
|
907
|
+
}
|
|
908
|
+
if (!tries.length) {
|
|
909
|
+
/* Naming the missing half, because «the bridge closed the connection» sent the last person
|
|
910
|
+
looking at the browser — the one part that was working. */
|
|
911
|
+
log('no token: set QIKSY_MCP_TOKEN, or run `npx qiksy-mcp connect` in this project first');
|
|
912
|
+
process.exit(0);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
let last = '';
|
|
916
|
+
for (const t of tries) {
|
|
917
|
+
const r = await ring(t.token, t.port);
|
|
918
|
+
last = r.said;
|
|
919
|
+
if (!r.refused) break;
|
|
920
|
+
/* Said out loud only when there IS a second key to try — otherwise it is noise on every stop. */
|
|
921
|
+
if (t !== tries[tries.length - 1]) log(`the token in ${t.from} was refused — trying the one in your agent config`);
|
|
922
|
+
}
|
|
923
|
+
log(last);
|
|
879
924
|
process.exit(0);
|
|
880
925
|
}
|
|
881
926
|
|