@mehmoodqureshi/chrome-mcp 0.6.5 → 0.6.6

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.
@@ -23,6 +23,15 @@ export interface StubOptions {
23
23
  /** A URL the backend claims to already know, as the extension reports on every
24
24
  * result frame. Set it to assert the gate uses it INSTEAD of calling tabsList. */
25
25
  cachedUrl?: string;
26
+ /** Background (non-active) tabs, so a test can target one by an explicit tabId
27
+ * and check the gate authorizes against THAT tab rather than the active one. */
28
+ backgroundTabs?: Array<{
29
+ tabId: TabId;
30
+ url: string;
31
+ }>;
32
+ /** When true, tabs exist but none is flagged active — the case the gate used to
33
+ * paper over by silently gating against `tabs[0]`. */
34
+ noActiveTab?: boolean;
26
35
  }
27
36
  export declare class StubExecutor implements Executor {
28
37
  readonly backend: BackendKind;
@@ -32,6 +41,8 @@ export declare class StubExecutor implements Executor {
32
41
  private readonly noTabs;
33
42
  private readonly blankTabUrl;
34
43
  private readonly cached;
44
+ private readonly backgroundTabs;
45
+ private readonly noActiveTab;
35
46
  /** How many times the gate actually asked for the tab list — the round-trip
36
47
  * counter the caching path exists to keep at zero. */
37
48
  tabsListCalls: number;
@@ -22,6 +22,8 @@ class StubExecutor {
22
22
  noTabs;
23
23
  blankTabUrl;
24
24
  cached;
25
+ backgroundTabs;
26
+ noActiveTab;
25
27
  /** How many times the gate actually asked for the tab list — the round-trip
26
28
  * counter the caching path exists to keep at zero. */
27
29
  tabsListCalls = 0;
@@ -33,13 +35,15 @@ class StubExecutor {
33
35
  this.noTabs = opts.noTabs ?? false;
34
36
  this.blankTabUrl = opts.blankTabUrl ?? false;
35
37
  this.cached = opts.cachedUrl ?? null;
38
+ this.backgroundTabs = opts.backgroundTabs ?? [];
39
+ this.noActiveTab = opts.noActiveTab ?? false;
36
40
  }
37
41
  tab() {
38
42
  return {
39
43
  tabId: 'extension:stub:1',
40
44
  url: this.blankTabUrl ? '' : this.url,
41
45
  title: 'Stub Page',
42
- active: true,
46
+ active: !this.noActiveTab,
43
47
  index: 0,
44
48
  };
45
49
  }
@@ -68,7 +72,18 @@ class StubExecutor {
68
72
  this.tabsListCalls++;
69
73
  if (this.tabsListThrows)
70
74
  throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub bridge is down');
71
- return this.noTabs ? [] : [this.tab()];
75
+ if (this.noTabs)
76
+ return [];
77
+ return [
78
+ this.tab(),
79
+ ...this.backgroundTabs.map((t, i) => ({
80
+ tabId: t.tabId,
81
+ url: t.url,
82
+ title: 'Stub Background Page',
83
+ active: false,
84
+ index: i + 1,
85
+ })),
86
+ ];
72
87
  }
73
88
  async tabSelect(tabId) {
74
89
  return { ...this.tab(), tabId };
@@ -81,24 +81,34 @@ exports.TOOL_DEFINITIONS = [
81
81
  },
82
82
  },
83
83
  ];
84
- const GATE_CONTEXT = 'cannot resolve the active tab URL for the policy gate';
84
+ const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
85
85
  /**
86
- * Resolve the URL the policy should be evaluated against (the active tab).
86
+ * Resolve the URL the policy should be evaluated against: the URL of the tab
87
+ * this very call will act on — the explicit `tabId` when the caller gave one,
88
+ * the active tab only when they didn't.
87
89
  *
88
- * NEVER substitutes a placeholder URL. If the tab list can't be read, the real
89
- * origin is unknown, and evaluating the policy against a fabricated URL would
90
- * silently allow or deny against the wrong origin with no signal to the caller.
91
- * So a `tabsList` failure (or a browser reporting no tabs at all) propagates
92
- * the dispatch firewall renders it as a structured error carrying the code.
90
+ * Gating on the ACTIVE tab regardless of `tabId` is an authorization bypass:
91
+ * park an allowlisted page as active and every `tabId`-addressed read (get_text,
92
+ * get_html, screenshot, eval, …) sails through against a tab whose origin was
93
+ * never checked. The tab that gets touched is the tab that must be authorized.
94
+ *
95
+ * NEVER substitutes a placeholder URL, and never falls back to some *other*
96
+ * tab. If the real origin is unknown, evaluating the policy against a stand-in
97
+ * would silently allow or deny against the wrong origin with no signal to the
98
+ * caller. So a `tabsList` failure (or a browser reporting no tabs at all)
99
+ * propagates — the dispatch firewall renders it as an error carrying the code.
93
100
  *
94
101
  * Prefers a URL the backend already reported over asking again: the extension
95
102
  * rides the tab's landing URL home on every result frame, which is what keeps a
96
- * gated call to ONE round-trip instead of two.
103
+ * gated call to ONE round-trip instead of two. That cache only ever describes
104
+ * the active tab, so it is bypassed whenever an explicit `tabId` is in play.
97
105
  */
98
- async function activeUrl(ex) {
99
- const known = ex.cachedActiveUrl?.();
100
- if (known)
101
- return known;
106
+ async function gatedUrl(ex, tabId) {
107
+ if (!tabId) {
108
+ const known = ex.cachedActiveUrl?.();
109
+ if (known)
110
+ return known;
111
+ }
102
112
  let tabs;
103
113
  try {
104
114
  tabs = await ex.tabsList();
@@ -111,30 +121,39 @@ async function activeUrl(ex) {
111
121
  throw new types_1.ExecutorError(err.code, `${GATE_CONTEXT}: ${err.message}`);
112
122
  throw err; // an internal fault, not a browser one — don't relabel it
113
123
  }
114
- const active = tabs.find((t) => t.active) ?? tabs[0];
115
- if (!active)
124
+ if (tabs.length === 0) {
116
125
  throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the browser reports no open tabs`);
126
+ }
127
+ const target = tabId ? tabs.find((t) => t.tabId === tabId) : tabs.find((t) => t.active);
128
+ if (!target) {
129
+ throw new types_1.ExecutorError('TAB_NOT_FOUND', tabId
130
+ ? `${GATE_CONTEXT}: no open tab has id ${tabId} — call tabs_list for the current ids`
131
+ : `${GATE_CONTEXT}: the browser reports open tabs but none active — pass an explicit tabId`);
132
+ }
117
133
  // An empty URL is Chrome declining to reveal one (a chrome:// page, or a tab
118
134
  // the extension has no host access to) — NOT an origin. Gating on '' would
119
135
  // produce a baffling "blocked on " denial that reads like a policy decision.
120
- if (!active.url) {
121
- throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the active tab (id ${active.tabId}) reports no URL. Chrome hides it for ` +
136
+ if (!target.url) {
137
+ throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the target tab (id ${target.tabId}) reports no URL. Chrome hides it for ` +
122
138
  `internal pages (chrome://, the Web Store) and until the extension has access to that site — ` +
123
139
  `switch to a normal page, or open the target site in a new tab.`);
124
140
  }
125
- return active.url;
141
+ return target.url;
126
142
  }
127
143
  /**
128
- * Policy chokepoint. `urlOverride` is the destination for navigation.
144
+ * Policy chokepoint. `opts.url` is the destination for navigation (it governs
145
+ * instead of any current tab URL); `opts.tabId` is the tab the call will act on,
146
+ * and MUST be threaded through by every URL-gated handler that accepts one —
147
+ * omitting it silently authorizes the call against the active tab instead.
129
148
  *
130
- * Only resolves the active URL for methods whose verdict actually depends on one
149
+ * Only resolves a tab URL for methods whose verdict actually depends on one
131
150
  * (`isUrlGated`). Tab management and the capability gates — eval, downloads,
132
151
  * uploads, mutations — are decided without any URL, so making them wait on the
133
152
  * tab list bought nothing and, worse, made `tab_new` fail exactly when the tab
134
153
  * list was unreadable: the one call that could dig you out.
135
154
  */
136
- async function gate(ctx, method, urlOverride) {
137
- const url = urlOverride ?? ((0, policy_1.isUrlGated)(method) ? await activeUrl(ctx.ex) : '');
155
+ async function gate(ctx, method, opts = {}) {
156
+ const url = opts.url ?? ((0, policy_1.isUrlGated)(method) ? await gatedUrl(ctx.ex, opts.tabId) : '');
138
157
  (0, policy_1.assertUrlAllowed)(url, method, ctx.policy);
139
158
  }
140
159
  const tabId = (args) => (0, validators_1.optionalString)(args, 'tabId');
@@ -180,24 +199,24 @@ exports.TOOL_HANDLERS = {
180
199
  },
181
200
  navigate: async (a, ctx) => {
182
201
  const url = (0, validators_1.requireString)(a, 'url');
183
- await gate(ctx, 'navigate', url);
202
+ await gate(ctx, 'navigate', { url });
184
203
  return (0, envelopes_1.jsonResult)(await ctx.ex.navigate({ url, tabId: tabId(a), waitUntil: waitUntil(a) }));
185
204
  },
186
205
  back: async (a, ctx) => {
187
- await gate(ctx, 'back');
206
+ await gate(ctx, 'back', { tabId: tabId(a) });
188
207
  return (0, envelopes_1.jsonResult)(await ctx.ex.back(tabId(a)));
189
208
  },
190
209
  forward: async (a, ctx) => {
191
- await gate(ctx, 'forward');
210
+ await gate(ctx, 'forward', { tabId: tabId(a) });
192
211
  return (0, envelopes_1.jsonResult)(await ctx.ex.forward(tabId(a)));
193
212
  },
194
213
  reload: async (a, ctx) => {
195
- await gate(ctx, 'reload');
214
+ await gate(ctx, 'reload', { tabId: tabId(a) });
196
215
  return (0, envelopes_1.jsonResult)(await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) }));
197
216
  },
198
217
  click: async (a, ctx) => {
199
218
  const t = (0, validators_1.requireTarget)(a);
200
- await gate(ctx, 'click');
219
+ await gate(ctx, 'click', { tabId: tabId(a) });
201
220
  return (0, envelopes_1.jsonResult)(await ctx.ex.click(t, {
202
221
  tabId: tabId(a),
203
222
  button: (0, validators_1.optionalString)(a, 'button'),
@@ -207,7 +226,7 @@ exports.TOOL_HANDLERS = {
207
226
  },
208
227
  type: async (a, ctx) => {
209
228
  const t = (0, validators_1.requireTarget)(a);
210
- await gate(ctx, 'type');
229
+ await gate(ctx, 'type', { tabId: tabId(a) });
211
230
  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), {
212
231
  tabId: tabId(a),
213
232
  clear: (0, validators_1.optionalBoolean)(a, 'clear'),
@@ -218,14 +237,14 @@ exports.TOOL_HANDLERS = {
218
237
  },
219
238
  select_option: async (a, ctx) => {
220
239
  const t = (0, validators_1.requireTarget)(a);
221
- await gate(ctx, 'type'); // mutating
240
+ await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
222
241
  const values = (0, validators_1.optionalStringArray)(a, 'values');
223
242
  if (!values || values.length === 0)
224
243
  throw new validators_1.McpToolError('"values" must be a non-empty array of strings');
225
244
  return (0, envelopes_1.jsonResult)(await ctx.ex.selectOption(t, values, { tabId: tabId(a) }));
226
245
  },
227
246
  press: async (a, ctx) => {
228
- await gate(ctx, 'press');
247
+ await gate(ctx, 'press', { tabId: tabId(a) });
229
248
  return (0, envelopes_1.jsonResult)(await ctx.ex.press((0, validators_1.requireString)(a, 'key'), {
230
249
  tabId: tabId(a),
231
250
  modifiers: (0, validators_1.optionalStringArray)(a, 'modifiers'),
@@ -233,11 +252,11 @@ exports.TOOL_HANDLERS = {
233
252
  },
234
253
  hover: async (a, ctx) => {
235
254
  const t = (0, validators_1.requireTarget)(a);
236
- await gate(ctx, 'hover');
255
+ await gate(ctx, 'hover', { tabId: tabId(a) });
237
256
  return (0, envelopes_1.jsonResult)(await ctx.ex.hover(t, { tabId: tabId(a) }));
238
257
  },
239
258
  scroll: async (a, ctx) => {
240
- await gate(ctx, 'scroll');
259
+ await gate(ctx, 'scroll', { tabId: tabId(a) });
241
260
  return (0, envelopes_1.jsonResult)(await ctx.ex.scroll({
242
261
  tabId: tabId(a),
243
262
  x: (0, validators_1.optionalNumber)(a, 'x'),
@@ -248,7 +267,7 @@ exports.TOOL_HANDLERS = {
248
267
  }));
249
268
  },
250
269
  screenshot: async (a, ctx) => {
251
- await gate(ctx, 'screenshot');
270
+ await gate(ctx, 'screenshot', { tabId: tabId(a) });
252
271
  const shot = await ctx.ex.screenshot({
253
272
  tabId: tabId(a),
254
273
  fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
@@ -259,17 +278,17 @@ exports.TOOL_HANDLERS = {
259
278
  return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
260
279
  },
261
280
  get_text: async (a, ctx) => {
262
- await gate(ctx, 'get_text');
281
+ await gate(ctx, 'get_text', { tabId: tabId(a) });
263
282
  const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
264
283
  (0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
265
284
  return (0, envelopes_1.jsonResult)(res);
266
285
  },
267
286
  get_html: async (a, ctx) => {
268
- await gate(ctx, 'get_html');
287
+ await gate(ctx, 'get_html', { tabId: tabId(a) });
269
288
  return (0, envelopes_1.jsonResult)(await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), { tabId: tabId(a), outer: (0, validators_1.optionalBoolean)(a, 'outer') }));
270
289
  },
271
290
  snapshot: async (a, ctx) => {
272
- await gate(ctx, 'get_text'); // read of page structure
291
+ await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
273
292
  return (0, envelopes_1.jsonResult)(await ctx.ex.snapshot({
274
293
  tabId: tabId(a),
275
294
  interactiveOnly: (0, validators_1.optionalBoolean)(a, 'interactiveOnly'),
@@ -277,13 +296,13 @@ exports.TOOL_HANDLERS = {
277
296
  }));
278
297
  },
279
298
  get_cookies: async (a, ctx) => {
280
- await gate(ctx, 'get_text'); // reads tab-scoped secrets; same domain gate as content reads
299
+ await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
281
300
  return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url: (0, validators_1.optionalString)(a, 'url') }));
282
301
  },
283
302
  storage: async (a, ctx) => {
284
303
  const op = (0, validators_1.requireString)(a, 'op');
285
304
  // get is a read; set/remove/clear mutate.
286
- await gate(ctx, op === 'get' ? 'get_text' : 'type');
305
+ await gate(ctx, op === 'get' ? 'get_text' : 'type', { tabId: tabId(a) });
287
306
  if ((op === 'set' || op === 'remove') && !(0, validators_1.optionalString)(a, 'key')) {
288
307
  throw new validators_1.McpToolError(`storage "${op}" requires a "key"`);
289
308
  }
@@ -296,14 +315,14 @@ exports.TOOL_HANDLERS = {
296
315
  }));
297
316
  },
298
317
  eval: async (a, ctx) => {
299
- await gate(ctx, 'eval');
318
+ await gate(ctx, 'eval', { tabId: tabId(a) });
300
319
  return (0, envelopes_1.jsonResult)(await ctx.ex.eval((0, validators_1.requireString)(a, 'expression'), {
301
320
  tabId: tabId(a),
302
321
  awaitPromise: (0, validators_1.optionalBoolean)(a, 'awaitPromise'),
303
322
  }));
304
323
  },
305
324
  wait_for: async (a, ctx) => {
306
- await gate(ctx, 'wait_for');
325
+ await gate(ctx, 'wait_for', { tabId: tabId(a) });
307
326
  return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
308
327
  tabId: tabId(a),
309
328
  selector: (0, validators_1.optionalString)(a, 'selector'),
@@ -313,7 +332,7 @@ exports.TOOL_HANDLERS = {
313
332
  }));
314
333
  },
315
334
  extract_links: async (a, ctx) => {
316
- await gate(ctx, 'get_text'); // read of page content
335
+ await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page content
317
336
  const res = await (0, helpers_1.extractLinks)(ctx.ex, {
318
337
  selector: (0, validators_1.optionalString)(a, 'selector'),
319
338
  sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
@@ -325,13 +344,13 @@ exports.TOOL_HANDLERS = {
325
344
  return (0, envelopes_1.jsonResult)(res);
326
345
  },
327
346
  read_as_markdown: async (a, ctx) => {
328
- await gate(ctx, 'get_text');
347
+ await gate(ctx, 'get_text', { tabId: tabId(a) });
329
348
  const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
330
349
  (0, workspace_1.saveResult)('read_as_markdown', 'md', md);
331
350
  return (0, envelopes_1.textResult)(md);
332
351
  },
333
352
  fill_form: async (a, ctx) => {
334
- await gate(ctx, 'type'); // mutating
353
+ await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
335
354
  const fields = a.fields;
336
355
  if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
337
356
  throw new validators_1.McpToolError('"fields" must be an object mapping selector -> string|boolean');
@@ -356,7 +375,7 @@ exports.TOOL_HANDLERS = {
356
375
  },
357
376
  upload_file: async (a, ctx) => {
358
377
  const t = (0, validators_1.requireTarget)(a);
359
- await gate(ctx, 'upload_file');
378
+ await gate(ctx, 'upload_file', { tabId: tabId(a) });
360
379
  const files = (0, validators_1.optionalStringArray)(a, 'files');
361
380
  if (!files || files.length === 0)
362
381
  throw new validators_1.McpToolError('"files" must be a non-empty array of absolute local paths');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
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",