@mehmoodqureshi/chrome-mcp 0.9.4 → 0.9.6

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.
@@ -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,7 @@ 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 config_1 = require("../config");
39
42
  const workspace_1 = require("../bridge/workspace");
40
43
  const validators_1 = require("./validators");
41
44
  /**
@@ -179,9 +182,10 @@ exports.TOOL_DEFINITIONS = [
179
182
  tabId: tabIdField,
180
183
  },
181
184
  },
182
- { name: 'chrome_status', description: 'Report backend/session status.', inputSchema: {} },
185
+ { name: 'chrome_status', description: 'Report backend/session status, including every paired browser profile and how it was named.', inputSchema: {} },
183
186
  { 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
187
  { 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).') } },
188
+ { 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
189
  { 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
190
  { name: 'tasks_list', description: 'List every task across all profiles under the data dir, with sizes and download counts.', inputSchema: {} },
187
191
  { name: 'task_status', description: 'Report the active profile/task and the folder paths where this run\'s artifacts are stored.', inputSchema: {} },
@@ -480,10 +484,21 @@ const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
480
484
  * the active tab when `tabId` is omitted, which races under concurrency. */
481
485
  const PARALLEL_TAB_EXEMPT = new Set([
482
486
  'tabs_list', 'tab_new', 'chrome_status', 'batch',
483
- 'profile_use', 'task_new', 'tasks_list', 'task_status',
487
+ 'profile_use', 'profile_rename', 'task_new', 'tasks_list', 'task_status',
484
488
  ]);
485
489
  /** 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']);
490
+ const NO_BACKEND_TOOLS = new Set([
491
+ 'profile_use', 'profile_rename', 'task_new', 'tasks_list', 'task_status',
492
+ // Must answer even when the active profile has no browser — that is exactly
493
+ // when you need it to see which profiles ARE paired.
494
+ 'chrome_status',
495
+ ]);
496
+ /** The bridge, for the profile tools that need live connection state. Set by
497
+ * the CLI once the bridge is up; null in tests that don't run one. */
498
+ let profileBridge = null;
499
+ function setProfileBridge(bridge) {
500
+ profileBridge = bridge;
501
+ }
487
502
  /** Project a Workspace to the path fields worth returning to the caller. */
488
503
  function workspaceView(w) {
489
504
  return {
@@ -911,7 +926,22 @@ exports.TOOL_HANDLERS = {
911
926
  ...(path ? {} : { note: 'no active task workspace, so the PDF was not saved to disk' }),
912
927
  });
913
928
  },
914
- chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
929
+ chrome_status: async () => {
930
+ const profiles = profileBridge ? { profiles: profileBridge.pairedProfiles() } : {};
931
+ try {
932
+ const ex = await (0, manager_1.getManager)().ensureReady();
933
+ return (0, envelopes_1.jsonResult)({ ...ex.status(), ...profiles });
934
+ }
935
+ catch (err) {
936
+ return (0, envelopes_1.jsonResult)({
937
+ ready: false,
938
+ backend: null,
939
+ detail: errMessage(err),
940
+ activeProfile: (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default',
941
+ ...profiles,
942
+ });
943
+ }
944
+ },
915
945
  auth_check: async (a, ctx) => {
916
946
  await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
917
947
  const snap = await ctx.ex.snapshot({ tabId: tabId(a), ...frameOpts(a), interactiveOnly: true, max: 200 });
@@ -932,6 +962,44 @@ exports.TOOL_HANDLERS = {
932
962
  (0, snapdiff_1.resetSnapshots)();
933
963
  return (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') })));
934
964
  },
965
+ profile_rename: async (a) => {
966
+ if (!profileBridge)
967
+ throw new types_1.ExecutorError('NO_BACKEND', 'profile_rename needs the chrome-mcp bridge running');
968
+ let from;
969
+ let to;
970
+ try {
971
+ // Sanitize once so the folder move and the active-profile check below
972
+ // use the same name the bridge routes by.
973
+ from = (0, config_1.sanitizeName)((0, validators_1.requireString)(a, 'from'), 'profile');
974
+ to = await profileBridge.renameProfile(from, (0, validators_1.requireString)(a, 'to'));
975
+ }
976
+ catch (err) {
977
+ // A refusal (typed-in name, name taken, bad name) is the caller's to fix, not a crash.
978
+ throw new validators_1.McpToolError(err instanceof Error ? err.message : String(err));
979
+ }
980
+ // Carry the profile's saved artifacts over, unless the new name already has
981
+ // a folder of its own (never merge or overwrite).
982
+ const ws = (0, workspace_1.getActiveWorkspace)();
983
+ const oldDir = (0, config_1.resolveProfileDir)(ws.dataDir, from);
984
+ const newDir = (0, config_1.resolveProfileDir)(ws.dataDir, to);
985
+ let artifactsMoved = false;
986
+ if (to !== from && (0, node_fs_1.existsSync)(oldDir) && !(0, node_fs_1.existsSync)(newDir)) {
987
+ try {
988
+ (0, node_fs_1.renameSync)(oldDir, newDir);
989
+ artifactsMoved = true;
990
+ }
991
+ catch {
992
+ /* leave them where they are; the rename itself already succeeded */
993
+ }
994
+ }
995
+ // Keep routing to the same browser if it was the active one.
996
+ let active;
997
+ if (ws.profile === from) {
998
+ (0, snapdiff_1.resetSnapshots)();
999
+ active = workspaceView((0, workspace_1.switchWorkspace)({ profile: to }));
1000
+ }
1001
+ return (0, envelopes_1.jsonResult)({ from, to, artifactsMoved, ...(active ? { active } : {}) });
1002
+ },
935
1003
  task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
936
1004
  task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
937
1005
  tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
@@ -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;
@@ -500,6 +502,26 @@
500
502
  else node.value = next;
501
503
  node.dispatchEvent(new Event("input", { bubbles: true }));
502
504
  };
505
+ const clearEditable = (host) => {
506
+ host.focus();
507
+ const sel2 = window.getSelection();
508
+ if (!sel2) return;
509
+ const range = document.createRange();
510
+ range.selectNodeContents(host);
511
+ sel2.removeAllRanges();
512
+ sel2.addRange(range);
513
+ document.execCommand("delete");
514
+ };
515
+ const caretToEnd = (host) => {
516
+ host.focus();
517
+ const sel2 = window.getSelection();
518
+ if (!sel2) return;
519
+ const range = document.createRange();
520
+ range.selectNodeContents(host);
521
+ range.collapse(false);
522
+ sel2.removeAllRanges();
523
+ sel2.addRange(range);
524
+ };
503
525
  const perform = (el2) => {
504
526
  const missing = sel !== null && el2 === null;
505
527
  switch (a.op) {
@@ -524,6 +546,14 @@
524
546
  return { found: true };
525
547
  }
526
548
  case "type": {
549
+ if (!el2) return { found: false };
550
+ if (el2.isContentEditable) {
551
+ if (a.clear) clearEditable(el2);
552
+ else caretToEnd(el2);
553
+ if (a.text) document.execCommand("insertText", false, a.text);
554
+ el2.dispatchEvent(new Event("change", { bubbles: true }));
555
+ return { found: true };
556
+ }
527
557
  const node = el2;
528
558
  if (!node) return { found: false };
529
559
  node.focus();
@@ -533,6 +563,15 @@
533
563
  return { found: true };
534
564
  }
535
565
  case "focus": {
566
+ if (!el2) return { found: false };
567
+ if (el2.isContentEditable) {
568
+ el2.focus();
569
+ if (a.clear) {
570
+ clearEditable(el2);
571
+ caretToEnd(el2);
572
+ }
573
+ return { found: true };
574
+ }
536
575
  const node = el2;
537
576
  if (!node) return { found: false };
538
577
  node.focus();
@@ -1041,9 +1080,22 @@
1041
1080
  });
1042
1081
  }
1043
1082
  async function trustedType(tabId, selector, text, clear, frameIds, pressEnter = false) {
1044
- const focused = await execOp(tabId, withWait({ op: "focus", selector, clear }), frameIds);
1083
+ const focused = await execOp(tabId, withWait({ op: "focus", selector, clear: false }), frameIds);
1045
1084
  if (!focused.found) return false;
1046
1085
  await withDebugger(tabId, async (t) => {
1086
+ if (clear) {
1087
+ for (const type of ["keyDown", "keyUp"]) {
1088
+ await chrome.debugger.sendCommand(t, "Input.dispatchKeyEvent", {
1089
+ type,
1090
+ key: "a",
1091
+ code: "KeyA",
1092
+ windowsVirtualKeyCode: 65,
1093
+ modifiers: 8,
1094
+ // Meta
1095
+ ...type === "keyDown" ? { commands: ["selectAll"] } : {}
1096
+ });
1097
+ }
1098
+ }
1047
1099
  await chrome.debugger.sendCommand(t, "Input.insertText", { text });
1048
1100
  if (!pressEnter) return;
1049
1101
  for (const type of ["keyDown", "keyUp"]) {
@@ -1809,6 +1861,8 @@
1809
1861
  currentPolicy = policy;
1810
1862
  void syncObserverScript(policy, (m) => console.debug("[chrome-mcp]", m));
1811
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 }),
1812
1866
  log: (m) => console.debug("[chrome-mcp]", m)
1813
1867
  });
1814
1868
  var router = new CommandRouter({
@@ -1853,11 +1907,22 @@
1853
1907
  } catch {
1854
1908
  }
1855
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
+ }
1856
1916
  async function getConfig() {
1857
- 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
+ ]);
1858
1923
  if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
1859
- const name = typeof profile === "string" && profile.trim() ? profile.trim() : "default";
1860
- return { wsPort, token, profile: name };
1924
+ const label = typeof profile === "string" ? profile.trim() : "";
1925
+ return { wsPort, token, profile: label, installId: await ensureInstallId(installId) };
1861
1926
  }
1862
1927
  return null;
1863
1928
  }
@@ -1884,7 +1949,7 @@
1884
1949
  if (ws.isConnected() || ws.state === "unauthorized") return;
1885
1950
  const cfg = await getConfig();
1886
1951
  if (!cfg) return;
1887
- ws.connect(cfg.wsPort, cfg.token, cfg.profile);
1952
+ ws.connect(cfg.wsPort, cfg.token, cfg.profile, cfg.installId);
1888
1953
  }
1889
1954
  async function keepalivePulse() {
1890
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.3",
4
+ "version": "0.9.6",
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.4",
3
+ "version": "0.9.6",
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",