@mehmoodqureshi/chrome-mcp 0.5.2 → 0.6.0

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.
@@ -24,6 +24,8 @@ const policy_1 = require("../security/policy");
24
24
  const envelopes_1 = require("./envelopes");
25
25
  const batch_1 = require("./batch");
26
26
  const helpers_1 = require("./helpers");
27
+ const tasks_1 = require("../bridge/tasks");
28
+ const workspace_1 = require("../bridge/workspace");
27
29
  const validators_1 = require("./validators");
28
30
  const TARGET_PROPS = {
29
31
  selector: { type: 'string', description: 'CSS selector (exactly one of selector|ref)' },
@@ -64,6 +66,10 @@ exports.TOOL_DEFINITIONS = [
64
66
  { name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: obj({ url: { type: 'string' }, ...TARGET_PROPS, suggestedName: { type: 'string' }, tabId: { type: 'string' } }) },
65
67
  { name: 'upload_file', description: 'Set local file(s) on a file <input> (target by selector or ref) — uploads without the OS dialog. Requires --enable-uploads. `files` are absolute local paths.', inputSchema: obj({ ...TARGET_PROPS, files: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['files']) },
66
68
  { name: 'chrome_status', description: 'Report backend/session status.', inputSchema: obj({}) },
69
+ { 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: obj({ name: { type: 'string', description: 'Profile name (becomes a folder; sanitized to a safe path segment).' } }, ['name']) },
70
+ { 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: obj({ name: { type: 'string', description: 'Task name (becomes a folder; sanitized to a safe path segment).' } }, ['name']) },
71
+ { name: 'tasks_list', description: 'List every task across all profiles under the data dir, with sizes and download counts.', inputSchema: obj({}) },
72
+ { name: 'task_status', description: 'Report the active profile/task and the folder paths where this run\'s artifacts are stored.', inputSchema: obj({}) },
67
73
  {
68
74
  name: 'batch',
69
75
  description: 'Run multiple tool calls in one request — parallel (default) or serial. Each op is { tool, args } and goes through the same policy gate, rate limit, and error handling as a direct call. In parallel mode, tab-scoped ops MUST pass an explicit tabId (the active-tab default is unsafe under concurrency). Use to drive several tabs at once (e.g. open tabs, then batch get_text across them). Cannot be nested.',
@@ -99,7 +105,24 @@ const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
99
105
  /** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
100
106
  * the parallel-batch explicit-tabId requirement. Everything else falls back to
101
107
  * the active tab when `tabId` is omitted, which races under concurrency. */
102
- const PARALLEL_TAB_EXEMPT = new Set(['tabs_list', 'tab_new', 'chrome_status', 'batch']);
108
+ const PARALLEL_TAB_EXEMPT = new Set([
109
+ 'tabs_list', 'tab_new', 'chrome_status', 'batch',
110
+ 'profile_use', 'task_new', 'tasks_list', 'task_status',
111
+ ]);
112
+ /** Server-side tools that manage the task workspace and need no browser backend. */
113
+ const NO_BACKEND_TOOLS = new Set(['profile_use', 'task_new', 'tasks_list', 'task_status']);
114
+ /** Project a Workspace to the path fields worth returning to the caller. */
115
+ function workspaceView(w) {
116
+ return {
117
+ profile: w.profile,
118
+ task: w.task,
119
+ taskDir: w.taskDir,
120
+ downloadDir: w.downloadDir,
121
+ resultsDir: w.resultsDir,
122
+ screenshotsDir: w.screenshotsDir,
123
+ historyPath: w.historyPath,
124
+ };
125
+ }
103
126
  /** A known tool that operates on a specific tab — needs an explicit tabId in a parallel batch. */
104
127
  function requiresExplicitTab(tool) {
105
128
  return tool in exports.TOOL_HANDLERS && !PARALLEL_TAB_EXEMPT.has(tool);
@@ -194,12 +217,15 @@ exports.TOOL_HANDLERS = {
194
217
  fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
195
218
  target: (0, validators_1.optionalTarget)(a),
196
219
  });
220
+ (0, workspace_1.saveScreenshot)(shot.dataBase64);
197
221
  const caption = shot.truncated ? `(truncated; full height ${shot.fullHeight}px)` : undefined;
198
222
  return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
199
223
  },
200
224
  get_text: async (a, ctx) => {
201
225
  await gate(ctx, 'get_text');
202
- return (0, envelopes_1.jsonResult)(await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) }));
226
+ const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
227
+ (0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
228
+ return (0, envelopes_1.jsonResult)(res);
203
229
  },
204
230
  get_html: async (a, ctx) => {
205
231
  await gate(ctx, 'get_html');
@@ -251,15 +277,19 @@ exports.TOOL_HANDLERS = {
251
277
  },
252
278
  extract_links: async (a, ctx) => {
253
279
  await gate(ctx, 'get_text'); // read of page content
254
- return (0, envelopes_1.jsonResult)(await (0, helpers_1.extractLinks)(ctx.ex, {
280
+ const res = await (0, helpers_1.extractLinks)(ctx.ex, {
255
281
  selector: (0, validators_1.optionalString)(a, 'selector'),
256
282
  sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
257
283
  tabId: tabId(a),
258
- }));
284
+ });
285
+ (0, workspace_1.saveResult)('extract_links', 'json', JSON.stringify(res, null, 2));
286
+ return (0, envelopes_1.jsonResult)(res);
259
287
  },
260
288
  read_as_markdown: async (a, ctx) => {
261
289
  await gate(ctx, 'get_text');
262
- return (0, envelopes_1.textResult)(await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) }));
290
+ const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
291
+ (0, workspace_1.saveResult)('read_as_markdown', 'md', md);
292
+ return (0, envelopes_1.textResult)(md);
263
293
  },
264
294
  fill_form: async (a, ctx) => {
265
295
  await gate(ctx, 'type'); // mutating
@@ -308,6 +338,11 @@ exports.TOOL_HANDLERS = {
308
338
  return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
309
339
  },
310
340
  chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
341
+ // --- task workspace management (server-side; no browser needed) ---
342
+ profile_use: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') }))),
343
+ task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
344
+ task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
345
+ tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
311
346
  // Fan-out: each sub-op is routed back through `dispatchToolCall`, so it gets
312
347
  // the same policy gate, rate limit, and never-throw handling as a direct call.
313
348
  batch: async (a) => (0, batch_1.runBatch)(a, { dispatch: dispatchToolCall, requiresExplicitTab }),
@@ -351,6 +386,24 @@ function allowCall(now) {
351
386
  rateWindow.push(now);
352
387
  return true;
353
388
  }
389
+ /** A compact, length-bounded summary of a call's args for the history log. */
390
+ function summarizeArgs(rawArgs) {
391
+ if (typeof rawArgs !== 'object' || rawArgs === null)
392
+ return undefined;
393
+ const a = rawArgs;
394
+ const out = {};
395
+ for (const k of ['url', 'selector', 'ref', 'name', 'text', 'key', 'op', 'tabId']) {
396
+ const v = a[k];
397
+ if (v === undefined)
398
+ continue;
399
+ out[k] = typeof v === 'string' && v.length > 120 ? `${v.slice(0, 120)}…` : v;
400
+ }
401
+ return Object.keys(out).length ? out : undefined;
402
+ }
403
+ /** Append one action record to the active task's history.jsonl (best-effort). */
404
+ function recordHistory(tool, rawArgs, ok, error) {
405
+ (0, workspace_1.appendHistory)({ ts: new Date().toISOString(), tool, args: summarizeArgs(rawArgs), ok, ...(error ? { error } : {}) });
406
+ }
354
407
  async function dispatchToolCall(name, rawArgs) {
355
408
  const handler = exports.TOOL_HANDLERS[name];
356
409
  if (!handler)
@@ -359,11 +412,17 @@ async function dispatchToolCall(name, rawArgs) {
359
412
  return (0, envelopes_1.errorResult)('rate limit exceeded; slow down');
360
413
  try {
361
414
  const mgr = (0, manager_1.getManager)();
362
- const ex = await mgr.ensureReady();
363
- return await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
415
+ // Workspace-management tools run server-side and must work even with no
416
+ // browser paired, so they skip the executor readiness check.
417
+ const ex = NO_BACKEND_TOOLS.has(name) ? null : await mgr.ensureReady();
418
+ const result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
419
+ recordHistory(name, rawArgs, !result.isError);
420
+ return result;
364
421
  }
365
422
  catch (err) {
366
- return (0, envelopes_1.errorResult)(errMessage(err));
423
+ const message = errMessage(err);
424
+ recordHistory(name, rawArgs, false, message);
425
+ return (0, envelopes_1.errorResult)(message);
367
426
  }
368
427
  }
369
428
  /** Assert the catalog and the dispatch table describe the same tool set. */
@@ -44,7 +44,7 @@
44
44
  isConnected() {
45
45
  return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
46
46
  }
47
- connect(port, token) {
47
+ connect(port, token, profile) {
48
48
  if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
49
49
  return;
50
50
  }
@@ -62,7 +62,8 @@
62
62
  type: "hello",
63
63
  v: PROTOCOL_VERSION,
64
64
  token,
65
- ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() }
65
+ ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
66
+ profile: profile && profile.trim() ? profile.trim() : void 0
66
67
  };
67
68
  ws2.send(JSON.stringify(hello));
68
69
  };
@@ -424,6 +425,36 @@
424
425
  this.code = code;
425
426
  }
426
427
  };
428
+ var DOWNLOAD_TIMEOUT_MS = 12e4;
429
+ function waitForDownloadComplete(id) {
430
+ return new Promise((resolve, reject) => {
431
+ let settled = false;
432
+ const settle = (fn) => {
433
+ if (settled) return;
434
+ settled = true;
435
+ chrome.downloads.onChanged.removeListener(onChanged);
436
+ clearTimeout(timer);
437
+ fn();
438
+ };
439
+ const timer = setTimeout(
440
+ () => settle(() => reject(new CmdError("DOWNLOAD_FAILED", "download timed out"))),
441
+ DOWNLOAD_TIMEOUT_MS
442
+ );
443
+ const check = async () => {
444
+ const [item] = await chrome.downloads.search({ id });
445
+ if (!item) return;
446
+ if (item.state === "complete") settle(() => resolve(item));
447
+ else if (item.state === "interrupted") {
448
+ settle(() => reject(new CmdError("DOWNLOAD_FAILED", `download interrupted: ${item.error ?? "unknown"}`)));
449
+ }
450
+ };
451
+ const onChanged = (delta) => {
452
+ if (delta.id === id) void check();
453
+ };
454
+ chrome.downloads.onChanged.addListener(onChanged);
455
+ void check();
456
+ });
457
+ }
427
458
  var SESSION = crypto.randomUUID();
428
459
  var CONTENT_SCHEME = /^(https?|file):/i;
429
460
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -1023,7 +1054,9 @@
1023
1054
  );
1024
1055
  return { matched: matched === true, waitedMs: Date.now() - start };
1025
1056
  }
1026
- // -- download (user's Downloads dir) --
1057
+ // -- download (saved to the user's Downloads dir; the server then moves it
1058
+ // into the active task's downloads/). We wait for completion and report
1059
+ // the absolute on-disk path so the server can relocate it. --
1027
1060
  case "download_file": {
1028
1061
  const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
1029
1062
  if (!url) throw new CmdError("DOWNLOAD_FAILED", "the extension download path requires a url");
@@ -1031,7 +1064,16 @@
1031
1064
  typeof cmd.params.suggestedName === "string" ? cmd.params.suggestedName : void 0
1032
1065
  );
1033
1066
  const downloadId = await chrome.downloads.download({ url, filename: name });
1034
- return { path: `(downloads)/${name}`, backend: "extension", bytes: 0, suggestedName: name };
1067
+ const item = await waitForDownloadComplete(downloadId);
1068
+ const bytes = item.fileSize > 0 ? item.fileSize : item.bytesReceived;
1069
+ return {
1070
+ path: item.filename,
1071
+ sourcePath: item.filename,
1072
+ downloadId,
1073
+ backend: "extension",
1074
+ bytes,
1075
+ suggestedName: name
1076
+ };
1035
1077
  }
1036
1078
  // -- upload: set local file(s) on a file <input> via CDP DOM.setFileInputFiles --
1037
1079
  case "upload_file": {
@@ -1117,9 +1159,10 @@
1117
1159
  log: (m) => console.debug("[chrome-mcp]", m)
1118
1160
  });
1119
1161
  async function getConfig() {
1120
- const { wsPort, token } = await chrome.storage.local.get(["wsPort", "token"]);
1162
+ const { wsPort, token, profile } = await chrome.storage.local.get(["wsPort", "token", "profile"]);
1121
1163
  if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
1122
- return { wsPort, token };
1164
+ const name = typeof profile === "string" && profile.trim() ? profile.trim() : "default";
1165
+ return { wsPort, token, profile: name };
1123
1166
  }
1124
1167
  return null;
1125
1168
  }
@@ -1146,7 +1189,7 @@
1146
1189
  if (ws.isConnected() || ws.state === "unauthorized") return;
1147
1190
  const cfg = await getConfig();
1148
1191
  if (!cfg) return;
1149
- ws.connect(cfg.wsPort, cfg.token);
1192
+ ws.connect(cfg.wsPort, cfg.token, cfg.profile);
1150
1193
  }
1151
1194
  async function keepalivePulse() {
1152
1195
  await chrome.storage.local.get("connState");
@@ -1158,8 +1201,9 @@
1158
1201
  if (alarm.name === KEEPALIVE_ALARM) void keepalivePulse();
1159
1202
  });
1160
1203
  chrome.storage.onChanged.addListener((changes, area) => {
1161
- if (area === "local" && (changes.wsPort || changes.token)) {
1204
+ if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
1162
1205
  if (ws.state === "unauthorized") ws.state = "idle";
1206
+ if (changes.profile) ws.close();
1163
1207
  void ensureConnected();
1164
1208
  }
1165
1209
  });
@@ -25,6 +25,13 @@
25
25
  <input id="port" type="number" inputmode="numeric" placeholder="38017" />
26
26
  <label for="token">Token</label>
27
27
  <input id="token" type="password" placeholder="paste the handshake token" />
28
+ <label for="profile">Profile</label>
29
+ <input id="profile" type="text" placeholder="default" />
30
+ <p class="hint">
31
+ A name for this browser (e.g. <code>mehmood</code>). Several browsers can pair to the
32
+ same port &amp; token at once, each with its own profile — switch between them with
33
+ <code>profile_use</code>. Leave blank for <code>default</code>.
34
+ </p>
28
35
  <button id="save">Save & connect</button>
29
36
  <div class="status" id="status">Status: unknown</div>
30
37
  <script src="options.js"></script>
@@ -6,11 +6,13 @@
6
6
  // extension/src/options/options.ts
7
7
  var portEl = document.getElementById("port");
8
8
  var tokenEl = document.getElementById("token");
9
+ var profileEl = document.getElementById("profile");
9
10
  var saveEl = document.getElementById("save");
10
11
  var statusEl = document.getElementById("status");
11
12
  async function loadExisting() {
12
- const { wsPort, connState } = await chrome.storage.local.get(["wsPort", "connState"]);
13
+ const { wsPort, profile, connState } = await chrome.storage.local.get(["wsPort", "profile", "connState"]);
13
14
  portEl.value = typeof wsPort === "number" && wsPort > 0 ? String(wsPort) : String(DEFAULT_WS_PORT);
15
+ profileEl.value = typeof profile === "string" ? profile : "";
14
16
  render(typeof connState === "string" ? connState : "idle");
15
17
  }
16
18
  function render(state) {
@@ -25,11 +27,12 @@
25
27
  saveEl.addEventListener("click", async () => {
26
28
  const wsPort = Number(portEl.value);
27
29
  const token = tokenEl.value.trim();
30
+ const profile = profileEl.value.trim() || "default";
28
31
  if (!Number.isInteger(wsPort) || wsPort <= 0 || !token) {
29
32
  statusEl.textContent = "Status: enter a valid port (> 0) and token";
30
33
  return;
31
34
  }
32
- await chrome.storage.local.set({ wsPort, token });
35
+ await chrome.storage.local.set({ wsPort, token, profile });
33
36
  await chrome.runtime.sendMessage({ type: "reconnect" }).catch(() => void 0);
34
37
  statusEl.textContent = "Status: \u2026 connecting";
35
38
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Drive a real Chrome browser over MCP. A stdio MCP server (CLI) plus an MV3 extension, behind one pluggable Executor (extension via chrome.scripting, or a Playwright CDP fallback).",
5
5
  "author": "Mehmood Ur Rehman Qureshi",
6
6
  "license": "MIT",