mcp-browser-extension 0.9.29 → 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/page-fns.d.ts +7 -1
- package/dist/shared/page-fns.js +37 -2
- package/dist/shared/policy.js +10 -1
- package/dist/shared/screenshot.d.ts +4 -2
- package/dist/shared/screenshot.js +13 -8
- package/dist/shared/snapshot.js +15 -4
- 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/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +14 -0
- package/dist/src/executor/extension-executor.js +14 -4
- package/dist/src/executor/types.d.ts +4 -0
- package/dist/src/mcp/helpers.js +11 -2
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/tools.js +51 -9
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +2 -0
- package/extension-dist/background.js +319 -77
- package/extension-dist/manifest.json +3 -3
- package/package.json +2 -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
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
* One resolver, used by every op, is what closes it.
|
|
17
17
|
*/
|
|
18
18
|
/** Ops the page-side dispatcher understands. */
|
|
19
|
-
export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
|
|
19
|
+
export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'check' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
|
|
20
20
|
export interface PageOpArgs {
|
|
21
21
|
op: PageOpName;
|
|
22
22
|
selector?: string | null;
|
|
23
23
|
text?: string;
|
|
24
24
|
clear?: boolean;
|
|
25
|
+
/** type: send keydown/keypress/keyup around each character. */
|
|
26
|
+
keyEvents?: boolean;
|
|
27
|
+
/** type: press Enter after the text (submits the field's form like a person would). */
|
|
28
|
+
pressEnter?: boolean;
|
|
29
|
+
/** check: the state a checkbox/radio should end up in. */
|
|
30
|
+
checked?: boolean;
|
|
25
31
|
outer?: boolean;
|
|
26
32
|
values?: string[];
|
|
27
33
|
x?: number | null;
|
package/dist/shared/page-fns.js
CHANGED
|
@@ -208,6 +208,18 @@ function pageOp(a) {
|
|
|
208
208
|
el.click();
|
|
209
209
|
return { found: true };
|
|
210
210
|
}
|
|
211
|
+
case 'check': {
|
|
212
|
+
if (!el)
|
|
213
|
+
return { found: false };
|
|
214
|
+
// Click only when the state differs: a click toggles, so clicking an
|
|
215
|
+
// already-checked box to "check" it would clear it.
|
|
216
|
+
const box = el;
|
|
217
|
+
if (box.checked !== (a.checked !== false)) {
|
|
218
|
+
el.scrollIntoView({ block: 'center' });
|
|
219
|
+
el.click();
|
|
220
|
+
}
|
|
221
|
+
return { found: true, checked: box.checked };
|
|
222
|
+
}
|
|
211
223
|
case 'type': {
|
|
212
224
|
if (!el)
|
|
213
225
|
return { found: false };
|
|
@@ -226,9 +238,32 @@ function pageOp(a) {
|
|
|
226
238
|
if (!node)
|
|
227
239
|
return { found: false };
|
|
228
240
|
node.focus();
|
|
229
|
-
|
|
230
|
-
|
|
241
|
+
if (a.keyEvents) {
|
|
242
|
+
// One character at a time, each inside its own key events, for pages
|
|
243
|
+
// that react to keystrokes rather than to the value.
|
|
244
|
+
if (a.clear)
|
|
245
|
+
setValue(node, '');
|
|
246
|
+
for (const ch of a.text ?? '') {
|
|
247
|
+
const ev = { key: ch, bubbles: true, cancelable: true };
|
|
248
|
+
const go = node.dispatchEvent(new KeyboardEvent('keydown', ev));
|
|
249
|
+
if (go && node.dispatchEvent(new KeyboardEvent('keypress', ev)))
|
|
250
|
+
setValue(node, (node.value ?? '') + ch);
|
|
251
|
+
node.dispatchEvent(new KeyboardEvent('keyup', ev));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
setValue(node, (a.clear ? '' : node.value ?? '') + (a.text ?? ''));
|
|
256
|
+
}
|
|
231
257
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
258
|
+
if (a.pressEnter) {
|
|
259
|
+
const ev = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
|
|
260
|
+
const go = node.dispatchEvent(new KeyboardEvent('keydown', ev)) && node.dispatchEvent(new KeyboardEvent('keypress', ev));
|
|
261
|
+
node.dispatchEvent(new KeyboardEvent('keyup', ev));
|
|
262
|
+
// Synthetic key events never trigger implicit submission; do what the
|
|
263
|
+
// browser would for Enter in a single-line field of a form.
|
|
264
|
+
if (go && node instanceof HTMLInputElement && node.form)
|
|
265
|
+
node.form.requestSubmit();
|
|
266
|
+
}
|
|
232
267
|
return { found: true };
|
|
233
268
|
}
|
|
234
269
|
case 'focus': {
|
package/dist/shared/policy.js
CHANGED
|
@@ -34,6 +34,12 @@ const READ_CONTENT = new Set([
|
|
|
34
34
|
'frames_list',
|
|
35
35
|
'observers',
|
|
36
36
|
'print_pdf',
|
|
37
|
+
// The page's accessibility tree, its cookies (session secrets), and its
|
|
38
|
+
// web storage are content too. A storage write is refused in safe mode by
|
|
39
|
+
// the extension's storage handler; the domain gate here covers every op.
|
|
40
|
+
'snapshot',
|
|
41
|
+
'get_cookies',
|
|
42
|
+
'storage',
|
|
37
43
|
]);
|
|
38
44
|
/** Content-mutating actions — URL-gated AND mutation-gated. */
|
|
39
45
|
const MUTATE_CONTENT = new Set([
|
|
@@ -44,6 +50,7 @@ const MUTATE_CONTENT = new Set([
|
|
|
44
50
|
'hover',
|
|
45
51
|
'scroll',
|
|
46
52
|
'fill_form',
|
|
53
|
+
'select_option',
|
|
47
54
|
]);
|
|
48
55
|
/** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
|
|
49
56
|
const NAVIGATION = new Set([
|
|
@@ -72,7 +79,9 @@ function isUrlGated(method) {
|
|
|
72
79
|
MUTATE_CONTENT.has(method) ||
|
|
73
80
|
NAVIGATION.has(method) ||
|
|
74
81
|
method === 'eval' ||
|
|
75
|
-
method === 'upload_file'
|
|
82
|
+
method === 'upload_file' ||
|
|
83
|
+
// Gated on where the bytes come from: the URL when one is given, else the tab.
|
|
84
|
+
method === 'download_file');
|
|
76
85
|
}
|
|
77
86
|
// ---------------------------------------------------------------------------
|
|
78
87
|
// Domain matching
|
|
@@ -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,
|
package/dist/shared/snapshot.js
CHANGED
|
@@ -72,6 +72,13 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
72
72
|
if (t)
|
|
73
73
|
return t;
|
|
74
74
|
}
|
|
75
|
+
// <label for> or a wrapping <label>: how ordinary forms name their fields.
|
|
76
|
+
const labels = el.labels;
|
|
77
|
+
if (labels && labels.length > 0) {
|
|
78
|
+
const t = Array.from(labels).map((l) => l.innerText).join(' ').replace(/\s+/g, ' ').trim();
|
|
79
|
+
if (t)
|
|
80
|
+
return t;
|
|
81
|
+
}
|
|
75
82
|
const ph = el.getAttribute('placeholder');
|
|
76
83
|
if (ph)
|
|
77
84
|
return ph.trim();
|
|
@@ -113,8 +120,11 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
113
120
|
// recursively. Defensive against null/closed shadow roots and re-visits.
|
|
114
121
|
const seen = new Set();
|
|
115
122
|
const candidates = [];
|
|
123
|
+
// Gather past `max` so hidden candidates dropped below still leave enough to
|
|
124
|
+
// fill it, and so a page with more than `max` can be reported as truncated.
|
|
125
|
+
const cap = Math.max(max * 2, max + 50);
|
|
116
126
|
const collect = (root) => {
|
|
117
|
-
if (candidates.length >=
|
|
127
|
+
if (candidates.length >= cap)
|
|
118
128
|
return;
|
|
119
129
|
let matched;
|
|
120
130
|
try {
|
|
@@ -124,7 +134,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
124
134
|
matched = [];
|
|
125
135
|
}
|
|
126
136
|
for (let i = 0; i < matched.length; i++) {
|
|
127
|
-
if (candidates.length >=
|
|
137
|
+
if (candidates.length >= cap)
|
|
128
138
|
break;
|
|
129
139
|
const el = matched[i];
|
|
130
140
|
if (seen.has(el))
|
|
@@ -142,7 +152,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
142
152
|
hosts = [];
|
|
143
153
|
}
|
|
144
154
|
for (let i = 0; i < hosts.length; i++) {
|
|
145
|
-
if (candidates.length >=
|
|
155
|
+
if (candidates.length >= cap)
|
|
146
156
|
break;
|
|
147
157
|
const sr = hosts[i].shadowRoot;
|
|
148
158
|
if (sr)
|
|
@@ -230,7 +240,8 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
230
240
|
break;
|
|
231
241
|
picked.push(read(el));
|
|
232
242
|
}
|
|
233
|
-
|
|
243
|
+
// At the cap there may be more on the page than were even looked at.
|
|
244
|
+
truncated = els.length > picked.length || candidates.length >= cap;
|
|
234
245
|
}
|
|
235
246
|
// -- phase 2: writes (one attribute per returned node) ----------------------
|
|
236
247
|
const nodes = [];
|
|
@@ -0,0 +1,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";
|