@mehmoodqureshi/chrome-mcp 0.2.0 → 0.4.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.
@@ -12,9 +12,11 @@
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.TOOL_HANDLERS = exports.TOOL_DEFINITIONS = void 0;
15
+ exports.resetRateLimiter = resetRateLimiter;
15
16
  exports.dispatchToolCall = dispatchToolCall;
16
17
  exports.assertNoDrift = assertNoDrift;
17
18
  exports.registerTools = registerTools;
19
+ const node_path_1 = require("node:path");
18
20
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
19
21
  const types_1 = require("../executor/types");
20
22
  const manager_1 = require("../executor/manager");
@@ -59,6 +61,7 @@ exports.TOOL_DEFINITIONS = [
59
61
  { name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: obj({ selector: { type: 'string' }, tabId: { type: 'string' } }) },
60
62
  { 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']) },
61
63
  { 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' } }) },
64
+ { 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']) },
62
65
  { name: 'chrome_status', description: 'Report backend/session status.', inputSchema: obj({}) },
63
66
  ];
64
67
  /** Resolve the URL the policy should be evaluated against (the active tab). */
@@ -122,7 +125,7 @@ exports.TOOL_HANDLERS = {
122
125
  type: async (a, ctx) => {
123
126
  const t = (0, validators_1.requireTarget)(a);
124
127
  await gate(ctx, 'type');
125
- return (0, envelopes_1.jsonResult)(await ctx.ex.type(t, (0, validators_1.requireString)(a, 'text'), {
128
+ return (0, envelopes_1.jsonResult)(await ctx.ex.type(t, (0, validators_1.requireWithinLength)((0, validators_1.requireString)(a, 'text'), 'text', validators_1.MAX_TEXT_LEN), {
126
129
  tabId: tabId(a),
127
130
  clear: (0, validators_1.optionalBoolean)(a, 'clear'),
128
131
  pressEnter: (0, validators_1.optionalBoolean)(a, 'pressEnter'),
@@ -241,6 +244,10 @@ exports.TOOL_HANDLERS = {
241
244
  if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
242
245
  throw new validators_1.McpToolError('"fields" must be an object mapping selector -> string|boolean');
243
246
  }
247
+ for (const [sel, val] of Object.entries(fields)) {
248
+ if (typeof val === 'string')
249
+ (0, validators_1.requireWithinLength)(val, `fields["${sel}"]`, validators_1.MAX_TEXT_LEN);
250
+ }
244
251
  return (0, envelopes_1.jsonResult)(await (0, helpers_1.fillForm)(ctx.ex, {
245
252
  fields: fields,
246
253
  submitSelector: (0, validators_1.optionalString)(a, 'submitSelector'),
@@ -255,6 +262,25 @@ exports.TOOL_HANDLERS = {
255
262
  throw new validators_1.McpToolError('provide "url" or a target (selector|ref)');
256
263
  return (0, envelopes_1.jsonResult)(await ctx.ex.download({ url, target, tabId: tabId(a), suggestedName: (0, validators_1.optionalString)(a, 'suggestedName') }));
257
264
  },
265
+ upload_file: async (a, ctx) => {
266
+ const t = (0, validators_1.requireTarget)(a);
267
+ await gate(ctx, 'upload_file');
268
+ const files = (0, validators_1.optionalStringArray)(a, 'files');
269
+ if (!files || files.length === 0)
270
+ throw new validators_1.McpToolError('"files" must be a non-empty array of absolute local paths');
271
+ // Path restriction: if uploadsDir is configured, every file must resolve to a
272
+ // location inside it (blocks `..` traversal and arbitrary-file exfiltration).
273
+ if (ctx.policy.uploadsDir) {
274
+ const dir = (0, node_path_1.resolve)(ctx.policy.uploadsDir);
275
+ for (const f of files) {
276
+ const abs = (0, node_path_1.resolve)(f);
277
+ if (abs !== dir && !abs.startsWith(dir + node_path_1.sep)) {
278
+ throw new validators_1.McpToolError(`upload denied: "${f}" is outside the allowed uploads dir (${dir})`);
279
+ }
280
+ }
281
+ }
282
+ return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
283
+ },
258
284
  chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
259
285
  };
260
286
  // ---------------------------------------------------------------------------
@@ -267,10 +293,41 @@ function errMessage(err) {
267
293
  return `internal error: ${err.message}`;
268
294
  return `internal error: ${String(err)}`;
269
295
  }
296
+ // ---------------------------------------------------------------------------
297
+ // Rate limiting — a sliding window over tool calls for the active session.
298
+ // Generous by default so normal use and the test suite are unaffected; tune
299
+ // via the constants below.
300
+ // ---------------------------------------------------------------------------
301
+ /** Max tool calls permitted within `RATE_WINDOW_MS`. */
302
+ const RATE_MAX_CALLS = 600;
303
+ /** Sliding-window length, in milliseconds. */
304
+ const RATE_WINDOW_MS = 60_000;
305
+ /** Timestamps (ms) of recent dispatches; older entries are evicted lazily. */
306
+ let rateWindow = [];
307
+ /** Reset limiter state — for tests that exercise the ceiling. */
308
+ function resetRateLimiter() {
309
+ rateWindow = [];
310
+ }
311
+ /**
312
+ * Record one call and report whether it is within the ceiling. Evicts entries
313
+ * older than the window so the array stays bounded.
314
+ */
315
+ function allowCall(now) {
316
+ const cutoff = now - RATE_WINDOW_MS;
317
+ if (rateWindow.length > 0 && rateWindow[0] <= cutoff) {
318
+ rateWindow = rateWindow.filter((t) => t > cutoff);
319
+ }
320
+ if (rateWindow.length >= RATE_MAX_CALLS)
321
+ return false;
322
+ rateWindow.push(now);
323
+ return true;
324
+ }
270
325
  async function dispatchToolCall(name, rawArgs) {
271
326
  const handler = exports.TOOL_HANDLERS[name];
272
327
  if (!handler)
273
328
  return (0, envelopes_1.errorResult)(`unknown tool: ${name}`);
329
+ if (!allowCall(Date.now()))
330
+ return (0, envelopes_1.errorResult)('rate limit exceeded; slow down');
274
331
  try {
275
332
  const mgr = (0, manager_1.getManager)();
276
333
  const ex = await mgr.ensureReady();
@@ -13,6 +13,12 @@ import type { Target } from '../executor/types';
13
13
  export declare class McpToolError extends Error {
14
14
  constructor(message: string);
15
15
  }
16
+ /** Max length for CSS selector strings. Real selectors are tiny; cap generously. */
17
+ export declare const MAX_SELECTOR_LEN = 2000;
18
+ /** Max length for free-text input (type text, fill_form values). ~100KB. */
19
+ export declare const MAX_TEXT_LEN = 100000;
20
+ /** Throw if `value` exceeds `max` chars. `key` names the field for the message. */
21
+ export declare function requireWithinLength(value: string, key: string, max: number): string;
16
22
  /** Coerce raw tool args into a plain object, rejecting non-objects. */
17
23
  export declare function asArgs(raw: unknown): Record<string, unknown>;
18
24
  export declare function requireString(args: Record<string, unknown>, key: string): string;
@@ -6,7 +6,8 @@
6
6
  * an actionable message (rendered as a structured `isError` result upstream).
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.McpToolError = void 0;
9
+ exports.MAX_TEXT_LEN = exports.MAX_SELECTOR_LEN = exports.McpToolError = void 0;
10
+ exports.requireWithinLength = requireWithinLength;
10
11
  exports.asArgs = asArgs;
11
12
  exports.requireString = requireString;
12
13
  exports.optionalString = optionalString;
@@ -27,6 +28,20 @@ class McpToolError extends Error {
27
28
  }
28
29
  }
29
30
  exports.McpToolError = McpToolError;
31
+ // ---------------------------------------------------------------------------
32
+ // Input size limits — defend against runaway callers passing huge strings.
33
+ // ---------------------------------------------------------------------------
34
+ /** Max length for CSS selector strings. Real selectors are tiny; cap generously. */
35
+ exports.MAX_SELECTOR_LEN = 2_000;
36
+ /** Max length for free-text input (type text, fill_form values). ~100KB. */
37
+ exports.MAX_TEXT_LEN = 100_000;
38
+ /** Throw if `value` exceeds `max` chars. `key` names the field for the message. */
39
+ function requireWithinLength(value, key, max) {
40
+ if (value.length > max) {
41
+ throw new McpToolError(`"${key}" is too long (${value.length} chars; max ${max})`);
42
+ }
43
+ return value;
44
+ }
30
45
  /** Coerce raw tool args into a plain object, rejecting non-objects. */
31
46
  function asArgs(raw) {
32
47
  if (raw === undefined || raw === null)
@@ -91,7 +106,10 @@ function requireTarget(args) {
91
106
  if (hasSel === hasRef) {
92
107
  throw new McpToolError('provide exactly one of "selector" or "ref"');
93
108
  }
94
- return hasSel ? { selector: args.selector } : { ref: args.ref };
109
+ if (hasSel) {
110
+ return { selector: requireWithinLength(args.selector, 'selector', exports.MAX_SELECTOR_LEN) };
111
+ }
112
+ return { ref: requireWithinLength(args.ref, 'ref', exports.MAX_SELECTOR_LEN) };
95
113
  }
96
114
  /** Like `requireTarget` but the target is optional (whole-page reads). */
97
115
  function optionalTarget(args) {
@@ -22,6 +22,10 @@ export interface Policy {
22
22
  allowEval: boolean;
23
23
  /** Allow `download_file`. */
24
24
  allowDownloads: boolean;
25
+ /** Allow `upload_file` (sends local files to the page — exfiltration risk). */
26
+ allowUploads: boolean;
27
+ /** If set, `upload_file` may only read files inside this directory (absolute path). */
28
+ uploadsDir?: string;
25
29
  /** Allow acting on / reading tabs whose URL is not in `allowDomains` is governed
26
30
  * by `allowDomains`; this flag instead relaxes tab *management* (list/select)
27
31
  * to all tabs regardless of their URL. Default false. */
@@ -29,6 +29,8 @@ exports.DEFAULT_POLICY = Object.freeze({
29
29
  allowDomains: [],
30
30
  allowEval: false,
31
31
  allowDownloads: false,
32
+ allowUploads: false,
33
+ uploadsDir: undefined,
32
34
  allowAllTabs: false,
33
35
  enableMutations: false,
34
36
  });
@@ -38,6 +40,8 @@ function resolvePolicy(partial) {
38
40
  allowDomains: partial?.allowDomains ?? [...exports.DEFAULT_POLICY.allowDomains],
39
41
  allowEval: partial?.allowEval ?? exports.DEFAULT_POLICY.allowEval,
40
42
  allowDownloads: partial?.allowDownloads ?? exports.DEFAULT_POLICY.allowDownloads,
43
+ allowUploads: partial?.allowUploads ?? exports.DEFAULT_POLICY.allowUploads,
44
+ uploadsDir: partial?.uploadsDir ?? exports.DEFAULT_POLICY.uploadsDir,
41
45
  allowAllTabs: partial?.allowAllTabs ?? exports.DEFAULT_POLICY.allowAllTabs,
42
46
  enableMutations: partial?.enableMutations ?? exports.DEFAULT_POLICY.enableMutations,
43
47
  };
@@ -83,7 +87,11 @@ function isMutatingMethod(method) {
83
87
  }
84
88
  /** Whether the method touches a specific URL that must be allowlisted. */
85
89
  function isUrlGated(method) {
86
- return READ_CONTENT.has(method) || MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || method === 'eval';
90
+ return (READ_CONTENT.has(method) ||
91
+ MUTATE_CONTENT.has(method) ||
92
+ NAVIGATION.has(method) ||
93
+ method === 'eval' ||
94
+ method === 'upload_file');
87
95
  }
88
96
  // ---------------------------------------------------------------------------
89
97
  // Domain matching
@@ -134,6 +142,10 @@ function assertUrlAllowed(url, method, policy) {
134
142
  if (method === 'download_file' && !policy.allowDownloads) {
135
143
  throw new types_1.ExecutorError('POLICY_DENIED', 'downloads are disabled. Pass --enable-downloads or set allowDownloads.');
136
144
  }
145
+ if (method === 'upload_file' && !policy.allowUploads) {
146
+ throw new types_1.ExecutorError('POLICY_DENIED', 'uploads are disabled (sending local files to a page is an exfiltration risk). ' +
147
+ 'Pass --enable-uploads or set allowUploads.');
148
+ }
137
149
  if (isMutatingMethod(method) && !policy.enableMutations) {
138
150
  throw new types_1.ExecutorError('POLICY_DENIED', `mutating tool "${method}" is disabled (safe-mode). Pass --enable-mutations to allow it.`);
139
151
  }
@@ -26,6 +26,7 @@
26
26
  "eval",
27
27
  "wait_for",
28
28
  "download_file",
29
+ "upload_file",
29
30
  "ping_probe"
30
31
  ];
31
32
 
@@ -174,7 +175,22 @@
174
175
  const r = el.getBoundingClientRect();
175
176
  if (r.width === 0 && r.height === 0) return false;
176
177
  const s = window.getComputedStyle(el);
177
- return s.visibility !== "hidden" && s.display !== "none";
178
+ if (s.visibility === "hidden" || s.display === "none") return false;
179
+ const cv = el.checkVisibility;
180
+ if (typeof cv === "function") {
181
+ try {
182
+ return cv.call(el, { checkOpacity: false, checkVisibilityCSS: true });
183
+ } catch {
184
+ }
185
+ }
186
+ let p = el.parentElement;
187
+ while (p) {
188
+ const ps = window.getComputedStyle(p);
189
+ if (ps.display === "none" || ps.visibility === "hidden") return false;
190
+ p = p.parentElement;
191
+ }
192
+ if (el.offsetParent === null && s.position !== "fixed") return false;
193
+ return true;
178
194
  };
179
195
  const accName = (el) => {
180
196
  const aria = el.getAttribute("aria-label");
@@ -210,7 +226,36 @@
210
226
  }
211
227
  return tag;
212
228
  };
213
- const els = Array.from(document.querySelectorAll(sel)).filter(visible);
229
+ const seen = /* @__PURE__ */ new Set();
230
+ const candidates = [];
231
+ const collect = (root) => {
232
+ if (candidates.length >= max) return;
233
+ let matched;
234
+ try {
235
+ matched = Array.from(root.querySelectorAll(sel));
236
+ } catch {
237
+ matched = [];
238
+ }
239
+ for (const el of matched) {
240
+ if (candidates.length >= max) break;
241
+ if (seen.has(el)) continue;
242
+ seen.add(el);
243
+ candidates.push(el);
244
+ }
245
+ let hosts;
246
+ try {
247
+ hosts = Array.from(root.querySelectorAll("*"));
248
+ } catch {
249
+ hosts = [];
250
+ }
251
+ for (const host of hosts) {
252
+ if (candidates.length >= max) break;
253
+ const sr = host.shadowRoot;
254
+ if (sr) collect(sr);
255
+ }
256
+ };
257
+ collect(document);
258
+ const els = candidates.filter(visible);
214
259
  const nodes = [];
215
260
  let n = 0;
216
261
  for (const el of els) {
@@ -377,6 +422,7 @@
377
422
  "eval",
378
423
  "wait_for",
379
424
  "download_file",
425
+ "upload_file",
380
426
  "ping_probe"
381
427
  ]);
382
428
  var ChromeExecutor = class {
@@ -396,8 +442,19 @@
396
442
  }
397
443
  case "tab_new": {
398
444
  const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
445
+ const BLANK = /^(about:blank|chrome:\/\/newtab|chrome:\/\/new-tab-page|edge:\/\/newtab)/i;
446
+ const blank = (await chrome.tabs.query({})).find(
447
+ (t2) => t2.id !== void 0 && (BLANK.test(t2.url ?? "") || (t2.url ?? "") === "" || t2.pendingUrl === "about:blank")
448
+ );
449
+ if (blank?.id !== void 0) {
450
+ if (url) {
451
+ await chrome.tabs.update(blank.id, { url });
452
+ await waitComplete(blank.id);
453
+ }
454
+ return { ...await tabInfo(await chrome.tabs.get(blank.id)), reused: true };
455
+ }
399
456
  const t = await chrome.tabs.create({ url, active: false });
400
- return tabInfo(t);
457
+ return { ...await tabInfo(t), reused: false };
401
458
  }
402
459
  case "tab_close": {
403
460
  const id = parseTabId(String(cmd.tabId));
@@ -732,6 +789,23 @@
732
789
  const downloadId = await chrome.downloads.download({ url, filename: name });
733
790
  return { path: `(downloads)/${name}`, backend: "extension", bytes: 0, suggestedName: name };
734
791
  }
792
+ // -- upload: set local file(s) on a file <input> via CDP DOM.setFileInputFiles --
793
+ case "upload_file": {
794
+ const id = await targetTab(cmd);
795
+ const sel = requireSelector(cmd);
796
+ const files = Array.isArray(cmd.params.files) ? cmd.params.files.map(String) : [];
797
+ if (files.length === 0) throw new CmdError("BAD_ARGS", 'upload_file requires a non-empty "files" array');
798
+ if (!await waitForSelector(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
799
+ await withDebugger(id, async (t) => {
800
+ const doc = await chrome.debugger.sendCommand(t, "DOM.getDocument", { depth: 0 });
801
+ const rootId = doc.root?.nodeId;
802
+ if (!rootId) throw new CmdError("CDP_ERROR", "could not read the document root");
803
+ const found = await chrome.debugger.sendCommand(t, "DOM.querySelector", { nodeId: rootId, selector: sel });
804
+ if (!found.nodeId) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
805
+ await chrome.debugger.sendCommand(t, "DOM.setFileInputFiles", { files, nodeId: found.nodeId });
806
+ });
807
+ return { ok: true };
808
+ }
735
809
  default:
736
810
  throw new CmdError("UNKNOWN_METHOD", `unhandled method: ${cmd.method}`);
737
811
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome MCP Bridge",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
6
6
  "minimum_chrome_version": "116",
7
7
  "background": { "service_worker": "background.js" },
8
8
  "permissions": ["tabs", "scripting", "activeTab", "downloads", "storage", "alarms", "cookies", "debugger"],
9
- "host_permissions": ["http://*/*", "https://*/*"],
9
+ "host_permissions": ["<all_urls>"],
10
10
  "options_page": "options.html",
11
11
  "action": { "default_title": "Chrome MCP — open options to pair" }
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",