@mehmoodqureshi/chrome-mcp 0.9.6 → 0.9.8
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 +25 -0
- package/dist/shared/fill-form.d.ts +41 -0
- package/dist/shared/fill-form.js +55 -0
- package/dist/shared/policy.js +1 -0
- package/dist/shared/protocol.d.ts +29 -4
- package/dist/shared/protocol.js +12 -4
- package/dist/src/bridge/connection.d.ts +8 -0
- package/dist/src/bridge/connection.js +10 -2
- package/dist/src/bridge/server.d.ts +3 -0
- package/dist/src/bridge/server.js +8 -0
- package/dist/src/cli.js +11 -1
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +9 -0
- package/dist/src/executor/extension-executor.d.ts +6 -1
- package/dist/src/executor/extension-executor.js +15 -0
- package/dist/src/executor/types.d.ts +13 -1
- package/dist/src/mcp/helpers.d.ts +8 -2
- package/dist/src/mcp/helpers.js +22 -12
- package/dist/src/mcp/tools.js +3 -0
- package/dist/src/telemetry.d.ts +53 -0
- package/dist/src/telemetry.js +207 -0
- package/extension-dist/background.js +56 -3
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -520,6 +520,31 @@ group/other-accessible. Windows has no such bits — `chmod` there only toggles
|
|
|
520
520
|
read-only attribute — so the check is skipped and the token's confidentiality
|
|
521
521
|
rests on the per-user ACL of `%USERPROFILE%\.chrome-mcp`.
|
|
522
522
|
|
|
523
|
+
## Telemetry
|
|
524
|
+
|
|
525
|
+
The chrome-mcp **server** sends anonymous usage statistics to PostHog, so the
|
|
526
|
+
project can see how many installs are active, which versions and platforms are
|
|
527
|
+
in use, and which tools fail most. A notice is printed the first time it runs.
|
|
528
|
+
|
|
529
|
+
What is sent: a random install id (kept in `~/.chrome-mcp/telemetry.json`), the
|
|
530
|
+
chrome-mcp version, OS, CPU architecture and Node major version, whether the
|
|
531
|
+
session owns the bridge port or shares it, how many browsers are paired, and
|
|
532
|
+
per-tool call and error **counts** with error codes — batched every 10 minutes.
|
|
533
|
+
|
|
534
|
+
What is never sent: URLs, domains, tool arguments, page content, screenshots,
|
|
535
|
+
cookies, profile names, tokens, file paths, or anything you type. Events are
|
|
536
|
+
personless and GeoIP lookup is disabled.
|
|
537
|
+
|
|
538
|
+
The **browser extension sends nothing** — it only ever talks to `127.0.0.1`.
|
|
539
|
+
|
|
540
|
+
Turn it off with any of:
|
|
541
|
+
|
|
542
|
+
```bash
|
|
543
|
+
CHROME_MCP_TELEMETRY=0 # or false / off
|
|
544
|
+
DO_NOT_TRACK=1
|
|
545
|
+
--no-telemetry # server flag
|
|
546
|
+
```
|
|
547
|
+
|
|
523
548
|
## Develop
|
|
524
549
|
|
|
525
550
|
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sequencing behind a batched `fill_form`.
|
|
3
|
+
*
|
|
4
|
+
* Batching a form into ONE wire command removes a round-trip per field, but it
|
|
5
|
+
* also removes the router's per-command policy gate: the router evaluates once,
|
|
6
|
+
* before the first field, and then N writes happen with nothing re-checking
|
|
7
|
+
* where they land. That matters because a field's input handler — or a
|
|
8
|
+
* checkbox's click — can navigate the tab, and the values still to be written
|
|
9
|
+
* are exactly the sensitive ones (passwords, personal data). Typing them into
|
|
10
|
+
* whatever page replaced the allowlisted one would be a silent exfiltration.
|
|
11
|
+
*
|
|
12
|
+
* So the gate moves in here and runs per field, against the tab's CURRENT url,
|
|
13
|
+
* reproducing what N separate commands would have enforced. This lives in
|
|
14
|
+
* `shared/` for the same reason `evaluatePolicy` does: it is a security
|
|
15
|
+
* decision, so it belongs where both ends and the test suite can reach it
|
|
16
|
+
* (`extension/` is outside the tsconfig the tests compile).
|
|
17
|
+
*/
|
|
18
|
+
import type { FillFieldOp, FillFormWireResult, WirePolicy } from './protocol';
|
|
19
|
+
/** What the caller must supply; everything chrome-specific stays outside. */
|
|
20
|
+
export interface FillFormHooks {
|
|
21
|
+
/** The target tab's url right now — re-read before every field. */
|
|
22
|
+
currentUrl: () => Promise<string>;
|
|
23
|
+
/** The live wire policy, or null before a handshake has delivered one. */
|
|
24
|
+
policy: () => WirePolicy | null;
|
|
25
|
+
/** Perform one field write. Throws to fail the batch at this field. */
|
|
26
|
+
write: (op: FillFieldOp) => Promise<void>;
|
|
27
|
+
/** Map a thrown value to a wire error. */
|
|
28
|
+
toError: (err: unknown) => {
|
|
29
|
+
code: FillFormError['code'];
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
type FillFormError = NonNullable<FillFormWireResult['error']>;
|
|
34
|
+
/**
|
|
35
|
+
* Run `ops` in order, re-gating before each one, stopping at the first failure.
|
|
36
|
+
* Never throws: the failing field is reported in `error` and `filled` counts the
|
|
37
|
+
* writes that actually landed, so the server can tell the caller how far the
|
|
38
|
+
* form got rather than leaving it ambiguous.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runFillFields(ops: readonly FillFieldOp[], hooks: FillFormHooks): Promise<FillFormWireResult>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The sequencing behind a batched `fill_form`.
|
|
4
|
+
*
|
|
5
|
+
* Batching a form into ONE wire command removes a round-trip per field, but it
|
|
6
|
+
* also removes the router's per-command policy gate: the router evaluates once,
|
|
7
|
+
* before the first field, and then N writes happen with nothing re-checking
|
|
8
|
+
* where they land. That matters because a field's input handler — or a
|
|
9
|
+
* checkbox's click — can navigate the tab, and the values still to be written
|
|
10
|
+
* are exactly the sensitive ones (passwords, personal data). Typing them into
|
|
11
|
+
* whatever page replaced the allowlisted one would be a silent exfiltration.
|
|
12
|
+
*
|
|
13
|
+
* So the gate moves in here and runs per field, against the tab's CURRENT url,
|
|
14
|
+
* reproducing what N separate commands would have enforced. This lives in
|
|
15
|
+
* `shared/` for the same reason `evaluatePolicy` does: it is a security
|
|
16
|
+
* decision, so it belongs where both ends and the test suite can reach it
|
|
17
|
+
* (`extension/` is outside the tsconfig the tests compile).
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.runFillFields = runFillFields;
|
|
21
|
+
const policy_1 = require("./policy");
|
|
22
|
+
/**
|
|
23
|
+
* Run `ops` in order, re-gating before each one, stopping at the first failure.
|
|
24
|
+
* Never throws: the failing field is reported in `error` and `filled` counts the
|
|
25
|
+
* writes that actually landed, so the server can tell the caller how far the
|
|
26
|
+
* form got rather than leaving it ambiguous.
|
|
27
|
+
*/
|
|
28
|
+
async function runFillFields(ops, hooks) {
|
|
29
|
+
const res = { filled: 0 };
|
|
30
|
+
for (const op of ops) {
|
|
31
|
+
try {
|
|
32
|
+
// Fails CLOSED, exactly as the router does: no policy means nothing runs.
|
|
33
|
+
const policy = hooks.policy();
|
|
34
|
+
if (!policy)
|
|
35
|
+
throw new PolicyStop('no policy is in force; refusing to fill');
|
|
36
|
+
const verdict = (0, policy_1.evaluatePolicy)(await hooks.currentUrl(), 'fill_form', policy);
|
|
37
|
+
if (!verdict.ok)
|
|
38
|
+
throw new PolicyStop(verdict.reason);
|
|
39
|
+
await hooks.write(op);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
res.error =
|
|
43
|
+
err instanceof PolicyStop
|
|
44
|
+
? { selector: op.selector, code: 'POLICY_DENIED', message: err.message }
|
|
45
|
+
: { selector: op.selector, ...hooks.toError(err) };
|
|
46
|
+
return res;
|
|
47
|
+
}
|
|
48
|
+
res.filled++;
|
|
49
|
+
}
|
|
50
|
+
return res;
|
|
51
|
+
}
|
|
52
|
+
/** A refusal raised by the gate itself, kept distinct from a page-op failure. */
|
|
53
|
+
class PolicyStop extends Error {
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=fill-form.js.map
|
package/dist/shared/policy.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The server is the WebSocket SERVER; the extension is the single privileged
|
|
10
10
|
* CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
|
|
11
|
-
* Helpers (extract_links / read_as_markdown
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Helpers (extract_links / read_as_markdown) are NOT on the wire — they are
|
|
12
|
+
* composed server-side from these primitives. `download_file` is a wire method
|
|
13
|
+
* beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
|
|
14
|
+
* in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
|
|
14
15
|
*/
|
|
15
16
|
/** Bumped on any breaking change to the frames below. */
|
|
16
17
|
export declare const PROTOCOL_VERSION: 1;
|
|
@@ -39,12 +40,36 @@ export declare const CLOSE_SUPERSEDED: 4000;
|
|
|
39
40
|
* every call. Without it, the server falls back to fetching the URL itself.
|
|
40
41
|
*/
|
|
41
42
|
export declare const WIRE_CAP_TAB_URL: "tab-url";
|
|
43
|
+
/**
|
|
44
|
+
* `fill-form`: this extension handles the `fill_form` wire method — every field
|
|
45
|
+
* of a form written in ONE round-trip. Without it the server fills field by
|
|
46
|
+
* field over `type`/`click`, which every extension build understands.
|
|
47
|
+
*/
|
|
48
|
+
export declare const WIRE_CAP_FILL_FORM: "fill-form";
|
|
49
|
+
/** One field write inside a `fill_form` command: set a value, or click to toggle. */
|
|
50
|
+
export interface FillFieldOp {
|
|
51
|
+
selector: string;
|
|
52
|
+
/** A string is value-set (cleared first); a boolean toggles via a click. */
|
|
53
|
+
value: string | boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `fill_form` result. Ops run in order and stop at the first failure, so
|
|
57
|
+
* `filled` counts the fields that landed and `error` names the one that did not.
|
|
58
|
+
*/
|
|
59
|
+
export interface FillFormWireResult {
|
|
60
|
+
filled: number;
|
|
61
|
+
error?: {
|
|
62
|
+
selector: string;
|
|
63
|
+
code: ExecutorErrorCode;
|
|
64
|
+
message: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
42
67
|
/**
|
|
43
68
|
* Every method that may travel on the wire = the MCP primitives 1:1, plus
|
|
44
69
|
* `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
|
|
45
70
|
* responsiveness check used to detect a dead-but-not-yet-reconnected worker).
|
|
46
71
|
*/
|
|
47
|
-
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'ping_probe';
|
|
72
|
+
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'fill_form' | 'ping_probe';
|
|
48
73
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
49
74
|
export declare const WIRE_METHODS: readonly WireMethod[];
|
|
50
75
|
export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNKNOWN_METHOD';
|
package/dist/shared/protocol.js
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The server is the WebSocket SERVER; the extension is the single privileged
|
|
11
11
|
* CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
|
|
12
|
-
* Helpers (extract_links / read_as_markdown
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Helpers (extract_links / read_as_markdown) are NOT on the wire — they are
|
|
13
|
+
* composed server-side from these primitives. `download_file` is a wire method
|
|
14
|
+
* beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
|
|
15
|
+
* in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
|
|
15
16
|
*/
|
|
16
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.WIRE_METHODS = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
18
|
+
exports.WIRE_METHODS = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
18
19
|
/** Bumped on any breaking change to the frames below. */
|
|
19
20
|
exports.PROTOCOL_VERSION = 1;
|
|
20
21
|
/**
|
|
@@ -41,6 +42,12 @@ exports.CLOSE_SUPERSEDED = 4000;
|
|
|
41
42
|
* every call. Without it, the server falls back to fetching the URL itself.
|
|
42
43
|
*/
|
|
43
44
|
exports.WIRE_CAP_TAB_URL = 'tab-url';
|
|
45
|
+
/**
|
|
46
|
+
* `fill-form`: this extension handles the `fill_form` wire method — every field
|
|
47
|
+
* of a form written in ONE round-trip. Without it the server fills field by
|
|
48
|
+
* field over `type`/`click`, which every extension build understands.
|
|
49
|
+
*/
|
|
50
|
+
exports.WIRE_CAP_FILL_FORM = 'fill-form';
|
|
44
51
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
45
52
|
exports.WIRE_METHODS = [
|
|
46
53
|
'tabs_list',
|
|
@@ -70,6 +77,7 @@ exports.WIRE_METHODS = [
|
|
|
70
77
|
'frames_list',
|
|
71
78
|
'observers',
|
|
72
79
|
'print_pdf',
|
|
80
|
+
'fill_form',
|
|
73
81
|
'ping_probe',
|
|
74
82
|
];
|
|
75
83
|
//# sourceMappingURL=protocol.js.map
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { WebSocket } from 'ws';
|
|
14
14
|
import { type WireEvent, type WireMethod } from '../../shared/protocol';
|
|
15
|
+
import { type ExecutorErrorCodeLocal } from '../executor/types';
|
|
16
|
+
/** Map a wire error code onto a local ExecutorError code (the wire enum is a
|
|
17
|
+
* near-superset; unknown codes degrade to CDP_ERROR while keeping the message). */
|
|
18
|
+
export declare function mapWireErrorCode(code: string): ExecutorErrorCodeLocal;
|
|
15
19
|
export interface ConnectionDeps {
|
|
16
20
|
ws: WebSocket;
|
|
17
21
|
extId: string;
|
|
@@ -34,6 +38,8 @@ export declare class ExtensionConnection {
|
|
|
34
38
|
private missedPongs;
|
|
35
39
|
/** Whether this extension reports `tabUrl` and gates fail-closed. */
|
|
36
40
|
private readonly reportsTabUrl;
|
|
41
|
+
/** Every capability the extension advertised in `hello`. */
|
|
42
|
+
private readonly caps;
|
|
37
43
|
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
38
44
|
private activeUrl;
|
|
39
45
|
/** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
|
|
@@ -69,6 +75,8 @@ export declare class ExtensionConnection {
|
|
|
69
75
|
* to forget what we knew, never to keep believing it.
|
|
70
76
|
*/
|
|
71
77
|
private rememberActiveUrl;
|
|
78
|
+
/** Whether the extension advertised `cap` in its `hello`. */
|
|
79
|
+
hasCap(cap: string): boolean;
|
|
72
80
|
/**
|
|
73
81
|
* The active tab's last reported URL if it is younger than `maxAgeMs`, else
|
|
74
82
|
* null — the caller then resolves it the slow way. Never returns a guess.
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.ExtensionConnection = void 0;
|
|
16
|
+
exports.mapWireErrorCode = mapWireErrorCode;
|
|
16
17
|
const protocol_1 = require("../../shared/protocol");
|
|
17
18
|
const types_1 = require("../executor/types");
|
|
18
19
|
const MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
19
|
-
const LONG_METHODS = new Set(['screenshot', 'wait_for', 'navigate', 'download_file']);
|
|
20
|
+
const LONG_METHODS = new Set(['screenshot', 'wait_for', 'navigate', 'download_file', 'fill_form']);
|
|
20
21
|
function defaultTimeoutFor(method) {
|
|
21
22
|
return LONG_METHODS.has(method) ? 60_000 : 30_000;
|
|
22
23
|
}
|
|
@@ -51,6 +52,8 @@ class ExtensionConnection {
|
|
|
51
52
|
missedPongs = 0;
|
|
52
53
|
/** Whether this extension reports `tabUrl` and gates fail-closed. */
|
|
53
54
|
reportsTabUrl;
|
|
55
|
+
/** Every capability the extension advertised in `hello`. */
|
|
56
|
+
caps;
|
|
54
57
|
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
55
58
|
activeUrl = null;
|
|
56
59
|
/** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
|
|
@@ -64,7 +67,8 @@ class ExtensionConnection {
|
|
|
64
67
|
this.ws = deps.ws;
|
|
65
68
|
this.extId = deps.extId;
|
|
66
69
|
this.sessionId = deps.sessionId;
|
|
67
|
-
this.
|
|
70
|
+
this.caps = new Set(deps.caps ?? []);
|
|
71
|
+
this.reportsTabUrl = this.caps.has(protocol_1.WIRE_CAP_TAB_URL);
|
|
68
72
|
this.onEvent = deps.onEvent;
|
|
69
73
|
this.onClose = deps.onClose;
|
|
70
74
|
this.onLog = deps.onLog;
|
|
@@ -237,6 +241,10 @@ class ExtensionConnection {
|
|
|
237
241
|
return; // already invalidated; re-caching would race
|
|
238
242
|
this.activeUrl = frame.tabUrl ? { url: frame.tabUrl, at: Date.now() } : null;
|
|
239
243
|
}
|
|
244
|
+
/** Whether the extension advertised `cap` in its `hello`. */
|
|
245
|
+
hasCap(cap) {
|
|
246
|
+
return this.caps.has(cap);
|
|
247
|
+
}
|
|
240
248
|
/**
|
|
241
249
|
* The active tab's last reported URL if it is younger than `maxAgeMs`, else
|
|
242
250
|
* null — the caller then resolves it the slow way. Never returns a guess.
|
|
@@ -122,6 +122,9 @@ export declare class BridgeServer {
|
|
|
122
122
|
timeoutMs?: number;
|
|
123
123
|
profile?: string;
|
|
124
124
|
}): Promise<unknown>;
|
|
125
|
+
/** Whether the browser paired as `profile` advertised capability `cap`. A peer
|
|
126
|
+
* cannot see the hub's handshake, so it answers false (the conservative path). */
|
|
127
|
+
hasCap(profile: string | undefined, cap: string): boolean;
|
|
125
128
|
/**
|
|
126
129
|
* The active tab's URL for `profile` as last reported by the extension, if it
|
|
127
130
|
* is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
|
|
@@ -296,6 +296,14 @@ class BridgeServer {
|
|
|
296
296
|
}
|
|
297
297
|
return conn.sendCommand(method, params, opts);
|
|
298
298
|
}
|
|
299
|
+
/** Whether the browser paired as `profile` advertised capability `cap`. A peer
|
|
300
|
+
* cannot see the hub's handshake, so it answers false (the conservative path). */
|
|
301
|
+
hasCap(profile, cap) {
|
|
302
|
+
if (this.hub)
|
|
303
|
+
return false;
|
|
304
|
+
const conn = this.conns.get(routeKey(profile));
|
|
305
|
+
return !!conn?.isOpen() && conn.hasCap(cap);
|
|
306
|
+
}
|
|
299
307
|
/**
|
|
300
308
|
* The active tab's URL for `profile` as last reported by the extension, if it
|
|
301
309
|
* is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
|
package/dist/src/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ const workspace_1 = require("./bridge/workspace");
|
|
|
22
22
|
const auth_1 = require("./bridge/auth");
|
|
23
23
|
const server_2 = require("./mcp/server");
|
|
24
24
|
const tools_1 = require("./mcp/tools");
|
|
25
|
+
const telemetry_1 = require("./telemetry");
|
|
25
26
|
const extension_install_1 = require("./extension-install");
|
|
26
27
|
/** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
|
|
27
28
|
const SHUTDOWN_DEADLINE_MS = 3000;
|
|
@@ -242,6 +243,13 @@ async function main() {
|
|
|
242
243
|
};
|
|
243
244
|
const port = await bridge.start();
|
|
244
245
|
(0, tools_1.setProfileBridge)(bridge);
|
|
246
|
+
(0, telemetry_1.initTelemetry)({
|
|
247
|
+
dataDir,
|
|
248
|
+
version: version(),
|
|
249
|
+
disabledByFlag: cfg.noTelemetry,
|
|
250
|
+
log: (m) => (0, server_2.logErr)(m),
|
|
251
|
+
context: () => ({ role: bridge.role, browsers: bridge.connectedProfiles().length }),
|
|
252
|
+
});
|
|
245
253
|
// Never includes the pairing token — only the resolved, non-secret config.
|
|
246
254
|
(0, server_2.logDebug)(`resolved config: ${JSON.stringify({
|
|
247
255
|
wsPort: port,
|
|
@@ -309,7 +317,9 @@ async function main() {
|
|
|
309
317
|
return;
|
|
310
318
|
shuttingDown = true;
|
|
311
319
|
cleanup();
|
|
312
|
-
|
|
320
|
+
// Telemetry first: its final summary reads the bridge's role and paired
|
|
321
|
+
// browsers, which bridge.stop() clears synchronously.
|
|
322
|
+
exitWithDeadline(Promise.allSettled([(0, telemetry_1.stopTelemetry)(), (0, server_2.stopMcpServer)(), bridge.stop()]));
|
|
313
323
|
};
|
|
314
324
|
process.on('SIGINT', shutdown);
|
|
315
325
|
process.on('SIGTERM', shutdown);
|
package/dist/src/config.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface CliConfig {
|
|
|
34
34
|
showExtensionPath: boolean;
|
|
35
35
|
/** `--persist-token`: reuse a stable on-disk token so the extension never re-pairs. */
|
|
36
36
|
persistToken: boolean;
|
|
37
|
+
/** `--no-telemetry`: send no anonymous usage statistics (see src/telemetry.ts). */
|
|
38
|
+
noTelemetry: boolean;
|
|
37
39
|
/**
|
|
38
40
|
* `--tools`: advertise only these tools. `undefined` = the whole catalog.
|
|
39
41
|
* Names are validated against the catalog by `setToolAllowlist` at startup —
|
package/dist/src/config.js
CHANGED
|
@@ -78,6 +78,7 @@ function parseArgs(argv) {
|
|
|
78
78
|
let headless = false;
|
|
79
79
|
let printPairing = false;
|
|
80
80
|
let persistToken = false;
|
|
81
|
+
let noTelemetry = false;
|
|
81
82
|
let showHelp = false;
|
|
82
83
|
let showVersion = false;
|
|
83
84
|
let showExtensionPath = false;
|
|
@@ -178,6 +179,9 @@ function parseArgs(argv) {
|
|
|
178
179
|
case '--persist-token':
|
|
179
180
|
persistToken = true;
|
|
180
181
|
break;
|
|
182
|
+
case '--no-telemetry':
|
|
183
|
+
noTelemetry = true;
|
|
184
|
+
break;
|
|
181
185
|
case '--tools':
|
|
182
186
|
toolsFlagSeen = true;
|
|
183
187
|
for (const name of splitList(requireValue(argv[++i], '--tools')))
|
|
@@ -234,6 +238,7 @@ function parseArgs(argv) {
|
|
|
234
238
|
headless,
|
|
235
239
|
printPairing,
|
|
236
240
|
persistToken,
|
|
241
|
+
noTelemetry,
|
|
237
242
|
showHelp,
|
|
238
243
|
showVersion,
|
|
239
244
|
showExtensionPath,
|
|
@@ -309,6 +314,10 @@ Connection:
|
|
|
309
314
|
--persist-token Reuse a stable on-disk token across restarts so the
|
|
310
315
|
extension never has to re-pair (default: fresh per boot).
|
|
311
316
|
CHROME_MCP_TOKEN env, if set, pins the token explicitly.
|
|
317
|
+
--no-telemetry Send no anonymous usage statistics. Same as
|
|
318
|
+
CHROME_MCP_TELEMETRY=0 or DO_NOT_TRACK=1. Only counts are
|
|
319
|
+
ever sent (version, OS, tool calls, error codes) — never
|
|
320
|
+
URLs, page content or arguments.
|
|
312
321
|
|
|
313
322
|
Backend:
|
|
314
323
|
This build is EXTENSION-ONLY — it drives ONLY your real Chrome via the paired
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* method-specific arguments travel in `params`. Results are trusted shapes
|
|
8
8
|
* produced by the extension router (validated there).
|
|
9
9
|
*/
|
|
10
|
-
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, 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';
|
|
10
|
+
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FillFieldOp, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, 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';
|
|
11
11
|
import type { BridgeServer } from '../bridge/server';
|
|
12
12
|
export declare class ExtensionExecutor implements Executor {
|
|
13
13
|
private readonly bridge;
|
|
@@ -69,6 +69,11 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
69
69
|
fill(t: Target, value: string, opts?: {
|
|
70
70
|
tabId?: TabId;
|
|
71
71
|
} & FrameOpts): Promise<ActionOk>;
|
|
72
|
+
fillFields(fields: FillFieldOp[], opts?: {
|
|
73
|
+
tabId?: TabId;
|
|
74
|
+
} & FrameOpts): Promise<{
|
|
75
|
+
filled: number;
|
|
76
|
+
} | null>;
|
|
72
77
|
press(key: string, opts?: {
|
|
73
78
|
tabId?: TabId;
|
|
74
79
|
modifiers?: KeyModifier[];
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.ExtensionExecutor = void 0;
|
|
13
13
|
const types_1 = require("./types");
|
|
14
|
+
const protocol_1 = require("../../shared/protocol");
|
|
15
|
+
const connection_1 = require("../bridge/connection");
|
|
14
16
|
const workspace_1 = require("../bridge/workspace");
|
|
15
17
|
/**
|
|
16
18
|
* How long a reported active-tab URL stays usable for the policy gate.
|
|
@@ -143,6 +145,19 @@ class ExtensionExecutor {
|
|
|
143
145
|
// No dedicated wire method: a cleared insertText is the fill primitive.
|
|
144
146
|
return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text: value, clear: true, keyEvents: false }, { tabId: opts?.tabId }));
|
|
145
147
|
}
|
|
148
|
+
async fillFields(fields, opts) {
|
|
149
|
+
// An extension that predates the op never advertised it: let the caller go field by field.
|
|
150
|
+
if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
|
|
151
|
+
return null;
|
|
152
|
+
const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId }));
|
|
153
|
+
if (res.error) {
|
|
154
|
+
// Say how far the batch got: the fields before this one DID land, and a
|
|
155
|
+
// blind retry of the whole form would write them a second time.
|
|
156
|
+
const where = `field ${res.filled + 1} of ${fields.length} (${res.error.selector}) failed after ${res.filled} filled`;
|
|
157
|
+
throw new types_1.ExecutorError((0, connection_1.mapWireErrorCode)(res.error.code), `${where}: ${res.error.message}`);
|
|
158
|
+
}
|
|
159
|
+
return { filled: res.filled };
|
|
160
|
+
}
|
|
146
161
|
async press(key, opts) {
|
|
147
162
|
return (await this.send('press', { key, modifiers: opts?.modifiers }, { tabId: opts?.tabId }));
|
|
148
163
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* `mcp/helpers.ts` from these primitives; only `download` is privileged.
|
|
11
11
|
*/
|
|
12
12
|
import type { ObserverReadResult, DialogPolicy } from '../../shared/observers';
|
|
13
|
-
|
|
13
|
+
import type { FillFieldOp } from '../../shared/protocol';
|
|
14
|
+
export type { ObserverReadResult, DialogPolicy, FillFieldOp };
|
|
14
15
|
export type BackendKind = 'extension' | 'cdp';
|
|
15
16
|
export type WaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
|
|
16
17
|
export type KeyModifier = 'Alt' | 'Control' | 'Meta' | 'Shift';
|
|
@@ -334,6 +335,17 @@ export interface Executor {
|
|
|
334
335
|
gone?: boolean;
|
|
335
336
|
timeoutMs?: number;
|
|
336
337
|
} & FrameOpts): Promise<WaitResult>;
|
|
338
|
+
/**
|
|
339
|
+
* Write several fields in ONE round-trip (string = value-set like `fill`,
|
|
340
|
+
* boolean = toggle via click), in order, throwing the first field's failure.
|
|
341
|
+
* Resolves null when the live backend cannot batch (e.g. an extension build
|
|
342
|
+
* older than the op) — the caller then falls back to `fill`/`click` per field.
|
|
343
|
+
*/
|
|
344
|
+
fillFields?(fields: FillFieldOp[], opts?: {
|
|
345
|
+
tabId?: TabId;
|
|
346
|
+
} & FrameOpts): Promise<{
|
|
347
|
+
filled: number;
|
|
348
|
+
} | null>;
|
|
337
349
|
/** Every frame of a tab that the extension can inject into, with its URL. */
|
|
338
350
|
framesList?(opts?: {
|
|
339
351
|
tabId?: TabId;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/mcp/helpers.ts — the high-level tools composed SERVER-SIDE from executor
|
|
3
3
|
* primitives. They never touch the wire directly: `extract_links` and
|
|
4
|
-
* `read_as_markdown` read via primitives; `fill_form`
|
|
4
|
+
* `read_as_markdown` read via primitives; `fill_form` batches its field writes
|
|
5
|
+
* when the backend can (else sequences fill+click).
|
|
5
6
|
* (Only `download_file` is privileged and lives on the executor.)
|
|
6
7
|
*/
|
|
7
8
|
import type { Executor, FrameOpts } from '../executor/types';
|
|
@@ -32,7 +33,12 @@ export declare function readAsMarkdown(ex: Executor, args: {
|
|
|
32
33
|
selector?: string;
|
|
33
34
|
tabId?: string;
|
|
34
35
|
} & FrameOpts): Promise<string>;
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Fill a set of fields (keyed by selector) and optionally submit. All writes go
|
|
38
|
+
* out as ONE executor call when the backend can batch them; otherwise (older
|
|
39
|
+
* extension, CDP) they fall back to one fill/click per field. Either way the
|
|
40
|
+
* first failing field throws, and submit is a separate click after the fills.
|
|
41
|
+
*/
|
|
36
42
|
export declare function fillForm(ex: Executor, args: {
|
|
37
43
|
fields: Record<string, string | boolean>;
|
|
38
44
|
submitSelector?: string;
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* src/mcp/helpers.ts — the high-level tools composed SERVER-SIDE from executor
|
|
4
4
|
* primitives. They never touch the wire directly: `extract_links` and
|
|
5
|
-
* `read_as_markdown` read via primitives; `fill_form`
|
|
5
|
+
* `read_as_markdown` read via primitives; `fill_form` batches its field writes
|
|
6
|
+
* when the backend can (else sequences fill+click).
|
|
6
7
|
* (Only `download_file` is privileged and lives on the executor.)
|
|
7
8
|
*/
|
|
8
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -80,20 +81,29 @@ async function readAsMarkdown(ex, args) {
|
|
|
80
81
|
});
|
|
81
82
|
return (0, markdown_extract_1.htmlToMarkdown)(html);
|
|
82
83
|
}
|
|
83
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Fill a set of fields (keyed by selector) and optionally submit. All writes go
|
|
86
|
+
* out as ONE executor call when the backend can batch them; otherwise (older
|
|
87
|
+
* extension, CDP) they fall back to one fill/click per field. Either way the
|
|
88
|
+
* first failing field throws, and submit is a separate click after the fills.
|
|
89
|
+
*/
|
|
84
90
|
async function fillForm(ex, args) {
|
|
85
91
|
const opts = { tabId: args.tabId, frameId: args.frameId, allFrames: args.allFrames };
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const ops = Object.entries(args.fields).map(([selector, value]) => ({ selector, value }));
|
|
93
|
+
const batched = ops.length > 0 && ex.fillFields ? await ex.fillFields(ops, opts) : null;
|
|
94
|
+
let filled = batched?.filled ?? 0;
|
|
95
|
+
if (!batched) {
|
|
96
|
+
for (const { selector, value } of ops) {
|
|
97
|
+
const target = { selector };
|
|
98
|
+
if (typeof value === 'boolean') {
|
|
99
|
+
// Checkbox/radio: a click toggles it.
|
|
100
|
+
await ex.click(target, opts);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
await ex.fill(target, value, opts);
|
|
104
|
+
}
|
|
105
|
+
filled++;
|
|
95
106
|
}
|
|
96
|
-
filled++;
|
|
97
107
|
}
|
|
98
108
|
let submitted = false;
|
|
99
109
|
if (args.submitSelector) {
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -38,6 +38,7 @@ const auth_wall_1 = require("../../shared/auth-wall");
|
|
|
38
38
|
const audit_1 = require("./audit");
|
|
39
39
|
const log_1 = require("./log");
|
|
40
40
|
const tasks_1 = require("../bridge/tasks");
|
|
41
|
+
const telemetry_1 = require("../telemetry");
|
|
41
42
|
const config_1 = require("../config");
|
|
42
43
|
const workspace_1 = require("../bridge/workspace");
|
|
43
44
|
const validators_1 = require("./validators");
|
|
@@ -1075,6 +1076,8 @@ function summarizeArgs(rawArgs) {
|
|
|
1075
1076
|
*/
|
|
1076
1077
|
function recordHistory(tool, rawArgs, ok, extra = {}) {
|
|
1077
1078
|
const a = extra.audit ?? {};
|
|
1079
|
+
// Counts only — the tool name and error code, never the args or URL below.
|
|
1080
|
+
(0, telemetry_1.noteToolCall)(tool, ok, extra.error);
|
|
1078
1081
|
(0, workspace_1.appendHistory)({
|
|
1079
1082
|
ts: new Date().toISOString(),
|
|
1080
1083
|
tool,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/telemetry.ts — anonymous usage statistics from the chrome-mcp SERVER.
|
|
3
|
+
*
|
|
4
|
+
* What it sends, to PostHog: a random per-install id, the chrome-mcp version,
|
|
5
|
+
* OS, CPU architecture and Node major version, whether this session owns the
|
|
6
|
+
* port or shares it, how many browsers are paired, and per-tool call and error
|
|
7
|
+
* COUNTS. Never URLs, domains, tool arguments, page content, profile names,
|
|
8
|
+
* tokens, file paths, or anything typed. Events are marked personless and ask
|
|
9
|
+
* PostHog not to geolocate them.
|
|
10
|
+
*
|
|
11
|
+
* The browser extension sends nothing; this lives only in the npm server.
|
|
12
|
+
*
|
|
13
|
+
* On by default with a one-time notice on first run. Off with
|
|
14
|
+
* CHROME_MCP_TELEMETRY=0 (or false/off), DO_NOT_TRACK=1, or --no-telemetry.
|
|
15
|
+
* Every failure is swallowed: telemetry can never break or slow a tool call.
|
|
16
|
+
*/
|
|
17
|
+
/** PostHog project key. Public by design: it can only WRITE events, never read them. */
|
|
18
|
+
export declare const POSTHOG_KEY = "phc_CG5QX5JEkRokfUN87rZQnPa3URdLWePCZnfqW6MCyCdU";
|
|
19
|
+
export declare const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
20
|
+
export declare const TELEMETRY_NOTICE: string;
|
|
21
|
+
interface Event {
|
|
22
|
+
event: string;
|
|
23
|
+
distinct_id: string;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
properties: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface TelemetryOptions {
|
|
28
|
+
dataDir: string;
|
|
29
|
+
version: string;
|
|
30
|
+
/** --no-telemetry */
|
|
31
|
+
disabledByFlag?: boolean;
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
33
|
+
/** Override the project key (tests, forks). Empty = telemetry off. */
|
|
34
|
+
key?: string;
|
|
35
|
+
log?: (message: string) => void;
|
|
36
|
+
/** Test seam: replaces the HTTP POST. */
|
|
37
|
+
send?: (events: Event[]) => Promise<void>;
|
|
38
|
+
/** Live context added to each summary (role, paired browsers). */
|
|
39
|
+
context?: () => Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
/** Whether the user has turned telemetry off, by env or flag. */
|
|
42
|
+
export declare function telemetryDisabled(env: NodeJS.ProcessEnv, disabledByFlag?: boolean): boolean;
|
|
43
|
+
/** Pull the `[CODE]` prefix off a tool error message, if any. */
|
|
44
|
+
export declare function errorCodeOf(message: string | undefined): string;
|
|
45
|
+
/** Start telemetry for this server process, unless turned off or unconfigured. */
|
|
46
|
+
export declare function initTelemetry(opts: TelemetryOptions): boolean;
|
|
47
|
+
/** Count one tool call. A no-op when telemetry is off. */
|
|
48
|
+
export declare function noteToolCall(tool: string, ok: boolean, error?: string): void;
|
|
49
|
+
/** Flush and send session_ended. Safe to call when telemetry is off. */
|
|
50
|
+
export declare function stopTelemetry(): Promise<void>;
|
|
51
|
+
/** Test seam: forget the active instance. */
|
|
52
|
+
export declare function resetTelemetryForTesting(): void;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/telemetry.ts — anonymous usage statistics from the chrome-mcp SERVER.
|
|
4
|
+
*
|
|
5
|
+
* What it sends, to PostHog: a random per-install id, the chrome-mcp version,
|
|
6
|
+
* OS, CPU architecture and Node major version, whether this session owns the
|
|
7
|
+
* port or shares it, how many browsers are paired, and per-tool call and error
|
|
8
|
+
* COUNTS. Never URLs, domains, tool arguments, page content, profile names,
|
|
9
|
+
* tokens, file paths, or anything typed. Events are marked personless and ask
|
|
10
|
+
* PostHog not to geolocate them.
|
|
11
|
+
*
|
|
12
|
+
* The browser extension sends nothing; this lives only in the npm server.
|
|
13
|
+
*
|
|
14
|
+
* On by default with a one-time notice on first run. Off with
|
|
15
|
+
* CHROME_MCP_TELEMETRY=0 (or false/off), DO_NOT_TRACK=1, or --no-telemetry.
|
|
16
|
+
* Every failure is swallowed: telemetry can never break or slow a tool call.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.TELEMETRY_NOTICE = exports.POSTHOG_HOST = exports.POSTHOG_KEY = void 0;
|
|
20
|
+
exports.telemetryDisabled = telemetryDisabled;
|
|
21
|
+
exports.errorCodeOf = errorCodeOf;
|
|
22
|
+
exports.initTelemetry = initTelemetry;
|
|
23
|
+
exports.noteToolCall = noteToolCall;
|
|
24
|
+
exports.stopTelemetry = stopTelemetry;
|
|
25
|
+
exports.resetTelemetryForTesting = resetTelemetryForTesting;
|
|
26
|
+
const node_crypto_1 = require("node:crypto");
|
|
27
|
+
const node_fs_1 = require("node:fs");
|
|
28
|
+
const node_os_1 = require("node:os");
|
|
29
|
+
const node_path_1 = require("node:path");
|
|
30
|
+
/** PostHog project key. Public by design: it can only WRITE events, never read them. */
|
|
31
|
+
exports.POSTHOG_KEY = 'phc_CG5QX5JEkRokfUN87rZQnPa3URdLWePCZnfqW6MCyCdU';
|
|
32
|
+
exports.POSTHOG_HOST = 'https://us.i.posthog.com';
|
|
33
|
+
/** How often the aggregated counts are sent while a session runs. */
|
|
34
|
+
const FLUSH_INTERVAL_MS = 10 * 60_000;
|
|
35
|
+
/** A send never holds up the process longer than this. */
|
|
36
|
+
const SEND_TIMEOUT_MS = 3_000;
|
|
37
|
+
const STATE_FILE = 'telemetry.json';
|
|
38
|
+
exports.TELEMETRY_NOTICE = 'chrome-mcp collects anonymous usage statistics (version, OS, tool call and error counts; never URLs, ' +
|
|
39
|
+
'page content or arguments) to see how it is used. Turn it off with CHROME_MCP_TELEMETRY=0 or DO_NOT_TRACK=1. ' +
|
|
40
|
+
'Details: https://github.com/Mehmoodqureshi/chrome-mcp#telemetry';
|
|
41
|
+
/** Whether the user has turned telemetry off, by env or flag. */
|
|
42
|
+
function telemetryDisabled(env, disabledByFlag = false) {
|
|
43
|
+
if (disabledByFlag)
|
|
44
|
+
return true;
|
|
45
|
+
const own = (env.CHROME_MCP_TELEMETRY ?? '').trim().toLowerCase();
|
|
46
|
+
if (['0', 'false', 'off', 'no'].includes(own))
|
|
47
|
+
return true;
|
|
48
|
+
const dnt = (env.DO_NOT_TRACK ?? '').trim().toLowerCase();
|
|
49
|
+
return dnt !== '' && dnt !== '0' && dnt !== 'false';
|
|
50
|
+
}
|
|
51
|
+
/** Pull the `[CODE]` prefix off a tool error message, if any. */
|
|
52
|
+
function errorCodeOf(message) {
|
|
53
|
+
const m = /^\[([A-Z_]+)\]/.exec(message ?? '');
|
|
54
|
+
return m ? m[1] : 'OTHER';
|
|
55
|
+
}
|
|
56
|
+
class Telemetry {
|
|
57
|
+
opts;
|
|
58
|
+
installId;
|
|
59
|
+
calls = new Map();
|
|
60
|
+
errorCodes = new Map();
|
|
61
|
+
timer = null;
|
|
62
|
+
base;
|
|
63
|
+
/** Sends still on the wire, so a quick exit doesn't cut session_started off. */
|
|
64
|
+
inflight = new Set();
|
|
65
|
+
constructor(opts) {
|
|
66
|
+
this.opts = opts;
|
|
67
|
+
this.installId = this.loadInstallId();
|
|
68
|
+
this.base = {
|
|
69
|
+
version: opts.version,
|
|
70
|
+
os: (0, node_os_1.platform)(),
|
|
71
|
+
arch: (0, node_os_1.arch)(),
|
|
72
|
+
node: process.versions.node.split('.')[0],
|
|
73
|
+
// Anonymous: no person profile, no GeoIP lookup from the request IP.
|
|
74
|
+
$process_person_profile: false,
|
|
75
|
+
$geoip_disable: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
start() {
|
|
79
|
+
void this.capture('session_started', this.opts.context?.() ?? {});
|
|
80
|
+
this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
|
81
|
+
this.timer.unref();
|
|
82
|
+
}
|
|
83
|
+
noteCall(tool, ok, error) {
|
|
84
|
+
const c = this.calls.get(tool) ?? { calls: 0, errors: 0 };
|
|
85
|
+
c.calls++;
|
|
86
|
+
if (!ok) {
|
|
87
|
+
c.errors++;
|
|
88
|
+
const code = errorCodeOf(error);
|
|
89
|
+
this.errorCodes.set(code, (this.errorCodes.get(code) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
this.calls.set(tool, c);
|
|
92
|
+
}
|
|
93
|
+
/** Send the counts gathered since the last flush (skipped when idle). */
|
|
94
|
+
async flush() {
|
|
95
|
+
const summary = this.takeSummary();
|
|
96
|
+
if (summary)
|
|
97
|
+
await this.send([summary]);
|
|
98
|
+
}
|
|
99
|
+
/** The counts since the last summary as an event, resetting them; null when idle. */
|
|
100
|
+
takeSummary() {
|
|
101
|
+
if (this.calls.size === 0)
|
|
102
|
+
return null;
|
|
103
|
+
const tools = Object.fromEntries(this.calls);
|
|
104
|
+
const errors = Object.fromEntries(this.errorCodes);
|
|
105
|
+
let total = 0;
|
|
106
|
+
let failed = 0;
|
|
107
|
+
for (const c of this.calls.values()) {
|
|
108
|
+
total += c.calls;
|
|
109
|
+
failed += c.errors;
|
|
110
|
+
}
|
|
111
|
+
this.calls = new Map();
|
|
112
|
+
this.errorCodes = new Map();
|
|
113
|
+
return this.event('usage_summary', { ...(this.opts.context?.() ?? {}), calls: total, errors: failed, tools, error_codes: errors });
|
|
114
|
+
}
|
|
115
|
+
/** Last summary and session_ended in ONE request, so both fit the shutdown deadline. */
|
|
116
|
+
async stop() {
|
|
117
|
+
if (this.timer)
|
|
118
|
+
clearInterval(this.timer);
|
|
119
|
+
this.timer = null;
|
|
120
|
+
const summary = this.takeSummary();
|
|
121
|
+
const final = this.send([...(summary ? [summary] : []), this.event('session_ended', {})]);
|
|
122
|
+
// Also wait for anything already sent (session_started on a short session).
|
|
123
|
+
await Promise.allSettled([...this.inflight, final]);
|
|
124
|
+
}
|
|
125
|
+
capture(event, props) {
|
|
126
|
+
return this.send([this.event(event, props)]);
|
|
127
|
+
}
|
|
128
|
+
event(event, props) {
|
|
129
|
+
return {
|
|
130
|
+
event,
|
|
131
|
+
distinct_id: this.installId,
|
|
132
|
+
timestamp: new Date().toISOString(),
|
|
133
|
+
properties: { ...this.base, ...props },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
send(batch) {
|
|
137
|
+
const p = (async () => {
|
|
138
|
+
try {
|
|
139
|
+
await (this.opts.send ?? ((b) => this.post(b)))(batch);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* offline, blocked, or PostHog down — never matters to the user */
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
this.inflight.add(p);
|
|
146
|
+
void p.finally(() => this.inflight.delete(p));
|
|
147
|
+
return p;
|
|
148
|
+
}
|
|
149
|
+
async post(batch) {
|
|
150
|
+
await fetch(`${exports.POSTHOG_HOST}/batch/`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'content-type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ api_key: this.opts.key, batch }),
|
|
154
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** The random install id, created (with the first-run notice) on first use. */
|
|
158
|
+
loadInstallId() {
|
|
159
|
+
const path = (0, node_path_1.join)(this.opts.dataDir, STATE_FILE);
|
|
160
|
+
try {
|
|
161
|
+
if ((0, node_fs_1.existsSync)(path)) {
|
|
162
|
+
const saved = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
163
|
+
if (typeof saved.installId === 'string' && saved.installId.length > 0)
|
|
164
|
+
return saved.installId;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* unreadable: start over with a new id */
|
|
169
|
+
}
|
|
170
|
+
const installId = (0, node_crypto_1.randomUUID)();
|
|
171
|
+
this.opts.log?.(exports.TELEMETRY_NOTICE);
|
|
172
|
+
try {
|
|
173
|
+
const tmp = `${path}.tmp`;
|
|
174
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify({ installId, noticeShownAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
175
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* read-only data dir: a new id (and notice) next boot is harmless */
|
|
179
|
+
}
|
|
180
|
+
return installId;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let active = null;
|
|
184
|
+
/** Start telemetry for this server process, unless turned off or unconfigured. */
|
|
185
|
+
function initTelemetry(opts) {
|
|
186
|
+
const key = opts.key ?? exports.POSTHOG_KEY;
|
|
187
|
+
if (!key || telemetryDisabled(opts.env ?? process.env, opts.disabledByFlag))
|
|
188
|
+
return false;
|
|
189
|
+
active = new Telemetry({ ...opts, key });
|
|
190
|
+
active.start();
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
/** Count one tool call. A no-op when telemetry is off. */
|
|
194
|
+
function noteToolCall(tool, ok, error) {
|
|
195
|
+
active?.noteCall(tool, ok, error);
|
|
196
|
+
}
|
|
197
|
+
/** Flush and send session_ended. Safe to call when telemetry is off. */
|
|
198
|
+
async function stopTelemetry() {
|
|
199
|
+
const t = active;
|
|
200
|
+
active = null;
|
|
201
|
+
await t?.stop();
|
|
202
|
+
}
|
|
203
|
+
/** Test seam: forget the active instance. */
|
|
204
|
+
function resetTelemetryForTesting() {
|
|
205
|
+
active = null;
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=telemetry.js.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// shared/protocol.ts
|
|
4
4
|
var PROTOCOL_VERSION = 1;
|
|
5
5
|
var WIRE_CAP_TAB_URL = "tab-url";
|
|
6
|
+
var WIRE_CAP_FILL_FORM = "fill-form";
|
|
6
7
|
var WIRE_METHODS = [
|
|
7
8
|
"tabs_list",
|
|
8
9
|
"tab_select",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"frames_list",
|
|
32
33
|
"observers",
|
|
33
34
|
"print_pdf",
|
|
35
|
+
"fill_form",
|
|
34
36
|
"ping_probe"
|
|
35
37
|
];
|
|
36
38
|
|
|
@@ -72,7 +74,7 @@
|
|
|
72
74
|
// This build gates fail-closed and reports tab URLs on results, so the
|
|
73
75
|
// server may skip its pre-flight tabs_list. An older build omits this and
|
|
74
76
|
// the server keeps fetching the URL itself.
|
|
75
|
-
caps: [WIRE_CAP_TAB_URL]
|
|
77
|
+
caps: [WIRE_CAP_TAB_URL, WIRE_CAP_FILL_FORM]
|
|
76
78
|
};
|
|
77
79
|
ws2.send(JSON.stringify(hello));
|
|
78
80
|
};
|
|
@@ -146,7 +148,8 @@
|
|
|
146
148
|
"type",
|
|
147
149
|
"press",
|
|
148
150
|
"hover",
|
|
149
|
-
"scroll"
|
|
151
|
+
"scroll",
|
|
152
|
+
"fill_form"
|
|
150
153
|
]);
|
|
151
154
|
var NAVIGATION = /* @__PURE__ */ new Set([
|
|
152
155
|
"navigate",
|
|
@@ -236,6 +239,27 @@
|
|
|
236
239
|
return `Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) \u2014 not an error. ${allowedLine} To allow ${host}, add it to the chrome-mcp settings as: --allow-domain "${host}" (or "*.${host}" to include subdomains), then restart/reconnect. To allow every site (less safe), use --unsafe-all-domains.`;
|
|
237
240
|
}
|
|
238
241
|
|
|
242
|
+
// shared/fill-form.ts
|
|
243
|
+
async function runFillFields(ops, hooks) {
|
|
244
|
+
const res = { filled: 0 };
|
|
245
|
+
for (const op of ops) {
|
|
246
|
+
try {
|
|
247
|
+
const policy = hooks.policy();
|
|
248
|
+
if (!policy) throw new PolicyStop("no policy is in force; refusing to fill");
|
|
249
|
+
const verdict = evaluatePolicy(await hooks.currentUrl(), "fill_form", policy);
|
|
250
|
+
if (!verdict.ok) throw new PolicyStop(verdict.reason);
|
|
251
|
+
await hooks.write(op);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
res.error = err instanceof PolicyStop ? { selector: op.selector, code: "POLICY_DENIED", message: err.message } : { selector: op.selector, ...hooks.toError(err) };
|
|
254
|
+
return res;
|
|
255
|
+
}
|
|
256
|
+
res.filled++;
|
|
257
|
+
}
|
|
258
|
+
return res;
|
|
259
|
+
}
|
|
260
|
+
var PolicyStop = class extends Error {
|
|
261
|
+
};
|
|
262
|
+
|
|
239
263
|
// shared/download.ts
|
|
240
264
|
var MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
241
265
|
var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
@@ -1225,7 +1249,8 @@
|
|
|
1225
1249
|
"ping_probe",
|
|
1226
1250
|
"frames_list",
|
|
1227
1251
|
"observers",
|
|
1228
|
-
"print_pdf"
|
|
1252
|
+
"print_pdf",
|
|
1253
|
+
"fill_form"
|
|
1229
1254
|
]);
|
|
1230
1255
|
var ChromeExecutor = class {
|
|
1231
1256
|
/**
|
|
@@ -1495,6 +1520,34 @@
|
|
|
1495
1520
|
if (!out.found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
1496
1521
|
return { ok: true, frameId: out.frameId };
|
|
1497
1522
|
}
|
|
1523
|
+
case "fill_form": {
|
|
1524
|
+
const id = await targetTab2(cmd);
|
|
1525
|
+
const raw = Array.isArray(cmd.params.ops) ? cmd.params.ops : [];
|
|
1526
|
+
const ops = raw.map((f) => ({
|
|
1527
|
+
selector: String(f.selector ?? ""),
|
|
1528
|
+
value: typeof f.value === "boolean" ? f.value : String(f.value ?? "")
|
|
1529
|
+
}));
|
|
1530
|
+
return runFillFields(ops, {
|
|
1531
|
+
currentUrl: () => observedTabUrl(cmd, id),
|
|
1532
|
+
policy: () => this.getPolicy(),
|
|
1533
|
+
// Frame ids are re-probed per field too: an iframe that navigates
|
|
1534
|
+
// mid-batch must not keep a grant the policy would no longer give it.
|
|
1535
|
+
write: async (op) => {
|
|
1536
|
+
const out = await execOp(
|
|
1537
|
+
id,
|
|
1538
|
+
withWait(
|
|
1539
|
+
typeof op.value === "boolean" ? { op: "click", selector: op.selector } : { op: "type", selector: op.selector, text: op.value, clear: true }
|
|
1540
|
+
),
|
|
1541
|
+
await this.frames(cmd, id)
|
|
1542
|
+
);
|
|
1543
|
+
if (!out.found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${op.selector}`);
|
|
1544
|
+
},
|
|
1545
|
+
toError: (err) => ({
|
|
1546
|
+
code: err instanceof CmdError ? err.code : "CDP_ERROR",
|
|
1547
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1548
|
+
})
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1498
1551
|
case "press": {
|
|
1499
1552
|
const id = await targetTab2(cmd);
|
|
1500
1553
|
const key = String(cmd.params.key ?? "");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "MCP Extension for Chrome",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.8",
|
|
5
5
|
"description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
|
|
6
6
|
"homepage_url": "https://chrome-mcp-omega.vercel.app",
|
|
7
7
|
"minimum_chrome_version": "116",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"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.",
|
|
5
5
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
6
6
|
"license": "MIT",
|