@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.
@@ -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, each declaring its own Profile in
298
- the extension Options; tools route to the active profile.
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
- throw new types_1.ExecutorError('NO_BACKEND', 'No Chrome available: pair the extension, attach a --cdp-endpoint, or enable the CDP fallback.');
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
@@ -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;
@@ -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(['profile_use', 'task_new', 'tasks_list', 'task_status']);
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 (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
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(["wsPort", "token", "profile"]);
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 name = typeof profile === "string" && profile.trim() ? profile.trim() : "default";
1910
- return { wsPort, token, profile: name };
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.5",
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="paste the handshake token" />
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="default" />
30
+ <input id="profile" type="text" placeholder="automatic" />
31
31
  <p class="hint">
32
- A name for this browser (e.g. <code>mehmood</code>). Several browsers can pair to the
33
- same port &amp; token at once, each with its own profile — switch between them with
34
- <code>profile_use</code>. Leave blank for <code>default</code>.
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 token = tokenEl.value.trim();
37
- const profile = profileEl.value.trim() || "default";
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.5",
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",