@mehmoodqureshi/chrome-mcp 0.9.9 → 0.9.10

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/dist/src/cli.js CHANGED
@@ -329,7 +329,9 @@ async function main() {
329
329
  // the next connection. This is what makes reconnects "just work".
330
330
  process.stdin.on('end', shutdown);
331
331
  process.stdin.on('close', shutdown);
332
- await (0, server_2.startMcpServer)(version());
332
+ // The resolved policy shapes the advertised catalog: a capability that is off
333
+ // means its tools are never described to the model.
334
+ await (0, server_2.startMcpServer)(version(), cfg.policy);
333
335
  }
334
336
  main().catch((err) => {
335
337
  // Port-busy and bad-flag failures carry a plain-English message already
@@ -7,11 +7,19 @@
7
7
  * go to stderr via `logErr`.
8
8
  */
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import type { Policy } from '../security/policy';
10
11
  export { getLogLevel, logDebug, logErr, setLogLevel } from './log';
11
- /** Build a fresh `Server` with the full tool surface registered (no transport). */
12
- export declare function createServer(version?: string): McpServer;
12
+ /**
13
+ * Build a fresh `Server` with the tool surface registered (no transport).
14
+ *
15
+ * `policy` is optional: pass it and tools whose capability the policy has
16
+ * switched off are left out of the catalog (they could only answer
17
+ * POLICY_DENIED, and the catalog is re-sent on every turn). Omit it and the
18
+ * full catalog is advertised, which is what the tests want.
19
+ */
20
+ export declare function createServer(version?: string, policy?: Policy): McpServer;
13
21
  /** Start over stdio. Idempotent. */
14
- export declare function startMcpServer(version?: string): Promise<void>;
22
+ export declare function startMcpServer(version?: string, policy?: Policy): Promise<void>;
15
23
  /** Stop and release the transport. Idempotent, best-effort. */
16
24
  export declare function stopMcpServer(): Promise<void>;
17
25
  export declare function isMcpServerRunning(): boolean;
@@ -30,10 +30,17 @@ const SERVER_VERSION = '0.1.0';
30
30
  const DEFAULT_VERSION = SERVER_VERSION;
31
31
  let server = null;
32
32
  let transport = null;
33
- /** Build a fresh `Server` with the full tool surface registered (no transport). */
34
- function createServer(version = DEFAULT_VERSION) {
33
+ /**
34
+ * Build a fresh `Server` with the tool surface registered (no transport).
35
+ *
36
+ * `policy` is optional: pass it and tools whose capability the policy has
37
+ * switched off are left out of the catalog (they could only answer
38
+ * POLICY_DENIED, and the catalog is re-sent on every turn). Omit it and the
39
+ * full catalog is advertised, which is what the tests want.
40
+ */
41
+ function createServer(version = DEFAULT_VERSION, policy) {
35
42
  const srv = new mcp_js_1.McpServer({ name: SERVER_NAME, version }, { capabilities: { tools: {} } });
36
- (0, tools_1.registerTools)(srv);
43
+ (0, tools_1.registerTools)(srv, policy);
37
44
  // `McpServer` wraps the low-level `Server`, which owns the `onerror` hook.
38
45
  srv.server.onerror = (err) => {
39
46
  (0, log_1.logErr)(`server error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
@@ -41,12 +48,12 @@ function createServer(version = DEFAULT_VERSION) {
41
48
  return srv;
42
49
  }
43
50
  /** Start over stdio. Idempotent. */
44
- async function startMcpServer(version = DEFAULT_VERSION) {
51
+ async function startMcpServer(version = DEFAULT_VERSION, policy) {
45
52
  if (server) {
46
53
  (0, log_1.logErr)('startMcpServer called but already running; ignoring.');
47
54
  return;
48
55
  }
49
- const srv = createServer(version);
56
+ const srv = createServer(version, policy);
50
57
  const tx = new stdio_js_1.StdioServerTransport();
51
58
  try {
52
59
  await srv.connect(tx);
@@ -42,6 +42,25 @@ export declare const TOOL_NAMES: readonly string[];
42
42
  export declare function setToolAllowlist(names: readonly string[] | null | undefined): void;
43
43
  /** Is `name` on the surface? True for every catalog tool when no allowlist is set. */
44
44
  export declare function isToolEnabled(name: string): boolean;
45
+ /**
46
+ * Is `name` usable at all under `policy`? Undefined policy = advertise
47
+ * everything (tests and any caller that builds a server without one).
48
+ */
49
+ export declare function isToolPolicyUsable(name: string, policy?: Policy): boolean;
50
+ export interface DisabledCapability {
51
+ capability: string;
52
+ flag: string;
53
+ hiddenTools: string[];
54
+ }
55
+ /**
56
+ * Which capabilities the policy has switched off, the flag for each, and the
57
+ * tools that left the catalog because of it. Surfaced by `chrome_status`: once
58
+ * `navigate` is hidden the model cannot learn about it from a POLICY_DENIED
59
+ * any more, so without this it would tell the user it "can't browse" instead
60
+ * of naming the one flag that fixes it. Tools already cut by `--tools` are not
61
+ * listed, since the flag would not bring them back.
62
+ */
63
+ export declare function disabledCapabilities(policy: Policy): DisabledCapability[];
45
64
  /** The names actually advertised, in catalog order. */
46
65
  export declare function enabledToolNames(): readonly string[];
47
66
  interface ToolCtx {
@@ -56,5 +75,5 @@ export declare function resetRateLimiter(): void;
56
75
  export declare function dispatchToolCall(name: string, rawArgs: unknown): Promise<CallToolResult>;
57
76
  /** Assert the catalog and the dispatch table describe the same tool set. */
58
77
  export declare function assertNoDrift(): void;
59
- export declare function registerTools(server: McpServer): void;
78
+ export declare function registerTools(server: McpServer, policy?: Policy): void;
60
79
  export {};
@@ -15,6 +15,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.TOOL_HANDLERS = exports.TOOL_NAMES = exports.TOOL_DEFINITIONS = void 0;
16
16
  exports.setToolAllowlist = setToolAllowlist;
17
17
  exports.isToolEnabled = isToolEnabled;
18
+ exports.isToolPolicyUsable = isToolPolicyUsable;
19
+ exports.disabledCapabilities = disabledCapabilities;
18
20
  exports.enabledToolNames = enabledToolNames;
19
21
  exports.setProfileBridge = setProfileBridge;
20
22
  exports.resetRateLimiter = resetRateLimiter;
@@ -27,6 +29,7 @@ const zod_1 = require("zod");
27
29
  const types_1 = require("../executor/types");
28
30
  const manager_1 = require("../executor/manager");
29
31
  const policy_1 = require("../security/policy");
32
+ const policy_2 = require("../../shared/policy");
30
33
  const envelopes_1 = require("./envelopes");
31
34
  const limits_1 = require("./limits");
32
35
  const batch_1 = require("./batch");
@@ -183,7 +186,7 @@ exports.TOOL_DEFINITIONS = [
183
186
  tabId: tabIdField,
184
187
  },
185
188
  },
186
- { name: 'chrome_status', description: 'Report backend/session status, including every paired browser profile and how it was named.', inputSchema: {} },
189
+ { name: 'chrome_status', description: 'Report backend/session status: paired browser profiles and how each was named, and flags for switched-off capabilities.', inputSchema: {} },
187
190
  { 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 } },
188
191
  { name: 'profile_use', description: 'Switch the active browser profile (identity). Subsequent downloads, results, screenshots, and the action log are stored under profiles/<name>/. Resets the active task to "default" unless you then call task_new.', inputSchema: { name: zod_1.z.string().describe('Profile name (becomes a folder; sanitized to a safe path segment).') } },
189
192
  { 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') } },
@@ -242,6 +245,141 @@ function setToolAllowlist(names) {
242
245
  function isToolEnabled(name) {
243
246
  return toolAllowlist === null || toolAllowlist.has(name);
244
247
  }
248
+ // ---------------------------------------------------------------------------
249
+ // Policy-dead tools (capability gates)
250
+ // ---------------------------------------------------------------------------
251
+ /**
252
+ * The wire method each tool is gated on, mirroring the `gate(ctx, ...)` call in
253
+ * its handler. `null` = the tool touches no page and no capability (tab listing,
254
+ * status, task bookkeeping, `batch` itself), so no policy can disable it.
255
+ *
256
+ * This exists so the catalog can drop tools the policy has switched off. The
257
+ * catalog is re-sent to the model on EVERY turn, and a tool whose capability is
258
+ * off can only ever answer POLICY_DENIED — so describing it is pure waste, and
259
+ * it also invites the model to spend a call discovering that.
260
+ *
261
+ * Kept in step with the handlers by `assertNoDrift`, which fails startup if a
262
+ * tool is missing an entry.
263
+ */
264
+ const TOOL_GATE = {
265
+ // no gate — always usable
266
+ tabs_list: null,
267
+ chrome_status: null,
268
+ batch: null,
269
+ profile_use: null,
270
+ profile_rename: null,
271
+ task_new: null,
272
+ task_status: null,
273
+ tasks_list: null,
274
+ storage: null,
275
+ // tab management (mutation-gated)
276
+ tab_select: 'tab_select',
277
+ tab_new: 'tab_new',
278
+ tab_close: 'tab_close',
279
+ // navigation (mutation-gated)
280
+ navigate: 'navigate',
281
+ back: 'back',
282
+ forward: 'forward',
283
+ reload: 'reload',
284
+ // interaction (mutation-gated)
285
+ click: 'click',
286
+ type: 'type',
287
+ select_option: 'type',
288
+ press: 'press',
289
+ hover: 'hover',
290
+ scroll: 'scroll',
291
+ fill_form: 'type',
292
+ // reads (domain-gated only)
293
+ screenshot: 'screenshot',
294
+ get_text: 'get_text',
295
+ get_html: 'get_html',
296
+ snapshot: 'get_text',
297
+ get_cookies: 'get_text',
298
+ extract_links: 'get_text',
299
+ read_as_markdown: 'get_text',
300
+ auth_check: 'get_text',
301
+ wait_for: 'wait_for',
302
+ frames_list: 'frames_list',
303
+ print_pdf: 'print_pdf',
304
+ // capability-gated
305
+ eval: 'eval',
306
+ download_file: 'download_file',
307
+ upload_file: 'upload_file',
308
+ console_logs: 'observers',
309
+ network_log: 'observers',
310
+ dialogs: 'observers',
311
+ };
312
+ /**
313
+ * Would `method` be refused on EVERY page under this policy?
314
+ *
315
+ * Runs the REAL `evaluatePolicy` — the same function the gate and the extension
316
+ * router run, so this can never disagree with them — but with the domain
317
+ * allowlist widened to `*`. Only the capability gates (eval / downloads /
318
+ * uploads / observers / mutations) can then reject, which is exactly the
319
+ * question we want: a tool blocked merely because the current tab is off the
320
+ * allowlist stays advertised, since another tab may well be on it.
321
+ */
322
+ function isCapabilityDenied(method, policy) {
323
+ return !(0, policy_2.evaluatePolicy)('https://probe.invalid/', method, { ...policy, allowDomains: ['*'] }).ok;
324
+ }
325
+ /**
326
+ * Is `name` usable at all under `policy`? Undefined policy = advertise
327
+ * everything (tests and any caller that builds a server without one).
328
+ */
329
+ function isToolPolicyUsable(name, policy) {
330
+ if (!policy)
331
+ return true;
332
+ const method = TOOL_GATE[name];
333
+ if (!method)
334
+ return true;
335
+ return !isCapabilityDenied(method, policy);
336
+ }
337
+ /**
338
+ * The switchable capabilities, the flag that turns each on, and which wire
339
+ * methods it covers. `probe` is any one method it gates, fed to the real
340
+ * `isCapabilityDenied` so this can never disagree with the catalog filter.
341
+ */
342
+ const CAPABILITIES = [
343
+ { capability: 'mutations', flag: '--enable-mutations', probe: 'click', covers: policy_2.isMutatingMethod },
344
+ { capability: 'eval', flag: '--unsafe-enable-eval', probe: 'eval', covers: (m) => m === 'eval' },
345
+ { capability: 'downloads', flag: '--enable-downloads', probe: 'download_file', covers: (m) => m === 'download_file' },
346
+ { capability: 'uploads', flag: '--enable-uploads', probe: 'upload_file', covers: (m) => m === 'upload_file' },
347
+ { capability: 'observers', flag: '--enable-observers', probe: 'observers', covers: (m) => m === 'observers' },
348
+ ];
349
+ /**
350
+ * Which capabilities the policy has switched off, the flag for each, and the
351
+ * tools that left the catalog because of it. Surfaced by `chrome_status`: once
352
+ * `navigate` is hidden the model cannot learn about it from a POLICY_DENIED
353
+ * any more, so without this it would tell the user it "can't browse" instead
354
+ * of naming the one flag that fixes it. Tools already cut by `--tools` are not
355
+ * listed, since the flag would not bring them back.
356
+ */
357
+ function disabledCapabilities(policy) {
358
+ const out = [];
359
+ for (const c of CAPABILITIES) {
360
+ if (!isCapabilityDenied(c.probe, policy))
361
+ continue;
362
+ const hiddenTools = enabledToolNames().filter((n) => {
363
+ const m = TOOL_GATE[n];
364
+ return m != null && c.covers(m);
365
+ });
366
+ if (hiddenTools.length > 0)
367
+ out.push({ capability: c.capability, flag: c.flag, hiddenTools });
368
+ }
369
+ return out;
370
+ }
371
+ /** The `chrome_status` fields describing switched-off capabilities, or nothing. */
372
+ function capabilityStatus(policy) {
373
+ const off = disabledCapabilities(policy);
374
+ if (off.length === 0)
375
+ return {};
376
+ return {
377
+ disabledCapabilities: off,
378
+ capabilityHint: 'These tools are not available in this session because their capability is off. ' +
379
+ 'If the user asks for one of them, tell them to add the listed flag to the chrome-mcp ' +
380
+ 'command in their MCP config and restart the client.',
381
+ };
382
+ }
245
383
  /** The names actually advertised, in catalog order. */
246
384
  function enabledToolNames() {
247
385
  return exports.TOOL_NAMES.filter(isToolEnabled);
@@ -927,11 +1065,12 @@ exports.TOOL_HANDLERS = {
927
1065
  ...(path ? {} : { note: 'no active task workspace, so the PDF was not saved to disk' }),
928
1066
  });
929
1067
  },
930
- chrome_status: async () => {
1068
+ chrome_status: async (_a, ctx) => {
931
1069
  const profiles = profileBridge ? { profiles: profileBridge.pairedProfiles() } : {};
1070
+ const capabilities = capabilityStatus(ctx.policy);
932
1071
  try {
933
1072
  const ex = await (0, manager_1.getManager)().ensureReady();
934
- return (0, envelopes_1.jsonResult)({ ...ex.status(), ...profiles });
1073
+ return (0, envelopes_1.jsonResult)({ ...ex.status(), ...profiles, ...capabilities });
935
1074
  }
936
1075
  catch (err) {
937
1076
  return (0, envelopes_1.jsonResult)({
@@ -940,6 +1079,7 @@ exports.TOOL_HANDLERS = {
940
1079
  detail: errMessage(err),
941
1080
  activeProfile: (0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default',
942
1081
  ...profiles,
1082
+ ...capabilities,
943
1083
  });
944
1084
  }
945
1085
  },
@@ -1169,24 +1309,41 @@ function assertNoDrift() {
1169
1309
  for (const n of handlers)
1170
1310
  if (!defs.has(n))
1171
1311
  throw new Error(`handler "${n}" has no advertised definition`);
1312
+ // A tool with no TOOL_GATE entry would silently be treated as always-usable,
1313
+ // so a new tool must declare the method it gates on (or `null`) explicitly.
1314
+ for (const n of defs) {
1315
+ if (!(n in TOOL_GATE))
1316
+ throw new Error(`tool "${n}" has no TOOL_GATE entry (declare its wire method, or null)`);
1317
+ }
1172
1318
  }
1173
1319
  // ---------------------------------------------------------------------------
1174
1320
  // Wiring
1175
1321
  // ---------------------------------------------------------------------------
1176
- function registerTools(server) {
1322
+ function registerTools(server, policy) {
1177
1323
  assertNoDrift();
1178
1324
  // Register each tool with its zod `inputSchema`. The SDK advertises it in
1179
1325
  // `tools/list` and validates arguments before invoking the handler, which
1180
1326
  // just routes back through `dispatchToolCall` — our never-throw firewall that
1181
1327
  // applies the rate limit, executor readiness, policy gate, and history log.
1328
+ const policyDropped = [];
1182
1329
  for (const d of exports.TOOL_DEFINITIONS) {
1183
1330
  if (!isToolEnabled(d.name))
1184
1331
  continue;
1332
+ // A tool the policy has switched off can only ever answer POLICY_DENIED.
1333
+ // Don't pay for its schema on every turn, and don't invite a wasted call.
1334
+ if (!isToolPolicyUsable(d.name, policy)) {
1335
+ policyDropped.push(d.name);
1336
+ continue;
1337
+ }
1185
1338
  server.registerTool(d.name, { description: d.description, inputSchema: d.inputSchema }, async (args) => dispatchToolCall(d.name, args));
1186
1339
  }
1187
1340
  const advertised = enabledToolNames();
1188
1341
  if (advertised.length < exports.TOOL_NAMES.length) {
1189
1342
  (0, log_1.logErr)(`--tools: advertising ${advertised.length} of ${exports.TOOL_NAMES.length} tools (${advertised.join(', ')})`);
1190
1343
  }
1344
+ if (policyDropped.length > 0) {
1345
+ (0, log_1.logErr)(`policy: ${policyDropped.length} tool(s) not advertised because the capability is off ` +
1346
+ `(${policyDropped.join(', ')}) — enable them with the matching flag to get them back.`);
1347
+ }
1191
1348
  }
1192
1349
  //# sourceMappingURL=tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
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",