@mehmoodqureshi/chrome-mcp 0.6.0 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # chrome-mcp
2
2
 
3
+ [![CI](https://github.com/Mehmoodqureshi/chrome-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Mehmoodqureshi/chrome-mcp/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/%40mehmoodqureshi%2Fchrome-mcp?label=npm)](https://www.npmjs.com/package/@mehmoodqureshi/chrome-mcp)
5
+ [![license](https://img.shields.io/npm/l/%40mehmoodqureshi%2Fchrome-mcp?label=license)](LICENSE)
6
+
3
7
  Drive a **real Chrome browser** from Claude (or any MCP host). One pluggable
4
8
  `Executor` interface, two backends:
5
9
 
@@ -80,17 +80,38 @@ function hostOf(url) {
80
80
  function isAboutBlank(url) {
81
81
  return url === 'about:blank' || url === '' || url.startsWith('about:');
82
82
  }
83
+ /**
84
+ * Reduce an allowlist entry to the bare host it constrains. Users routinely paste
85
+ * a full URL ("https://example.com/app") or a "host:port/path" instead of a bare
86
+ * host; those forms would never equal a hostname and so silently match nothing.
87
+ * We strip the scheme, userinfo, port, and path/query/fragment, preserving a
88
+ * leading "*." wildcard and the two catch-all forms. Returns '' for a pattern
89
+ * that carries no host (which then matches nothing).
90
+ */
91
+ function normalizeDomainPattern(pattern) {
92
+ let p = pattern.trim().toLowerCase();
93
+ if (p === '*' || p === '*://*/*')
94
+ return '*';
95
+ // Strip a leading scheme ("https://", "http://", any "scheme://").
96
+ p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
97
+ // Drop everything from the first path/query/fragment separator onward.
98
+ p = p.replace(/[/?#].*$/, '');
99
+ // Strip userinfo ("user:pass@") then a trailing port (":8080").
100
+ p = p.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
101
+ return p;
102
+ }
83
103
  /** Convert a single domain glob to a predicate. '*' matches everything;
84
- * '*.example.com' matches example.com and any subdomain; otherwise exact host. */
104
+ * '*.example.com' matches example.com and any subdomain; otherwise exact host.
105
+ * URL/port/path forms are normalized to their bare host first. */
85
106
  function globMatches(host, pattern) {
86
- const p = pattern.trim().toLowerCase();
87
- if (p === '*' || p === '*://*/*')
107
+ const p = normalizeDomainPattern(pattern);
108
+ if (p === '*')
88
109
  return true;
89
110
  if (p.startsWith('*.')) {
90
111
  const base = p.slice(2);
91
112
  return host === base || host.endsWith('.' + base);
92
113
  }
93
- return host === p;
114
+ return p !== '' && host === p;
94
115
  }
95
116
  function isDomainAllowed(url, policy) {
96
117
  const host = hostOf(url);
@@ -162,6 +162,14 @@ function parseArgs(argv) {
162
162
  throw new Error(`unknown argument: ${arg}`);
163
163
  }
164
164
  }
165
+ // Fallback permanently removed — this build is EXTENSION-ONLY. It NEVER
166
+ // launches or attaches a Chromium of its own; it only ever drives the user's
167
+ // real Chrome through the paired extension. Any CDP flags (--cdp-fallback,
168
+ // --cdp-endpoint, --prefer cdp) are still accepted for back-compat but are
169
+ // hard-overridden here so no separate/"fallback" browser can ever open.
170
+ cdpFallback = false;
171
+ cdpEndpoint = undefined;
172
+ prefer = 'extension';
165
173
  // File first, then flags win.
166
174
  const policy = (0, policy_1.resolvePolicy)({ ...policyFile, ...policyFlags });
167
175
  // Uploads must be confined to a directory — refuse to start with uploads enabled
@@ -242,13 +250,14 @@ Connection:
242
250
  CHROME_MCP_TOKEN env, if set, pins the token explicitly.
243
251
 
244
252
  Backend:
245
- --cdp-fallback Opt in to launching/attaching Chromium when no extension
246
- is paired. OFF by default extension-only, never opens
247
- a browser of its own.
248
- --no-cdp-fallback Explicitly disable the fallback (already the default).
249
- --cdp-endpoint <url> Attach to an existing Chrome (e.g. http://127.0.0.1:9222)
250
- --prefer <which> "extension" (default) or "cdp"
251
- --headless Run the CDP-fallback Chromium headless
253
+ This build is EXTENSION-ONLY it drives ONLY your real Chrome via the paired
254
+ extension and never launches or attaches a Chromium of its own. The CDP flags
255
+ below are accepted for back-compat but IGNORED (there is no fallback).
256
+ --cdp-fallback (ignored fallback permanently removed)
257
+ --no-cdp-fallback (ignored extension-only is always on)
258
+ --cdp-endpoint <url> (ignored no CDP attach)
259
+ --prefer <which> (ignored always "extension")
260
+ --headless (ignored — no CDP Chromium to run headless)
252
261
 
253
262
  Security (default: deny-all safe mode):
254
263
  --policy <file> Load a JSON policy file
@@ -13,10 +13,16 @@ export interface LinkOut {
13
13
  /**
14
14
  * Collect anchors from the page (or a subtree). Implemented as a single page
15
15
  * eval so it is one round-trip; falls back to parsing getHtml if eval is denied.
16
+ *
17
+ * `dedupe` collapses anchors that share an href (nav/footer repetition is common
18
+ * noise when crawling); `limit` caps the number of links returned. Both are
19
+ * applied server-side after collection, so they work on either code path.
16
20
  */
17
21
  export declare function extractLinks(ex: Executor, args: {
18
22
  selector?: string;
19
23
  sameOriginOnly?: boolean;
24
+ dedupe?: boolean;
25
+ limit?: number;
20
26
  tabId?: string;
21
27
  }): Promise<{
22
28
  links: LinkOut[];
@@ -13,6 +13,10 @@ const markdown_extract_1 = require("./markdown-extract");
13
13
  /**
14
14
  * Collect anchors from the page (or a subtree). Implemented as a single page
15
15
  * eval so it is one round-trip; falls back to parsing getHtml if eval is denied.
16
+ *
17
+ * `dedupe` collapses anchors that share an href (nav/footer repetition is common
18
+ * noise when crawling); `limit` caps the number of links returned. Both are
19
+ * applied server-side after collection, so they work on either code path.
16
20
  */
17
21
  async function extractLinks(ex, args) {
18
22
  const root = args.selector ? JSON.stringify(args.selector) : 'null';
@@ -24,21 +28,46 @@ async function extractLinks(ex, args) {
24
28
  href: a.href, text: (a.textContent || '').trim().slice(0, 200),
25
29
  })).filter(l => l.href && (${args.sameOriginOnly ? 'l.href.startsWith(here)' : 'true'}));
26
30
  })()`;
31
+ let links;
27
32
  const res = await ex.eval(expr, { tabId: args.tabId });
28
33
  if (res.ok && Array.isArray(res.value)) {
29
- return { links: res.value };
34
+ links = res.value;
30
35
  }
31
- // Fallback: parse hrefs out of the HTML (e.g. when eval is policy-denied).
32
- const { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
33
- tabId: args.tabId,
34
- });
35
- const links = [];
36
- const re = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
37
- let m;
38
- while ((m = re.exec(html)) !== null) {
39
- links.push({ href: m[1], text: m[2].replace(/<[^>]+>/g, '').trim().slice(0, 200) });
36
+ else {
37
+ // Fallback: parse hrefs out of the HTML (e.g. when eval is policy-denied).
38
+ const { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
39
+ tabId: args.tabId,
40
+ });
41
+ links = [];
42
+ const re = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
43
+ let m;
44
+ while ((m = re.exec(html)) !== null) {
45
+ links.push({ href: m[1], text: m[2].replace(/<[^>]+>/g, '').trim().slice(0, 200) });
46
+ }
47
+ }
48
+ return { links: refineLinks(links, args) };
49
+ }
50
+ /**
51
+ * Collapse links that share an href (keeping the first, but preferring a
52
+ * non-empty label) when `dedupe` is set, then cap to `limit`. Order is
53
+ * preserved so the first occurrence of each href wins.
54
+ */
55
+ function refineLinks(links, opts) {
56
+ let out = links;
57
+ if (opts.dedupe) {
58
+ const byHref = new Map();
59
+ for (const l of links) {
60
+ const existing = byHref.get(l.href);
61
+ if (!existing)
62
+ byHref.set(l.href, { ...l });
63
+ else if (!existing.text && l.text)
64
+ existing.text = l.text;
65
+ }
66
+ out = [...byHref.values()];
40
67
  }
41
- return { links };
68
+ if (opts.limit !== undefined && out.length > opts.limit)
69
+ out = out.slice(0, opts.limit);
70
+ return out;
42
71
  }
43
72
  /** Read a page (or subtree) as readable markdown. */
44
73
  async function readAsMarkdown(ex, args) {
@@ -6,11 +6,11 @@
6
6
  * NOTHING may be written to stdout except the JSON-RPC stream — all diagnostics
7
7
  * go to stderr via `logErr`.
8
8
  */
9
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  /** stderr only — never stdout in stdio mode. */
11
11
  export declare function logErr(message: string): void;
12
12
  /** Build a fresh `Server` with the full tool surface registered (no transport). */
13
- export declare function createServer(version?: string): Server;
13
+ export declare function createServer(version?: string): McpServer;
14
14
  /** Start over stdio. Idempotent. */
15
15
  export declare function startMcpServer(version?: string): Promise<void>;
16
16
  /** Stop and release the transport. Idempotent, best-effort. */
@@ -13,7 +13,7 @@ exports.createServer = createServer;
13
13
  exports.startMcpServer = startMcpServer;
14
14
  exports.stopMcpServer = stopMcpServer;
15
15
  exports.isMcpServerRunning = isMcpServerRunning;
16
- const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
16
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
17
17
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
18
18
  const tools_1 = require("./tools");
19
19
  const SERVER_NAME = 'chrome-mcp';
@@ -28,9 +28,10 @@ function logErr(message) {
28
28
  }
29
29
  /** Build a fresh `Server` with the full tool surface registered (no transport). */
30
30
  function createServer(version = DEFAULT_VERSION) {
31
- const srv = new index_js_1.Server({ name: SERVER_NAME, version }, { capabilities: { tools: {} } });
31
+ const srv = new mcp_js_1.McpServer({ name: SERVER_NAME, version }, { capabilities: { tools: {} } });
32
32
  (0, tools_1.registerTools)(srv);
33
- srv.onerror = (err) => {
33
+ // `McpServer` wraps the low-level `Server`, which owns the `onerror` hook.
34
+ srv.server.onerror = (err) => {
34
35
  logErr(`server error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
35
36
  };
36
37
  return srv;
@@ -1,22 +1,25 @@
1
1
  /**
2
2
  * src/mcp/tools.ts — the MCP tool surface: the advertised catalog
3
- * (`TOOL_DEFINITIONS`), the name→handler dispatch (`TOOL_HANDLERS`), the
4
- * never-throw firewall (`dispatchToolCall`), and `registerTools()` which wires
5
- * both onto a `Server`.
3
+ * (`TOOL_DEFINITIONS`, each with a zod `inputSchema`), the name→handler dispatch
4
+ * (`TOOL_HANDLERS`), the never-throw firewall (`dispatchToolCall`), and
5
+ * `registerTools()` which registers every tool on an `McpServer` via
6
+ * `registerTool` — the SDK validates the zod schema before dispatch runs.
6
7
  *
7
8
  * Each handler: validate args → **policy-gate against the relevant URL** → call
8
9
  * the active Executor (or a server-side helper) → serialize via an envelope.
9
10
  * Nothing here throws to the transport: `dispatchToolCall` renders any thrown
10
11
  * `Error` as an `isError` result.
11
12
  */
12
- import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
- import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
13
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
15
+ import { z } from 'zod';
14
16
  import type { Executor } from '../executor/types';
15
17
  import { type Policy } from '../security/policy';
16
18
  export interface ToolDefinition {
17
19
  name: string;
18
20
  description: string;
19
- inputSchema: Record<string, unknown>;
21
+ /** zod raw shape passed to `McpServer.registerTool`; the SDK validates it before dispatch. */
22
+ inputSchema: z.ZodRawShape;
20
23
  }
21
24
  export declare const TOOL_DEFINITIONS: ToolDefinition[];
22
25
  interface ToolCtx {
@@ -30,5 +33,5 @@ export declare function resetRateLimiter(): void;
30
33
  export declare function dispatchToolCall(name: string, rawArgs: unknown): Promise<CallToolResult>;
31
34
  /** Assert the catalog and the dispatch table describe the same tool set. */
32
35
  export declare function assertNoDrift(): void;
33
- export declare function registerTools(server: Server): void;
36
+ export declare function registerTools(server: McpServer): void;
34
37
  export {};
@@ -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,7 +18,7 @@ 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");
@@ -27,62 +28,57 @@ const helpers_1 = require("./helpers");
27
28
  const tasks_1 = require("../bridge/tasks");
28
29
  const workspace_1 = require("../bridge/workspace");
29
30
  const validators_1 = require("./validators");
31
+ /** Shared selector|ref target — both optional; a handler that needs one calls `requireTarget`. */
30
32
  const TARGET_PROPS = {
31
- selector: { type: 'string', description: 'CSS selector (exactly one of selector|ref)' },
32
- 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(),
33
35
  };
34
- const obj = (properties, required = []) => ({
35
- type: 'object',
36
- properties,
37
- required,
38
- additionalProperties: false,
39
- });
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();
40
38
  exports.TOOL_DEFINITIONS = [
41
- { name: 'tabs_list', description: 'List open browser tabs.', inputSchema: obj({}) },
42
- { name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
43
- { 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' } }) },
44
- { name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
45
- { 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']) },
46
- { name: 'back', description: 'Go back in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
47
- { name: 'forward', description: 'Go forward in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
48
- { name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: obj({ tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }) },
49
- { 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' } }) },
50
- { 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']) },
51
- { 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']) },
52
- { name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: obj({ key: { type: 'string' }, modifiers: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['key']) },
53
- { name: 'hover', description: 'Hover over an element.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' } }) },
54
- { 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' } }) },
55
- { name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: obj({ ...TARGET_PROPS, fullPage: { type: 'boolean' }, tabId: { type: 'string' } }) },
56
- { name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' } }) },
57
- { name: 'get_html', description: 'Get HTML of the page or an element.', inputSchema: obj({ ...TARGET_PROPS, outer: { type: 'boolean' }, tabId: { type: 'string' } }) },
58
- { 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' } }) },
59
- { name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: obj({ url: { type: 'string' }, tabId: { type: 'string' } }) },
60
- { 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']) },
61
- { name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: obj({ expression: { type: 'string' }, awaitPromise: { type: 'boolean' }, tabId: { type: 'string' } }, ['expression']) },
62
- { 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' } }) },
63
- { name: 'extract_links', description: 'Extract anchors from the page or a subtree.', inputSchema: obj({ selector: { type: 'string' }, sameOriginOnly: { type: 'boolean' }, tabId: { type: 'string' } }) },
64
- { name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: obj({ selector: { type: 'string' }, tabId: { type: 'string' } }) },
65
- { 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']) },
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' } }) },
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']) },
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({}) },
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. dedupe=true collapses links sharing an href (nav/footer noise); limit caps the count.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), dedupe: zod_1.z.boolean().optional(), limit: zod_1.z.number().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: {} },
73
71
  {
74
72
  name: 'batch',
75
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.',
76
- inputSchema: obj({
77
- ops: {
78
- type: 'array',
79
- description: 'Operations to run; each is a tool name + its args.',
80
- items: obj({ tool: { type: 'string' }, args: { type: 'object' } }, ['tool']),
81
- },
82
- mode: { type: 'string', enum: ['parallel', 'serial'], description: 'Default "parallel".' },
83
- stopOnError: { type: 'boolean', description: 'Serial mode only: stop after the first failing op (the rest are skipped).' },
84
- maxConcurrency: { type: 'number', description: 'Parallel mode: max ops in flight at once (default 6).' },
85
- }, ['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
+ },
86
82
  },
87
83
  ];
88
84
  /** Resolve the URL the policy should be evaluated against (the active tab). */
@@ -280,6 +276,8 @@ exports.TOOL_HANDLERS = {
280
276
  const res = await (0, helpers_1.extractLinks)(ctx.ex, {
281
277
  selector: (0, validators_1.optionalString)(a, 'selector'),
282
278
  sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
279
+ dedupe: (0, validators_1.optionalBoolean)(a, 'dedupe'),
280
+ limit: (0, validators_1.optionalNumber)(a, 'limit', { min: 1, max: 10_000 }),
283
281
  tabId: tabId(a),
284
282
  });
285
283
  (0, workspace_1.saveResult)('extract_links', 'json', JSON.stringify(res, null, 2));
@@ -441,13 +439,12 @@ function assertNoDrift() {
441
439
  // ---------------------------------------------------------------------------
442
440
  function registerTools(server) {
443
441
  assertNoDrift();
444
- server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
445
- tools: exports.TOOL_DEFINITIONS.map((d) => ({
446
- name: d.name,
447
- description: d.description,
448
- inputSchema: d.inputSchema,
449
- })),
450
- }));
451
- server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => dispatchToolCall(req.params.name, req.params.arguments));
442
+ // Register each tool with its zod `inputSchema`. The SDK advertises it in
443
+ // `tools/list` and validates arguments before invoking the handler, which
444
+ // just routes back through `dispatchToolCall` — our never-throw firewall that
445
+ // applies the rate limit, executor readiness, policy gate, and history log.
446
+ for (const d of exports.TOOL_DEFINITIONS) {
447
+ server.registerTool(d.name, { description: d.description, inputSchema: d.inputSchema }, async (args) => dispatchToolCall(d.name, args));
448
+ }
452
449
  }
453
450
  //# sourceMappingURL=tools.js.map
@@ -159,14 +159,22 @@
159
159
  function isAboutBlank(url) {
160
160
  return url === "about:blank" || url === "" || url.startsWith("about:");
161
161
  }
162
+ function normalizeDomainPattern(pattern) {
163
+ let p = pattern.trim().toLowerCase();
164
+ if (p === "*" || p === "*://*/*") return "*";
165
+ p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
166
+ p = p.replace(/[/?#].*$/, "");
167
+ p = p.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
168
+ return p;
169
+ }
162
170
  function globMatches(host, pattern) {
163
- const p = pattern.trim().toLowerCase();
164
- if (p === "*" || p === "*://*/*") return true;
171
+ const p = normalizeDomainPattern(pattern);
172
+ if (p === "*") return true;
165
173
  if (p.startsWith("*.")) {
166
174
  const base = p.slice(2);
167
175
  return host === base || host.endsWith("." + base);
168
176
  }
169
- return host === p;
177
+ return p !== "" && host === p;
170
178
  }
171
179
  function isDomainAllowed(url, policy) {
172
180
  const host = hostOf(url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
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",