@mehmoodqureshi/chrome-mcp 0.9.5 → 0.9.7
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 +60 -17
- package/dist/shared/protocol.d.ts +7 -0
- package/dist/src/bridge/evict.d.ts +5 -5
- package/dist/src/bridge/evict.js +5 -5
- package/dist/src/bridge/peer.d.ts +94 -0
- package/dist/src/bridge/peer.js +187 -0
- package/dist/src/bridge/profiles.d.ts +44 -0
- package/dist/src/bridge/profiles.js +121 -0
- package/dist/src/bridge/server.d.ts +84 -4
- package/dist/src/bridge/server.js +267 -14
- package/dist/src/cli.js +45 -19
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +13 -2
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +9 -0
- package/dist/src/executor/select.js +5 -1
- package/dist/src/mcp/tools.d.ts +2 -0
- package/dist/src/mcp/tools.js +75 -4
- package/dist/src/telemetry.d.ts +53 -0
- package/dist/src/telemetry.js +207 -0
- package/extension-dist/background.js +20 -5
- package/extension-dist/manifest.json +1 -1
- package/extension-dist/options.html +7 -5
- package/extension-dist/options.js +12 -4
- package/package.json +1 -1
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,
|
|
@@ -294,8 +299,10 @@ Connection:
|
|
|
294
299
|
--profile <name> Default browser profile / identity (default "default").
|
|
295
300
|
Artifacts live under profiles/<name>/. At runtime, switch
|
|
296
301
|
with the profile_use tool. Several browsers can pair to the
|
|
297
|
-
SAME port+token at once
|
|
298
|
-
|
|
302
|
+
SAME port+token at once; each is named automatically
|
|
303
|
+
(default, profile-2, ...; rename with profile_rename) unless
|
|
304
|
+
its extension Options pins a Profile. Tools route to the
|
|
305
|
+
active profile.
|
|
299
306
|
--task <name> Task label (default "default"). Downloads and a meta.json
|
|
300
307
|
land in profiles/<profile>/tasks/<task>/.
|
|
301
308
|
--print-pairing Write the handshake, print its path, and keep the bridge
|
|
@@ -307,6 +314,10 @@ Connection:
|
|
|
307
314
|
--persist-token Reuse a stable on-disk token across restarts so the
|
|
308
315
|
extension never has to re-pair (default: fresh per boot).
|
|
309
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.
|
|
310
321
|
|
|
311
322
|
Backend:
|
|
312
323
|
This build is EXTENSION-ONLY — it drives ONLY your real Chrome via the paired
|
|
@@ -20,6 +20,9 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
20
20
|
status(): ExecutorStatus;
|
|
21
21
|
ensureReady(): Promise<void>;
|
|
22
22
|
ping(deadlineMs?: number): Promise<boolean>;
|
|
23
|
+
/** Why this executor can't serve the active profile right now: not paired, or
|
|
24
|
+
* paired but not answering pings. Used for the selector's NO_BACKEND message. */
|
|
25
|
+
unavailableReason(): string;
|
|
23
26
|
dispose(): Promise<void>;
|
|
24
27
|
/** The active tab's URL as reported by the last command on this profile, if it
|
|
25
28
|
* is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */
|
|
@@ -81,6 +81,15 @@ class ExtensionExecutor {
|
|
|
81
81
|
return false;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
/** Why this executor can't serve the active profile right now: not paired, or
|
|
85
|
+
* paired but not answering pings. Used for the selector's NO_BACKEND message. */
|
|
86
|
+
unavailableReason() {
|
|
87
|
+
const profile = this.activeProfile();
|
|
88
|
+
if (!this.bridge.hasConnection(profile))
|
|
89
|
+
return this.bridge.noPairMessage(profile);
|
|
90
|
+
return (`The browser paired for profile "${profile}" is not responding. Open that Chrome, ` +
|
|
91
|
+
`or reload the chrome-mcp extension in chrome://extensions, then retry.`);
|
|
92
|
+
}
|
|
84
93
|
async dispose() {
|
|
85
94
|
// Never close the user's Chrome.
|
|
86
95
|
}
|
|
@@ -73,7 +73,11 @@ function createSelector(deps) {
|
|
|
73
73
|
if (c)
|
|
74
74
|
return c;
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// CDP is off in the published build, so the extension is the only way in:
|
|
77
|
+
// name the profile and the exact Options to set rather than suggest flags
|
|
78
|
+
// that are ignored.
|
|
79
|
+
const reason = ext.unavailableReason?.();
|
|
80
|
+
throw new types_1.ExecutorError('NO_BACKEND', reason ?? 'No Chrome available: pair the chrome-mcp extension (Options: Port, token, Profile).');
|
|
77
81
|
};
|
|
78
82
|
}
|
|
79
83
|
//# sourceMappingURL=select.js.map
|
package/dist/src/mcp/tools.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import type { Executor } from '../executor/types';
|
|
17
17
|
import { type Policy } from '../security/policy';
|
|
18
|
+
import type { BridgeServer } from '../bridge/server';
|
|
18
19
|
export interface ToolDefinition {
|
|
19
20
|
name: string;
|
|
20
21
|
description: string;
|
|
@@ -48,6 +49,7 @@ interface ToolCtx {
|
|
|
48
49
|
policy: Policy;
|
|
49
50
|
}
|
|
50
51
|
type ToolHandler = (args: Record<string, unknown>, ctx: ToolCtx) => Promise<CallToolResult>;
|
|
52
|
+
export declare function setProfileBridge(bridge: BridgeServer | null): void;
|
|
51
53
|
export declare const TOOL_HANDLERS: Record<string, ToolHandler>;
|
|
52
54
|
/** Reset limiter state — for tests that exercise the ceiling. */
|
|
53
55
|
export declare function resetRateLimiter(): void;
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -16,10 +16,12 @@ exports.TOOL_HANDLERS = exports.TOOL_NAMES = exports.TOOL_DEFINITIONS = void 0;
|
|
|
16
16
|
exports.setToolAllowlist = setToolAllowlist;
|
|
17
17
|
exports.isToolEnabled = isToolEnabled;
|
|
18
18
|
exports.enabledToolNames = enabledToolNames;
|
|
19
|
+
exports.setProfileBridge = setProfileBridge;
|
|
19
20
|
exports.resetRateLimiter = resetRateLimiter;
|
|
20
21
|
exports.dispatchToolCall = dispatchToolCall;
|
|
21
22
|
exports.assertNoDrift = assertNoDrift;
|
|
22
23
|
exports.registerTools = registerTools;
|
|
24
|
+
const node_fs_1 = require("node:fs");
|
|
23
25
|
const node_path_1 = require("node:path");
|
|
24
26
|
const zod_1 = require("zod");
|
|
25
27
|
const types_1 = require("../executor/types");
|
|
@@ -36,6 +38,8 @@ const auth_wall_1 = require("../../shared/auth-wall");
|
|
|
36
38
|
const audit_1 = require("./audit");
|
|
37
39
|
const log_1 = require("./log");
|
|
38
40
|
const tasks_1 = require("../bridge/tasks");
|
|
41
|
+
const telemetry_1 = require("../telemetry");
|
|
42
|
+
const config_1 = require("../config");
|
|
39
43
|
const workspace_1 = require("../bridge/workspace");
|
|
40
44
|
const validators_1 = require("./validators");
|
|
41
45
|
/**
|
|
@@ -179,9 +183,10 @@ exports.TOOL_DEFINITIONS = [
|
|
|
179
183
|
tabId: tabIdField,
|
|
180
184
|
},
|
|
181
185
|
},
|
|
182
|
-
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: {} },
|
|
186
|
+
{ name: 'chrome_status', description: 'Report backend/session status, including every paired browser profile and how it was named.', inputSchema: {} },
|
|
183
187
|
{ name: 'auth_check', description: 'Is the tab sitting on a sign-in wall? Reads the page (URL, title, password fields, sign-in controls) and returns { authRequired, confidence, signals }. Use it after a navigate, or whenever a step fails unexpectedly, to tell "the session expired" apart from "the agent got lost". Pass failOnAuthWall:true to get an [AUTH_REQUIRED] error instead of a verdict, so a harness can bucket the run as an auth failure.', inputSchema: { failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
|
|
184
188
|
{ name: 'profile_use', description: 'Switch the active browser profile (identity). Subsequent downloads, results, screenshots, and the action log are stored under profiles/<name>/. Resets the active task to "default" unless you then call task_new.', inputSchema: { name: zod_1.z.string().describe('Profile name (becomes a folder; sanitized to a safe path segment).') } },
|
|
189
|
+
{ name: 'profile_rename', description: 'Rename an automatically named browser profile (e.g. "profile-2" -> "work"). The name sticks across restarts; its saved artifacts move with it.', inputSchema: { from: zod_1.z.string().describe('Current profile name'), to: zod_1.z.string().describe('New profile name') } },
|
|
185
190
|
{ name: 'task_new', description: 'Start a new task (run) under the active profile. Creates profiles/<profile>/tasks/<name>/ with downloads/, results/, screenshots/ and makes it the active task so all captured artifacts land there.', inputSchema: { name: zod_1.z.string().describe('Task name (becomes a folder; sanitized to a safe path segment).') } },
|
|
186
191
|
{ name: 'tasks_list', description: 'List every task across all profiles under the data dir, with sizes and download counts.', inputSchema: {} },
|
|
187
192
|
{ name: 'task_status', description: 'Report the active profile/task and the folder paths where this run\'s artifacts are stored.', inputSchema: {} },
|
|
@@ -480,10 +485,21 @@ const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
|
480
485
|
* the active tab when `tabId` is omitted, which races under concurrency. */
|
|
481
486
|
const PARALLEL_TAB_EXEMPT = new Set([
|
|
482
487
|
'tabs_list', 'tab_new', 'chrome_status', 'batch',
|
|
483
|
-
'profile_use', 'task_new', 'tasks_list', 'task_status',
|
|
488
|
+
'profile_use', 'profile_rename', 'task_new', 'tasks_list', 'task_status',
|
|
484
489
|
]);
|
|
485
490
|
/** Server-side tools that manage the task workspace and need no browser backend. */
|
|
486
|
-
const NO_BACKEND_TOOLS = new Set([
|
|
491
|
+
const NO_BACKEND_TOOLS = new Set([
|
|
492
|
+
'profile_use', 'profile_rename', 'task_new', 'tasks_list', 'task_status',
|
|
493
|
+
// Must answer even when the active profile has no browser — that is exactly
|
|
494
|
+
// when you need it to see which profiles ARE paired.
|
|
495
|
+
'chrome_status',
|
|
496
|
+
]);
|
|
497
|
+
/** The bridge, for the profile tools that need live connection state. Set by
|
|
498
|
+
* the CLI once the bridge is up; null in tests that don't run one. */
|
|
499
|
+
let profileBridge = null;
|
|
500
|
+
function setProfileBridge(bridge) {
|
|
501
|
+
profileBridge = bridge;
|
|
502
|
+
}
|
|
487
503
|
/** Project a Workspace to the path fields worth returning to the caller. */
|
|
488
504
|
function workspaceView(w) {
|
|
489
505
|
return {
|
|
@@ -911,7 +927,22 @@ exports.TOOL_HANDLERS = {
|
|
|
911
927
|
...(path ? {} : { note: 'no active task workspace, so the PDF was not saved to disk' }),
|
|
912
928
|
});
|
|
913
929
|
},
|
|
914
|
-
chrome_status: async (
|
|
930
|
+
chrome_status: async () => {
|
|
931
|
+
const profiles = profileBridge ? { profiles: profileBridge.pairedProfiles() } : {};
|
|
932
|
+
try {
|
|
933
|
+
const ex = await (0, manager_1.getManager)().ensureReady();
|
|
934
|
+
return (0, envelopes_1.jsonResult)({ ...ex.status(), ...profiles });
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
return (0, envelopes_1.jsonResult)({
|
|
938
|
+
ready: false,
|
|
939
|
+
backend: null,
|
|
940
|
+
detail: errMessage(err),
|
|
941
|
+
activeProfile: (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default',
|
|
942
|
+
...profiles,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
},
|
|
915
946
|
auth_check: async (a, ctx) => {
|
|
916
947
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
|
|
917
948
|
const snap = await ctx.ex.snapshot({ tabId: tabId(a), ...frameOpts(a), interactiveOnly: true, max: 200 });
|
|
@@ -932,6 +963,44 @@ exports.TOOL_HANDLERS = {
|
|
|
932
963
|
(0, snapdiff_1.resetSnapshots)();
|
|
933
964
|
return (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') })));
|
|
934
965
|
},
|
|
966
|
+
profile_rename: async (a) => {
|
|
967
|
+
if (!profileBridge)
|
|
968
|
+
throw new types_1.ExecutorError('NO_BACKEND', 'profile_rename needs the chrome-mcp bridge running');
|
|
969
|
+
let from;
|
|
970
|
+
let to;
|
|
971
|
+
try {
|
|
972
|
+
// Sanitize once so the folder move and the active-profile check below
|
|
973
|
+
// use the same name the bridge routes by.
|
|
974
|
+
from = (0, config_1.sanitizeName)((0, validators_1.requireString)(a, 'from'), 'profile');
|
|
975
|
+
to = await profileBridge.renameProfile(from, (0, validators_1.requireString)(a, 'to'));
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
// A refusal (typed-in name, name taken, bad name) is the caller's to fix, not a crash.
|
|
979
|
+
throw new validators_1.McpToolError(err instanceof Error ? err.message : String(err));
|
|
980
|
+
}
|
|
981
|
+
// Carry the profile's saved artifacts over, unless the new name already has
|
|
982
|
+
// a folder of its own (never merge or overwrite).
|
|
983
|
+
const ws = (0, workspace_1.getActiveWorkspace)();
|
|
984
|
+
const oldDir = (0, config_1.resolveProfileDir)(ws.dataDir, from);
|
|
985
|
+
const newDir = (0, config_1.resolveProfileDir)(ws.dataDir, to);
|
|
986
|
+
let artifactsMoved = false;
|
|
987
|
+
if (to !== from && (0, node_fs_1.existsSync)(oldDir) && !(0, node_fs_1.existsSync)(newDir)) {
|
|
988
|
+
try {
|
|
989
|
+
(0, node_fs_1.renameSync)(oldDir, newDir);
|
|
990
|
+
artifactsMoved = true;
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
/* leave them where they are; the rename itself already succeeded */
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// Keep routing to the same browser if it was the active one.
|
|
997
|
+
let active;
|
|
998
|
+
if (ws.profile === from) {
|
|
999
|
+
(0, snapdiff_1.resetSnapshots)();
|
|
1000
|
+
active = workspaceView((0, workspace_1.switchWorkspace)({ profile: to }));
|
|
1001
|
+
}
|
|
1002
|
+
return (0, envelopes_1.jsonResult)({ from, to, artifactsMoved, ...(active ? { active } : {}) });
|
|
1003
|
+
},
|
|
935
1004
|
task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
|
|
936
1005
|
task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
|
|
937
1006
|
tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
|
|
@@ -1007,6 +1076,8 @@ function summarizeArgs(rawArgs) {
|
|
|
1007
1076
|
*/
|
|
1008
1077
|
function recordHistory(tool, rawArgs, ok, extra = {}) {
|
|
1009
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);
|
|
1010
1081
|
(0, workspace_1.appendHistory)({
|
|
1011
1082
|
ts: new Date().toISOString(),
|
|
1012
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
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
isConnected() {
|
|
49
49
|
return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
|
|
50
50
|
}
|
|
51
|
-
connect(port, token, profile) {
|
|
51
|
+
connect(port, token, profile, installId) {
|
|
52
52
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
token,
|
|
69
69
|
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
|
|
70
70
|
profile: profile && profile.trim() ? profile.trim() : void 0,
|
|
71
|
+
installId,
|
|
71
72
|
// This build gates fail-closed and reports tab URLs on results, so the
|
|
72
73
|
// server may skip its pre-flight tabs_list. An older build omits this and
|
|
73
74
|
// the server keeps fetching the URL itself.
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
switch (frame.type) {
|
|
86
87
|
case "welcome":
|
|
87
88
|
this.deps.onPolicy(frame.policy);
|
|
89
|
+
if (typeof frame.profile === "string") this.deps.onProfile?.(frame.profile);
|
|
88
90
|
this.setState("connected");
|
|
89
91
|
this.deps.log("paired with server");
|
|
90
92
|
break;
|
|
@@ -1859,6 +1861,8 @@
|
|
|
1859
1861
|
currentPolicy = policy;
|
|
1860
1862
|
void syncObserverScript(policy, (m) => console.debug("[chrome-mcp]", m));
|
|
1861
1863
|
},
|
|
1864
|
+
// Shown on the Options page, so you can tell which name this browser got.
|
|
1865
|
+
onProfile: (profile) => void chrome.storage.local.set({ pairedProfile: profile }),
|
|
1862
1866
|
log: (m) => console.debug("[chrome-mcp]", m)
|
|
1863
1867
|
});
|
|
1864
1868
|
var router = new CommandRouter({
|
|
@@ -1903,11 +1907,22 @@
|
|
|
1903
1907
|
} catch {
|
|
1904
1908
|
}
|
|
1905
1909
|
}
|
|
1910
|
+
async function ensureInstallId(installId) {
|
|
1911
|
+
if (typeof installId === "string" && installId.length > 0) return installId;
|
|
1912
|
+
const fresh = crypto.randomUUID();
|
|
1913
|
+
await chrome.storage.local.set({ installId: fresh });
|
|
1914
|
+
return fresh;
|
|
1915
|
+
}
|
|
1906
1916
|
async function getConfig() {
|
|
1907
|
-
const { wsPort, token, profile } = await chrome.storage.local.get([
|
|
1917
|
+
const { wsPort, token, profile, installId } = await chrome.storage.local.get([
|
|
1918
|
+
"wsPort",
|
|
1919
|
+
"token",
|
|
1920
|
+
"profile",
|
|
1921
|
+
"installId"
|
|
1922
|
+
]);
|
|
1908
1923
|
if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
|
|
1909
|
-
const
|
|
1910
|
-
return { wsPort, token, profile:
|
|
1924
|
+
const label = typeof profile === "string" ? profile.trim() : "";
|
|
1925
|
+
return { wsPort, token, profile: label, installId: await ensureInstallId(installId) };
|
|
1911
1926
|
}
|
|
1912
1927
|
return null;
|
|
1913
1928
|
}
|
|
@@ -1934,7 +1949,7 @@
|
|
|
1934
1949
|
if (ws.isConnected() || ws.state === "unauthorized") return;
|
|
1935
1950
|
const cfg = await getConfig();
|
|
1936
1951
|
if (!cfg) return;
|
|
1937
|
-
ws.connect(cfg.wsPort, cfg.token, cfg.profile);
|
|
1952
|
+
ws.connect(cfg.wsPort, cfg.token, cfg.profile, cfg.installId);
|
|
1938
1953
|
}
|
|
1939
1954
|
async function keepalivePulse() {
|
|
1940
1955
|
await chrome.storage.local.get("connState");
|
|
@@ -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.7",
|
|
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",
|
|
@@ -25,14 +25,16 @@
|
|
|
25
25
|
<label for="port">Port</label>
|
|
26
26
|
<input id="port" type="number" inputmode="numeric" placeholder="38017" />
|
|
27
27
|
<label for="token">Token</label>
|
|
28
|
-
<input id="token" type="password" placeholder="
|
|
28
|
+
<input id="token" type="password" placeholder="leave blank to keep the current token" />
|
|
29
29
|
<label for="profile">Profile</label>
|
|
30
|
-
<input id="profile" type="text" placeholder="
|
|
30
|
+
<input id="profile" type="text" placeholder="automatic" />
|
|
31
31
|
<p class="hint">
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
Optional. Leave blank and the server names this browser for you — the first is
|
|
33
|
+
<code>default</code>, then <code>profile-2</code>, <code>profile-3</code>… — and
|
|
34
|
+
remembers it. Rename one with <code>profile_rename</code>, switch with
|
|
35
|
+
<code>profile_use</code>. Type a name here only to pin one yourself.
|
|
35
36
|
</p>
|
|
37
|
+
<p class="hint" id="paired-as"></p>
|
|
36
38
|
<button id="save">Save & connect</button>
|
|
37
39
|
<div class="status" id="status">Status: unknown</div>
|
|
38
40
|
<script src="options.js"></script>
|
|
@@ -10,13 +10,19 @@
|
|
|
10
10
|
var saveEl = document.getElementById("save");
|
|
11
11
|
var statusEl = document.getElementById("status");
|
|
12
12
|
var sourceEl = document.getElementById("source");
|
|
13
|
+
var pairedAsEl = document.getElementById("paired-as");
|
|
14
|
+
function renderPairedAs(name) {
|
|
15
|
+
pairedAsEl.textContent = typeof name === "string" && name ? `This browser is paired as "${name}".` : "";
|
|
16
|
+
}
|
|
13
17
|
async function loadExisting() {
|
|
14
|
-
const { wsPort, profile, connState, pairingSource } = await chrome.storage.local.get([
|
|
18
|
+
const { wsPort, profile, connState, pairingSource, pairedProfile } = await chrome.storage.local.get([
|
|
15
19
|
"wsPort",
|
|
16
20
|
"profile",
|
|
17
21
|
"connState",
|
|
18
|
-
"pairingSource"
|
|
22
|
+
"pairingSource",
|
|
23
|
+
"pairedProfile"
|
|
19
24
|
]);
|
|
25
|
+
renderPairedAs(pairedProfile);
|
|
20
26
|
sourceEl.textContent = pairingSource === "auto" ? "Paired automatically from the pairing.json the server wrote into this extension folder. Saving here overrides it." : pairingSource === "manual" ? "Paired by hand. Saved values take precedence over the bundled pairing.json." : typeof wsPort === "number" && wsPort > 0 && connState !== "idle" ? "Paired with values saved by an earlier version. Saving here keeps them manual." : "Not paired yet. If you loaded this extension from the chrome-mcp package folder, start the server once and it pairs itself; otherwise paste the values below.";
|
|
21
27
|
portEl.value = typeof wsPort === "number" && wsPort > 0 ? String(wsPort) : String(DEFAULT_WS_PORT);
|
|
22
28
|
profileEl.value = typeof profile === "string" ? profile : "";
|
|
@@ -33,8 +39,9 @@
|
|
|
33
39
|
}
|
|
34
40
|
saveEl.addEventListener("click", async () => {
|
|
35
41
|
const wsPort = Number(portEl.value);
|
|
36
|
-
const
|
|
37
|
-
const
|
|
42
|
+
const stored = await chrome.storage.local.get("token");
|
|
43
|
+
const token = tokenEl.value.trim() || (typeof stored.token === "string" ? stored.token : "");
|
|
44
|
+
const profile = profileEl.value.trim();
|
|
38
45
|
if (!Number.isInteger(wsPort) || wsPort <= 0 || !token) {
|
|
39
46
|
statusEl.textContent = "Status: enter a valid port (> 0) and token";
|
|
40
47
|
return;
|
|
@@ -45,6 +52,7 @@
|
|
|
45
52
|
});
|
|
46
53
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
47
54
|
if (area === "local" && changes.connState) render(String(changes.connState.newValue));
|
|
55
|
+
if (area === "local" && changes.pairedProfile) renderPairedAs(changes.pairedProfile.newValue);
|
|
48
56
|
});
|
|
49
57
|
void loadExisting();
|
|
50
58
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
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",
|