mcp-browser-extension 0.9.30 → 0.9.31
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 +19 -4
- package/SETUP.md +13 -4
- package/dist/shared/screenshot.d.ts +4 -2
- package/dist/shared/screenshot.js +13 -8
- package/dist/src/access-browser.d.ts +15 -0
- package/dist/src/access-browser.js +159 -0
- package/dist/src/access.d.ts +2 -0
- package/dist/src/access.js +91 -18
- package/dist/src/config.js +6 -0
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/tools.js +13 -2
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +2 -0
- package/extension-dist/background.js +85 -17
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,13 +174,24 @@ the way to install it.
|
|
|
174
174
|
**3. Verify your email.** In your MCP client, ask the agent to call
|
|
175
175
|
`chrome_status`. On this first browser tool call, the client shows an email
|
|
176
176
|
prompt. Enter an address you can access, then open the sign-in link sent to
|
|
177
|
-
that address
|
|
177
|
+
that address and click **Verify this device**. The waiting tool reports
|
|
178
|
+
**Email verified** and continues.
|
|
178
179
|
|
|
179
180
|
If the tool returns `ACCESS_PENDING`, ask the agent to call `chrome_status`
|
|
180
181
|
again to keep waiting on the same link. You do not need another email or a
|
|
181
|
-
verification code.
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
verification code.
|
|
183
|
+
|
|
184
|
+
If your client suppresses the prompt (for example, in YOLO/bypass mode), does
|
|
185
|
+
not support MCP elicitation, or the dialog is dismissed, the server automatically
|
|
186
|
+
opens a temporary local verification form in your browser. The tool also returns
|
|
187
|
+
`ACCESS_INPUT_REQUIRED` with the link, so you can open it yourself if necessary
|
|
188
|
+
on the computer running the MCP server. Enter your email in the browser form,
|
|
189
|
+
and open the sign-in link from your inbox. Click **Verify this device**, then
|
|
190
|
+
call `chrome_status` to continue.
|
|
191
|
+
The local form expires after 10 minutes; call the tool again for a fresh one.
|
|
192
|
+
Opening the form does not send an email, and access remains blocked until the
|
|
193
|
+
email link and device grant are verified. Installing the extension alone does
|
|
194
|
+
not activate access.
|
|
184
195
|
|
|
185
196
|
**4. Click Connect if Chrome is not already paired.** A fresh install opens the
|
|
186
197
|
extension's Settings with a
|
|
@@ -675,6 +686,10 @@ what the flags say.
|
|
|
675
686
|
it says nothing about what is on them. A logged-in page routinely renders a
|
|
676
687
|
session token into a script tag or an API key onto a settings screen.
|
|
677
688
|
|
|
689
|
+
- **Cookie values are hidden by default.** `get_cookies` returns each cookie's
|
|
690
|
+
name, domain, path and flags with the value replaced by `[hidden]`. A session
|
|
691
|
+
cookie can grant access to an account without its password.
|
|
692
|
+
Pass `--enable-cookie-values` if a task really needs them.
|
|
678
693
|
- **Password field values are always suppressed** — in `get_html`, and in
|
|
679
694
|
`snapshot`, where the field still appears (so you can type into it) flagged
|
|
680
695
|
`secret: true` with no value. No flag, no opt-in: nobody wants those characters.
|
package/SETUP.md
CHANGED
|
@@ -128,13 +128,24 @@ the Web Store build passes review separately and can trail it by a version.
|
|
|
128
128
|
|
|
129
129
|
The first browser tool call opens an email prompt in the MCP client. Ask the
|
|
130
130
|
human to enter an email address. The website sends a Firebase sign-in link;
|
|
131
|
-
the human opens it in their browser
|
|
131
|
+
the human opens it in their browser, clicks **Verify this device**, and returns
|
|
132
|
+
to the MCP client. Do not ask
|
|
132
133
|
them to copy or paste a verification code. The server detects the verified
|
|
133
134
|
link automatically and reports the result to the waiting tool call. On success,
|
|
134
135
|
the result includes **Email verified** and the requested browser action continues.
|
|
135
136
|
The user does not need to send another chat message. Progress updates and status
|
|
136
137
|
notifications are also sent to clients that display them.
|
|
137
138
|
|
|
139
|
+
If the client suppresses or cannot display the prompt, or it is dismissed,
|
|
140
|
+
the server automatically opens a temporary local browser form and returns
|
|
141
|
+
`ACCESS_INPUT_REQUIRED` with its link. Show the link as a backup if the form
|
|
142
|
+
did not appear on the computer running the server.
|
|
143
|
+
The human enters their email, opens the sign-in link from their inbox, and clicks
|
|
144
|
+
**Verify this device**. Do not fill
|
|
145
|
+
the form or open their email for them. The form expires after 10 minutes.
|
|
146
|
+
After they submit it, call `chrome_status` to wait for verification.
|
|
147
|
+
YOLO/bypass permission mode does not skip email verification.
|
|
148
|
+
|
|
138
149
|
If the human explicitly configured `--no-email-verification`, there is no email
|
|
139
150
|
step; continue to pairing. This option is intended only for trusted local
|
|
140
151
|
clients and cannot be combined with a public tunnel.
|
|
@@ -146,9 +157,7 @@ remain blocked until access is confirmed. Cancellation stops the waiting action,
|
|
|
146
157
|
while background verification can still activate the local installation.
|
|
147
158
|
|
|
148
159
|
Click **Connect** in the extension if the result says Chrome is not already
|
|
149
|
-
paired.
|
|
150
|
-
support elicitation; clients that cannot show the prompt cannot activate
|
|
151
|
-
through this flow.
|
|
160
|
+
paired.
|
|
152
161
|
|
|
153
162
|
The server also writes a `pairing.json` file for Load unpacked installs, which
|
|
154
163
|
may connect automatically. For the Chrome Web Store copy, or whenever Chrome
|
|
@@ -14,8 +14,10 @@ export interface PageDims {
|
|
|
14
14
|
/** Full content box (document) width/height. */
|
|
15
15
|
fullW: number;
|
|
16
16
|
fullH: number;
|
|
17
|
-
/** window.devicePixelRatio (default 1).
|
|
17
|
+
/** window.devicePixelRatio, including page zoom (default 1). */
|
|
18
18
|
dpr?: number;
|
|
19
|
+
/** Chrome tab zoom: CSS pixels to device-independent pixels (default 1). */
|
|
20
|
+
zoom?: number;
|
|
19
21
|
/** Current scroll offset of the top document (default 0,0); a viewport clip starts here. */
|
|
20
22
|
scrollX?: number;
|
|
21
23
|
scrollY?: number;
|
|
@@ -35,7 +37,7 @@ export interface ElementRect {
|
|
|
35
37
|
w: number;
|
|
36
38
|
h: number;
|
|
37
39
|
}
|
|
38
|
-
/** A CDP `Page.captureScreenshot` clip (
|
|
40
|
+
/** A CDP `Page.captureScreenshot` clip (device-independent px, not CSS px). */
|
|
39
41
|
export interface CaptureClip {
|
|
40
42
|
x: number;
|
|
41
43
|
y: number;
|
|
@@ -27,15 +27,20 @@ exports.MAX_CAPTURE_PX = 16384;
|
|
|
27
27
|
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
28
28
|
*/
|
|
29
29
|
function planScreenshot(dims, opts = {}) {
|
|
30
|
-
//
|
|
31
|
-
// by the DPR
|
|
30
|
+
// Page.Viewport uses device-independent pixels, so convert the CSS geometry
|
|
31
|
+
// by the tab zoom. DPR already includes that zoom: dividing the output scale
|
|
32
|
+
// by DPR then yields exactly `opts.scale` output pixels per CSS pixel.
|
|
32
33
|
const dpr = dims.dpr && dims.dpr > 0 ? dims.dpr : 1;
|
|
34
|
+
const zoom = dims.zoom && dims.zoom > 0 ? dims.zoom : 1;
|
|
33
35
|
const scale = (opts.scale && opts.scale > 0 ? opts.scale : exports.DEFAULT_SCREENSHOT_SCALE) / dpr;
|
|
36
|
+
const clip = (x, y, width, height) => ({
|
|
37
|
+
x: x * zoom, y: y * zoom, width: width * zoom, height: height * zoom, scale,
|
|
38
|
+
});
|
|
34
39
|
if (opts.element) {
|
|
35
40
|
const realH = Math.max(1, Math.round(opts.element.h));
|
|
36
41
|
const clipH = Math.min(opts.element.h, exports.MAX_CAPTURE_PX);
|
|
37
42
|
return {
|
|
38
|
-
clip:
|
|
43
|
+
clip: clip(opts.element.x, opts.element.y, opts.element.w, clipH),
|
|
39
44
|
captureBeyondViewport: true,
|
|
40
45
|
width: Math.max(1, Math.round(opts.element.w)),
|
|
41
46
|
height: Math.min(realH, exports.MAX_CAPTURE_PX),
|
|
@@ -46,7 +51,7 @@ function planScreenshot(dims, opts = {}) {
|
|
|
46
51
|
if (opts.fullPage) {
|
|
47
52
|
const clipH = Math.min(dims.fullH, exports.MAX_CAPTURE_PX);
|
|
48
53
|
return {
|
|
49
|
-
clip:
|
|
54
|
+
clip: clip(0, 0, dims.fullW, clipH),
|
|
50
55
|
captureBeyondViewport: true,
|
|
51
56
|
width: dims.fullW,
|
|
52
57
|
height: clipH,
|
|
@@ -54,11 +59,11 @@ function planScreenshot(dims, opts = {}) {
|
|
|
54
59
|
fullHeight: dims.fullH,
|
|
55
60
|
};
|
|
56
61
|
}
|
|
57
|
-
// Plain viewport:
|
|
58
|
-
//
|
|
62
|
+
// Plain viewport: a clip is needed to apply an output scale or page zoom.
|
|
63
|
+
// The caller can also force a fresh paint with a viewport clip at 100%.
|
|
59
64
|
return {
|
|
60
|
-
...(scale !== 1
|
|
61
|
-
? { clip:
|
|
65
|
+
...(scale !== 1 || zoom !== 1
|
|
66
|
+
? { clip: clip(dims.scrollX ?? 0, dims.scrollY ?? 0, dims.w, dims.h) }
|
|
62
67
|
: {}),
|
|
63
68
|
captureBeyondViewport: false,
|
|
64
69
|
width: dims.w,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface BrowserEmailPrompt {
|
|
2
|
+
url: string;
|
|
3
|
+
close(): void;
|
|
4
|
+
}
|
|
5
|
+
export interface EmailSubmission {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
/** Best effort: the tool also returns the link for remote/headless computers. */
|
|
10
|
+
export declare function openBrowserEmailPrompt(url: string): void;
|
|
11
|
+
/** Only the person submitting this form can start an email. GET never sends one. */
|
|
12
|
+
export declare function createBrowserEmailPrompt(submit: (email: string) => Promise<EmailSubmission>, options?: {
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
}): Promise<BrowserEmailPrompt>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openBrowserEmailPrompt = openBrowserEmailPrompt;
|
|
4
|
+
exports.createBrowserEmailPrompt = createBrowserEmailPrompt;
|
|
5
|
+
/** A temporary loopback email form for clients that cannot display elicitation. */
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const node_http_1 = require("node:http");
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
/** Best effort: the tool also returns the link for remote/headless computers. */
|
|
10
|
+
function openBrowserEmailPrompt(url) {
|
|
11
|
+
// Only launch a form we host, never a URL received from an MCP request.
|
|
12
|
+
if (!/^http:\/\/127\.0\.0\.1:\d+\/verify\/[a-f0-9]{64}$/.test(url))
|
|
13
|
+
return;
|
|
14
|
+
const command = process.platform === 'darwin' ? 'open'
|
|
15
|
+
: process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
|
|
16
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
17
|
+
try {
|
|
18
|
+
const child = (0, node_child_process_1.spawn)(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
19
|
+
child.on('error', () => { });
|
|
20
|
+
child.unref();
|
|
21
|
+
}
|
|
22
|
+
catch { /* Keep the form available even if the operating system cannot open it. */ }
|
|
23
|
+
}
|
|
24
|
+
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
25
|
+
function page(path, csrf, message, sent) {
|
|
26
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8">
|
|
27
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
28
|
+
<title>Verify your email | MCP Browser Extension</title>
|
|
29
|
+
<style>
|
|
30
|
+
:root{color-scheme:light dark;font-family:system-ui,sans-serif}
|
|
31
|
+
body{max-width:440px;margin:12vh auto;padding:24px;line-height:1.5}
|
|
32
|
+
h1{font-size:28px;line-height:1.2} label{display:block;font-weight:600}
|
|
33
|
+
input,button{box-sizing:border-box;width:100%;font:inherit;padding:12px;margin-top:8px;border:1px solid #888;border-radius:6px}
|
|
34
|
+
button{margin-top:18px;background:#175cd3;color:white;border-color:#175cd3;cursor:pointer}
|
|
35
|
+
p{margin:20px 0} .brand{font-size:14px} small{display:block;margin-top:18px}
|
|
36
|
+
</style>
|
|
37
|
+
<body><p class="brand">MCP Browser Extension</p><h1>${sent ? 'Check your inbox' : 'Verify your email'}</h1>
|
|
38
|
+
<p role="status">${escapeHtml(message)}</p>
|
|
39
|
+
${sent ? '<p>After opening the sign-in link, return to your assistant to continue.</p>' : `
|
|
40
|
+
<form method="post" action="${path}">
|
|
41
|
+
<input type="hidden" name="csrf" value="${csrf}">
|
|
42
|
+
<label for="email">Email address</label>
|
|
43
|
+
<input id="email" name="email" type="email" autocomplete="email" maxlength="254" required autofocus>
|
|
44
|
+
<button type="submit">Send verification link</button>
|
|
45
|
+
</form><small>Your email is sent to mcp-browser-extension.com to verify and manage access.</small>`}
|
|
46
|
+
</body></html>`;
|
|
47
|
+
}
|
|
48
|
+
/** Only the person submitting this form can start an email. GET never sends one. */
|
|
49
|
+
async function createBrowserEmailPrompt(submit, options = {}) {
|
|
50
|
+
const path = `/verify/${(0, node_crypto_1.randomBytes)(32).toString('hex')}`;
|
|
51
|
+
const csrf = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
52
|
+
let origin = '';
|
|
53
|
+
let sent = false;
|
|
54
|
+
let busy = false;
|
|
55
|
+
let closed = false;
|
|
56
|
+
let expiry;
|
|
57
|
+
const sentMessage = 'A sign-in link has been sent. Open it from your email and click Verify this device to finish verification.';
|
|
58
|
+
const respond = (res, status, message) => {
|
|
59
|
+
res.writeHead(status, {
|
|
60
|
+
'content-type': 'text/html; charset=utf-8',
|
|
61
|
+
'cache-control': 'no-store',
|
|
62
|
+
// Keep Origin on same-origin form POSTs; no-referrer makes Chromium send
|
|
63
|
+
// Origin: null. Cross-origin destinations still receive no referrer.
|
|
64
|
+
'referrer-policy': 'same-origin',
|
|
65
|
+
'x-content-type-options': 'nosniff',
|
|
66
|
+
'x-frame-options': 'DENY',
|
|
67
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
68
|
+
});
|
|
69
|
+
res.end(page(path, csrf, message, sent));
|
|
70
|
+
};
|
|
71
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
72
|
+
void (async () => {
|
|
73
|
+
// Exact Host + Origin checks also prevent DNS rebinding and cross-site
|
|
74
|
+
// submissions. The random path is independent of every device credential.
|
|
75
|
+
if (req.headers.host !== origin.slice('http://'.length) || req.url !== path) {
|
|
76
|
+
res.writeHead(404).end();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (req.method === 'GET') {
|
|
80
|
+
respond(res, 200, sent ? sentMessage : 'Enter your email below. We will send you a one-time sign-in link.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (req.method !== 'POST') {
|
|
84
|
+
res.writeHead(405, { allow: 'GET, POST' }).end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (req.headers.origin !== origin) {
|
|
88
|
+
res.writeHead(403).end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (req.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded') {
|
|
92
|
+
res.writeHead(415).end();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let body = '';
|
|
96
|
+
let bytes = 0;
|
|
97
|
+
for await (const chunk of req) {
|
|
98
|
+
bytes += Buffer.byteLength(chunk);
|
|
99
|
+
if (bytes > 2048) {
|
|
100
|
+
res.writeHead(413).end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
body += chunk.toString();
|
|
104
|
+
}
|
|
105
|
+
const fields = new URLSearchParams(body);
|
|
106
|
+
if (fields.get('csrf') !== csrf) {
|
|
107
|
+
res.writeHead(403).end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (sent) {
|
|
111
|
+
respond(res, 200, sentMessage);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (busy) {
|
|
115
|
+
respond(res, 409, 'Your request is already being sent. Please wait.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
busy = true;
|
|
119
|
+
try {
|
|
120
|
+
const outcome = await submit(fields.get('email') ?? '');
|
|
121
|
+
sent = outcome.ok;
|
|
122
|
+
respond(res, outcome.ok ? 200 : 400, outcome.ok ? sentMessage : outcome.message);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
busy = false;
|
|
126
|
+
}
|
|
127
|
+
})().catch(() => { if (!res.headersSent)
|
|
128
|
+
respond(res, 503, 'The verification service could not be reached. Please try again.'); });
|
|
129
|
+
});
|
|
130
|
+
server.requestTimeout = 10_000;
|
|
131
|
+
server.headersTimeout = 10_000;
|
|
132
|
+
const close = () => {
|
|
133
|
+
if (closed)
|
|
134
|
+
return;
|
|
135
|
+
closed = true;
|
|
136
|
+
clearTimeout(expiry);
|
|
137
|
+
server.close();
|
|
138
|
+
server.closeAllConnections();
|
|
139
|
+
options.onClose?.();
|
|
140
|
+
};
|
|
141
|
+
await new Promise((resolve, reject) => {
|
|
142
|
+
server.once('error', reject);
|
|
143
|
+
server.listen(0, '127.0.0.1', () => {
|
|
144
|
+
server.removeListener('error', reject);
|
|
145
|
+
resolve();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
const address = server.address();
|
|
149
|
+
if (!address || typeof address === 'string') {
|
|
150
|
+
close();
|
|
151
|
+
throw new Error('No loopback address');
|
|
152
|
+
}
|
|
153
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
154
|
+
expiry = setTimeout(close, options.ttlMs ?? 10 * 60_000);
|
|
155
|
+
expiry.unref();
|
|
156
|
+
server.unref();
|
|
157
|
+
return { url: origin + path, close };
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=access-browser.js.map
|
package/dist/src/access.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type AccessRequest = {
|
|
|
9
9
|
};
|
|
10
10
|
/** @internal Test seam for the verification watcher. */
|
|
11
11
|
export declare function setAccessPollIntervalForTesting(ms: number, waitMs?: number): void;
|
|
12
|
+
/** Close a pending form when verification finishes or its owner shuts down. */
|
|
13
|
+
export declare function closeAccessBrowserPrompt(dataDir: string): void;
|
|
12
14
|
declare function result(message: string): {
|
|
13
15
|
content: {
|
|
14
16
|
type: "text";
|
package/dist/src/access.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.bypassEmailVerification = void 0;
|
|
4
4
|
exports.setAccessPollIntervalForTesting = setAccessPollIntervalForTesting;
|
|
5
|
+
exports.closeAccessBrowserPrompt = closeAccessBrowserPrompt;
|
|
5
6
|
exports.requireAccess = requireAccess;
|
|
6
7
|
/** Email-link access gate for the MCP tool surface. */
|
|
7
8
|
const node_crypto_1 = require("node:crypto");
|
|
8
9
|
const node_fs_1 = require("node:fs");
|
|
9
10
|
const node_path_1 = require("node:path");
|
|
10
11
|
const compat_1 = require("./compat");
|
|
12
|
+
const access_browser_1 = require("./access-browser");
|
|
11
13
|
const SITE = ((0, compat_1.runtimeEnv)('ACCESS_API') ?? 'https://mcp-browser-extension.com/api/access').replace(/\/$/, '');
|
|
12
14
|
let accessPollMs = 2_000;
|
|
13
15
|
let accessWaitMs = 45_000;
|
|
@@ -18,10 +20,12 @@ const EMAIL_SCHEMA = {
|
|
|
18
20
|
};
|
|
19
21
|
const validUntil = new Map();
|
|
20
22
|
const currentChecks = new Map();
|
|
23
|
+
const pendingEmailStarts = new Map();
|
|
21
24
|
const pollingChallenges = new Map();
|
|
22
25
|
const completionToReport = new Map();
|
|
26
|
+
const browserPrompts = new Map();
|
|
23
27
|
const VERIFIED = { status: 'verified', message: 'Email verified. MCP Browser Extension access is active.' };
|
|
24
|
-
const PENDING = { status: 'pending', message: '[ACCESS_PENDING] Email verification is still pending. Open the sign-in link sent to your email. Continue waiting by calling chrome_status; no new email or chat reply is needed.' };
|
|
28
|
+
const PENDING = { status: 'pending', message: '[ACCESS_PENDING] Email verification is still pending. Open the sign-in link sent to your email and click Verify this device. Continue waiting by calling chrome_status; no new email or chat reply is needed.' };
|
|
25
29
|
const CANCELLED = { status: 'cancelled', message: 'Email verification wait was cancelled. The browser action was not run.' };
|
|
26
30
|
const UNAVAILABLE = { status: 'unavailable', message: 'Email verification could not be confirmed because the verification service is unavailable. Access remains blocked. Retry chrome_status shortly.' };
|
|
27
31
|
/** @internal Test seam for the verification watcher. */
|
|
@@ -29,6 +33,10 @@ function setAccessPollIntervalForTesting(ms, waitMs = 45_000) {
|
|
|
29
33
|
accessPollMs = Math.max(1, ms);
|
|
30
34
|
accessWaitMs = Math.max(1, waitMs);
|
|
31
35
|
}
|
|
36
|
+
/** Close a pending form when verification finishes or its owner shuts down. */
|
|
37
|
+
function closeAccessBrowserPrompt(dataDir) {
|
|
38
|
+
browserPrompts.get(dataDir)?.close();
|
|
39
|
+
}
|
|
32
40
|
function statePath(dataDir) { return (0, node_path_1.join)(dataDir, 'access.json'); }
|
|
33
41
|
function loadOrCreate(dataDir) {
|
|
34
42
|
(0, node_fs_1.mkdirSync)(dataDir, { recursive: true, mode: 0o700 });
|
|
@@ -68,6 +76,7 @@ function watchForEmailApproval(server, dataDir, challengeId) {
|
|
|
68
76
|
let delayMs = accessPollMs;
|
|
69
77
|
const finish = (outcome) => {
|
|
70
78
|
pollingChallenges.delete(challengeId);
|
|
79
|
+
closeAccessBrowserPrompt(dataDir);
|
|
71
80
|
completionToReport.set(dataDir, outcome);
|
|
72
81
|
void notifyAccess(server, outcome);
|
|
73
82
|
resolve(outcome);
|
|
@@ -133,10 +142,10 @@ async function waitForEmailApproval(server, dataDir, challengeId, request) {
|
|
|
133
142
|
}
|
|
134
143
|
catch { /* Progress is optional; the tool result still carries the outcome. */ }
|
|
135
144
|
};
|
|
136
|
-
await report('Sign-in link sent. Open it in your email; this tool is waiting for verification automatically.');
|
|
145
|
+
await report('Sign-in link sent. Open it in your email and click Verify this device; this tool is waiting for verification automatically.');
|
|
137
146
|
let timeout;
|
|
138
147
|
let onAbort;
|
|
139
|
-
const heartbeat = setInterval(() => { void report('Waiting for email verification. Open the sign-in link sent to your email.'); }, 5_000);
|
|
148
|
+
const heartbeat = setInterval(() => { void report('Waiting for email verification. Open the sign-in link sent to your email and click Verify this device.'); }, 5_000);
|
|
140
149
|
try {
|
|
141
150
|
const outcome = await Promise.race([
|
|
142
151
|
approval.promise,
|
|
@@ -176,6 +185,74 @@ async function api(path, body) {
|
|
|
176
185
|
function result(message) {
|
|
177
186
|
return { content: [{ type: 'text', text: message }], isError: true };
|
|
178
187
|
}
|
|
188
|
+
const inputRequired = (url) => ({
|
|
189
|
+
status: 'input-required',
|
|
190
|
+
message: '[ACCESS_INPUT_REQUIRED] Complete email verification in the browser form. It opens automatically when the client does not display its email prompt. ' +
|
|
191
|
+
`If no form appeared, open this local page on the computer running MCP Browser Extension:\n${url}\n` +
|
|
192
|
+
'Enter your email there, open the sign-in link from your inbox, and click Verify this device. This page expires in 10 minutes. ' +
|
|
193
|
+
'Show this link to the user; do not fill the form or open their email for them. After they submit their email, call chrome_status to wait for verification. Access remains blocked until verification succeeds.',
|
|
194
|
+
});
|
|
195
|
+
function startEmail(server, dataDir, state, input) {
|
|
196
|
+
const existing = pendingEmailStarts.get(dataDir);
|
|
197
|
+
if (existing)
|
|
198
|
+
return existing;
|
|
199
|
+
const sending = sendVerificationEmail(server, dataDir, state, input)
|
|
200
|
+
.finally(() => { pendingEmailStarts.delete(dataDir); });
|
|
201
|
+
pendingEmailStarts.set(dataDir, sending);
|
|
202
|
+
return sending;
|
|
203
|
+
}
|
|
204
|
+
async function sendVerificationEmail(server, dataDir, state, input) {
|
|
205
|
+
const email = input.trim().toLowerCase();
|
|
206
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
207
|
+
return { status: 'denied', message: 'Enter a valid email address.' };
|
|
208
|
+
}
|
|
209
|
+
state.challengeId = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
210
|
+
let started;
|
|
211
|
+
try {
|
|
212
|
+
started = await api('start', { email, challengeId: state.challengeId, deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
delete state.challengeId;
|
|
216
|
+
saveState(dataDir, state);
|
|
217
|
+
return { status: 'unavailable', message: 'The verification service could not be reached. Please try again.' };
|
|
218
|
+
}
|
|
219
|
+
if (!started.ok) {
|
|
220
|
+
delete state.challengeId;
|
|
221
|
+
saveState(dataDir, state);
|
|
222
|
+
const message = typeof started.data.error === 'string' ? started.data.error : 'The verification email could not be sent. Check your connection and try again.';
|
|
223
|
+
return { status: 'unavailable', message };
|
|
224
|
+
}
|
|
225
|
+
// Other tool calls and local processes must not poll a challenge before the
|
|
226
|
+
// backend creates it; a premature 410 would discard a valid sign-in request.
|
|
227
|
+
saveState(dataDir, state);
|
|
228
|
+
await notifyAccess(server, { status: 'pending', message: 'A sign-in link has been sent. Open it from your email; the browser tool is waiting for verification.' });
|
|
229
|
+
return { challengeId: state.challengeId };
|
|
230
|
+
}
|
|
231
|
+
async function offerBrowserPrompt(server, dataDir, request) {
|
|
232
|
+
if (request.signal?.aborted)
|
|
233
|
+
return CANCELLED;
|
|
234
|
+
const existing = browserPrompts.get(dataDir);
|
|
235
|
+
if (existing)
|
|
236
|
+
return inputRequired(existing.url);
|
|
237
|
+
const prompt = await (0, access_browser_1.createBrowserEmailPrompt)(async (email) => {
|
|
238
|
+
const state = loadOrCreate(dataDir);
|
|
239
|
+
if (state.verified || state.challengeId)
|
|
240
|
+
return { ok: false, message: 'A verification request is already active. Return to your assistant and call chrome_status.' };
|
|
241
|
+
const outcome = await startEmail(server, dataDir, state, email);
|
|
242
|
+
if (outcome && 'challengeId' in outcome) {
|
|
243
|
+
watchForEmailApproval(server, dataDir, outcome.challengeId);
|
|
244
|
+
return { ok: true, message: '' };
|
|
245
|
+
}
|
|
246
|
+
return { ok: false, message: outcome?.message ?? UNAVAILABLE.message };
|
|
247
|
+
}, { onClose: () => { browserPrompts.delete(dataDir); } });
|
|
248
|
+
browserPrompts.set(dataDir, prompt);
|
|
249
|
+
if (request.signal?.aborted) {
|
|
250
|
+
prompt.close();
|
|
251
|
+
return CANCELLED;
|
|
252
|
+
}
|
|
253
|
+
(0, access_browser_1.openBrowserEmailPrompt)(prompt.url);
|
|
254
|
+
return inputRequired(prompt.url);
|
|
255
|
+
}
|
|
179
256
|
/** Returns null when authorized, otherwise a user-facing tool error. */
|
|
180
257
|
async function requireAccess(server, dataDir, request = {}) {
|
|
181
258
|
if (request.signal?.aborted)
|
|
@@ -214,11 +291,15 @@ async function requireAccess(server, dataDir, request = {}) {
|
|
|
214
291
|
const bypassEmailVerification = async () => null;
|
|
215
292
|
exports.bypassEmailVerification = bypassEmailVerification;
|
|
216
293
|
async function check(server, dataDir, request) {
|
|
294
|
+
const sending = pendingEmailStarts.get(dataDir);
|
|
295
|
+
if (sending)
|
|
296
|
+
return sending;
|
|
217
297
|
const state = loadOrCreate(dataDir);
|
|
218
298
|
try {
|
|
219
299
|
if (state.verified) {
|
|
220
300
|
const revalidated = await api('validate', { deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
221
301
|
if (revalidated.ok && revalidated.data.authorized === true) {
|
|
302
|
+
closeAccessBrowserPrompt(dataDir);
|
|
222
303
|
validUntil.set(dataDir, Date.now() + 60_000);
|
|
223
304
|
if (completionToReport.delete(dataDir))
|
|
224
305
|
return VERIFIED;
|
|
@@ -235,6 +316,9 @@ async function check(server, dataDir, request) {
|
|
|
235
316
|
return { status: 'unavailable', message: 'Access could not be checked right now. Retry the browser tool shortly.' };
|
|
236
317
|
}
|
|
237
318
|
if (!state.challengeId) {
|
|
319
|
+
const existing = browserPrompts.get(dataDir);
|
|
320
|
+
if (existing)
|
|
321
|
+
return inputRequired(existing.url);
|
|
238
322
|
let elicitation;
|
|
239
323
|
// The SDK retains the signal listener after an elicitation completes.
|
|
240
324
|
// Detach our forwarding listener so later cancellation cannot cancel a finished prompt.
|
|
@@ -253,28 +337,17 @@ async function check(server, dataDir, request) {
|
|
|
253
337
|
catch {
|
|
254
338
|
if (request.signal?.aborted)
|
|
255
339
|
return CANCELLED;
|
|
256
|
-
return
|
|
340
|
+
return await offerBrowserPrompt(server, dataDir, request);
|
|
257
341
|
}
|
|
258
342
|
finally {
|
|
259
343
|
request.signal?.removeEventListener('abort', cancelPrompt);
|
|
260
344
|
}
|
|
261
345
|
if (request.signal?.aborted)
|
|
262
346
|
return CANCELLED;
|
|
263
|
-
if (elicitation.action !== 'accept' || typeof elicitation.content?.email !== 'string')
|
|
264
|
-
return
|
|
265
|
-
const email = elicitation.content.email.trim().toLowerCase();
|
|
266
|
-
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
|
267
|
-
return { status: 'denied', message: 'That email address is invalid. Retry the browser tool and enter a valid address.' };
|
|
268
|
-
state.challengeId = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
269
|
-
saveState(dataDir, state);
|
|
270
|
-
const started = await api('start', { email, challengeId: state.challengeId, deviceId: state.deviceId, deviceSecret: state.deviceSecret });
|
|
271
|
-
if (!started.ok) {
|
|
272
|
-
delete state.challengeId;
|
|
273
|
-
saveState(dataDir, state);
|
|
274
|
-
const message = typeof started.data.error === 'string' ? started.data.error : 'The verification email could not be sent. Check your connection and retry the browser tool.';
|
|
275
|
-
return { status: 'unavailable', message };
|
|
347
|
+
if (elicitation.action !== 'accept' || typeof elicitation.content?.email !== 'string' || !elicitation.content.email.trim()) {
|
|
348
|
+
return await offerBrowserPrompt(server, dataDir, request);
|
|
276
349
|
}
|
|
277
|
-
await
|
|
350
|
+
return await startEmail(server, dataDir, state, elicitation.content.email);
|
|
278
351
|
}
|
|
279
352
|
return { challengeId: state.challengeId };
|
|
280
353
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -157,6 +157,9 @@ function parseArgs(argv) {
|
|
|
157
157
|
case '--enable-observers':
|
|
158
158
|
policyFlags.allowObservers = true;
|
|
159
159
|
break;
|
|
160
|
+
case '--enable-cookie-values':
|
|
161
|
+
policyFlags.allowCookieValues = true;
|
|
162
|
+
break;
|
|
160
163
|
case '--redact':
|
|
161
164
|
policyFlags.redact = true;
|
|
162
165
|
break;
|
|
@@ -419,6 +422,9 @@ Security (default: deny-all safe mode):
|
|
|
419
422
|
in-page hook on allowlisted sites that records console
|
|
420
423
|
output, fetch/XHR traffic, and intercepts alert/confirm/
|
|
421
424
|
prompt (off by default: it patches page globals).
|
|
425
|
+
--enable-cookie-values Let get_cookies return cookie values. Off by default:
|
|
426
|
+
a session cookie is the login itself, so get_cookies
|
|
427
|
+
returns names and flags with every value hidden.
|
|
422
428
|
--redact Scrub secret-shaped strings (JWTs, cloud keys, bearer
|
|
423
429
|
tokens, private keys) out of page reads. Password field
|
|
424
430
|
values are always suppressed, with or without this.
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -36,7 +36,7 @@ const SERVER_VERSION = '0.1.0';
|
|
|
36
36
|
* than retry.
|
|
37
37
|
*/
|
|
38
38
|
exports.SERVER_INSTRUCTIONS = "MCP Browser Extension drives the user's real, signed-in Chrome. " +
|
|
39
|
-
'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
|
|
39
|
+
'On the first browser tool call, enter your email in the MCP input prompt and open the sign-in link sent to you, then click Verify this device on the website. If it returns ACCESS_INPUT_REQUIRED, the local verification form opens automatically in the browser; also show its URL as a backup so the user can enter their email. Do not fill it or read their email for them. The tool waits for verification, reports the result, and continues automatically on success. If it returns ACCESS_PENDING, call chrome_status again to keep waiting; do not ask for a chat reply or another email. Only ask the user to click Connect if the result says Chrome is not paired. ' +
|
|
40
40
|
'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), do not retry: ' +
|
|
41
41
|
'give the user the steps in its `setup` field, which name what went wrong and how to fix it. ' +
|
|
42
42
|
'If a tool fails with POLICY_DENIED because a site is not allowed, ask the user to allow it from the extension\'s ' +
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -124,7 +124,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
124
124
|
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can. Password field values are always blanked.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
125
125
|
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
|
|
126
126
|
{ name: 'page_watch', description: 'Save a local page checkpoint and compare it later. Actions: create (name), compare (watchId), list, delete. Stores accessibility labels and state, never form values. Site allowlist still applies.', inputSchema: { action: zod_1.z.enum(['create', 'compare', 'list', 'delete']), name: zod_1.z.string().optional(), watchId: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
127
|
-
{ name: 'get_cookies', description: "
|
|
127
|
+
{ name: 'get_cookies', description: "Cookies for the tab's URL (or url); values need --enable-cookie-values.", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
128
128
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
129
129
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
130
130
|
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField, failOnAuthWall: authWallField } },
|
|
@@ -400,6 +400,8 @@ function enabledToolNames() {
|
|
|
400
400
|
const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
|
|
401
401
|
/** How long the gate waits for a just-opened background tab to report its URL. */
|
|
402
402
|
const OPENING_TAB_WAIT_MS = 5_000;
|
|
403
|
+
/** What `get_cookies` returns in place of a value unless --enable-cookie-values is set. */
|
|
404
|
+
const COOKIE_VALUE_HIDDEN = '[hidden]';
|
|
403
405
|
/**
|
|
404
406
|
* Resolve the URL the policy should be evaluated against: the URL of the tab
|
|
405
407
|
* this very call will act on — the explicit `tabId` when the caller gave one,
|
|
@@ -965,7 +967,16 @@ exports.TOOL_HANDLERS = {
|
|
|
965
967
|
const url = (0, validators_1.optionalString)(a, 'url');
|
|
966
968
|
if (url !== undefined)
|
|
967
969
|
await gate(ctx, 'get_text', { url });
|
|
968
|
-
|
|
970
|
+
const result = await ctx.ex.getCookies({ tabId: tabId(a), url });
|
|
971
|
+
if (ctx.policy.allowCookieValues)
|
|
972
|
+
return (0, envelopes_1.jsonResult)(result);
|
|
973
|
+
// A session cookie is the login itself: whoever holds its value can use the
|
|
974
|
+
// account from anywhere. The browser stays signed in without the agent ever
|
|
975
|
+
// seeing it, so hide every value unless the operator opted in.
|
|
976
|
+
return (0, envelopes_1.jsonResult)({
|
|
977
|
+
cookies: result.cookies.map((c) => ({ ...c, value: COOKIE_VALUE_HIDDEN })),
|
|
978
|
+
valuesHidden: 'Cookie values are hidden. Ask the user to restart the server with --enable-cookie-values if they need them.',
|
|
979
|
+
});
|
|
969
980
|
},
|
|
970
981
|
storage: async (a, ctx) => {
|
|
971
982
|
const op = (0, validators_1.requireString)(a, 'op');
|
|
@@ -36,6 +36,10 @@ export interface Policy extends WirePolicy {
|
|
|
36
36
|
* `AUTH_REQUIRED` when the page it lands on is a high-confidence sign-in
|
|
37
37
|
* wall, so an expired session is never scored as some other failure. */
|
|
38
38
|
failOnAuthWall?: boolean;
|
|
39
|
+
/** `--enable-cookie-values`: `get_cookies` returns each cookie's value. Off by
|
|
40
|
+
* default, because a session cookie is the login itself: whoever holds it can
|
|
41
|
+
* use the account from anywhere, with no password and no browser. */
|
|
42
|
+
allowCookieValues?: boolean;
|
|
39
43
|
}
|
|
40
44
|
/** The SAFE default: deny everything until the user opts in. */
|
|
41
45
|
export declare const DEFAULT_POLICY: Readonly<Policy>;
|
|
@@ -45,6 +45,7 @@ exports.DEFAULT_POLICY = Object.freeze({
|
|
|
45
45
|
redact: false,
|
|
46
46
|
redactPatterns: [],
|
|
47
47
|
failOnAuthWall: false,
|
|
48
|
+
allowCookieValues: false,
|
|
48
49
|
});
|
|
49
50
|
/** Merge a partial (from a policy file and/or CLI flags) over the safe default. */
|
|
50
51
|
function resolvePolicy(partial) {
|
|
@@ -60,6 +61,7 @@ function resolvePolicy(partial) {
|
|
|
60
61
|
redact: partial?.redact ?? exports.DEFAULT_POLICY.redact,
|
|
61
62
|
redactPatterns: partial?.redactPatterns ?? [...(exports.DEFAULT_POLICY.redactPatterns ?? [])],
|
|
62
63
|
failOnAuthWall: partial?.failOnAuthWall ?? exports.DEFAULT_POLICY.failOnAuthWall,
|
|
64
|
+
allowCookieValues: partial?.allowCookieValues ?? exports.DEFAULT_POLICY.allowCookieValues,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
@@ -922,12 +922,20 @@
|
|
|
922
922
|
var MAX_CAPTURE_PX = 16384;
|
|
923
923
|
function planScreenshot(dims, opts = {}) {
|
|
924
924
|
const dpr = dims.dpr && dims.dpr > 0 ? dims.dpr : 1;
|
|
925
|
+
const zoom = dims.zoom && dims.zoom > 0 ? dims.zoom : 1;
|
|
925
926
|
const scale = (opts.scale && opts.scale > 0 ? opts.scale : DEFAULT_SCREENSHOT_SCALE) / dpr;
|
|
927
|
+
const clip = (x, y, width, height) => ({
|
|
928
|
+
x: x * zoom,
|
|
929
|
+
y: y * zoom,
|
|
930
|
+
width: width * zoom,
|
|
931
|
+
height: height * zoom,
|
|
932
|
+
scale
|
|
933
|
+
});
|
|
926
934
|
if (opts.element) {
|
|
927
935
|
const realH = Math.max(1, Math.round(opts.element.h));
|
|
928
936
|
const clipH = Math.min(opts.element.h, MAX_CAPTURE_PX);
|
|
929
937
|
return {
|
|
930
|
-
clip:
|
|
938
|
+
clip: clip(opts.element.x, opts.element.y, opts.element.w, clipH),
|
|
931
939
|
captureBeyondViewport: true,
|
|
932
940
|
width: Math.max(1, Math.round(opts.element.w)),
|
|
933
941
|
height: Math.min(realH, MAX_CAPTURE_PX),
|
|
@@ -938,7 +946,7 @@
|
|
|
938
946
|
if (opts.fullPage) {
|
|
939
947
|
const clipH = Math.min(dims.fullH, MAX_CAPTURE_PX);
|
|
940
948
|
return {
|
|
941
|
-
clip:
|
|
949
|
+
clip: clip(0, 0, dims.fullW, clipH),
|
|
942
950
|
captureBeyondViewport: true,
|
|
943
951
|
width: dims.fullW,
|
|
944
952
|
height: clipH,
|
|
@@ -947,7 +955,7 @@
|
|
|
947
955
|
};
|
|
948
956
|
}
|
|
949
957
|
return {
|
|
950
|
-
...scale !== 1 ? { clip:
|
|
958
|
+
...scale !== 1 || zoom !== 1 ? { clip: clip(dims.scrollX ?? 0, dims.scrollY ?? 0, dims.w, dims.h) } : {},
|
|
951
959
|
captureBeyondViewport: false,
|
|
952
960
|
width: dims.w,
|
|
953
961
|
height: dims.h,
|
|
@@ -1233,8 +1241,9 @@
|
|
|
1233
1241
|
const w = cmd.params.waitUntil;
|
|
1234
1242
|
return w === "domcontentloaded" || w === "networkidle" ? w : "load";
|
|
1235
1243
|
}
|
|
1236
|
-
function armNavigationWait(tabId, waitUntil, timeoutMs = 3e4) {
|
|
1244
|
+
function armNavigationWait(tabId, waitUntil, timeoutMs = 3e4, expectedUrl) {
|
|
1237
1245
|
const start = Date.now();
|
|
1246
|
+
const destination = expectedUrl === void 0 ? void 0 : new URL(expectedUrl).href;
|
|
1238
1247
|
const NETWORK_QUIET_MS = 500;
|
|
1239
1248
|
let done = () => void 0;
|
|
1240
1249
|
const finished = new Promise((resolve) => {
|
|
@@ -1250,21 +1259,26 @@
|
|
|
1250
1259
|
};
|
|
1251
1260
|
const onBefore = (d) => {
|
|
1252
1261
|
if (!isTop(d)) return;
|
|
1262
|
+
if (destination && !started && d.url !== destination) return;
|
|
1253
1263
|
started = true;
|
|
1254
1264
|
startUrl = d.url;
|
|
1255
1265
|
committed = false;
|
|
1256
1266
|
};
|
|
1257
1267
|
const onCommitted = (d) => {
|
|
1258
|
-
if (isTop(d))
|
|
1268
|
+
if (!isTop(d)) return;
|
|
1269
|
+
const redirected = d.transitionQualifiers.some((q) => q === "server_redirect" || q === "client_redirect");
|
|
1270
|
+
if (destination && (!started || d.url !== startUrl && !redirected)) return;
|
|
1271
|
+
startUrl = d.url;
|
|
1272
|
+
committed = true;
|
|
1259
1273
|
};
|
|
1260
1274
|
const onDom = (d) => {
|
|
1261
|
-
if (isTop(d) && committed && waitUntil === "domcontentloaded") done();
|
|
1275
|
+
if (isTop(d) && committed && (!destination || d.url === startUrl) && waitUntil === "domcontentloaded") done();
|
|
1262
1276
|
};
|
|
1263
1277
|
const onCompleted = (d) => {
|
|
1264
|
-
if (isTop(d) && committed) reached();
|
|
1278
|
+
if (isTop(d) && committed && (!destination || d.url === startUrl)) reached();
|
|
1265
1279
|
};
|
|
1266
1280
|
const onSameDoc = (d) => {
|
|
1267
|
-
if (isTop(d)) done();
|
|
1281
|
+
if (isTop(d) && (!destination || d.url === destination)) done();
|
|
1268
1282
|
};
|
|
1269
1283
|
const onError = (d) => {
|
|
1270
1284
|
if (!isTop(d) || !started) return;
|
|
@@ -1458,7 +1472,7 @@
|
|
|
1458
1472
|
const found = await execOp(tabId, { op: "measure", selector: selector ?? null }, frameIds);
|
|
1459
1473
|
const top = selector && frameIds && frameIds.length > 0 ? await execOp(tabId, { op: "measure", selector: null }) : found;
|
|
1460
1474
|
return {
|
|
1461
|
-
dims: top.dims,
|
|
1475
|
+
dims: { ...top.dims, zoom: await chrome.tabs.getZoom(tabId) },
|
|
1462
1476
|
element: found.element ?? null,
|
|
1463
1477
|
missing: found.missing === true
|
|
1464
1478
|
};
|
|
@@ -1504,6 +1518,43 @@
|
|
|
1504
1518
|
fullHeight: plan.fullHeight
|
|
1505
1519
|
};
|
|
1506
1520
|
}
|
|
1521
|
+
var SETTLE_MS = 5e3;
|
|
1522
|
+
var SETTLE_QUIET_MS = 400;
|
|
1523
|
+
async function waitForSettled(tabId, quietMs = 0) {
|
|
1524
|
+
const deadline = Date.now() + SETTLE_MS;
|
|
1525
|
+
let last = "";
|
|
1526
|
+
let since = Date.now();
|
|
1527
|
+
while (Date.now() < deadline) {
|
|
1528
|
+
const t = await chrome.tabs.get(tabId).catch(() => null);
|
|
1529
|
+
if (!t) return;
|
|
1530
|
+
const state = `${t.status}|${t.url}`;
|
|
1531
|
+
if (state !== last) {
|
|
1532
|
+
last = state;
|
|
1533
|
+
since = Date.now();
|
|
1534
|
+
}
|
|
1535
|
+
if (t.status === "complete" && Date.now() - since >= quietMs) return;
|
|
1536
|
+
await delay(100);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
async function pageMoved(tabId, url) {
|
|
1540
|
+
const t = await chrome.tabs.get(tabId).catch(() => null);
|
|
1541
|
+
return !!t && (t.status === "loading" || t.url !== url);
|
|
1542
|
+
}
|
|
1543
|
+
async function scaleShot(dataUrl, width, height, enc) {
|
|
1544
|
+
const raw = dataUrl.split(",")[1] ?? "";
|
|
1545
|
+
if (!(width > 0 && height > 0)) return raw;
|
|
1546
|
+
const bitmap = await createImageBitmap(await (await fetch(dataUrl)).blob());
|
|
1547
|
+
if (Math.abs(bitmap.width - width) <= 1) return raw;
|
|
1548
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
1549
|
+
canvas.getContext("2d")?.drawImage(bitmap, 0, 0, width, height);
|
|
1550
|
+
const blob = await canvas.convertToBlob(
|
|
1551
|
+
enc.format === "jpeg" ? { type: "image/jpeg", quality: enc.quality / 100 } : { type: "image/png" }
|
|
1552
|
+
);
|
|
1553
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
1554
|
+
let bin = "";
|
|
1555
|
+
for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
1556
|
+
return btoa(bin);
|
|
1557
|
+
}
|
|
1507
1558
|
async function screenshotViaVisibleTab(tabId, fullPage, enc) {
|
|
1508
1559
|
let t = await chrome.tabs.get(tabId);
|
|
1509
1560
|
let restore;
|
|
@@ -1532,8 +1583,11 @@
|
|
|
1532
1583
|
);
|
|
1533
1584
|
const viewportH = dims?.h ?? 0;
|
|
1534
1585
|
const fullH = dims?.full ?? viewportH;
|
|
1586
|
+
const scale = enc.scale ?? DEFAULT_SCREENSHOT_SCALE;
|
|
1587
|
+
const out = Math.round((dims?.w ?? 0) * scale);
|
|
1588
|
+
const outH = Math.round(viewportH * scale);
|
|
1535
1589
|
return {
|
|
1536
|
-
dataBase64: dataUrl.split(",")[1] ?? "",
|
|
1590
|
+
dataBase64: await scaleShot(dataUrl, out, outH, enc).catch(() => dataUrl.split(",")[1] ?? ""),
|
|
1537
1591
|
mimeType: enc.format === "jpeg" ? "image/jpeg" : "image/png",
|
|
1538
1592
|
width: dims?.w ?? 0,
|
|
1539
1593
|
height: viewportH,
|
|
@@ -1722,7 +1776,7 @@
|
|
|
1722
1776
|
return { id: created.id, reused: false, needsNav: url !== void 0 };
|
|
1723
1777
|
});
|
|
1724
1778
|
if (claim.needsNav) {
|
|
1725
|
-
const nav = armNavigationWait(claim.id, "load", 25e3);
|
|
1779
|
+
const nav = armNavigationWait(claim.id, "load", 25e3, url);
|
|
1726
1780
|
await chrome.tabs.update(claim.id, { url }).catch((err) => {
|
|
1727
1781
|
nav.cancel();
|
|
1728
1782
|
throw err;
|
|
@@ -1745,7 +1799,7 @@
|
|
|
1745
1799
|
case "navigate": {
|
|
1746
1800
|
const id = await targetTab2(cmd);
|
|
1747
1801
|
const url = String(cmd.params.url);
|
|
1748
|
-
const nav = armNavigationWait(id, waitUntilOf(cmd));
|
|
1802
|
+
const nav = armNavigationWait(id, waitUntilOf(cmd), 3e4, url);
|
|
1749
1803
|
await chrome.tabs.update(id, { url }).catch((err) => {
|
|
1750
1804
|
nav.cancel();
|
|
1751
1805
|
throw err;
|
|
@@ -2062,9 +2116,24 @@
|
|
|
2062
2116
|
if (selector && !await waitForSelector(id, selector, frameIds, READ_WAIT_MS)) {
|
|
2063
2117
|
throw notFound(selector, await execOp(id, { op: "waitSelector", selector, timeoutMs: 0 }, frameIds));
|
|
2064
2118
|
}
|
|
2065
|
-
|
|
2119
|
+
await waitForSettled(id);
|
|
2120
|
+
const shoot = async () => {
|
|
2121
|
+
const at = (await chrome.tabs.get(id)).url;
|
|
2122
|
+
if (at) this.assertAllowed(at, "screenshot");
|
|
2123
|
+
const shot = await screenshotViaDebugger(id, fullPage, enc, selector, frameIds).catch(async (err) => {
|
|
2124
|
+
if (!await pageMoved(id, at)) throw err;
|
|
2125
|
+
return null;
|
|
2126
|
+
});
|
|
2127
|
+
if (shot && !await pageMoved(id, at)) return shot;
|
|
2128
|
+
await waitForSettled(id, SETTLE_QUIET_MS);
|
|
2129
|
+
const now = (await chrome.tabs.get(id)).url;
|
|
2130
|
+
if (now) this.assertAllowed(now, "screenshot");
|
|
2066
2131
|
return await screenshotViaDebugger(id, fullPage, enc, selector, frameIds);
|
|
2132
|
+
};
|
|
2133
|
+
try {
|
|
2134
|
+
return await shoot();
|
|
2067
2135
|
} catch (err) {
|
|
2136
|
+
if (err instanceof CmdError && err.code === "POLICY_DENIED") throw err;
|
|
2068
2137
|
if (err instanceof CmdError && (err.code === "SELECTOR_NOT_FOUND" || err.code === "TIMEOUT")) throw err;
|
|
2069
2138
|
return await screenshotViaVisibleTab(id, fullPage, enc);
|
|
2070
2139
|
}
|
|
@@ -2536,7 +2605,7 @@
|
|
|
2536
2605
|
delete marked[tabId];
|
|
2537
2606
|
} else {
|
|
2538
2607
|
await unpaint(tabId, marked[tabId]);
|
|
2539
|
-
marked[tabId] = await paint(tabId);
|
|
2608
|
+
marked[tabId] = this.capturing.has(tabId) ? "" : await paint(tabId);
|
|
2540
2609
|
}
|
|
2541
2610
|
await saveMarked(marked);
|
|
2542
2611
|
});
|
|
@@ -2554,11 +2623,10 @@
|
|
|
2554
2623
|
if (tabId === null) return fn();
|
|
2555
2624
|
const css = await this.locked(async () => {
|
|
2556
2625
|
const c = (await loadMarked())[tabId] ?? "";
|
|
2557
|
-
|
|
2626
|
+
this.capturing.set(tabId, (this.capturing.get(tabId) ?? 0) + 1);
|
|
2558
2627
|
await unpaint(tabId, c);
|
|
2559
2628
|
return c;
|
|
2560
2629
|
});
|
|
2561
|
-
if (!css) return fn();
|
|
2562
2630
|
try {
|
|
2563
2631
|
return await fn();
|
|
2564
2632
|
} finally {
|
|
@@ -2566,7 +2634,7 @@
|
|
|
2566
2634
|
const left = (this.capturing.get(tabId) ?? 1) - 1;
|
|
2567
2635
|
if (left > 0) return void this.capturing.set(tabId, left);
|
|
2568
2636
|
this.capturing.delete(tabId);
|
|
2569
|
-
if ((await loadMarked())[tabId] !== css) return;
|
|
2637
|
+
if (!css || (await loadMarked())[tabId] !== css) return;
|
|
2570
2638
|
await bounded(chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" }), void 0);
|
|
2571
2639
|
});
|
|
2572
2640
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "MCP Browser Extension – AI agent in your logged-in Chrome",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.31",
|
|
5
5
|
"description": "Lets your AI agent use the Chrome you are signed into, through a local MCP server. Deny-all by default.",
|
|
6
6
|
"homepage_url": "https://mcp-browser-extension.com",
|
|
7
7
|
"minimum_chrome_version": "116",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-browser-extension",
|
|
3
3
|
"mcpName": "io.github.Mehmoodqureshi/mcp-browser-extension",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.31",
|
|
5
5
|
"description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
|
|
6
6
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
7
7
|
"license": "MIT",
|