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 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. The waiting tool reports **Email verified** and continues.
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. The MCP client must support interactive input prompts
182
- (MCP elicitation). If no prompt appears, enable interactive prompts in your
183
- client and retry; installing the extension alone does not activate access.
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 and returns to the MCP client. Do not ask
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. This requires the MCP client to
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). Output pixels = CSS px * dpr * clip.scale. */
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 (CSS px; `scale` multiplies output). */
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
- // CDP's clip.scale multiplies on top of the device scale factor, so dividing
31
- // by the DPR yields exactly `opts.scale` output pixels per CSS pixel.
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: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale },
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: { x: 0, y: 0, width: dims.fullW, height: clipH, scale },
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: capture what's visible. A clip is only needed to apply a
58
- // scale; at scale 1 on a 1x display the bare capture is identical and cheaper.
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: { x: dims.scrollX ?? 0, y: dims.scrollY ?? 0, width: dims.w, height: dims.h, scale } }
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
@@ -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";
@@ -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 { status: 'unavailable', message: 'Your MCP client did not display the email prompt. This access flow requires an MCP client that supports elicitation. Enable elicitation or connect from a supported client, then retry.' };
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 { status: 'cancelled', message: 'Email verification was not completed. Retry the browser tool to start again.' };
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 notifyAccess(server, { status: 'pending', message: 'A sign-in link has been sent. Open it from your email; the browser tool is waiting for verification.' });
350
+ return await startEmail(server, dataDir, state, elicitation.content.email);
278
351
  }
279
352
  return { challengeId: state.challengeId };
280
353
  }
@@ -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.
@@ -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 ' +
@@ -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: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url }));
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: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale },
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: { x: 0, y: 0, width: dims.fullW, height: clipH, scale },
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: { x: dims.scrollX ?? 0, y: dims.scrollY ?? 0, width: dims.w, height: dims.h, scale } } : {},
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)) committed = true;
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
- try {
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
- if (c) this.capturing.set(tabId, (this.capturing.get(tabId) ?? 0) + 1);
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.30",
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.30",
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",