mcp-browser-extension 0.9.28 → 0.9.30
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 +20 -2
- package/dist/shared/page-fns.d.ts +7 -1
- package/dist/shared/page-fns.js +37 -2
- package/dist/shared/policy.js +10 -1
- package/dist/shared/snapshot.js +15 -4
- package/dist/src/bridge/host-claim.d.ts +17 -0
- package/dist/src/bridge/host-claim.js +42 -0
- package/dist/src/cli.js +17 -5
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +14 -0
- package/dist/src/executor/extension-executor.js +14 -4
- package/dist/src/executor/types.d.ts +4 -0
- package/dist/src/mcp/helpers.js +11 -2
- package/dist/src/mcp/page-watch.d.ts +12 -0
- package/dist/src/mcp/page-watch.js +79 -0
- package/dist/src/mcp/tools.js +73 -9
- package/dist/src/security/policy.d.ts +7 -0
- package/dist/src/security/policy.js +11 -0
- package/extension-dist/background.js +238 -64
- package/extension-dist/manifest.json +3 -3
- package/extension-dist/page-hook.js +6 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**Website and docs:** [mcp-browser-extension.com](https://mcp-browser-extension.com) ·
|
|
8
8
|
[Quickstart](https://mcp-browser-extension.com/docs/quickstart) ·
|
|
9
|
-
[All
|
|
9
|
+
[All 43 tools](https://mcp-browser-extension.com/docs/tools) ·
|
|
10
10
|
[Install the extension from the Chrome Web Store](https://chromewebstore.google.com/detail/mcp-browser-extension/jelfhdlkhbfmlpbghoeaepijllcnplgh)
|
|
11
11
|
|
|
12
12
|
**Let Claude use the Chrome you are already logged into.** Not a fresh
|
|
@@ -447,7 +447,7 @@ Reading the DOM tells you what a page looks like after it failed, not why. With
|
|
|
447
447
|
and `fetch`/`XMLHttpRequest` traffic, and intercepts native dialogs:
|
|
448
448
|
|
|
449
449
|
```jsonc
|
|
450
|
-
console_logs { "level": "
|
|
450
|
+
console_logs { "level": "exception" } // the uncaught error behind a broken page
|
|
451
451
|
network_log { "failedOnly": true } // the 500 behind the blank screen
|
|
452
452
|
dialogs { "policy": "accept" } // answer confirm() with true from here on
|
|
453
453
|
```
|
|
@@ -487,6 +487,24 @@ refs renumber in document order on every snapshot, so diffing on them would
|
|
|
487
487
|
report an unchanged button as removed-and-re-added the moment anything above it
|
|
488
488
|
appears.
|
|
489
489
|
|
|
490
|
+
### Remember a page and catch changes later — `page_watch`
|
|
491
|
+
|
|
492
|
+
Save a named accessibility checkpoint, then compare the current page against it
|
|
493
|
+
in a later session. Checkpoints live under the active Chrome profile, so work
|
|
494
|
+
and personal browser identities stay separate:
|
|
495
|
+
|
|
496
|
+
```jsonc
|
|
497
|
+
page_watch { "action": "create", "name": "Vendor pricing" }
|
|
498
|
+
page_watch { "action": "compare", "watchId": "watch_…" }
|
|
499
|
+
page_watch { "action": "list" }
|
|
500
|
+
page_watch { "action": "delete", "watchId": "watch_…" }
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
The result shows added, removed and changed controls. It stores accessibility
|
|
504
|
+
labels and control state, strips URL query strings, applies configured redaction,
|
|
505
|
+
and never saves live form values. Creating or comparing a checkpoint still
|
|
506
|
+
requires the page's domain to be allowed.
|
|
507
|
+
|
|
490
508
|
### Targeting by role and name
|
|
491
509
|
|
|
492
510
|
Actions accept a locator instead of a CSS selector, so you don't need a snapshot
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
* One resolver, used by every op, is what closes it.
|
|
17
17
|
*/
|
|
18
18
|
/** Ops the page-side dispatcher understands. */
|
|
19
|
-
export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
|
|
19
|
+
export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'check' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
|
|
20
20
|
export interface PageOpArgs {
|
|
21
21
|
op: PageOpName;
|
|
22
22
|
selector?: string | null;
|
|
23
23
|
text?: string;
|
|
24
24
|
clear?: boolean;
|
|
25
|
+
/** type: send keydown/keypress/keyup around each character. */
|
|
26
|
+
keyEvents?: boolean;
|
|
27
|
+
/** type: press Enter after the text (submits the field's form like a person would). */
|
|
28
|
+
pressEnter?: boolean;
|
|
29
|
+
/** check: the state a checkbox/radio should end up in. */
|
|
30
|
+
checked?: boolean;
|
|
25
31
|
outer?: boolean;
|
|
26
32
|
values?: string[];
|
|
27
33
|
x?: number | null;
|
package/dist/shared/page-fns.js
CHANGED
|
@@ -208,6 +208,18 @@ function pageOp(a) {
|
|
|
208
208
|
el.click();
|
|
209
209
|
return { found: true };
|
|
210
210
|
}
|
|
211
|
+
case 'check': {
|
|
212
|
+
if (!el)
|
|
213
|
+
return { found: false };
|
|
214
|
+
// Click only when the state differs: a click toggles, so clicking an
|
|
215
|
+
// already-checked box to "check" it would clear it.
|
|
216
|
+
const box = el;
|
|
217
|
+
if (box.checked !== (a.checked !== false)) {
|
|
218
|
+
el.scrollIntoView({ block: 'center' });
|
|
219
|
+
el.click();
|
|
220
|
+
}
|
|
221
|
+
return { found: true, checked: box.checked };
|
|
222
|
+
}
|
|
211
223
|
case 'type': {
|
|
212
224
|
if (!el)
|
|
213
225
|
return { found: false };
|
|
@@ -226,9 +238,32 @@ function pageOp(a) {
|
|
|
226
238
|
if (!node)
|
|
227
239
|
return { found: false };
|
|
228
240
|
node.focus();
|
|
229
|
-
|
|
230
|
-
|
|
241
|
+
if (a.keyEvents) {
|
|
242
|
+
// One character at a time, each inside its own key events, for pages
|
|
243
|
+
// that react to keystrokes rather than to the value.
|
|
244
|
+
if (a.clear)
|
|
245
|
+
setValue(node, '');
|
|
246
|
+
for (const ch of a.text ?? '') {
|
|
247
|
+
const ev = { key: ch, bubbles: true, cancelable: true };
|
|
248
|
+
const go = node.dispatchEvent(new KeyboardEvent('keydown', ev));
|
|
249
|
+
if (go && node.dispatchEvent(new KeyboardEvent('keypress', ev)))
|
|
250
|
+
setValue(node, (node.value ?? '') + ch);
|
|
251
|
+
node.dispatchEvent(new KeyboardEvent('keyup', ev));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
setValue(node, (a.clear ? '' : node.value ?? '') + (a.text ?? ''));
|
|
256
|
+
}
|
|
231
257
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
258
|
+
if (a.pressEnter) {
|
|
259
|
+
const ev = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
|
|
260
|
+
const go = node.dispatchEvent(new KeyboardEvent('keydown', ev)) && node.dispatchEvent(new KeyboardEvent('keypress', ev));
|
|
261
|
+
node.dispatchEvent(new KeyboardEvent('keyup', ev));
|
|
262
|
+
// Synthetic key events never trigger implicit submission; do what the
|
|
263
|
+
// browser would for Enter in a single-line field of a form.
|
|
264
|
+
if (go && node instanceof HTMLInputElement && node.form)
|
|
265
|
+
node.form.requestSubmit();
|
|
266
|
+
}
|
|
232
267
|
return { found: true };
|
|
233
268
|
}
|
|
234
269
|
case 'focus': {
|
package/dist/shared/policy.js
CHANGED
|
@@ -34,6 +34,12 @@ const READ_CONTENT = new Set([
|
|
|
34
34
|
'frames_list',
|
|
35
35
|
'observers',
|
|
36
36
|
'print_pdf',
|
|
37
|
+
// The page's accessibility tree, its cookies (session secrets), and its
|
|
38
|
+
// web storage are content too. A storage write is refused in safe mode by
|
|
39
|
+
// the extension's storage handler; the domain gate here covers every op.
|
|
40
|
+
'snapshot',
|
|
41
|
+
'get_cookies',
|
|
42
|
+
'storage',
|
|
37
43
|
]);
|
|
38
44
|
/** Content-mutating actions — URL-gated AND mutation-gated. */
|
|
39
45
|
const MUTATE_CONTENT = new Set([
|
|
@@ -44,6 +50,7 @@ const MUTATE_CONTENT = new Set([
|
|
|
44
50
|
'hover',
|
|
45
51
|
'scroll',
|
|
46
52
|
'fill_form',
|
|
53
|
+
'select_option',
|
|
47
54
|
]);
|
|
48
55
|
/** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
|
|
49
56
|
const NAVIGATION = new Set([
|
|
@@ -72,7 +79,9 @@ function isUrlGated(method) {
|
|
|
72
79
|
MUTATE_CONTENT.has(method) ||
|
|
73
80
|
NAVIGATION.has(method) ||
|
|
74
81
|
method === 'eval' ||
|
|
75
|
-
method === 'upload_file'
|
|
82
|
+
method === 'upload_file' ||
|
|
83
|
+
// Gated on where the bytes come from: the URL when one is given, else the tab.
|
|
84
|
+
method === 'download_file');
|
|
76
85
|
}
|
|
77
86
|
// ---------------------------------------------------------------------------
|
|
78
87
|
// Domain matching
|
package/dist/shared/snapshot.js
CHANGED
|
@@ -72,6 +72,13 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
72
72
|
if (t)
|
|
73
73
|
return t;
|
|
74
74
|
}
|
|
75
|
+
// <label for> or a wrapping <label>: how ordinary forms name their fields.
|
|
76
|
+
const labels = el.labels;
|
|
77
|
+
if (labels && labels.length > 0) {
|
|
78
|
+
const t = Array.from(labels).map((l) => l.innerText).join(' ').replace(/\s+/g, ' ').trim();
|
|
79
|
+
if (t)
|
|
80
|
+
return t;
|
|
81
|
+
}
|
|
75
82
|
const ph = el.getAttribute('placeholder');
|
|
76
83
|
if (ph)
|
|
77
84
|
return ph.trim();
|
|
@@ -113,8 +120,11 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
113
120
|
// recursively. Defensive against null/closed shadow roots and re-visits.
|
|
114
121
|
const seen = new Set();
|
|
115
122
|
const candidates = [];
|
|
123
|
+
// Gather past `max` so hidden candidates dropped below still leave enough to
|
|
124
|
+
// fill it, and so a page with more than `max` can be reported as truncated.
|
|
125
|
+
const cap = Math.max(max * 2, max + 50);
|
|
116
126
|
const collect = (root) => {
|
|
117
|
-
if (candidates.length >=
|
|
127
|
+
if (candidates.length >= cap)
|
|
118
128
|
return;
|
|
119
129
|
let matched;
|
|
120
130
|
try {
|
|
@@ -124,7 +134,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
124
134
|
matched = [];
|
|
125
135
|
}
|
|
126
136
|
for (let i = 0; i < matched.length; i++) {
|
|
127
|
-
if (candidates.length >=
|
|
137
|
+
if (candidates.length >= cap)
|
|
128
138
|
break;
|
|
129
139
|
const el = matched[i];
|
|
130
140
|
if (seen.has(el))
|
|
@@ -142,7 +152,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
142
152
|
hosts = [];
|
|
143
153
|
}
|
|
144
154
|
for (let i = 0; i < hosts.length; i++) {
|
|
145
|
-
if (candidates.length >=
|
|
155
|
+
if (candidates.length >= cap)
|
|
146
156
|
break;
|
|
147
157
|
const sr = hosts[i].shadowRoot;
|
|
148
158
|
if (sr)
|
|
@@ -230,7 +240,8 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
230
240
|
break;
|
|
231
241
|
picked.push(read(el));
|
|
232
242
|
}
|
|
233
|
-
|
|
243
|
+
// At the cap there may be more on the page than were even looked at.
|
|
244
|
+
truncated = els.length > picked.length || candidates.length >= cap;
|
|
234
245
|
}
|
|
235
246
|
// -- phase 2: writes (one attribute per returned node) ----------------------
|
|
236
247
|
const nodes = [];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/bridge/host-claim.ts — retire a server its MCP host has replaced.
|
|
3
|
+
*
|
|
4
|
+
* Some hosts (Claude Code's /mcp "Reconnect" among them) spawn a fresh child
|
|
5
|
+
* without closing the old one's stdin or signalling it. The old server then
|
|
6
|
+
* lingers with its OLD flags and, if it holds the port, keeps serving the
|
|
7
|
+
* browser: a reconnect that was meant to apply a new flag silently doesn't.
|
|
8
|
+
*
|
|
9
|
+
* Each stdio server claims `hosts/<parentPid>-<profile>` on start. The newest
|
|
10
|
+
* claim wins; an older server that finds someone else's pid in its claim file
|
|
11
|
+
* knows the same host replaced it and shuts itself down. Nothing is ever killed
|
|
12
|
+
* here, so a recycled pid cannot cause a wrong kill.
|
|
13
|
+
*/
|
|
14
|
+
/** Record this process as the current server for (parent, profile). */
|
|
15
|
+
export declare function claimHost(dataDir: string, parentPid: number, profile: string, pid?: number): void;
|
|
16
|
+
/** True when a newer server from the same parent has claimed (parent, profile). */
|
|
17
|
+
export declare function supersededBy(dataDir: string, parentPid: number, profile: string, pid?: number): number | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/bridge/host-claim.ts — retire a server its MCP host has replaced.
|
|
4
|
+
*
|
|
5
|
+
* Some hosts (Claude Code's /mcp "Reconnect" among them) spawn a fresh child
|
|
6
|
+
* without closing the old one's stdin or signalling it. The old server then
|
|
7
|
+
* lingers with its OLD flags and, if it holds the port, keeps serving the
|
|
8
|
+
* browser: a reconnect that was meant to apply a new flag silently doesn't.
|
|
9
|
+
*
|
|
10
|
+
* Each stdio server claims `hosts/<parentPid>-<profile>` on start. The newest
|
|
11
|
+
* claim wins; an older server that finds someone else's pid in its claim file
|
|
12
|
+
* knows the same host replaced it and shuts itself down. Nothing is ever killed
|
|
13
|
+
* here, so a recycled pid cannot cause a wrong kill.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.claimHost = claimHost;
|
|
17
|
+
exports.supersededBy = supersededBy;
|
|
18
|
+
const node_fs_1 = require("node:fs");
|
|
19
|
+
const node_path_1 = require("node:path");
|
|
20
|
+
const atomic_file_1 = require("../atomic-file");
|
|
21
|
+
function claimPath(dataDir, parentPid, profile) {
|
|
22
|
+
const safe = profile.replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
|
|
23
|
+
return (0, node_path_1.join)(dataDir, 'hosts', `${parentPid}-${safe}`);
|
|
24
|
+
}
|
|
25
|
+
/** Record this process as the current server for (parent, profile). */
|
|
26
|
+
function claimHost(dataDir, parentPid, profile, pid = process.pid) {
|
|
27
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(dataDir, 'hosts'), { recursive: true, mode: 0o700 });
|
|
28
|
+
(0, atomic_file_1.writeFileAtomic)(claimPath(dataDir, parentPid, profile), `${pid}\n`, 0o600);
|
|
29
|
+
}
|
|
30
|
+
/** True when a newer server from the same parent has claimed (parent, profile). */
|
|
31
|
+
function supersededBy(dataDir, parentPid, profile, pid = process.pid) {
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = (0, node_fs_1.readFileSync)(claimPath(dataDir, parentPid, profile), 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null; // missing or unreadable: never retire on doubt
|
|
38
|
+
}
|
|
39
|
+
const owner = Number.parseInt(text.trim(), 10);
|
|
40
|
+
return Number.isInteger(owner) && owner > 0 && owner !== pid ? owner : null;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=host-claim.js.map
|
package/dist/src/cli.js
CHANGED
|
@@ -19,6 +19,8 @@ const tasks_1 = require("./bridge/tasks");
|
|
|
19
19
|
const manager_1 = require("./executor/manager");
|
|
20
20
|
const select_1 = require("./executor/select");
|
|
21
21
|
const server_1 = require("./bridge/server");
|
|
22
|
+
const policy_1 = require("./security/policy");
|
|
23
|
+
const host_claim_1 = require("./bridge/host-claim");
|
|
22
24
|
const datadir_1 = require("./bridge/datadir");
|
|
23
25
|
const workspace_1 = require("./bridge/workspace");
|
|
24
26
|
const auth_1 = require("./bridge/auth");
|
|
@@ -260,14 +262,10 @@ async function main() {
|
|
|
260
262
|
(0, tools_1.setToolAllowlist)(cfg.tools);
|
|
261
263
|
const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
|
|
262
264
|
const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
|
|
263
|
-
const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations } = cfg.policy;
|
|
264
265
|
const bridge = new server_1.BridgeServer({
|
|
265
266
|
token,
|
|
266
267
|
serverVersion: version(),
|
|
267
|
-
|
|
268
|
-
// `allowDomains` is passed BY REFERENCE: sites allowed from the extension's
|
|
269
|
-
// Options are spliced into this same array, which the tool gate reads too.
|
|
270
|
-
policy: { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations },
|
|
268
|
+
policy: (0, policy_1.toWirePolicy)(cfg.policy),
|
|
271
269
|
siteGrants: !cfg.noSiteGrants,
|
|
272
270
|
latestExtensionVersion: bundledExtensionVersion(),
|
|
273
271
|
port: cfg.wsPort,
|
|
@@ -436,9 +434,23 @@ async function main() {
|
|
|
436
434
|
// holding the port, and its usage is never reported. Notice instead when the
|
|
437
435
|
// parent is gone, or when writing to it fails.
|
|
438
436
|
const parentPid = process.ppid;
|
|
437
|
+
// A host that "reconnects" by spawning a new child may leave us running with
|
|
438
|
+
// our old flags; the newest server from the same parent claims the slot.
|
|
439
|
+
const hostProfile = cfg.profile ?? 'default';
|
|
440
|
+
try {
|
|
441
|
+
(0, host_claim_1.claimHost)(dataDir, parentPid, hostProfile);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
(0, server_2.logDebug)(`host claim skipped: ${err.message}`);
|
|
445
|
+
}
|
|
439
446
|
const watchdog = setInterval(() => {
|
|
440
447
|
if (process.ppid !== parentPid)
|
|
441
448
|
return shutdown(); // reparented: the parent exited
|
|
449
|
+
const newer = (0, host_claim_1.supersededBy)(dataDir, parentPid, hostProfile);
|
|
450
|
+
if (newer !== null) {
|
|
451
|
+
(0, server_2.logErr)(`replaced by a newer server (pid ${newer}) from the same MCP host; exiting`);
|
|
452
|
+
return shutdown();
|
|
453
|
+
}
|
|
442
454
|
try {
|
|
443
455
|
process.kill(parentPid, 0);
|
|
444
456
|
}
|
|
@@ -97,6 +97,9 @@ export declare class CdpExecutor implements Executor {
|
|
|
97
97
|
selectOption(t: Target, values: string[], opts?: {
|
|
98
98
|
tabId?: TabId;
|
|
99
99
|
}): Promise<ActionOk>;
|
|
100
|
+
setChecked(t: Target, checked: boolean, opts?: {
|
|
101
|
+
tabId?: TabId;
|
|
102
|
+
}): Promise<ActionOk>;
|
|
100
103
|
fill(t: Target, value: string, opts?: {
|
|
101
104
|
tabId?: TabId;
|
|
102
105
|
}): Promise<ActionOk>;
|
|
@@ -272,8 +272,15 @@ class CdpExecutor {
|
|
|
272
272
|
catch (err) {
|
|
273
273
|
const msg = err.message ?? '';
|
|
274
274
|
if (CLOSED_TARGET.test(msg)) {
|
|
275
|
+
const stale = this.context;
|
|
275
276
|
this.context = null;
|
|
276
277
|
this.tabs.clear();
|
|
278
|
+
// A browser we launched is ours to close; otherwise the relaunch on the
|
|
279
|
+
// next call leaves this one running. Never close the user's Chrome.
|
|
280
|
+
if (this.opts.mode !== 'connect' && this.launched) {
|
|
281
|
+
this.launched = false;
|
|
282
|
+
void stale?.close().catch(() => undefined);
|
|
283
|
+
}
|
|
277
284
|
throw new types_1.ExecutorError('DETACHED', 'browser target closed or crashed; reconnect and retry');
|
|
278
285
|
}
|
|
279
286
|
throw err;
|
|
@@ -397,6 +404,13 @@ class CdpExecutor {
|
|
|
397
404
|
return this.ok;
|
|
398
405
|
});
|
|
399
406
|
}
|
|
407
|
+
async setChecked(t, checked, opts) {
|
|
408
|
+
return this.guard(async () => {
|
|
409
|
+
const p = await this.resolveTab(opts?.tabId);
|
|
410
|
+
await this.locator(p, t).setChecked(checked);
|
|
411
|
+
return this.ok;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
400
414
|
async fill(t, value, opts) {
|
|
401
415
|
return this.guard(async () => {
|
|
402
416
|
const p = await this.resolveTab(opts?.tabId);
|
|
@@ -168,10 +168,20 @@ class ExtensionExecutor {
|
|
|
168
168
|
return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text: value, clear: true, keyEvents: false }, { tabId: opts?.tabId }));
|
|
169
169
|
}
|
|
170
170
|
async fillFields(fields, opts) {
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
// No capability pre-check alone: a server relaying through another one (a
|
|
172
|
+
// second AI session) cannot see the extension's caps, so ask and read the
|
|
173
|
+
// answer. An extension that predates the op says it has no such method:
|
|
174
|
+
// then let the caller go field by field.
|
|
175
|
+
let res;
|
|
176
|
+
try {
|
|
177
|
+
res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM) && err instanceof types_1.ExecutorError && /unhandled method/.test(err.message)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
175
185
|
if (res.error) {
|
|
176
186
|
// Say how far the batch got: the fields before this one DID land, and a
|
|
177
187
|
// blind retry of the whole form would write them a second time.
|
|
@@ -361,6 +361,10 @@ export interface Executor {
|
|
|
361
361
|
} & FrameOpts): Promise<{
|
|
362
362
|
filled: number;
|
|
363
363
|
} | null>;
|
|
364
|
+
/** Put a checkbox/radio into `checked` (no-op when it already is). */
|
|
365
|
+
setChecked?(t: Target, checked: boolean, opts?: {
|
|
366
|
+
tabId?: TabId;
|
|
367
|
+
} & FrameOpts): Promise<ActionOk>;
|
|
364
368
|
/** Every frame of a tab that the extension can inject into, with its URL. */
|
|
365
369
|
framesList?(opts?: {
|
|
366
370
|
tabId?: TabId;
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -166,8 +166,17 @@ async function fillForm(ex, args) {
|
|
|
166
166
|
for (const { selector, value } of ops) {
|
|
167
167
|
const target = { selector };
|
|
168
168
|
if (typeof value === 'boolean') {
|
|
169
|
-
// Checkbox/radio:
|
|
170
|
-
|
|
169
|
+
// Checkbox/radio: set the state. A bare click toggles, so it is only
|
|
170
|
+
// right for a backend that cannot set it, and only to turn it on.
|
|
171
|
+
if (ex.setChecked)
|
|
172
|
+
await ex.setChecked(target, value, opts);
|
|
173
|
+
else if (value)
|
|
174
|
+
await ex.click(target, opts);
|
|
175
|
+
else {
|
|
176
|
+
// Unticking needs the current state, which this backend cannot read;
|
|
177
|
+
// a silent no-op would report a field as filled that was not.
|
|
178
|
+
throw new types_1.ExecutorError('UNSUPPORTED', `cannot set ${selector} to unchecked with this browser backend; update the MCP Browser Extension`);
|
|
179
|
+
}
|
|
171
180
|
}
|
|
172
181
|
else {
|
|
173
182
|
await ex.fill(target, value, opts);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SnapshotResult } from '../executor/types';
|
|
2
|
+
import type { SnapshotDiff, StoredSnapshot } from './snapdiff';
|
|
3
|
+
export interface PageWatch extends StoredSnapshot {
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
/** Keep enough structure for useful diffs while never persisting live form values. */
|
|
7
|
+
export declare function safeSnapshot(snap: SnapshotResult, redact: (s: string) => string): SnapshotResult;
|
|
8
|
+
export declare function createWatch(profileDir: string, name: string, snap: SnapshotResult): PageWatch;
|
|
9
|
+
export declare function readWatch(profileDir: string, id: string): PageWatch;
|
|
10
|
+
export declare function listWatches(profileDir: string): Array<Pick<PageWatch, 'id' | 'name' | 'ts' | 'url'>>;
|
|
11
|
+
export declare function removeWatch(profileDir: string, id: string): void;
|
|
12
|
+
export declare function compareWatch(previous: PageWatch, current: SnapshotResult, redact: (s: string) => string): SnapshotDiff;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.safeSnapshot = safeSnapshot;
|
|
4
|
+
exports.createWatch = createWatch;
|
|
5
|
+
exports.readWatch = readWatch;
|
|
6
|
+
exports.listWatches = listWatches;
|
|
7
|
+
exports.removeWatch = removeWatch;
|
|
8
|
+
exports.compareWatch = compareWatch;
|
|
9
|
+
/** Persistent, profile-scoped accessibility checkpoints for pages the user allows. */
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
const snapdiff_1 = require("./snapdiff");
|
|
14
|
+
function root(profileDir) {
|
|
15
|
+
const dir = (0, node_path_1.join)(profileDir, 'page-watches');
|
|
16
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
function watchPath(profileDir, id) {
|
|
20
|
+
if (!/^watch_[0-9a-f-]{36}$/i.test(id))
|
|
21
|
+
throw new Error('invalid watchId');
|
|
22
|
+
return (0, node_path_1.join)(root(profileDir), `${id}.json`);
|
|
23
|
+
}
|
|
24
|
+
/** Keep enough structure for useful diffs while never persisting live form values. */
|
|
25
|
+
function safeSnapshot(snap, redact) {
|
|
26
|
+
const nodes = snap.nodes.slice(0, 500).map((node) => {
|
|
27
|
+
const { value: _privateValue, ...safe } = node;
|
|
28
|
+
return { ...safe, name: redact(safe.name) };
|
|
29
|
+
});
|
|
30
|
+
let url = snap.url;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(url);
|
|
33
|
+
parsed.search = '';
|
|
34
|
+
parsed.hash = '';
|
|
35
|
+
url = parsed.toString();
|
|
36
|
+
}
|
|
37
|
+
catch { /* keep non-URL test fixtures as given */ }
|
|
38
|
+
return { url, title: redact(snap.title), nodes, truncated: snap.truncated };
|
|
39
|
+
}
|
|
40
|
+
function createWatch(profileDir, name, snap) {
|
|
41
|
+
const id = `watch_${(0, node_crypto_1.randomUUID)()}`;
|
|
42
|
+
const watch = { id, name, ts: Date.now(), url: snap.url, nodes: snap.nodes };
|
|
43
|
+
const dir = root(profileDir);
|
|
44
|
+
const temp = (0, node_path_1.join)(dir, `.${id}.${process.pid}.tmp`);
|
|
45
|
+
(0, node_fs_1.writeFileSync)(temp, JSON.stringify(watch), { mode: 0o600, flag: 'wx' });
|
|
46
|
+
(0, node_fs_1.renameSync)(temp, watchPath(profileDir, id));
|
|
47
|
+
return watch;
|
|
48
|
+
}
|
|
49
|
+
function readWatch(profileDir, id) {
|
|
50
|
+
const path = watchPath(profileDir, id);
|
|
51
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
52
|
+
throw new Error(`page watch not found: ${id}`);
|
|
53
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
54
|
+
if (parsed.id !== id || !Array.isArray(parsed.nodes))
|
|
55
|
+
throw new Error('page watch file is invalid');
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
function listWatches(profileDir) {
|
|
59
|
+
return (0, node_fs_1.readdirSync)(root(profileDir)).filter((f) => /^watch_[0-9a-f-]{36}\.json$/i.test(f)).flatMap((file) => {
|
|
60
|
+
try {
|
|
61
|
+
const { id, name, ts, url } = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root(profileDir), file), 'utf8'));
|
|
62
|
+
return [{ id, name, ts, url }];
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}).sort((a, b) => b.ts - a.ts);
|
|
68
|
+
}
|
|
69
|
+
function removeWatch(profileDir, id) {
|
|
70
|
+
const path = watchPath(profileDir, id);
|
|
71
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
72
|
+
throw new Error(`page watch not found: ${id}`);
|
|
73
|
+
(0, node_fs_1.rmSync)(path);
|
|
74
|
+
}
|
|
75
|
+
function compareWatch(previous, current, redact) {
|
|
76
|
+
const clean = safeSnapshot(current, redact);
|
|
77
|
+
return (0, snapdiff_1.diffSnapshots)(previous, { ...clean, nodes: clean.nodes });
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=page-watch.js.map
|