@mehmoodqureshi/chrome-mcp 0.5.2 → 0.6.1
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 +4 -0
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/src/bridge/datadir.d.ts +39 -0
- package/dist/src/bridge/datadir.js +66 -0
- package/dist/src/bridge/server.d.ts +16 -3
- package/dist/src/bridge/server.js +67 -19
- package/dist/src/bridge/tasks.d.ts +44 -0
- package/dist/src/bridge/tasks.js +131 -0
- package/dist/src/bridge/workspace.d.ts +54 -0
- package/dist/src/bridge/workspace.js +162 -0
- package/dist/src/cli.js +106 -1
- package/dist/src/config.d.ts +19 -1
- package/dist/src/config.js +52 -0
- package/dist/src/executor/extension-executor.d.ts +3 -0
- package/dist/src/executor/extension-executor.js +29 -4
- package/dist/src/executor/types.d.ts +13 -0
- package/dist/src/mcp/server.d.ts +2 -2
- package/dist/src/mcp/server.js +4 -3
- package/dist/src/mcp/tools.d.ts +10 -7
- package/dist/src/mcp/tools.js +120 -66
- package/extension-dist/background.js +59 -12
- package/extension-dist/options.html +7 -0
- package/extension-dist/options.js +5 -2
- package/package.json +5 -4
package/dist/src/mcp/tools.js
CHANGED
|
@@ -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
|
|
5
|
-
* never-throw firewall (`dispatchToolCall`), and
|
|
6
|
-
*
|
|
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,66 +18,67 @@ exports.dispatchToolCall = dispatchToolCall;
|
|
|
17
18
|
exports.assertNoDrift = assertNoDrift;
|
|
18
19
|
exports.registerTools = registerTools;
|
|
19
20
|
const node_path_1 = require("node:path");
|
|
20
|
-
const
|
|
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");
|
|
24
25
|
const envelopes_1 = require("./envelopes");
|
|
25
26
|
const batch_1 = require("./batch");
|
|
26
27
|
const helpers_1 = require("./helpers");
|
|
28
|
+
const tasks_1 = require("../bridge/tasks");
|
|
29
|
+
const workspace_1 = require("../bridge/workspace");
|
|
27
30
|
const validators_1 = require("./validators");
|
|
31
|
+
/** Shared selector|ref target — both optional; a handler that needs one calls `requireTarget`. */
|
|
28
32
|
const TARGET_PROPS = {
|
|
29
|
-
selector:
|
|
30
|
-
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(),
|
|
31
35
|
};
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
properties,
|
|
35
|
-
required,
|
|
36
|
-
additionalProperties: false,
|
|
37
|
-
});
|
|
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();
|
|
38
38
|
exports.TOOL_DEFINITIONS = [
|
|
39
|
-
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema:
|
|
40
|
-
{ name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema:
|
|
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:
|
|
42
|
-
{ name: 'tab_close', description: 'Close a tab by tabId.', inputSchema:
|
|
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:
|
|
44
|
-
{ name: 'back', description: 'Go back in history.', inputSchema:
|
|
45
|
-
{ name: 'forward', description: 'Go forward in history.', inputSchema:
|
|
46
|
-
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema:
|
|
47
|
-
{ name: 'click', description: 'Click an element (target by selector or a snapshot ref). trusted=true uses real OS-level input.', inputSchema:
|
|
48
|
-
{ name: 'type', description: 'Type text into an element. trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema:
|
|
49
|
-
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema:
|
|
50
|
-
{ name: 'press', description: 'Press a key (with optional modifiers).', inputSchema:
|
|
51
|
-
{ name: 'hover', description: 'Hover over an element.', inputSchema:
|
|
52
|
-
{ name: 'scroll', description: 'Scroll the page or to an element.', inputSchema:
|
|
53
|
-
{ name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema:
|
|
54
|
-
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema:
|
|
55
|
-
{ name: 'get_html', description: 'Get HTML of the page or an element.', inputSchema:
|
|
56
|
-
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with stable refs to target by `ref` (more reliable than guessing CSS selectors).', inputSchema:
|
|
57
|
-
{ name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema:
|
|
58
|
-
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema:
|
|
59
|
-
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema:
|
|
60
|
-
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema:
|
|
61
|
-
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree.', inputSchema:
|
|
62
|
-
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema:
|
|
63
|
-
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema:
|
|
64
|
-
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema:
|
|
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:
|
|
66
|
-
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema:
|
|
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.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().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: {} },
|
|
67
71
|
{
|
|
68
72
|
name: 'batch',
|
|
69
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.',
|
|
70
|
-
inputSchema:
|
|
71
|
-
ops:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
maxConcurrency: { type: 'number', description: 'Parallel mode: max ops in flight at once (default 6).' },
|
|
79
|
-
}, ['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
|
+
},
|
|
80
82
|
},
|
|
81
83
|
];
|
|
82
84
|
/** Resolve the URL the policy should be evaluated against (the active tab). */
|
|
@@ -99,7 +101,24 @@ const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
|
99
101
|
/** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
|
|
100
102
|
* the parallel-batch explicit-tabId requirement. Everything else falls back to
|
|
101
103
|
* the active tab when `tabId` is omitted, which races under concurrency. */
|
|
102
|
-
const PARALLEL_TAB_EXEMPT = new Set([
|
|
104
|
+
const PARALLEL_TAB_EXEMPT = new Set([
|
|
105
|
+
'tabs_list', 'tab_new', 'chrome_status', 'batch',
|
|
106
|
+
'profile_use', 'task_new', 'tasks_list', 'task_status',
|
|
107
|
+
]);
|
|
108
|
+
/** Server-side tools that manage the task workspace and need no browser backend. */
|
|
109
|
+
const NO_BACKEND_TOOLS = new Set(['profile_use', 'task_new', 'tasks_list', 'task_status']);
|
|
110
|
+
/** Project a Workspace to the path fields worth returning to the caller. */
|
|
111
|
+
function workspaceView(w) {
|
|
112
|
+
return {
|
|
113
|
+
profile: w.profile,
|
|
114
|
+
task: w.task,
|
|
115
|
+
taskDir: w.taskDir,
|
|
116
|
+
downloadDir: w.downloadDir,
|
|
117
|
+
resultsDir: w.resultsDir,
|
|
118
|
+
screenshotsDir: w.screenshotsDir,
|
|
119
|
+
historyPath: w.historyPath,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
103
122
|
/** A known tool that operates on a specific tab — needs an explicit tabId in a parallel batch. */
|
|
104
123
|
function requiresExplicitTab(tool) {
|
|
105
124
|
return tool in exports.TOOL_HANDLERS && !PARALLEL_TAB_EXEMPT.has(tool);
|
|
@@ -194,12 +213,15 @@ exports.TOOL_HANDLERS = {
|
|
|
194
213
|
fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
|
|
195
214
|
target: (0, validators_1.optionalTarget)(a),
|
|
196
215
|
});
|
|
216
|
+
(0, workspace_1.saveScreenshot)(shot.dataBase64);
|
|
197
217
|
const caption = shot.truncated ? `(truncated; full height ${shot.fullHeight}px)` : undefined;
|
|
198
218
|
return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
|
|
199
219
|
},
|
|
200
220
|
get_text: async (a, ctx) => {
|
|
201
221
|
await gate(ctx, 'get_text');
|
|
202
|
-
|
|
222
|
+
const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
|
|
223
|
+
(0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
|
|
224
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
203
225
|
},
|
|
204
226
|
get_html: async (a, ctx) => {
|
|
205
227
|
await gate(ctx, 'get_html');
|
|
@@ -251,15 +273,19 @@ exports.TOOL_HANDLERS = {
|
|
|
251
273
|
},
|
|
252
274
|
extract_links: async (a, ctx) => {
|
|
253
275
|
await gate(ctx, 'get_text'); // read of page content
|
|
254
|
-
|
|
276
|
+
const res = await (0, helpers_1.extractLinks)(ctx.ex, {
|
|
255
277
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
256
278
|
sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
|
|
257
279
|
tabId: tabId(a),
|
|
258
|
-
})
|
|
280
|
+
});
|
|
281
|
+
(0, workspace_1.saveResult)('extract_links', 'json', JSON.stringify(res, null, 2));
|
|
282
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
259
283
|
},
|
|
260
284
|
read_as_markdown: async (a, ctx) => {
|
|
261
285
|
await gate(ctx, 'get_text');
|
|
262
|
-
|
|
286
|
+
const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
|
|
287
|
+
(0, workspace_1.saveResult)('read_as_markdown', 'md', md);
|
|
288
|
+
return (0, envelopes_1.textResult)(md);
|
|
263
289
|
},
|
|
264
290
|
fill_form: async (a, ctx) => {
|
|
265
291
|
await gate(ctx, 'type'); // mutating
|
|
@@ -308,6 +334,11 @@ exports.TOOL_HANDLERS = {
|
|
|
308
334
|
return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
|
|
309
335
|
},
|
|
310
336
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
337
|
+
// --- task workspace management (server-side; no browser needed) ---
|
|
338
|
+
profile_use: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') }))),
|
|
339
|
+
task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
|
|
340
|
+
task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
|
|
341
|
+
tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
|
|
311
342
|
// Fan-out: each sub-op is routed back through `dispatchToolCall`, so it gets
|
|
312
343
|
// the same policy gate, rate limit, and never-throw handling as a direct call.
|
|
313
344
|
batch: async (a) => (0, batch_1.runBatch)(a, { dispatch: dispatchToolCall, requiresExplicitTab }),
|
|
@@ -351,6 +382,24 @@ function allowCall(now) {
|
|
|
351
382
|
rateWindow.push(now);
|
|
352
383
|
return true;
|
|
353
384
|
}
|
|
385
|
+
/** A compact, length-bounded summary of a call's args for the history log. */
|
|
386
|
+
function summarizeArgs(rawArgs) {
|
|
387
|
+
if (typeof rawArgs !== 'object' || rawArgs === null)
|
|
388
|
+
return undefined;
|
|
389
|
+
const a = rawArgs;
|
|
390
|
+
const out = {};
|
|
391
|
+
for (const k of ['url', 'selector', 'ref', 'name', 'text', 'key', 'op', 'tabId']) {
|
|
392
|
+
const v = a[k];
|
|
393
|
+
if (v === undefined)
|
|
394
|
+
continue;
|
|
395
|
+
out[k] = typeof v === 'string' && v.length > 120 ? `${v.slice(0, 120)}…` : v;
|
|
396
|
+
}
|
|
397
|
+
return Object.keys(out).length ? out : undefined;
|
|
398
|
+
}
|
|
399
|
+
/** Append one action record to the active task's history.jsonl (best-effort). */
|
|
400
|
+
function recordHistory(tool, rawArgs, ok, error) {
|
|
401
|
+
(0, workspace_1.appendHistory)({ ts: new Date().toISOString(), tool, args: summarizeArgs(rawArgs), ok, ...(error ? { error } : {}) });
|
|
402
|
+
}
|
|
354
403
|
async function dispatchToolCall(name, rawArgs) {
|
|
355
404
|
const handler = exports.TOOL_HANDLERS[name];
|
|
356
405
|
if (!handler)
|
|
@@ -359,11 +408,17 @@ async function dispatchToolCall(name, rawArgs) {
|
|
|
359
408
|
return (0, envelopes_1.errorResult)('rate limit exceeded; slow down');
|
|
360
409
|
try {
|
|
361
410
|
const mgr = (0, manager_1.getManager)();
|
|
362
|
-
|
|
363
|
-
|
|
411
|
+
// Workspace-management tools run server-side and must work even with no
|
|
412
|
+
// browser paired, so they skip the executor readiness check.
|
|
413
|
+
const ex = NO_BACKEND_TOOLS.has(name) ? null : await mgr.ensureReady();
|
|
414
|
+
const result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
|
|
415
|
+
recordHistory(name, rawArgs, !result.isError);
|
|
416
|
+
return result;
|
|
364
417
|
}
|
|
365
418
|
catch (err) {
|
|
366
|
-
|
|
419
|
+
const message = errMessage(err);
|
|
420
|
+
recordHistory(name, rawArgs, false, message);
|
|
421
|
+
return (0, envelopes_1.errorResult)(message);
|
|
367
422
|
}
|
|
368
423
|
}
|
|
369
424
|
/** Assert the catalog and the dispatch table describe the same tool set. */
|
|
@@ -382,13 +437,12 @@ function assertNoDrift() {
|
|
|
382
437
|
// ---------------------------------------------------------------------------
|
|
383
438
|
function registerTools(server) {
|
|
384
439
|
assertNoDrift();
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}))
|
|
391
|
-
}
|
|
392
|
-
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => dispatchToolCall(req.params.name, req.params.arguments));
|
|
440
|
+
// Register each tool with its zod `inputSchema`. The SDK advertises it in
|
|
441
|
+
// `tools/list` and validates arguments before invoking the handler, which
|
|
442
|
+
// just routes back through `dispatchToolCall` — our never-throw firewall that
|
|
443
|
+
// applies the rate limit, executor readiness, policy gate, and history log.
|
|
444
|
+
for (const d of exports.TOOL_DEFINITIONS) {
|
|
445
|
+
server.registerTool(d.name, { description: d.description, inputSchema: d.inputSchema }, async (args) => dispatchToolCall(d.name, args));
|
|
446
|
+
}
|
|
393
447
|
}
|
|
394
448
|
//# sourceMappingURL=tools.js.map
|
|
@@ -39,12 +39,13 @@
|
|
|
39
39
|
constructor(deps) {
|
|
40
40
|
this.deps = deps;
|
|
41
41
|
}
|
|
42
|
+
deps;
|
|
42
43
|
ws = null;
|
|
43
44
|
state = "idle";
|
|
44
45
|
isConnected() {
|
|
45
46
|
return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
|
|
46
47
|
}
|
|
47
|
-
connect(port, token) {
|
|
48
|
+
connect(port, token, profile) {
|
|
48
49
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
@@ -62,7 +63,8 @@
|
|
|
62
63
|
type: "hello",
|
|
63
64
|
v: PROTOCOL_VERSION,
|
|
64
65
|
token,
|
|
65
|
-
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() }
|
|
66
|
+
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
|
|
67
|
+
profile: profile && profile.trim() ? profile.trim() : void 0
|
|
66
68
|
};
|
|
67
69
|
ws2.send(JSON.stringify(hello));
|
|
68
70
|
};
|
|
@@ -423,7 +425,38 @@
|
|
|
423
425
|
super(message);
|
|
424
426
|
this.code = code;
|
|
425
427
|
}
|
|
428
|
+
code;
|
|
426
429
|
};
|
|
430
|
+
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
431
|
+
function waitForDownloadComplete(id) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
let settled = false;
|
|
434
|
+
const settle = (fn) => {
|
|
435
|
+
if (settled) return;
|
|
436
|
+
settled = true;
|
|
437
|
+
chrome.downloads.onChanged.removeListener(onChanged);
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
fn();
|
|
440
|
+
};
|
|
441
|
+
const timer = setTimeout(
|
|
442
|
+
() => settle(() => reject(new CmdError("DOWNLOAD_FAILED", "download timed out"))),
|
|
443
|
+
DOWNLOAD_TIMEOUT_MS
|
|
444
|
+
);
|
|
445
|
+
const check = async () => {
|
|
446
|
+
const [item] = await chrome.downloads.search({ id });
|
|
447
|
+
if (!item) return;
|
|
448
|
+
if (item.state === "complete") settle(() => resolve(item));
|
|
449
|
+
else if (item.state === "interrupted") {
|
|
450
|
+
settle(() => reject(new CmdError("DOWNLOAD_FAILED", `download interrupted: ${item.error ?? "unknown"}`)));
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
const onChanged = (delta) => {
|
|
454
|
+
if (delta.id === id) void check();
|
|
455
|
+
};
|
|
456
|
+
chrome.downloads.onChanged.addListener(onChanged);
|
|
457
|
+
void check();
|
|
458
|
+
});
|
|
459
|
+
}
|
|
427
460
|
var SESSION = crypto.randomUUID();
|
|
428
461
|
var CONTENT_SCHEME = /^(https?|file):/i;
|
|
429
462
|
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -494,7 +527,7 @@
|
|
|
494
527
|
async function waitForSelector(tabId, selector, timeoutMs = 5e3) {
|
|
495
528
|
const found = await execInTab(
|
|
496
529
|
tabId,
|
|
497
|
-
(s, timeout, interval) => new Promise((resolve) => {
|
|
530
|
+
((s, timeout, interval) => new Promise((resolve) => {
|
|
498
531
|
const deadline = Date.now() + timeout;
|
|
499
532
|
const tick = () => {
|
|
500
533
|
if (document.querySelector(s)) return resolve(true);
|
|
@@ -502,7 +535,7 @@
|
|
|
502
535
|
setTimeout(tick, interval);
|
|
503
536
|
};
|
|
504
537
|
tick();
|
|
505
|
-
}),
|
|
538
|
+
})),
|
|
506
539
|
[selector, timeoutMs, 120]
|
|
507
540
|
);
|
|
508
541
|
return found === true;
|
|
@@ -1003,7 +1036,7 @@
|
|
|
1003
1036
|
const start = Date.now();
|
|
1004
1037
|
const matched = await execInTab(
|
|
1005
1038
|
id,
|
|
1006
|
-
(sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
|
|
1039
|
+
((sel, text, gone, timeoutMs, interval) => new Promise((resolve) => {
|
|
1007
1040
|
const deadline = Date.now() + timeoutMs;
|
|
1008
1041
|
const hit = () => {
|
|
1009
1042
|
let present;
|
|
@@ -1018,12 +1051,14 @@
|
|
|
1018
1051
|
setTimeout(tick, interval);
|
|
1019
1052
|
};
|
|
1020
1053
|
tick();
|
|
1021
|
-
}),
|
|
1054
|
+
})),
|
|
1022
1055
|
[cmd.params.selector ?? null, cmd.params.textContains ?? null, cmd.params.gone === true, timeout, 150]
|
|
1023
1056
|
);
|
|
1024
1057
|
return { matched: matched === true, waitedMs: Date.now() - start };
|
|
1025
1058
|
}
|
|
1026
|
-
// -- download (user's Downloads dir
|
|
1059
|
+
// -- download (saved to the user's Downloads dir; the server then moves it
|
|
1060
|
+
// into the active task's downloads/). We wait for completion and report
|
|
1061
|
+
// the absolute on-disk path so the server can relocate it. --
|
|
1027
1062
|
case "download_file": {
|
|
1028
1063
|
const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
|
|
1029
1064
|
if (!url) throw new CmdError("DOWNLOAD_FAILED", "the extension download path requires a url");
|
|
@@ -1031,7 +1066,16 @@
|
|
|
1031
1066
|
typeof cmd.params.suggestedName === "string" ? cmd.params.suggestedName : void 0
|
|
1032
1067
|
);
|
|
1033
1068
|
const downloadId = await chrome.downloads.download({ url, filename: name });
|
|
1034
|
-
|
|
1069
|
+
const item = await waitForDownloadComplete(downloadId);
|
|
1070
|
+
const bytes = item.fileSize > 0 ? item.fileSize : item.bytesReceived;
|
|
1071
|
+
return {
|
|
1072
|
+
path: item.filename,
|
|
1073
|
+
sourcePath: item.filename,
|
|
1074
|
+
downloadId,
|
|
1075
|
+
backend: "extension",
|
|
1076
|
+
bytes,
|
|
1077
|
+
suggestedName: name
|
|
1078
|
+
};
|
|
1035
1079
|
}
|
|
1036
1080
|
// -- upload: set local file(s) on a file <input> via CDP DOM.setFileInputFiles --
|
|
1037
1081
|
case "upload_file": {
|
|
@@ -1071,6 +1115,7 @@
|
|
|
1071
1115
|
if (!HANDLED.has(m)) throw new Error(`router drift: no handler for wire method "${m}"`);
|
|
1072
1116
|
}
|
|
1073
1117
|
}
|
|
1118
|
+
deps;
|
|
1074
1119
|
async dispatch(cmd) {
|
|
1075
1120
|
try {
|
|
1076
1121
|
const policy = this.deps.getPolicy();
|
|
@@ -1117,9 +1162,10 @@
|
|
|
1117
1162
|
log: (m) => console.debug("[chrome-mcp]", m)
|
|
1118
1163
|
});
|
|
1119
1164
|
async function getConfig() {
|
|
1120
|
-
const { wsPort, token } = await chrome.storage.local.get(["wsPort", "token"]);
|
|
1165
|
+
const { wsPort, token, profile } = await chrome.storage.local.get(["wsPort", "token", "profile"]);
|
|
1121
1166
|
if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
|
|
1122
|
-
|
|
1167
|
+
const name = typeof profile === "string" && profile.trim() ? profile.trim() : "default";
|
|
1168
|
+
return { wsPort, token, profile: name };
|
|
1123
1169
|
}
|
|
1124
1170
|
return null;
|
|
1125
1171
|
}
|
|
@@ -1146,7 +1192,7 @@
|
|
|
1146
1192
|
if (ws.isConnected() || ws.state === "unauthorized") return;
|
|
1147
1193
|
const cfg = await getConfig();
|
|
1148
1194
|
if (!cfg) return;
|
|
1149
|
-
ws.connect(cfg.wsPort, cfg.token);
|
|
1195
|
+
ws.connect(cfg.wsPort, cfg.token, cfg.profile);
|
|
1150
1196
|
}
|
|
1151
1197
|
async function keepalivePulse() {
|
|
1152
1198
|
await chrome.storage.local.get("connState");
|
|
@@ -1158,8 +1204,9 @@
|
|
|
1158
1204
|
if (alarm.name === KEEPALIVE_ALARM) void keepalivePulse();
|
|
1159
1205
|
});
|
|
1160
1206
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
1161
|
-
if (area === "local" && (changes.wsPort || changes.token)) {
|
|
1207
|
+
if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
|
|
1162
1208
|
if (ws.state === "unauthorized") ws.state = "idle";
|
|
1209
|
+
if (changes.profile) ws.close();
|
|
1163
1210
|
void ensureConnected();
|
|
1164
1211
|
}
|
|
1165
1212
|
});
|
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
<input id="port" type="number" inputmode="numeric" placeholder="38017" />
|
|
26
26
|
<label for="token">Token</label>
|
|
27
27
|
<input id="token" type="password" placeholder="paste the handshake token" />
|
|
28
|
+
<label for="profile">Profile</label>
|
|
29
|
+
<input id="profile" type="text" placeholder="default" />
|
|
30
|
+
<p class="hint">
|
|
31
|
+
A name for this browser (e.g. <code>mehmood</code>). Several browsers can pair to the
|
|
32
|
+
same port & token at once, each with its own profile — switch between them with
|
|
33
|
+
<code>profile_use</code>. Leave blank for <code>default</code>.
|
|
34
|
+
</p>
|
|
28
35
|
<button id="save">Save & connect</button>
|
|
29
36
|
<div class="status" id="status">Status: unknown</div>
|
|
30
37
|
<script src="options.js"></script>
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// extension/src/options/options.ts
|
|
7
7
|
var portEl = document.getElementById("port");
|
|
8
8
|
var tokenEl = document.getElementById("token");
|
|
9
|
+
var profileEl = document.getElementById("profile");
|
|
9
10
|
var saveEl = document.getElementById("save");
|
|
10
11
|
var statusEl = document.getElementById("status");
|
|
11
12
|
async function loadExisting() {
|
|
12
|
-
const { wsPort, connState } = await chrome.storage.local.get(["wsPort", "connState"]);
|
|
13
|
+
const { wsPort, profile, connState } = await chrome.storage.local.get(["wsPort", "profile", "connState"]);
|
|
13
14
|
portEl.value = typeof wsPort === "number" && wsPort > 0 ? String(wsPort) : String(DEFAULT_WS_PORT);
|
|
15
|
+
profileEl.value = typeof profile === "string" ? profile : "";
|
|
14
16
|
render(typeof connState === "string" ? connState : "idle");
|
|
15
17
|
}
|
|
16
18
|
function render(state) {
|
|
@@ -25,11 +27,12 @@
|
|
|
25
27
|
saveEl.addEventListener("click", async () => {
|
|
26
28
|
const wsPort = Number(portEl.value);
|
|
27
29
|
const token = tokenEl.value.trim();
|
|
30
|
+
const profile = profileEl.value.trim() || "default";
|
|
28
31
|
if (!Number.isInteger(wsPort) || wsPort <= 0 || !token) {
|
|
29
32
|
statusEl.textContent = "Status: enter a valid port (> 0) and token";
|
|
30
33
|
return;
|
|
31
34
|
}
|
|
32
|
-
await chrome.storage.local.set({ wsPort, token });
|
|
35
|
+
await chrome.storage.local.set({ wsPort, token, profile });
|
|
33
36
|
await chrome.runtime.sendMessage({ type: "reconnect" }).catch(() => void 0);
|
|
34
37
|
statusEl.textContent = "Status: \u2026 connecting";
|
|
35
38
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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
|
|
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",
|