qiksy-mcp 1.64.0 → 1.65.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/open-tab.mjs +165 -0
- package/package.json +2 -1
- package/server.mjs +38 -3
package/open-tab.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPENING A TAB IN THE BROWSER THE BRIDGE IS ACTUALLY CONNECTED TO.
|
|
3
|
+
*
|
|
4
|
+
* The bridge refuses to navigate a tab onto a foreign domain, and that refusal is correct: it is
|
|
5
|
+
* the thing that stops an agent from walking somebody's browser wherever it likes. But the way
|
|
6
|
+
* OUT of that dead end lived nowhere near the product — it was a shell trick written down in one
|
|
7
|
+
* person's instructions, so every fresh agent rediscovered the dead end and stopped there
|
|
8
|
+
* (owner, 30.09.2026: «постоянно возникает вопрос, он говорит, что я не нахожу таб»).
|
|
9
|
+
*
|
|
10
|
+
* THE PROFILE IS THE WHOLE DIFFICULTY, AND ANCHORING IS WHAT REMOVES IT. A machine has several
|
|
11
|
+
* Chrome profiles; the extension lives in exactly one, and a tab opened anywhere else is invisible
|
|
12
|
+
* to the bridge — every reading afterwards is then about a tab nobody can see. Naming the profile
|
|
13
|
+
* does not work: a running Chrome silently ignores `--profile-directory`, the command still exits
|
|
14
|
+
* 0, and the tab still lands elsewhere. There is no error to diagnose.
|
|
15
|
+
*
|
|
16
|
+
* So this never names a profile. It takes a tab the bridge ALREADY SEES, finds the window holding
|
|
17
|
+
* it, and inserts the new tab beside it. Same window, therefore same profile, therefore visible —
|
|
18
|
+
* by construction rather than by care.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ONLY http AND https. `file://` hands an agent the local disk through a browser and
|
|
24
|
+
* `javascript:` hands it the page it lands on; neither is «open a site», and a verb that accepts
|
|
25
|
+
* them is a different, much larger verb wearing this one's name.
|
|
26
|
+
*/
|
|
27
|
+
export function safeUrl(raw) {
|
|
28
|
+
let u;
|
|
29
|
+
try {
|
|
30
|
+
u = new URL(String(raw ?? '').trim());
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The anchor: the ORIGIN of a tab the bridge can see. An origin rather than a whole address
|
|
39
|
+
* because the address moves — a single-page app rewrites its path as the person clicks, and an
|
|
40
|
+
* anchor that has to match what was there a second ago is an anchor that misses.
|
|
41
|
+
*/
|
|
42
|
+
export function anchorFrom(tabs) {
|
|
43
|
+
for (const t of Array.isArray(tabs) ? tabs : []) {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(String(t?.url || ''));
|
|
46
|
+
if (u.protocol === 'http:' || u.protocol === 'https:') return u.origin;
|
|
47
|
+
} catch {
|
|
48
|
+
/* a tab with no readable address is simply not an anchor */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* AppleScript string literals take the same two escapes as C. The script goes in on stdin rather
|
|
55
|
+
than through `-e`, so the SHELL never sees the address: an OAuth redirect full of & and % is
|
|
56
|
+
exactly the kind of URL this is for, and quoting it for a shell is where that goes wrong. */
|
|
57
|
+
const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
58
|
+
|
|
59
|
+
export function appleScript({ anchor, url, app = 'Google Chrome' }) {
|
|
60
|
+
return `tell application "${esc(app)}"
|
|
61
|
+
set wi to 0
|
|
62
|
+
repeat with w in windows
|
|
63
|
+
set wi to wi + 1
|
|
64
|
+
repeat with t in tabs of w
|
|
65
|
+
if (URL of t) contains "${esc(anchor)}" then
|
|
66
|
+
make new tab at end of tabs of w with properties {URL:"${esc(url)}"}
|
|
67
|
+
set active tab index of w to (count of tabs of w)
|
|
68
|
+
return "opened in window " & wi
|
|
69
|
+
end if
|
|
70
|
+
end repeat
|
|
71
|
+
end repeat
|
|
72
|
+
return "no window holds the anchor"
|
|
73
|
+
end tell`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Runs the script. Separated so the gate can drive every branch without a browser on the machine. */
|
|
77
|
+
export function runAppleScript(script) {
|
|
78
|
+
const r = spawnSync('osascript', ['-'], { input: script, encoding: 'utf8', timeout: 15_000 });
|
|
79
|
+
if (r.error) return { ok: false, said: String(r.error.message || r.error) };
|
|
80
|
+
const said = String(r.stdout || '').trim() || String(r.stderr || '').trim();
|
|
81
|
+
return { ok: r.status === 0 && /^opened in window/.test(said), said };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The whole verb, with the platform check and both ways it can legitimately fail.
|
|
86
|
+
*
|
|
87
|
+
* `tabsNow` returns the bridge's current tab list; it is asked TWICE on purpose — once for the
|
|
88
|
+
* anchor, once to find out what actually happened. A command that exits 0 proves the script ran,
|
|
89
|
+
* not that a tab the bridge can see now exists, and those are the two facts that get confused.
|
|
90
|
+
*/
|
|
91
|
+
export async function openTabHere({
|
|
92
|
+
url,
|
|
93
|
+
tabsNow,
|
|
94
|
+
platform = process.platform,
|
|
95
|
+
run = runAppleScript,
|
|
96
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
97
|
+
tries = 8,
|
|
98
|
+
}) {
|
|
99
|
+
const clean = safeUrl(url);
|
|
100
|
+
if (!clean) return { ok: false, error: 'bad-url', note: 'Only http:// and https:// addresses can be opened this way.' };
|
|
101
|
+
|
|
102
|
+
const before = await tabsNow();
|
|
103
|
+
const anchor = anchorFrom(before);
|
|
104
|
+
if (!anchor) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: 'no-anchor',
|
|
108
|
+
note:
|
|
109
|
+
'The bridge can see no tab at all, so there is no window to open this beside — and opening it anywhere else would put it in a Chrome profile the extension does not live in, where nothing you do next is visible. Ask the person to open any page in the browser that has Qiksy in it, then call this again.',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (platform !== 'darwin') {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: 'not-on-this-platform',
|
|
117
|
+
anchor,
|
|
118
|
+
note: `Opening a tab this way is macOS-only for now. Ask the person to open ${clean} themselves in the browser that has Qiksy in it — any other browser or profile is invisible to the bridge.`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const said = run(appleScript({ anchor, url: clean }));
|
|
123
|
+
if (!said.ok) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: 'could-not-open',
|
|
127
|
+
anchor,
|
|
128
|
+
said: said.said,
|
|
129
|
+
note: 'The browser did not take the new tab. If this says the anchor was not found, the window holding it was probably closed between the two steps.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* WAIT FOR THE TAB TO BE VISIBLE TO THE BRIDGE, not for a number of milliseconds: a page that
|
|
134
|
+
is still loading has no address the extension reports yet, and answering before it does would
|
|
135
|
+
hand back «opened, and I cannot see it», which reads as a failure. */
|
|
136
|
+
const wantOrigin = new URL(clean).origin;
|
|
137
|
+
const knownIds = new Set((before || []).map((t) => t.tabId));
|
|
138
|
+
let fresh = null;
|
|
139
|
+
for (let i = 0; i < tries && !fresh; i++) {
|
|
140
|
+
await sleep(600);
|
|
141
|
+
const now = await tabsNow();
|
|
142
|
+
fresh = (now || []).find((t) => !knownIds.has(t.tabId)) || null;
|
|
143
|
+
if (!fresh) fresh = (now || []).find((t) => String(t.url || '').startsWith(wantOrigin) && !knownIds.has(t.tabId)) || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (fresh) {
|
|
147
|
+
return { ok: true, tabId: fresh.tabId, url: fresh.url, inSession: true, said: said.said, note: 'The tab is open and the bridge can act on it. Pass this tabId to the other verbs.' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* OPENED, BUT NOT IN THE SESSION — and this is the ordinary case rather than a fault.
|
|
152
|
+
*
|
|
153
|
+
* The active tab counts as the session only while NOTHING has been attached; the moment a person
|
|
154
|
+
* has switched «Agent drives this tab» on anywhere, the session is that explicit set and a new
|
|
155
|
+
* tab is not in it. Reporting «opened» and stopping would leave the agent measuring a tab it
|
|
156
|
+
* cannot see and blaming the bridge, which is the exact failure this verb was built to end.
|
|
157
|
+
*/
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
tabId: null,
|
|
161
|
+
inSession: false,
|
|
162
|
+
said: said.said,
|
|
163
|
+
note: `The tab is open in the right browser, but it is not in the agent's session: once any tab has been switched on for the agent, the session is that explicit set and a new tab does not join it by itself. Ask the person to switch «Agent drives this tab» on for ${wantOrigin} — reading and driving both start working the moment they do.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.65.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",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"project.mjs",
|
|
35
35
|
"gaps.mjs",
|
|
36
36
|
"reports.mjs",
|
|
37
|
+
"open-tab.mjs",
|
|
37
38
|
"gallery.mjs",
|
|
38
39
|
"svg-shot.mjs",
|
|
39
40
|
"connect.mjs",
|
package/server.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { LIBRARIES, datePlan, hintsFor, recipeFor, classifyNode, classifySnapsho
|
|
|
35
35
|
import { project } from './project.mjs';
|
|
36
36
|
import { recordGap, listGaps, gapInvite, GAPS_FILE } from './gaps.mjs';
|
|
37
37
|
import { reportsDir, reportFileName } from './reports.mjs';
|
|
38
|
+
import { openTabHere } from './open-tab.mjs';
|
|
38
39
|
import { STATES, STATE_KEYS, renderGallery } from './gallery.mjs';
|
|
39
40
|
import { renderSearchableSvg } from './svg-shot.mjs';
|
|
40
41
|
import { connectAgents, toClipboard, pairingInPlace } from './connect.mjs';
|
|
@@ -1566,12 +1567,20 @@ function stuckHelp(text) {
|
|
|
1566
1567
|
};
|
|
1567
1568
|
}
|
|
1568
1569
|
if (kind === 'scope') {
|
|
1570
|
+
/* THE DEAD END HAS TO NAME ITS OWN WAY OUT. This refusal used to end at «open it in a tab
|
|
1571
|
+
yourself», and the only reliable way to do that — anchoring on a window the bridge can
|
|
1572
|
+
already see — lived in one person's notes rather than in the product. So every fresh agent
|
|
1573
|
+
rediscovered the dead end and stopped there, and the person watching saw the bridge give up
|
|
1574
|
+
(owner, 30.09.2026). `qa_open_tab` is that way out, and it belongs in the answer that
|
|
1575
|
+
refuses, not two hundred lines up in a manual read once at initialize. */
|
|
1569
1576
|
return brief
|
|
1570
|
-
? {
|
|
1577
|
+
? { nextCall: 'qa_open_tab { url } — a NEW tab in their own browser, beside the ones already open. Do not reach around this with another driver.' }
|
|
1571
1578
|
: {
|
|
1572
1579
|
whyThisHappened: 'The agent session is a SET of tabs the human opted into. This one is not in it — by design, so nothing you do can wander into their mail or bank.',
|
|
1573
|
-
nextCall:
|
|
1574
|
-
|
|
1580
|
+
nextCall:
|
|
1581
|
+
'qa_open_tab { url } opens the address as a NEW tab in the very browser this bridge is connected to, losing nothing. Or qa_tabs to see what IS attached; a tab opened BY an attached tab joins automatically.',
|
|
1582
|
+
sayThis:
|
|
1583
|
+
'If the new tab comes back with inSession:false, ask them to attach it: the popup switch "Agent drives this tab", or right-click on the page → "Qiksy — test this tab".',
|
|
1575
1584
|
stayHere: 'Do not open the page in another driver to get around the boundary — that browser has none of their sessions in it anyway.',
|
|
1576
1585
|
};
|
|
1577
1586
|
}
|
|
@@ -4867,6 +4876,32 @@ server.registerTool(
|
|
|
4867
4876
|
* A shot is addressed by NAME («checkout»), never by «the last one»: there is no implicit current
|
|
4868
4877
|
* shot, so marking the wrong picture is not a mistake that can be made by default.
|
|
4869
4878
|
*/
|
|
4879
|
+
server.registerTool(
|
|
4880
|
+
'qa_open_tab',
|
|
4881
|
+
{
|
|
4882
|
+
title: 'Open a page in the browser the bridge is connected to',
|
|
4883
|
+
description:
|
|
4884
|
+
'OPEN AN ADDRESS THE BRIDGE WILL NOT NAVIGATE TO. The driving verbs stay inside the site under test on purpose — that refusal is what stops an agent from walking somebody\'s browser onto an arbitrary domain, and it must not be worked around. This is the sanctioned way through it: a NEW tab, beside the ones already open, losing nothing. ' +
|
|
4885
|
+
'It goes into the RIGHT BROWSER by construction, not by care: the machine has several Chrome profiles, the extension lives in exactly one, and a tab opened into any other is invisible here — so this finds the window holding a tab the bridge can already see and inserts the new one beside it. Never start a browser yourself instead; a second browser is a DIFFERENT, EMPTY browser with none of the person\'s logins, and everything measured in it stops being the run that was asked for. ' +
|
|
4886
|
+
'Read the answer rather than assuming: `inSession: true` means the tab is yours to drive and carries the tabId to pass to the other verbs; `inSession: false` means it is open and the person has to switch «Agent drives this tab» on for it, because once any tab has been switched on the session is that explicit set. macOS for now; elsewhere it says so and names what to ask for. Free.',
|
|
4887
|
+
inputSchema: {
|
|
4888
|
+
url: z.string().describe('The full http(s) address to open. Query strings are fine — nothing here goes through a shell.'),
|
|
4889
|
+
},
|
|
4890
|
+
},
|
|
4891
|
+
async ({ url }, extra) => {
|
|
4892
|
+
const bar = loader(extra, 'opening a tab');
|
|
4893
|
+
try {
|
|
4894
|
+
const tabsNow = async () => {
|
|
4895
|
+
const r = await callExtension('qa_tabs', {}, 15_000).catch(() => null);
|
|
4896
|
+
return Array.isArray(r?.tabs) ? r.tabs : [];
|
|
4897
|
+
};
|
|
4898
|
+
return asText(await openTabHere({ url, tabsNow }));
|
|
4899
|
+
} finally {
|
|
4900
|
+
bar.stop();
|
|
4901
|
+
}
|
|
4902
|
+
},
|
|
4903
|
+
);
|
|
4904
|
+
|
|
4870
4905
|
server.registerTool(
|
|
4871
4906
|
'qa_shot_list',
|
|
4872
4907
|
{
|