@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:
|
|
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
|
-
|
|
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 };
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -81,24 +81,34 @@ exports.TOOL_DEFINITIONS = [
|
|
|
81
81
|
},
|
|
82
82
|
},
|
|
83
83
|
];
|
|
84
|
-
const GATE_CONTEXT = 'cannot resolve the
|
|
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
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 (!
|
|
121
|
-
throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the
|
|
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
|
|
141
|
+
return target.url;
|
|
126
142
|
}
|
|
127
143
|
/**
|
|
128
|
-
* Policy chokepoint. `
|
|
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
|
|
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,
|
|
137
|
-
const url =
|
|
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.
|
|
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",
|