mcp-browser-extension 0.9.30 → 0.9.32
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 +77 -14
- package/SETUP.md +40 -7
- package/dist/shared/activity.d.ts +25 -0
- package/dist/shared/activity.js +72 -0
- package/dist/shared/browser-permissions.d.ts +20 -0
- package/dist/shared/browser-permissions.js +40 -0
- package/dist/shared/client-auth.d.ts +28 -0
- package/dist/shared/client-auth.js +38 -0
- package/dist/shared/observers.d.ts +1 -1
- package/dist/shared/observers.js +1 -3
- package/dist/shared/policy.d.ts +5 -7
- package/dist/shared/policy.js +11 -26
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/shared/protocol.js +3 -3
- 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 +202 -0
- package/dist/src/access.d.ts +2 -0
- package/dist/src/access.js +92 -18
- package/dist/src/bridge/client-identity.d.ts +10 -0
- package/dist/src/bridge/client-identity.js +28 -0
- package/dist/src/bridge/connection.d.ts +5 -0
- package/dist/src/bridge/connection.js +4 -12
- package/dist/src/bridge/peer.d.ts +4 -19
- package/dist/src/bridge/peer.js +17 -8
- package/dist/src/bridge/server.d.ts +7 -16
- package/dist/src/bridge/server.js +64 -17
- package/dist/src/bridge/site-grants.d.ts +1 -2
- package/dist/src/bridge/site-grants.js +4 -3
- package/dist/src/cli.js +1 -3
- package/dist/src/config.js +16 -5
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +69 -25
- package/dist/src/mcp/server.js +5 -3
- package/dist/src/mcp/tools.d.ts +0 -12
- package/dist/src/mcp/tools.js +48 -22
- package/dist/src/remote/key.js +12 -2
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +3 -1
- package/docs/BLUEPRINT.md +6 -1
- package/docs/CLIENT-APPROVAL.md +150 -0
- package/extension-dist/activity-dashboard.css +43 -0
- package/extension-dist/background.js +1036 -73
- package/extension-dist/manifest.json +10 -9
- package/extension-dist/options.html +101 -32
- package/extension-dist/options.js +558 -70
- package/extension-dist/popup.html +164 -90
- package/extension-dist/popup.js +576 -79
- package/package.json +3 -2
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* src/bridge/peer.ts — let several mcp-browser-extension servers share one Chrome.
|
|
3
|
-
*
|
|
4
|
-
* Every MCP host session (each Claude terminal/tab) spawns its own mcp-browser-extension,
|
|
5
|
-
* but a browser's extension dials exactly one port. So the first server to bind
|
|
6
|
-
* the port is the HUB: it owns the extension connections. A later server that
|
|
7
|
-
* finds the port held by a live mcp-browser-extension joins it as a PEER over the same
|
|
8
|
-
* port and relays its commands through the hub, instead of killing it. If the
|
|
9
|
-
* hub exits, its peers race to bind the port; the winner becomes the new hub
|
|
10
|
-
* (with the same token, so the extension re-pairs on its own) and the rest join
|
|
11
|
-
* it.
|
|
12
|
-
*
|
|
13
|
-
* Peers authenticate with the same token the extension uses — the handshake
|
|
14
|
-
* file is 0600, so only the same OS user can read it — and speak a small
|
|
15
|
-
* server-to-server protocol that the extension never sees:
|
|
16
|
-
* peer → hub: peer_hello, relay (a wire command), peer_rename, peer_blocked
|
|
17
|
-
* hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant
|
|
18
|
-
*/
|
|
1
|
+
import type { ClientProof } from '../../shared/client-auth';
|
|
19
2
|
import { PROTOCOL_VERSION, type WireMethod } from '../../shared/protocol';
|
|
20
3
|
import type { PairedProfile } from './server';
|
|
21
4
|
/** How long a joining server waits for the hub's welcome before giving up. */
|
|
@@ -35,9 +18,11 @@ export interface RelayFrame {
|
|
|
35
18
|
tabId?: string;
|
|
36
19
|
timeoutMs?: number;
|
|
37
20
|
profile?: string;
|
|
21
|
+
authorization?: ClientProof;
|
|
38
22
|
};
|
|
39
23
|
}
|
|
40
24
|
export interface PeerRenameFrame {
|
|
25
|
+
authorization?: ClientProof;
|
|
41
26
|
type: 'peer_rename';
|
|
42
27
|
id: string;
|
|
43
28
|
from: string;
|
|
@@ -99,7 +84,7 @@ export declare class HubLink {
|
|
|
99
84
|
static join(host: string, port: number, token: string, onClose: () => void, onGrant?: (host: string, allow: boolean) => void): Promise<HubLink | null>;
|
|
100
85
|
isOpen(): boolean;
|
|
101
86
|
relay(method: WireMethod, params: Record<string, unknown>, opts?: RelayFrame['opts']): Promise<unknown>;
|
|
102
|
-
rename(from: string, to: string): Promise<unknown>;
|
|
87
|
+
rename(from: string, to: string, authorization: ClientProof): Promise<unknown>;
|
|
103
88
|
/** Tell the hub our gate refused `host`, so it can offer the person an Allow. */
|
|
104
89
|
blocked(host: string, method: string): void;
|
|
105
90
|
close(): void;
|
package/dist/src/bridge/peer.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.relayError = relayError;
|
|
2
5
|
/**
|
|
3
6
|
* src/bridge/peer.ts — let several mcp-browser-extension servers share one Chrome.
|
|
4
7
|
*
|
|
@@ -17,9 +20,6 @@
|
|
|
17
20
|
* peer → hub: peer_hello, relay (a wire command), peer_rename, peer_blocked
|
|
18
21
|
* hub → peer: peer_welcome, peer_state (paired browsers), relay_result, peer_grant
|
|
19
22
|
*/
|
|
20
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
|
|
22
|
-
exports.relayError = relayError;
|
|
23
23
|
const ws_1 = require("ws");
|
|
24
24
|
const protocol_1 = require("../../shared/protocol");
|
|
25
25
|
const types_1 = require("../executor/types");
|
|
@@ -70,6 +70,7 @@ class HubLink {
|
|
|
70
70
|
static join(host, port, token, onClose, onGrant) {
|
|
71
71
|
return new Promise((resolve) => {
|
|
72
72
|
let settled = false;
|
|
73
|
+
let joined = null;
|
|
73
74
|
const ws = new ws_1.WebSocket(`ws://${host}:${port}`);
|
|
74
75
|
const done = (link) => {
|
|
75
76
|
if (settled)
|
|
@@ -97,9 +98,17 @@ class HubLink {
|
|
|
97
98
|
catch {
|
|
98
99
|
return done(null);
|
|
99
100
|
}
|
|
100
|
-
if (frame.type
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
if (!joined && frame.type === 'peer_welcome') {
|
|
102
|
+
joined = new HubLink(ws, typeof frame.hubPid === 'number' ? frame.hubPid : 0, onClose, onGrant);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (joined && frame.type === 'peer_state' && Array.isArray(frame.profiles)) {
|
|
106
|
+
// The first command needs the extension's signing challenge. Do not
|
|
107
|
+
// report a successful join before the initial browser state arrives.
|
|
108
|
+
joined.profiles = frame.profiles;
|
|
109
|
+
return done(joined);
|
|
110
|
+
}
|
|
111
|
+
done(null); // unauthorized or an incomplete/incompatible handshake
|
|
103
112
|
};
|
|
104
113
|
ws.on('message', onFirst);
|
|
105
114
|
ws.once('open', () => {
|
|
@@ -117,8 +126,8 @@ class HubLink {
|
|
|
117
126
|
const timeoutMs = (opts?.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS - RELAY_GRACE_MS) + RELAY_GRACE_MS;
|
|
118
127
|
return this.request({ type: 'relay', id: '', method, params, ...(opts ? { opts } : {}) }, timeoutMs);
|
|
119
128
|
}
|
|
120
|
-
rename(from, to) {
|
|
121
|
-
return this.request({ type: 'peer_rename', id: '', from, to }, exports.JOIN_TIMEOUT_MS * 2);
|
|
129
|
+
rename(from, to, authorization) {
|
|
130
|
+
return this.request({ type: 'peer_rename', id: '', from, to, authorization }, exports.JOIN_TIMEOUT_MS * 2);
|
|
122
131
|
}
|
|
123
132
|
/** Tell the hub our gate refused `host`, so it can offer the person an Allow. */
|
|
124
133
|
blocked(host, method) {
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* src/bridge/server.ts — the localhost WebSocket bridge.
|
|
3
|
-
*
|
|
4
|
-
* The server is the WS SERVER; the extension dials in as the single privileged
|
|
5
|
-
* CLIENT. The token is the ONLY trust boundary (the loopback bind is merely
|
|
6
|
-
* defense-in-depth; Origin is NOT a gate). Flow:
|
|
7
|
-
* 1. Accept any loopback upgrade.
|
|
8
|
-
* 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
|
|
9
|
-
* otherwise send `unauthorized` and close 4401.
|
|
10
|
-
* 3. On success, send `welcome` and file the connection under its profile
|
|
11
|
-
* (superseding only that profile's prior one — a security-relevant
|
|
12
|
-
* displacement event).
|
|
13
|
-
* Other mcp-browser-extension servers (one per Claude session) may join the same port as
|
|
14
|
-
* peers with `peer_hello` and relay their commands through us; see ./peer.
|
|
15
|
-
*/
|
|
16
1
|
import { type WireEvent, type WireMethod, type WirePolicy } from '../../shared/protocol';
|
|
17
2
|
/** Where a person gets the extension. */
|
|
18
3
|
export declare const WEB_STORE_URL: "https://chromewebstore.google.com/detail/mcp-browser-extension/jelfhdlkhbfmlpbghoeaepijllcnplgh";
|
|
@@ -29,6 +14,8 @@ export type PairState = 'ok' | 'profile_mismatch' | 'token_mismatch' | 'version_
|
|
|
29
14
|
export type ProfileNaming = 'options' | 'auto' | 'legacy';
|
|
30
15
|
/** One paired browser, as reported by chrome_status. */
|
|
31
16
|
export interface PairedProfile {
|
|
17
|
+
/** Public, extension-generated replay scope; not a credential. */
|
|
18
|
+
approvalChallenge?: string;
|
|
32
19
|
name: string;
|
|
33
20
|
/** options = typed in the extension's Options; auto = assigned by the server
|
|
34
21
|
* (renamable with profile_rename); legacy = an older extension, "default". */
|
|
@@ -86,6 +73,7 @@ export interface BridgeOptions {
|
|
|
86
73
|
export type BridgeRole = 'hub' | 'peer';
|
|
87
74
|
export declare class BridgeServer {
|
|
88
75
|
private readonly opts;
|
|
76
|
+
private readonly clientIdentity;
|
|
89
77
|
private wss;
|
|
90
78
|
/** Profile routing key → its live connection. Multiple browsers stay paired at
|
|
91
79
|
* once; a command is routed to the connection for its target profile. A new
|
|
@@ -167,6 +155,8 @@ export declare class BridgeServer {
|
|
|
167
155
|
timeoutMs?: number;
|
|
168
156
|
profile?: string;
|
|
169
157
|
}): Promise<unknown>;
|
|
158
|
+
/** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
|
|
159
|
+
private forwardCommand;
|
|
170
160
|
/** Whether the browser paired as `profile` advertised capability `cap`. A peer
|
|
171
161
|
* cannot see the hub's handshake, so it answers false (the conservative path). */
|
|
172
162
|
hasCap(profile: string | undefined, cap: string): boolean;
|
|
@@ -201,13 +191,14 @@ export declare class BridgeServer {
|
|
|
201
191
|
/** How to pair a browser for `profile` — surfaced whenever that profile has no live connection. */
|
|
202
192
|
noPairMessage(profile: string): string;
|
|
203
193
|
/** Every paired browser with how it was named — for chrome_status. */
|
|
204
|
-
pairedProfiles(): PairedProfile[];
|
|
194
|
+
pairedProfiles(includeChallenge?: boolean): PairedProfile[];
|
|
205
195
|
/**
|
|
206
196
|
* Rename an automatically named browser. The new name sticks across restarts
|
|
207
197
|
* (it's stored against the extension install); a live connection is re-keyed
|
|
208
198
|
* in place, so it keeps working without re-pairing.
|
|
209
199
|
*/
|
|
210
200
|
renameProfile(from: string, to: string): Promise<string>;
|
|
201
|
+
private renameApprovedProfile;
|
|
211
202
|
status(): {
|
|
212
203
|
extensionConnected: boolean;
|
|
213
204
|
port: number;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BridgeServer = exports.WEB_STORE_URL = void 0;
|
|
4
|
+
const client_auth_1 = require("../../shared/client-auth");
|
|
5
|
+
const client_identity_1 = require("./client-identity");
|
|
2
6
|
/**
|
|
3
7
|
* src/bridge/server.ts — the localhost WebSocket bridge.
|
|
4
8
|
*
|
|
5
9
|
* The server is the WS SERVER; the extension dials in as the single privileged
|
|
6
|
-
* CLIENT. The token
|
|
7
|
-
*
|
|
10
|
+
* CLIENT. The token authenticates this transport; the extension separately
|
|
11
|
+
* verifies each command against its approved client keys. Flow:
|
|
8
12
|
* 1. Accept any loopback upgrade.
|
|
9
13
|
* 2. Require a valid `hello` (matching version + token) within HELLO_TIMEOUT;
|
|
10
14
|
* otherwise send `unauthorized` and close 4401.
|
|
@@ -14,8 +18,6 @@
|
|
|
14
18
|
* Other mcp-browser-extension servers (one per Claude session) may join the same port as
|
|
15
19
|
* peers with `peer_hello` and relay their commands through us; see ./peer.
|
|
16
20
|
*/
|
|
17
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.BridgeServer = exports.WEB_STORE_URL = void 0;
|
|
19
21
|
const ws_1 = require("ws");
|
|
20
22
|
const node_crypto_1 = require("node:crypto");
|
|
21
23
|
const protocol_1 = require("../../shared/protocol");
|
|
@@ -75,6 +77,12 @@ const MAX_PREAUTH_FRAMES = 10;
|
|
|
75
77
|
const PORT_WAIT_MS = 4_000;
|
|
76
78
|
/** Pause between port-bind retries while waiting for the old listener to exit. */
|
|
77
79
|
const PORT_RETRY_MS = 250;
|
|
80
|
+
/** Bridge frames are small JSON control messages; cap unauthenticated memory use. */
|
|
81
|
+
const MAX_BRIDGE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
82
|
+
/** Native clients omit Origin; Chrome extensions use their own isolated origin. */
|
|
83
|
+
function allowBridgeOrigin(origin) {
|
|
84
|
+
return origin === undefined || /^chrome-extension:\/\/[a-p]{32}$/.test(origin);
|
|
85
|
+
}
|
|
78
86
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
79
87
|
/** A friendly, actionable message for the rare case the port stays busy past PORT_WAIT_MS. */
|
|
80
88
|
function portBusyMessage(host, port) {
|
|
@@ -88,6 +96,7 @@ function portBusyMessage(host, port) {
|
|
|
88
96
|
}
|
|
89
97
|
class BridgeServer {
|
|
90
98
|
opts;
|
|
99
|
+
clientIdentity = (0, client_identity_1.createClientIdentity)(() => 'Local browser client');
|
|
91
100
|
wss = null;
|
|
92
101
|
/** Profile routing key → its live connection. Multiple browsers stay paired at
|
|
93
102
|
* once; a command is routed to the connection for its target profile. A new
|
|
@@ -295,7 +304,13 @@ class BridgeServer {
|
|
|
295
304
|
/** One bind attempt. Resolves with a listening server or rejects with the listen error. */
|
|
296
305
|
listenOnce(host, port) {
|
|
297
306
|
return new Promise((resolve, reject) => {
|
|
298
|
-
const wss = new ws_1.WebSocketServer({
|
|
307
|
+
const wss = new ws_1.WebSocketServer({
|
|
308
|
+
host,
|
|
309
|
+
port,
|
|
310
|
+
maxPayload: MAX_BRIDGE_FRAME_BYTES,
|
|
311
|
+
perMessageDeflate: false,
|
|
312
|
+
verifyClient: ({ origin }) => allowBridgeOrigin(origin || undefined),
|
|
313
|
+
});
|
|
299
314
|
const onError = (err) => {
|
|
300
315
|
wss.off('listening', onListening);
|
|
301
316
|
// Close so the failed server doesn't linger and leak a handle on retry.
|
|
@@ -365,12 +380,33 @@ class BridgeServer {
|
|
|
365
380
|
* Rejects with an actionable message if that profile has no live browser.
|
|
366
381
|
*/
|
|
367
382
|
async sendCommand(method, params, opts) {
|
|
383
|
+
const profile = routeKey(opts?.profile);
|
|
384
|
+
const timeoutMs = opts?.timeoutMs ?? (0, connection_1.defaultTimeoutFor)(method);
|
|
385
|
+
const intent = { v: protocol_1.PROTOCOL_VERSION, method, params, tabId: opts?.tabId, timeoutMs };
|
|
386
|
+
const challenge = this.hub
|
|
387
|
+
? this.hub.profiles.find((p) => p.name === profile)?.approvalChallenge
|
|
388
|
+
: this.conns.get(profile)?.approvalChallenge;
|
|
389
|
+
let authorization;
|
|
390
|
+
if (!(0, client_auth_1.isHealthProbe)(intent)) {
|
|
391
|
+
if (!this.hasConnection(profile))
|
|
392
|
+
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
|
|
393
|
+
if (!challenge)
|
|
394
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'Update the extension and MCP server together: this browser cannot enforce client approval yet.');
|
|
395
|
+
authorization = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, challenge);
|
|
396
|
+
}
|
|
397
|
+
const options = { ...opts, timeoutMs, authorization };
|
|
368
398
|
if (this.hub)
|
|
369
|
-
return this.hub.relay(method, params,
|
|
399
|
+
return this.hub.relay(method, params, options);
|
|
400
|
+
return this.forwardCommand(method, params, options);
|
|
401
|
+
}
|
|
402
|
+
/** Never sign a peer's request using the hub's identity. Chrome verifies its original proof. */
|
|
403
|
+
async forwardCommand(method, params, opts) {
|
|
370
404
|
const profile = routeKey(opts?.profile);
|
|
371
405
|
const conn = this.conns.get(profile);
|
|
372
|
-
if (!conn
|
|
406
|
+
if (!conn?.isOpen())
|
|
373
407
|
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', this.noPairMessage(profile));
|
|
408
|
+
if (!(0, client_auth_1.isHealthProbe)({ method, params }) && (!conn.hasCap(client_auth_1.CLIENT_APPROVAL_CAP) || !opts?.authorization)) {
|
|
409
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'Signed client authorization and an updated extension are required.');
|
|
374
410
|
}
|
|
375
411
|
return conn.sendCommand(method, params, opts);
|
|
376
412
|
}
|
|
@@ -478,19 +514,18 @@ class BridgeServer {
|
|
|
478
514
|
return steps.length > 0 ? `${head} ${steps.join(' ')}` : head;
|
|
479
515
|
}
|
|
480
516
|
/** Every paired browser with how it was named — for chrome_status. */
|
|
481
|
-
pairedProfiles() {
|
|
517
|
+
pairedProfiles(includeChallenge = false) {
|
|
482
518
|
if (this.hub)
|
|
483
|
-
return
|
|
519
|
+
return this.hub.profiles.map(({ approvalChallenge, ...p }) => includeChallenge ? { ...p, approvalChallenge } : p);
|
|
484
520
|
const out = [];
|
|
485
521
|
for (const [name, conn] of this.conns) {
|
|
486
522
|
if (!conn.isOpen())
|
|
487
523
|
continue;
|
|
488
|
-
const activeUrl = conn.lastActiveUrl(Number.POSITIVE_INFINITY) ?? undefined;
|
|
489
524
|
const o = this.origins.get(name);
|
|
490
525
|
out.push({
|
|
491
526
|
name,
|
|
492
527
|
naming: o?.naming ?? 'legacy',
|
|
493
|
-
...(
|
|
528
|
+
...(includeChallenge && conn.approvalChallenge ? { approvalChallenge: conn.approvalChallenge } : {}),
|
|
494
529
|
...(o?.extVersion ? { extVersion: o.extVersion } : {}),
|
|
495
530
|
...(o?.extId || o?.install ? { extSource: extensionSource(o) } : {}),
|
|
496
531
|
});
|
|
@@ -503,8 +538,18 @@ class BridgeServer {
|
|
|
503
538
|
* in place, so it keeps working without re-pairing.
|
|
504
539
|
*/
|
|
505
540
|
async renameProfile(from, to) {
|
|
506
|
-
if (this.hub)
|
|
507
|
-
|
|
541
|
+
if (this.hub) {
|
|
542
|
+
const challenge = this.hub.profiles.find((p) => p.name === routeKey(from))?.approvalChallenge;
|
|
543
|
+
if (!challenge)
|
|
544
|
+
throw new types_1.ExecutorError('POLICY_DENIED', 'Connect an updated browser before renaming it.');
|
|
545
|
+
const intent = { v: protocol_1.PROTOCOL_VERSION, method: 'ping_probe', params: { requireClientApproval: true, renameProfile: { from, to } }, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe') };
|
|
546
|
+
const authorization = ((0, client_identity_1.currentClientIdentity)() ?? this.clientIdentity).sign(intent, challenge);
|
|
547
|
+
return String(await this.hub.rename(from, to, authorization));
|
|
548
|
+
}
|
|
549
|
+
await this.sendCommand('ping_probe', { requireClientApproval: true, renameProfile: { from, to } }, { profile: from });
|
|
550
|
+
return this.renameApprovedProfile(from, to);
|
|
551
|
+
}
|
|
552
|
+
async renameApprovedProfile(from, to) {
|
|
508
553
|
const src = routeKey(from);
|
|
509
554
|
const live = this.conns.get(src);
|
|
510
555
|
if (live?.isOpen() && this.origins.get(src)?.naming === 'options') {
|
|
@@ -600,6 +645,7 @@ class BridgeServer {
|
|
|
600
645
|
this.promote(ws, frame.ext ?? { id: 'unknown', version: '0', chrome: '0' }, profile, Array.isArray(frame.caps) ? frame.caps : undefined, {
|
|
601
646
|
naming,
|
|
602
647
|
installId,
|
|
648
|
+
approvalChallenge: typeof frame.approvalChallenge === 'string' && /^[a-f0-9-]{36}$/.test(frame.approvalChallenge) ? frame.approvalChallenge : undefined,
|
|
603
649
|
...(typeof frame.ext?.version === 'string' ? { extVersion: frame.ext.version.slice(0, 32) } : {}),
|
|
604
650
|
...(typeof frame.ext?.id === 'string' ? { extId: frame.ext.id.slice(0, 64) } : {}),
|
|
605
651
|
...(typeof frame.install === 'string' ? { install: frame.install.slice(0, 32) } : {}),
|
|
@@ -634,7 +680,7 @@ class BridgeServer {
|
|
|
634
680
|
this.peers.add(ws);
|
|
635
681
|
this.log(`mcp-browser-extension pid ${pid ?? '?'} joined as a peer (${this.peers.size} sharing these browsers)`);
|
|
636
682
|
this.sendPeer(ws, { type: 'peer_welcome', hubPid: process.pid });
|
|
637
|
-
this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles() });
|
|
683
|
+
this.sendPeer(ws, { type: 'peer_state', profiles: this.pairedProfiles(true) });
|
|
638
684
|
ws.on('message', (raw) => this.handlePeerFrame(ws, raw.toString()));
|
|
639
685
|
ws.on('close', () => this.peers.delete(ws));
|
|
640
686
|
ws.on('error', () => {
|
|
@@ -653,9 +699,9 @@ class BridgeServer {
|
|
|
653
699
|
work.then((data) => this.sendPeer(ws, { type: 'relay_result', id, ok: true, data }), (err) => this.sendPeer(ws, (0, peer_1.relayError)(id, err)));
|
|
654
700
|
};
|
|
655
701
|
if (frame.type === 'relay')
|
|
656
|
-
answer(frame.id, this.
|
|
702
|
+
answer(frame.id, this.forwardCommand(frame.method, frame.params ?? {}, frame.opts));
|
|
657
703
|
else if (frame.type === 'peer_rename')
|
|
658
|
-
answer(frame.id, this.renameProfile(frame.from, frame.to));
|
|
704
|
+
answer(frame.id, this.forwardCommand('ping_probe', { requireClientApproval: true, renameProfile: { from: frame.from, to: frame.to } }, { profile: frame.from, timeoutMs: (0, connection_1.defaultTimeoutFor)('ping_probe'), authorization: frame.authorization }).then(() => this.renameApprovedProfile(frame.from, frame.to)));
|
|
659
705
|
else if (frame.type === 'peer_blocked' && typeof frame.host === 'string')
|
|
660
706
|
this.noteBlocked(frame.host, String(frame.method ?? ''));
|
|
661
707
|
}
|
|
@@ -663,7 +709,7 @@ class BridgeServer {
|
|
|
663
709
|
broadcastState() {
|
|
664
710
|
if (this.peers.size === 0)
|
|
665
711
|
return;
|
|
666
|
-
const state = { type: 'peer_state', profiles: this.pairedProfiles() };
|
|
712
|
+
const state = { type: 'peer_state', profiles: this.pairedProfiles(true) };
|
|
667
713
|
for (const peer of this.peers)
|
|
668
714
|
this.sendPeer(peer, state);
|
|
669
715
|
}
|
|
@@ -709,6 +755,7 @@ class BridgeServer {
|
|
|
709
755
|
sessionId,
|
|
710
756
|
heartbeatMs: this.heartbeatMs,
|
|
711
757
|
caps,
|
|
758
|
+
approvalChallenge: origin.approvalChallenge,
|
|
712
759
|
onEvent: this.opts.onEvent,
|
|
713
760
|
onSiteGrant: (host, allow) => this.applySiteGrant(host, allow),
|
|
714
761
|
onLog: (m) => this.log(m),
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Grants only ever ADD to the flags' list, and only grants can be removed again:
|
|
14
14
|
* a site that came from `--allow-domain` stays until the flags change. A grant
|
|
15
|
-
* is always one concrete host (or `*.host`), never
|
|
16
|
-
* every site stays a deliberate flag (`--unsafe-all-domains`).
|
|
15
|
+
* is always one concrete host (or `*.host`), never an all-site catch-all.
|
|
17
16
|
*/
|
|
18
17
|
export declare const GRANTS_FILE = "allowed-sites.json";
|
|
19
18
|
/**
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Grants only ever ADD to the flags' list, and only grants can be removed again:
|
|
15
15
|
* a site that came from `--allow-domain` stays until the flags change. A grant
|
|
16
|
-
* is always one concrete host (or `*.host`), never
|
|
17
|
-
* every site stays a deliberate flag (`--unsafe-all-domains`).
|
|
16
|
+
* is always one concrete host (or `*.host`), never an all-site catch-all.
|
|
18
17
|
*/
|
|
19
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
19
|
exports.SiteGrants = exports.GRANTS_FILE = void 0;
|
|
@@ -59,7 +58,9 @@ class SiteGrants {
|
|
|
59
58
|
constructor(allowDomains, dataDir) {
|
|
60
59
|
this.allowDomains = allowDomains;
|
|
61
60
|
this.dataDir = dataDir;
|
|
62
|
-
|
|
61
|
+
const namedSites = allowDomains.map(policy_1.normalizeDomainPattern).filter(Boolean);
|
|
62
|
+
allowDomains.splice(0, allowDomains.length, ...namedSites);
|
|
63
|
+
this.base = new Set(namedSites);
|
|
63
64
|
for (const site of this.load())
|
|
64
65
|
this.add(site);
|
|
65
66
|
}
|
package/dist/src/cli.js
CHANGED
|
@@ -400,6 +400,7 @@ async function main() {
|
|
|
400
400
|
userDataDir: workspace.profileDir,
|
|
401
401
|
downloadDir: workspace.downloadDir,
|
|
402
402
|
headless: cfg.headless,
|
|
403
|
+
policy: (0, policy_1.toWirePolicy)(cfg.policy),
|
|
403
404
|
},
|
|
404
405
|
}),
|
|
405
406
|
});
|
|
@@ -526,9 +527,6 @@ async function serveRemote(cfg, dataDir, stopBridge) {
|
|
|
526
527
|
'Anyone with this URL can drive this Chrome within the allowed sites. Keep it private.',
|
|
527
528
|
'Cut everyone off: restart with --rotate-remote-key.',
|
|
528
529
|
...(tunnel ? ['The tunnel URL changes every time this starts.'] : []),
|
|
529
|
-
...(cfg.policy.allowDomains.includes('*')
|
|
530
|
-
? ['', 'WARNING: --unsafe-all-domains is on, so remote clients can reach every site you are signed in to.']
|
|
531
|
-
: []),
|
|
532
530
|
'',
|
|
533
531
|
];
|
|
534
532
|
process.stdout.write(`${lines.join('\n')}\n`);
|
package/dist/src/config.js
CHANGED
|
@@ -20,7 +20,8 @@ const node_os_1 = require("node:os");
|
|
|
20
20
|
const node_path_1 = require("node:path");
|
|
21
21
|
const node_fs_1 = require("node:fs");
|
|
22
22
|
const protocol_1 = require("../shared/protocol");
|
|
23
|
-
const policy_1 = require("
|
|
23
|
+
const policy_1 = require("../shared/policy");
|
|
24
|
+
const policy_2 = require("./security/policy");
|
|
24
25
|
const compat_1 = require("./compat");
|
|
25
26
|
/** Local port for `--http` (one above the bridge's). */
|
|
26
27
|
exports.DEFAULT_HTTP_PORT = 38018;
|
|
@@ -131,8 +132,7 @@ function parseArgs(argv) {
|
|
|
131
132
|
policyFile = readPolicyFile(requireValue(argv[++i], '--policy'));
|
|
132
133
|
break;
|
|
133
134
|
case '--unsafe-all-domains':
|
|
134
|
-
|
|
135
|
-
break;
|
|
135
|
+
throw new Error('All-site access is no longer supported. Remove --unsafe-all-domains from your MCP configuration and approve websites in the Chrome extension popup.');
|
|
136
136
|
case '--allow-domain':
|
|
137
137
|
(policyFlags.allowDomains ??= []).push(requireValue(argv[++i], '--allow-domain'));
|
|
138
138
|
break;
|
|
@@ -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;
|
|
@@ -244,7 +247,13 @@ function parseArgs(argv) {
|
|
|
244
247
|
throw new Error('--no-email-verification cannot be used with --tunnel');
|
|
245
248
|
}
|
|
246
249
|
// File first, then flags win.
|
|
247
|
-
const
|
|
250
|
+
for (const source of [policyFile, policyFlags]) {
|
|
251
|
+
if (source?.allowDomains !== undefined && (!Array.isArray(source.allowDomains) ||
|
|
252
|
+
source.allowDomains.some((domain) => typeof domain !== 'string' || !(0, policy_1.normalizeDomainPattern)(domain)))) {
|
|
253
|
+
throw new Error('Each allowed site must name a website. All-site access is no longer supported; approve websites in the Chrome extension popup.');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const policy = (0, policy_2.resolvePolicy)({ ...policyFile, ...policyFlags });
|
|
248
257
|
// Uploads must be confined to a directory — refuse to start with uploads enabled
|
|
249
258
|
// but no dir, rather than silently allow unrestricted local-file access.
|
|
250
259
|
if (policy.allowUploads && !policy.uploadsDir) {
|
|
@@ -408,7 +417,6 @@ Security (default: deny-all safe mode):
|
|
|
408
417
|
to <data-dir>/allowed-sites.json; no restart needed)
|
|
409
418
|
--no-site-grants Ignore sites allowed from the extension's Options page; the
|
|
410
419
|
allowlist is exactly what these flags and --policy say
|
|
411
|
-
--unsafe-all-domains Allow every domain (loud footgun)
|
|
412
420
|
--enable-mutations Enable click/type/navigate/… (off by default)
|
|
413
421
|
--unsafe-enable-eval Enable the eval primitive (off by default)
|
|
414
422
|
--enable-downloads Enable download_file (off by default)
|
|
@@ -419,6 +427,9 @@ Security (default: deny-all safe mode):
|
|
|
419
427
|
in-page hook on allowlisted sites that records console
|
|
420
428
|
output, fetch/XHR traffic, and intercepts alert/confirm/
|
|
421
429
|
prompt (off by default: it patches page globals).
|
|
430
|
+
--enable-cookie-values Let get_cookies return cookie values. Off by default:
|
|
431
|
+
a session cookie is the login itself, so get_cookies
|
|
432
|
+
returns names and flags with every value hidden.
|
|
422
433
|
--redact Scrub secret-shaped strings (JWTs, cloud keys, bearer
|
|
423
434
|
tokens, private keys) out of page reads. Password field
|
|
424
435
|
values are always suppressed, with or without this.
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* stealth init on the launch path only, and tab resolution. `tabId`s are stamped
|
|
13
13
|
* `cdp:<sessionId>:<n>` so a handle never mis-routes across a backend switch.
|
|
14
14
|
*/
|
|
15
|
+
import type { WirePolicy } from '../../shared/protocol';
|
|
15
16
|
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
16
17
|
export interface CdpOptions {
|
|
17
18
|
mode: 'connect' | 'launch';
|
|
@@ -19,6 +20,8 @@ export interface CdpOptions {
|
|
|
19
20
|
userDataDir: string;
|
|
20
21
|
headless?: boolean;
|
|
21
22
|
downloadDir?: string;
|
|
23
|
+
/** The same URL/method policy enforced by the MCP tool layer. */
|
|
24
|
+
policy?: WirePolicy;
|
|
22
25
|
}
|
|
23
26
|
export declare class CdpExecutor implements Executor {
|
|
24
27
|
private readonly opts;
|
|
@@ -21,6 +21,7 @@ const node_path_1 = require("node:path");
|
|
|
21
21
|
const node_crypto_1 = require("node:crypto");
|
|
22
22
|
const playwright_1 = require("playwright");
|
|
23
23
|
const download_1 = require("../../shared/download");
|
|
24
|
+
const policy_1 = require("../../shared/policy");
|
|
24
25
|
const snapshot_1 = require("../../shared/snapshot");
|
|
25
26
|
const screenshot_1 = require("../../shared/screenshot");
|
|
26
27
|
const types_1 = require("./types");
|
|
@@ -29,6 +30,7 @@ const STEALTH = `Object.defineProperty(navigator,'webdriver',{get:()=>undefined}
|
|
|
29
30
|
const NON_CONTENT = /^(file|data|devtools|chrome|about):/i;
|
|
30
31
|
/** Playwright errors that mean the page/context/browser died under us → DETACHED. */
|
|
31
32
|
const CLOSED_TARGET = /Target closed|Browser has been closed|Target page, context or browser has been closed|Execution context was destroyed/i;
|
|
33
|
+
const MAX_CDP_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
32
34
|
/** Minimal CSS attribute-value escape for ref selectors (refs are `e\d+`, but be safe). */
|
|
33
35
|
function cssEscape(s) {
|
|
34
36
|
return s.replace(/["\\]/g, '\\$&');
|
|
@@ -587,38 +589,80 @@ class CdpExecutor {
|
|
|
587
589
|
const ctx = await this.getContext();
|
|
588
590
|
const dir = this.opts.downloadDir ?? (0, node_path_1.join)(this.opts.userDataDir, 'downloads');
|
|
589
591
|
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (
|
|
593
|
-
const
|
|
594
|
-
const
|
|
595
|
-
if (!
|
|
596
|
-
throw new types_1.ExecutorError('DOWNLOAD_FAILED',
|
|
592
|
+
let url = args.url ?? '';
|
|
593
|
+
let suggestedName = args.suggestedName;
|
|
594
|
+
if (!url) {
|
|
595
|
+
const p = await this.resolveTab(args.tabId);
|
|
596
|
+
const target = args.target;
|
|
597
|
+
if (!target)
|
|
598
|
+
throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'provide a url or a target');
|
|
599
|
+
const locator = this.locator(p, target);
|
|
600
|
+
const href = await locator.getAttribute('href');
|
|
601
|
+
if (!href)
|
|
602
|
+
throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'the target must be a link with an href');
|
|
603
|
+
const baseUrl = await locator.evaluate((el) => el.ownerDocument.baseURI);
|
|
604
|
+
try {
|
|
605
|
+
url = new URL(href, baseUrl).href;
|
|
597
606
|
}
|
|
598
|
-
|
|
599
|
-
|
|
607
|
+
catch {
|
|
608
|
+
throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'the target has an invalid download URL');
|
|
609
|
+
}
|
|
610
|
+
suggestedName = (await locator.getAttribute('download')) || suggestedName;
|
|
600
611
|
}
|
|
601
|
-
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const [dl] = await Promise.all([p.waitForEvent('download'), this.locator(p, target).click()]);
|
|
607
|
-
let bytes = 0;
|
|
612
|
+
const policy = this.opts.policy ?? { allowDomains: [], allowEval: false, allowDownloads: false, allowUploads: false, allowAllTabs: false, enableMutations: false, allowObservers: false };
|
|
613
|
+
const verdict = (0, policy_1.evaluatePolicy)(url, 'download_file', policy);
|
|
614
|
+
if (!verdict.ok)
|
|
615
|
+
throw new types_1.ExecutorError('POLICY_DENIED', verdict.reason);
|
|
616
|
+
let response;
|
|
608
617
|
try {
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
throw new types_1.ExecutorError('DOWNLOAD_FAILED', `download failed: ${failure}`);
|
|
613
|
-
await dl.saveAs(dest);
|
|
614
|
-
bytes = (0, node_fs_1.statSync)(dest).size;
|
|
618
|
+
// Node fetch has no browser cookie jar; error mode refuses every redirect
|
|
619
|
+
// before any intermediate destination is contacted.
|
|
620
|
+
response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(120_000) });
|
|
615
621
|
}
|
|
616
|
-
catch
|
|
617
|
-
|
|
622
|
+
catch {
|
|
623
|
+
throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'download request failed or redirects are not supported');
|
|
624
|
+
}
|
|
625
|
+
if (!response.ok) {
|
|
626
|
+
await response.body?.cancel().catch(() => undefined);
|
|
627
|
+
throw new types_1.ExecutorError('DOWNLOAD_FAILED', `download returned HTTP ${response.status}`);
|
|
628
|
+
}
|
|
629
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
630
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_CDP_DOWNLOAD_BYTES) {
|
|
631
|
+
await response.body?.cancel().catch(() => undefined);
|
|
632
|
+
throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'download exceeds the 50 MiB limit');
|
|
633
|
+
}
|
|
634
|
+
const chunks = [];
|
|
635
|
+
let totalBytes = 0;
|
|
636
|
+
const reader = response.body?.getReader();
|
|
637
|
+
if (reader) {
|
|
638
|
+
try {
|
|
639
|
+
while (true) {
|
|
640
|
+
const { done, value } = await reader.read();
|
|
641
|
+
if (done)
|
|
642
|
+
break;
|
|
643
|
+
totalBytes += value.byteLength;
|
|
644
|
+
if (totalBytes > MAX_CDP_DOWNLOAD_BYTES) {
|
|
645
|
+
await reader.cancel();
|
|
646
|
+
throw new types_1.ExecutorError('DOWNLOAD_FAILED', 'download exceeds the 50 MiB limit');
|
|
647
|
+
}
|
|
648
|
+
chunks.push(value);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
await reader.cancel().catch(() => undefined);
|
|
618
653
|
throw err;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), totalBytes);
|
|
657
|
+
const safeName = (0, download_1.sanitizeDownloadName)(suggestedName);
|
|
658
|
+
const dest = (0, node_path_1.join)(dir, safeName);
|
|
659
|
+
try {
|
|
660
|
+
(0, node_fs_1.writeFileSync)(dest, body);
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
619
663
|
throw new types_1.ExecutorError('DOWNLOAD_FAILED', `failed to save download: ${err.message}`);
|
|
620
664
|
}
|
|
621
|
-
return { path: dest, backend: this.backend, bytes,
|
|
665
|
+
return { path: dest, backend: this.backend, bytes: body.length, mimeType: response.headers.get('content-type') ?? undefined, suggestedName: safeName };
|
|
622
666
|
}
|
|
623
667
|
async uploadFile(t, files, opts) {
|
|
624
668
|
return this.guard(async () => {
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -36,16 +36,18 @@ 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 open the backup URL again or add another chat email prompt while that form is active. 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
|
+
'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
|
|
40
41
|
'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), do not retry: ' +
|
|
41
42
|
'give the user the steps in its `setup` field, which name what went wrong and how to fix it. ' +
|
|
42
43
|
'If a tool fails with POLICY_DENIED because a site is not allowed, ask the user to allow it from the extension\'s ' +
|
|
43
|
-
'toolbar icon (its popup has an Allow button). It applies at once, with no restart. You cannot approve a site yourself. ' +
|
|
44
|
+
'toolbar icon (its popup has an Allow button). Relay the friendly one-line approval steps from the error and wait for the person. It applies at once, with no restart. You cannot approve a site yourself or enable all-site access. ' +
|
|
44
45
|
"Work in your own tab: tab_new opens one in the background, and calls without a tabId go to it, never to the tab the user is on. " +
|
|
45
46
|
"To act on the user's current page, pass its tabId from tabs_list.";
|
|
46
47
|
const LOCAL_BYPASS_INSTRUCTIONS = 'Email verification is disabled by the local operator for this server. Only trusted local MCP clients should connect; the browser-domain allowlist and other capability safety gates remain active. ' +
|
|
48
|
+
'When Chrome approval is required, show the app name and key fingerprint and ask the person to review App connections in the extension popup or Settings. Approval lasts eight hours. Never approve a client yourself; YOLO and email-verification bypass do not bypass Chrome approval. ' +
|
|
47
49
|
'Start with chrome_status. If it reports no paired browser (or any tool fails with NO_BACKEND), give the user the steps in its `setup` field. ' +
|
|
48
|
-
'If a tool fails with POLICY_DENIED because a site is not allowed,
|
|
50
|
+
'If a tool fails with POLICY_DENIED because a site is not allowed, relay its friendly one-line approval steps and wait for the person to allow it from the extension\'s toolbar icon. Never approve it yourself or enable all-site access. ' +
|
|
49
51
|
"Work in your own tab: tab_new opens one in the background. To act on the user's current page, pass its tabId from tabs_list.";
|
|
50
52
|
/** Default version reported when no explicit version is passed in (legacy callers/tests). */
|
|
51
53
|
const DEFAULT_VERSION = SERVER_VERSION;
|
package/dist/src/mcp/tools.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* src/mcp/tools.ts — the MCP tool surface: the advertised catalog
|
|
3
|
-
* (`TOOL_DEFINITIONS`, each with a zod `inputSchema`), the name→handler dispatch
|
|
4
|
-
* (`TOOL_HANDLERS`), the never-throw firewall (`dispatchToolCall`), and
|
|
5
|
-
* `registerTools()` which registers every tool on an `McpServer` via
|
|
6
|
-
* `registerTool` — the SDK validates the zod schema before dispatch runs.
|
|
7
|
-
*
|
|
8
|
-
* Each handler: validate args → **policy-gate against the relevant URL** → call
|
|
9
|
-
* the active Executor (or a server-side helper) → serialize via an envelope.
|
|
10
|
-
* Nothing here throws to the transport: `dispatchToolCall` renders any thrown
|
|
11
|
-
* `Error` as an `isError` result.
|
|
12
|
-
*/
|
|
13
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
14
2
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
15
3
|
import { z } from 'zod';
|