@mehmoodqureshi/chrome-mcp 0.5.2 → 0.6.1

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.
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  /**
3
3
  * src/mcp/tools.ts — the MCP tool surface: the advertised catalog
4
- * (`TOOL_DEFINITIONS`), the name→handler dispatch (`TOOL_HANDLERS`), the
5
- * never-throw firewall (`dispatchToolCall`), and `registerTools()` which wires
6
- * both onto a `Server`.
4
+ * (`TOOL_DEFINITIONS`, each with a zod `inputSchema`), the name→handler dispatch
5
+ * (`TOOL_HANDLERS`), the never-throw firewall (`dispatchToolCall`), and
6
+ * `registerTools()` which registers every tool on an `McpServer` via
7
+ * `registerTool` — the SDK validates the zod schema before dispatch runs.
7
8
  *
8
9
  * Each handler: validate args → **policy-gate against the relevant URL** → call
9
10
  * the active Executor (or a server-side helper) → serialize via an envelope.
@@ -17,66 +18,67 @@ exports.dispatchToolCall = dispatchToolCall;
17
18
  exports.assertNoDrift = assertNoDrift;
18
19
  exports.registerTools = registerTools;
19
20
  const node_path_1 = require("node:path");
20
- const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
21
+ const zod_1 = require("zod");
21
22
  const types_1 = require("../executor/types");
22
23
  const manager_1 = require("../executor/manager");
23
24
  const policy_1 = require("../security/policy");
24
25
  const envelopes_1 = require("./envelopes");
25
26
  const batch_1 = require("./batch");
26
27
  const helpers_1 = require("./helpers");
28
+ const tasks_1 = require("../bridge/tasks");
29
+ const workspace_1 = require("../bridge/workspace");
27
30
  const validators_1 = require("./validators");
31
+ /** Shared selector|ref target — both optional; a handler that needs one calls `requireTarget`. */
28
32
  const TARGET_PROPS = {
29
- selector: { type: 'string', description: 'CSS selector (exactly one of selector|ref)' },
30
- ref: { type: 'string', description: 'Element ref from a prior read (exactly one of selector|ref)' },
33
+ selector: zod_1.z.string().describe('CSS selector (exactly one of selector|ref)').optional(),
34
+ ref: zod_1.z.string().describe('Element ref from a prior read (exactly one of selector|ref)').optional(),
31
35
  };
32
- const obj = (properties, required = []) => ({
33
- type: 'object',
34
- properties,
35
- required,
36
- additionalProperties: false,
37
- });
36
+ const tabIdField = zod_1.z.string().describe('Target tab id (defaults to the active tab)').optional();
37
+ const waitUntilField = zod_1.z.enum(['load', 'domcontentloaded', 'networkidle']).describe('When to consider navigation done').optional();
38
38
  exports.TOOL_DEFINITIONS = [
39
- { name: 'tabs_list', description: 'List open browser tabs.', inputSchema: obj({}) },
40
- { name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
41
- { name: 'tab_new', description: 'Open a NEW tab (optionally at a URL) and focus it. Prefer this over `navigate` when the user says "open"/"go to" a site — `navigate` REPLACES the current tab. Pass active:false to open in the background (used by parallel batches).', inputSchema: obj({ url: { type: 'string' }, active: { type: 'boolean' } }) },
42
- { name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
43
- { name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: obj({ url: { type: 'string' }, tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }, ['url']) },
44
- { name: 'back', description: 'Go back in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
45
- { name: 'forward', description: 'Go forward in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
46
- { name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: obj({ tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }) },
47
- { name: 'click', description: 'Click an element (target by selector or a snapshot ref). trusted=true uses real OS-level input.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' }, button: { type: 'string', enum: ['left', 'right', 'middle'] }, clickCount: { type: 'number' }, trusted: { type: 'boolean' } }) },
48
- { name: 'type', description: 'Type text into an element. trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: obj({ ...TARGET_PROPS, text: { type: 'string' }, tabId: { type: 'string' }, clear: { type: 'boolean' }, pressEnter: { type: 'boolean' }, keyEvents: { type: 'boolean' }, trusted: { type: 'boolean' } }, ['text']) },
49
- { name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: obj({ ...TARGET_PROPS, values: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['values']) },
50
- { name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: obj({ key: { type: 'string' }, modifiers: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['key']) },
51
- { name: 'hover', description: 'Hover over an element.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' } }) },
52
- { name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: obj({ ...TARGET_PROPS, x: { type: 'number' }, y: { type: 'number' }, deltaX: { type: 'number' }, deltaY: { type: 'number' }, tabId: { type: 'string' } }) },
53
- { name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: obj({ ...TARGET_PROPS, fullPage: { type: 'boolean' }, tabId: { type: 'string' } }) },
54
- { name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' } }) },
55
- { name: 'get_html', description: 'Get HTML of the page or an element.', inputSchema: obj({ ...TARGET_PROPS, outer: { type: 'boolean' }, tabId: { type: 'string' } }) },
56
- { name: 'snapshot', description: 'Accessibility snapshot: interactive elements with stable refs to target by `ref` (more reliable than guessing CSS selectors).', inputSchema: obj({ interactiveOnly: { type: 'boolean' }, max: { type: 'number' }, tabId: { type: 'string' } }) },
57
- { name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: obj({ url: { type: 'string' }, tabId: { type: 'string' } }) },
58
- { name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: obj({ op: { type: 'string', enum: ['get', 'set', 'remove', 'clear'] }, key: { type: 'string' }, value: { type: 'string' }, session: { type: 'boolean' }, tabId: { type: 'string' } }, ['op']) },
59
- { name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: obj({ expression: { type: 'string' }, awaitPromise: { type: 'boolean' }, tabId: { type: 'string' } }, ['expression']) },
60
- { name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: obj({ selector: { type: 'string' }, textContains: { type: 'string' }, gone: { type: 'boolean' }, timeoutMs: { type: 'number' }, tabId: { type: 'string' } }) },
61
- { name: 'extract_links', description: 'Extract anchors from the page or a subtree.', inputSchema: obj({ selector: { type: 'string' }, sameOriginOnly: { type: 'boolean' }, tabId: { type: 'string' } }) },
62
- { name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: obj({ selector: { type: 'string' }, tabId: { type: 'string' } }) },
63
- { name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: obj({ fields: { type: 'object' }, submitSelector: { type: 'string' }, tabId: { type: 'string' } }, ['fields']) },
64
- { 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
- { 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
- { name: 'chrome_status', description: 'Report backend/session status.', inputSchema: obj({}) },
39
+ { name: 'tabs_list', description: 'List open browser tabs.', inputSchema: {} },
40
+ { name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema: { tabId: zod_1.z.string() } },
41
+ { name: 'tab_new', description: 'Open a NEW tab (optionally at a URL) and focus it. Prefer this over `navigate` when the user says "open"/"go to" a site — `navigate` REPLACES the current tab. Pass active:false to open in the background (used by parallel batches).', inputSchema: { url: zod_1.z.string().optional(), active: zod_1.z.boolean().optional() } },
42
+ { name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: { tabId: zod_1.z.string() } },
43
+ { name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField } },
44
+ { name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField } },
45
+ { name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField } },
46
+ { name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField } },
47
+ { name: 'click', description: 'Click an element (target by selector or a snapshot ref). trusted=true uses real OS-level input.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField, button: zod_1.z.enum(['left', 'right', 'middle']).optional(), clickCount: zod_1.z.number().optional(), trusted: zod_1.z.boolean().optional() } },
48
+ { name: 'type', description: 'Type text into an element. trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: { ...TARGET_PROPS, text: zod_1.z.string(), tabId: tabIdField, clear: zod_1.z.boolean().optional(), pressEnter: zod_1.z.boolean().optional(), keyEvents: zod_1.z.boolean().optional(), trusted: zod_1.z.boolean().optional() } },
49
+ { name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...TARGET_PROPS, values: zod_1.z.array(zod_1.z.string()), tabId: tabIdField } },
50
+ { name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: { key: zod_1.z.string(), modifiers: zod_1.z.array(zod_1.z.string()).optional(), tabId: tabIdField } },
51
+ { name: 'hover', description: 'Hover over an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField } },
52
+ { name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: { ...TARGET_PROPS, x: zod_1.z.number().optional(), y: zod_1.z.number().optional(), deltaX: zod_1.z.number().optional(), deltaY: zod_1.z.number().optional(), tabId: tabIdField } },
53
+ { name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: { ...TARGET_PROPS, fullPage: zod_1.z.boolean().optional(), tabId: tabIdField } },
54
+ { name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField } },
55
+ { name: 'get_html', description: 'Get HTML of the page or an element.', inputSchema: { ...TARGET_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField } },
56
+ { name: 'snapshot', description: 'Accessibility snapshot: interactive elements with stable refs to target by `ref` (more reliable than guessing CSS selectors).', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), tabId: tabIdField } },
57
+ { name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
58
+ { name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
59
+ { name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), tabId: tabIdField } },
60
+ { name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), tabId: tabIdField } },
61
+ { name: 'extract_links', description: 'Extract anchors from the page or a subtree.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), tabId: tabIdField } },
62
+ { name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), tabId: tabIdField } },
63
+ { name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), tabId: tabIdField } },
64
+ { name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: { url: zod_1.z.string().optional(), ...TARGET_PROPS, suggestedName: zod_1.z.string().optional(), tabId: tabIdField } },
65
+ { 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: { ...TARGET_PROPS, files: zod_1.z.array(zod_1.z.string()), tabId: tabIdField } },
66
+ { name: 'chrome_status', description: 'Report backend/session status.', inputSchema: {} },
67
+ { 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).') } },
68
+ { 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).') } },
69
+ { name: 'tasks_list', description: 'List every task across all profiles under the data dir, with sizes and download counts.', inputSchema: {} },
70
+ { name: 'task_status', description: 'Report the active profile/task and the folder paths where this run\'s artifacts are stored.', inputSchema: {} },
67
71
  {
68
72
  name: 'batch',
69
73
  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.',
70
- inputSchema: obj({
71
- ops: {
72
- type: 'array',
73
- description: 'Operations to run; each is a tool name + its args.',
74
- items: obj({ tool: { type: 'string' }, args: { type: 'object' } }, ['tool']),
75
- },
76
- mode: { type: 'string', enum: ['parallel', 'serial'], description: 'Default "parallel".' },
77
- stopOnError: { type: 'boolean', description: 'Serial mode only: stop after the first failing op (the rest are skipped).' },
78
- maxConcurrency: { type: 'number', description: 'Parallel mode: max ops in flight at once (default 6).' },
79
- }, ['ops']),
74
+ inputSchema: {
75
+ ops: zod_1.z
76
+ .array(zod_1.z.object({ tool: zod_1.z.string(), args: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional() }))
77
+ .describe('Operations to run; each is a tool name + its args.'),
78
+ mode: zod_1.z.enum(['parallel', 'serial']).describe('Default "parallel".').optional(),
79
+ stopOnError: zod_1.z.boolean().describe('Serial mode only: stop after the first failing op (the rest are skipped).').optional(),
80
+ maxConcurrency: zod_1.z.number().describe('Parallel mode: max ops in flight at once (default 6).').optional(),
81
+ },
80
82
  },
81
83
  ];
82
84
  /** Resolve the URL the policy should be evaluated against (the active tab). */
@@ -99,7 +101,24 @@ const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
99
101
  /** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
100
102
  * the parallel-batch explicit-tabId requirement. Everything else falls back to
101
103
  * 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']);
104
+ const PARALLEL_TAB_EXEMPT = new Set([
105
+ 'tabs_list', 'tab_new', 'chrome_status', 'batch',
106
+ 'profile_use', 'task_new', 'tasks_list', 'task_status',
107
+ ]);
108
+ /** Server-side tools that manage the task workspace and need no browser backend. */
109
+ const NO_BACKEND_TOOLS = new Set(['profile_use', 'task_new', 'tasks_list', 'task_status']);
110
+ /** Project a Workspace to the path fields worth returning to the caller. */
111
+ function workspaceView(w) {
112
+ return {
113
+ profile: w.profile,
114
+ task: w.task,
115
+ taskDir: w.taskDir,
116
+ downloadDir: w.downloadDir,
117
+ resultsDir: w.resultsDir,
118
+ screenshotsDir: w.screenshotsDir,
119
+ historyPath: w.historyPath,
120
+ };
121
+ }
103
122
  /** A known tool that operates on a specific tab — needs an explicit tabId in a parallel batch. */
104
123
  function requiresExplicitTab(tool) {
105
124
  return tool in exports.TOOL_HANDLERS && !PARALLEL_TAB_EXEMPT.has(tool);
@@ -194,12 +213,15 @@ exports.TOOL_HANDLERS = {
194
213
  fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
195
214
  target: (0, validators_1.optionalTarget)(a),
196
215
  });
216
+ (0, workspace_1.saveScreenshot)(shot.dataBase64);
197
217
  const caption = shot.truncated ? `(truncated; full height ${shot.fullHeight}px)` : undefined;
198
218
  return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
199
219
  },
200
220
  get_text: async (a, ctx) => {
201
221
  await gate(ctx, 'get_text');
202
- return (0, envelopes_1.jsonResult)(await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) }));
222
+ const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
223
+ (0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
224
+ return (0, envelopes_1.jsonResult)(res);
203
225
  },
204
226
  get_html: async (a, ctx) => {
205
227
  await gate(ctx, 'get_html');
@@ -251,15 +273,19 @@ exports.TOOL_HANDLERS = {
251
273
  },
252
274
  extract_links: async (a, ctx) => {
253
275
  await gate(ctx, 'get_text'); // read of page content
254
- return (0, envelopes_1.jsonResult)(await (0, helpers_1.extractLinks)(ctx.ex, {
276
+ const res = await (0, helpers_1.extractLinks)(ctx.ex, {
255
277
  selector: (0, validators_1.optionalString)(a, 'selector'),
256
278
  sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
257
279
  tabId: tabId(a),
258
- }));
280
+ });
281
+ (0, workspace_1.saveResult)('extract_links', 'json', JSON.stringify(res, null, 2));
282
+ return (0, envelopes_1.jsonResult)(res);
259
283
  },
260
284
  read_as_markdown: async (a, ctx) => {
261
285
  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) }));
286
+ const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
287
+ (0, workspace_1.saveResult)('read_as_markdown', 'md', md);
288
+ return (0, envelopes_1.textResult)(md);
263
289
  },
264
290
  fill_form: async (a, ctx) => {
265
291
  await gate(ctx, 'type'); // mutating
@@ -308,6 +334,11 @@ exports.TOOL_HANDLERS = {
308
334
  return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
309
335
  },
310
336
  chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
337
+ // --- task workspace management (server-side; no browser needed) ---
338
+ profile_use: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') }))),
339
+ task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
340
+ task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
341
+ tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
311
342
  // Fan-out: each sub-op is routed back through `dispatchToolCall`, so it gets
312
343
  // the same policy gate, rate limit, and never-throw handling as a direct call.
313
344
  batch: async (a) => (0, batch_1.runBatch)(a, { dispatch: dispatchToolCall, requiresExplicitTab }),
@@ -351,6 +382,24 @@ function allowCall(now) {
351
382
  rateWindow.push(now);
352
383
  return true;
353
384
  }
385
+ /** A compact, length-bounded summary of a call's args for the history log. */
386
+ function summarizeArgs(rawArgs) {
387
+ if (typeof rawArgs !== 'object' || rawArgs === null)
388
+ return undefined;
389
+ const a = rawArgs;
390
+ const out = {};
391
+ for (const k of ['url', 'selector', 'ref', 'name', 'text', 'key', 'op', 'tabId']) {
392
+ const v = a[k];
393
+ if (v === undefined)
394
+ continue;
395
+ out[k] = typeof v === 'string' && v.length > 120 ? `${v.slice(0, 120)}…` : v;
396
+ }
397
+ return Object.keys(out).length ? out : undefined;
398
+ }
399
+ /** Append one action record to the active task's history.jsonl (best-effort). */
400
+ function recordHistory(tool, rawArgs, ok, error) {
401
+ (0, workspace_1.appendHistory)({ ts: new Date().toISOString(), tool, args: summarizeArgs(rawArgs), ok, ...(error ? { error } : {}) });
402
+ }
354
403
  async function dispatchToolCall(name, rawArgs) {
355
404
  const handler = exports.TOOL_HANDLERS[name];
356
405
  if (!handler)
@@ -359,11 +408,17 @@ async function dispatchToolCall(name, rawArgs) {
359
408
  return (0, envelopes_1.errorResult)('rate limit exceeded; slow down');
360
409
  try {
361
410
  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 });
411
+ // Workspace-management tools run server-side and must work even with no
412
+ // browser paired, so they skip the executor readiness check.
413
+ const ex = NO_BACKEND_TOOLS.has(name) ? null : await mgr.ensureReady();
414
+ const result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
415
+ recordHistory(name, rawArgs, !result.isError);
416
+ return result;
364
417
  }
365
418
  catch (err) {
366
- return (0, envelopes_1.errorResult)(errMessage(err));
419
+ const message = errMessage(err);
420
+ recordHistory(name, rawArgs, false, message);
421
+ return (0, envelopes_1.errorResult)(message);
367
422
  }
368
423
  }
369
424
  /** Assert the catalog and the dispatch table describe the same tool set. */
@@ -382,13 +437,12 @@ function assertNoDrift() {
382
437
  // ---------------------------------------------------------------------------
383
438
  function registerTools(server) {
384
439
  assertNoDrift();
385
- server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
386
- tools: exports.TOOL_DEFINITIONS.map((d) => ({
387
- name: d.name,
388
- description: d.description,
389
- inputSchema: d.inputSchema,
390
- })),
391
- }));
392
- server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => dispatchToolCall(req.params.name, req.params.arguments));
440
+ // Register each tool with its zod `inputSchema`. The SDK advertises it in
441
+ // `tools/list` and validates arguments before invoking the handler, which
442
+ // just routes back through `dispatchToolCall` — our never-throw firewall that
443
+ // applies the rate limit, executor readiness, policy gate, and history log.
444
+ for (const d of exports.TOOL_DEFINITIONS) {
445
+ server.registerTool(d.name, { description: d.description, inputSchema: d.inputSchema }, async (args) => dispatchToolCall(d.name, args));
446
+ }
393
447
  }
394
448
  //# sourceMappingURL=tools.js.map
@@ -39,12 +39,13 @@
39
39
  constructor(deps) {
40
40
  this.deps = deps;
41
41
  }
42
+ deps;
42
43
  ws = null;
43
44
  state = "idle";
44
45
  isConnected() {
45
46
  return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
46
47
  }
47
- connect(port, token) {
48
+ connect(port, token, profile) {
48
49
  if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
49
50
  return;
50
51
  }
@@ -62,7 +63,8 @@
62
63
  type: "hello",
63
64
  v: PROTOCOL_VERSION,
64
65
  token,
65
- ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() }
66
+ ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
67
+ profile: profile && profile.trim() ? profile.trim() : void 0
66
68
  };
67
69
  ws2.send(JSON.stringify(hello));
68
70
  };
@@ -423,7 +425,38 @@
423
425
  super(message);
424
426
  this.code = code;
425
427
  }
428
+ code;
426
429
  };
430
+ var DOWNLOAD_TIMEOUT_MS = 12e4;
431
+ function waitForDownloadComplete(id) {
432
+ return new Promise((resolve, reject) => {
433
+ let settled = false;
434
+ const settle = (fn) => {
435
+ if (settled) return;
436
+ settled = true;
437
+ chrome.downloads.onChanged.removeListener(onChanged);
438
+ clearTimeout(timer);
439
+ fn();
440
+ };
441
+ const timer = setTimeout(
442
+ () => settle(() => reject(new CmdError("DOWNLOAD_FAILED", "download timed out"))),
443
+ DOWNLOAD_TIMEOUT_MS
444
+ );
445
+ const check = async () => {
446
+ const [item] = await chrome.downloads.search({ id });
447
+ if (!item) return;
448
+ if (item.state === "complete") settle(() => resolve(item));
449
+ else if (item.state === "interrupted") {
450
+ settle(() => reject(new CmdError("DOWNLOAD_FAILED", `download interrupted: ${item.error ?? "unknown"}`)));
451
+ }
452
+ };
453
+ const onChanged = (delta) => {
454
+ if (delta.id === id) void check();
455
+ };
456
+ chrome.downloads.onChanged.addListener(onChanged);
457
+ void check();
458
+ });
459
+ }
427
460
  var SESSION = crypto.randomUUID();
428
461
  var CONTENT_SCHEME = /^(https?|file):/i;
429
462
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -494,7 +527,7 @@
494
527
  async function waitForSelector(tabId, selector, timeoutMs = 5e3) {
495
528
  const found = await execInTab(
496
529
  tabId,
497
- (s, timeout, interval) => new Promise((resolve) => {
530
+ ((s, timeout, interval) => new Promise((resolve) => {
498
531
  const deadline = Date.now() + timeout;
499
532
  const tick = () => {
500
533
  if (document.querySelector(s)) return resolve(true);
@@ -502,7 +535,7 @@
502
535
  setTimeout(tick, interval);
503
536
  };
504
537
  tick();
505
- }),
538
+ })),
506
539
  [selector, timeoutMs, 120]
507
540
  );
508
541
  return found === true;
@@ -1003,7 +1036,7 @@
1003
1036
  const start = Date.now();
1004
1037
  const matched = await execInTab(
1005
1038
  id,
1006
- (sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
1039
+ ((sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
1007
1040
  const deadline = Date.now() + timeoutMs;
1008
1041
  const hit = () => {
1009
1042
  let present;
@@ -1018,12 +1051,14 @@
1018
1051
  setTimeout(tick, interval);
1019
1052
  };
1020
1053
  tick();
1021
- }),
1054
+ })),
1022
1055
  [cmd.params.selector ?? null, cmd.params.textContains ?? null, cmd.params.gone === true, timeout, 150]
1023
1056
  );
1024
1057
  return { matched: matched === true, waitedMs: Date.now() - start };
1025
1058
  }
1026
- // -- download (user's Downloads dir) --
1059
+ // -- download (saved to the user's Downloads dir; the server then moves it
1060
+ // into the active task's downloads/). We wait for completion and report
1061
+ // the absolute on-disk path so the server can relocate it. --
1027
1062
  case "download_file": {
1028
1063
  const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
1029
1064
  if (!url) throw new CmdError("DOWNLOAD_FAILED", "the extension download path requires a url");
@@ -1031,7 +1066,16 @@
1031
1066
  typeof cmd.params.suggestedName === "string" ? cmd.params.suggestedName : void 0
1032
1067
  );
1033
1068
  const downloadId = await chrome.downloads.download({ url, filename: name });
1034
- return { path: `(downloads)/${name}`, backend: "extension", bytes: 0, suggestedName: name };
1069
+ const item = await waitForDownloadComplete(downloadId);
1070
+ const bytes = item.fileSize > 0 ? item.fileSize : item.bytesReceived;
1071
+ return {
1072
+ path: item.filename,
1073
+ sourcePath: item.filename,
1074
+ downloadId,
1075
+ backend: "extension",
1076
+ bytes,
1077
+ suggestedName: name
1078
+ };
1035
1079
  }
1036
1080
  // -- upload: set local file(s) on a file <input> via CDP DOM.setFileInputFiles --
1037
1081
  case "upload_file": {
@@ -1071,6 +1115,7 @@
1071
1115
  if (!HANDLED.has(m)) throw new Error(`router drift: no handler for wire method "${m}"`);
1072
1116
  }
1073
1117
  }
1118
+ deps;
1074
1119
  async dispatch(cmd) {
1075
1120
  try {
1076
1121
  const policy = this.deps.getPolicy();
@@ -1117,9 +1162,10 @@
1117
1162
  log: (m) => console.debug("[chrome-mcp]", m)
1118
1163
  });
1119
1164
  async function getConfig() {
1120
- const { wsPort, token } = await chrome.storage.local.get(["wsPort", "token"]);
1165
+ const { wsPort, token, profile } = await chrome.storage.local.get(["wsPort", "token", "profile"]);
1121
1166
  if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
1122
- return { wsPort, token };
1167
+ const name = typeof profile === "string" && profile.trim() ? profile.trim() : "default";
1168
+ return { wsPort, token, profile: name };
1123
1169
  }
1124
1170
  return null;
1125
1171
  }
@@ -1146,7 +1192,7 @@
1146
1192
  if (ws.isConnected() || ws.state === "unauthorized") return;
1147
1193
  const cfg = await getConfig();
1148
1194
  if (!cfg) return;
1149
- ws.connect(cfg.wsPort, cfg.token);
1195
+ ws.connect(cfg.wsPort, cfg.token, cfg.profile);
1150
1196
  }
1151
1197
  async function keepalivePulse() {
1152
1198
  await chrome.storage.local.get("connState");
@@ -1158,8 +1204,9 @@
1158
1204
  if (alarm.name === KEEPALIVE_ALARM) void keepalivePulse();
1159
1205
  });
1160
1206
  chrome.storage.onChanged.addListener((changes, area) => {
1161
- if (area === "local" && (changes.wsPort || changes.token)) {
1207
+ if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
1162
1208
  if (ws.state === "unauthorized") ws.state = "idle";
1209
+ if (changes.profile) ws.close();
1163
1210
  void ensureConnected();
1164
1211
  }
1165
1212
  });
@@ -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.1",
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",
@@ -45,15 +45,16 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit",
46
46
  "typecheck:ext": "tsc -p tsconfig.ext.json",
47
47
  "clean": "rimraf dist extension-dist",
48
- "test": "npm run clean && npm run build && npm run build:ext && node --test dist/test",
48
+ "test": "npm run clean && npm run build && npm run build:ext && node --test dist/test/*.test.js",
49
49
  "test:hitl": "npm run build && node dist/hitl/index.js",
50
50
  "prepack": "npm run clean && npm run build && npm run build:ext",
51
51
  "postinstall": "node scripts/postinstall.js"
52
52
  },
53
53
  "dependencies": {
54
- "@modelcontextprotocol/sdk": "^1.0.4",
54
+ "@modelcontextprotocol/sdk": "^1.29.0",
55
55
  "playwright": "^1.49.1",
56
- "ws": "^8.18.0"
56
+ "ws": "^8.18.0",
57
+ "zod": "^4.4.3"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/chrome": "^0.0.287",