@mehmoodqureshi/chrome-mcp 0.5.2 → 0.6.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.
- 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/tools.js +67 -8
- package/extension-dist/background.js +52 -8
- package/extension-dist/options.html +7 -0
- package/extension-dist/options.js +5 -2
- package/package.json +1 -1
package/dist/src/mcp/tools.js
CHANGED
|
@@ -24,6 +24,8 @@ const policy_1 = require("../security/policy");
|
|
|
24
24
|
const envelopes_1 = require("./envelopes");
|
|
25
25
|
const batch_1 = require("./batch");
|
|
26
26
|
const helpers_1 = require("./helpers");
|
|
27
|
+
const tasks_1 = require("../bridge/tasks");
|
|
28
|
+
const workspace_1 = require("../bridge/workspace");
|
|
27
29
|
const validators_1 = require("./validators");
|
|
28
30
|
const TARGET_PROPS = {
|
|
29
31
|
selector: { type: 'string', description: 'CSS selector (exactly one of selector|ref)' },
|
|
@@ -64,6 +66,10 @@ exports.TOOL_DEFINITIONS = [
|
|
|
64
66
|
{ 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' } }) },
|
|
65
67
|
{ 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']) },
|
|
66
68
|
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: obj({}) },
|
|
69
|
+
{ 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: obj({ name: { type: 'string', description: 'Profile name (becomes a folder; sanitized to a safe path segment).' } }, ['name']) },
|
|
70
|
+
{ 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: obj({ name: { type: 'string', description: 'Task name (becomes a folder; sanitized to a safe path segment).' } }, ['name']) },
|
|
71
|
+
{ name: 'tasks_list', description: 'List every task across all profiles under the data dir, with sizes and download counts.', inputSchema: obj({}) },
|
|
72
|
+
{ name: 'task_status', description: 'Report the active profile/task and the folder paths where this run\'s artifacts are stored.', inputSchema: obj({}) },
|
|
67
73
|
{
|
|
68
74
|
name: 'batch',
|
|
69
75
|
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.',
|
|
@@ -99,7 +105,24 @@ const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
|
99
105
|
/** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
|
|
100
106
|
* the parallel-batch explicit-tabId requirement. Everything else falls back to
|
|
101
107
|
* the active tab when `tabId` is omitted, which races under concurrency. */
|
|
102
|
-
const PARALLEL_TAB_EXEMPT = new Set([
|
|
108
|
+
const PARALLEL_TAB_EXEMPT = new Set([
|
|
109
|
+
'tabs_list', 'tab_new', 'chrome_status', 'batch',
|
|
110
|
+
'profile_use', 'task_new', 'tasks_list', 'task_status',
|
|
111
|
+
]);
|
|
112
|
+
/** Server-side tools that manage the task workspace and need no browser backend. */
|
|
113
|
+
const NO_BACKEND_TOOLS = new Set(['profile_use', 'task_new', 'tasks_list', 'task_status']);
|
|
114
|
+
/** Project a Workspace to the path fields worth returning to the caller. */
|
|
115
|
+
function workspaceView(w) {
|
|
116
|
+
return {
|
|
117
|
+
profile: w.profile,
|
|
118
|
+
task: w.task,
|
|
119
|
+
taskDir: w.taskDir,
|
|
120
|
+
downloadDir: w.downloadDir,
|
|
121
|
+
resultsDir: w.resultsDir,
|
|
122
|
+
screenshotsDir: w.screenshotsDir,
|
|
123
|
+
historyPath: w.historyPath,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
103
126
|
/** A known tool that operates on a specific tab — needs an explicit tabId in a parallel batch. */
|
|
104
127
|
function requiresExplicitTab(tool) {
|
|
105
128
|
return tool in exports.TOOL_HANDLERS && !PARALLEL_TAB_EXEMPT.has(tool);
|
|
@@ -194,12 +217,15 @@ exports.TOOL_HANDLERS = {
|
|
|
194
217
|
fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
|
|
195
218
|
target: (0, validators_1.optionalTarget)(a),
|
|
196
219
|
});
|
|
220
|
+
(0, workspace_1.saveScreenshot)(shot.dataBase64);
|
|
197
221
|
const caption = shot.truncated ? `(truncated; full height ${shot.fullHeight}px)` : undefined;
|
|
198
222
|
return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
|
|
199
223
|
},
|
|
200
224
|
get_text: async (a, ctx) => {
|
|
201
225
|
await gate(ctx, 'get_text');
|
|
202
|
-
|
|
226
|
+
const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
|
|
227
|
+
(0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
|
|
228
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
203
229
|
},
|
|
204
230
|
get_html: async (a, ctx) => {
|
|
205
231
|
await gate(ctx, 'get_html');
|
|
@@ -251,15 +277,19 @@ exports.TOOL_HANDLERS = {
|
|
|
251
277
|
},
|
|
252
278
|
extract_links: async (a, ctx) => {
|
|
253
279
|
await gate(ctx, 'get_text'); // read of page content
|
|
254
|
-
|
|
280
|
+
const res = await (0, helpers_1.extractLinks)(ctx.ex, {
|
|
255
281
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
256
282
|
sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
|
|
257
283
|
tabId: tabId(a),
|
|
258
|
-
})
|
|
284
|
+
});
|
|
285
|
+
(0, workspace_1.saveResult)('extract_links', 'json', JSON.stringify(res, null, 2));
|
|
286
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
259
287
|
},
|
|
260
288
|
read_as_markdown: async (a, ctx) => {
|
|
261
289
|
await gate(ctx, 'get_text');
|
|
262
|
-
|
|
290
|
+
const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
|
|
291
|
+
(0, workspace_1.saveResult)('read_as_markdown', 'md', md);
|
|
292
|
+
return (0, envelopes_1.textResult)(md);
|
|
263
293
|
},
|
|
264
294
|
fill_form: async (a, ctx) => {
|
|
265
295
|
await gate(ctx, 'type'); // mutating
|
|
@@ -308,6 +338,11 @@ exports.TOOL_HANDLERS = {
|
|
|
308
338
|
return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
|
|
309
339
|
},
|
|
310
340
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
341
|
+
// --- task workspace management (server-side; no browser needed) ---
|
|
342
|
+
profile_use: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') }))),
|
|
343
|
+
task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
|
|
344
|
+
task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
|
|
345
|
+
tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
|
|
311
346
|
// Fan-out: each sub-op is routed back through `dispatchToolCall`, so it gets
|
|
312
347
|
// the same policy gate, rate limit, and never-throw handling as a direct call.
|
|
313
348
|
batch: async (a) => (0, batch_1.runBatch)(a, { dispatch: dispatchToolCall, requiresExplicitTab }),
|
|
@@ -351,6 +386,24 @@ function allowCall(now) {
|
|
|
351
386
|
rateWindow.push(now);
|
|
352
387
|
return true;
|
|
353
388
|
}
|
|
389
|
+
/** A compact, length-bounded summary of a call's args for the history log. */
|
|
390
|
+
function summarizeArgs(rawArgs) {
|
|
391
|
+
if (typeof rawArgs !== 'object' || rawArgs === null)
|
|
392
|
+
return undefined;
|
|
393
|
+
const a = rawArgs;
|
|
394
|
+
const out = {};
|
|
395
|
+
for (const k of ['url', 'selector', 'ref', 'name', 'text', 'key', 'op', 'tabId']) {
|
|
396
|
+
const v = a[k];
|
|
397
|
+
if (v === undefined)
|
|
398
|
+
continue;
|
|
399
|
+
out[k] = typeof v === 'string' && v.length > 120 ? `${v.slice(0, 120)}…` : v;
|
|
400
|
+
}
|
|
401
|
+
return Object.keys(out).length ? out : undefined;
|
|
402
|
+
}
|
|
403
|
+
/** Append one action record to the active task's history.jsonl (best-effort). */
|
|
404
|
+
function recordHistory(tool, rawArgs, ok, error) {
|
|
405
|
+
(0, workspace_1.appendHistory)({ ts: new Date().toISOString(), tool, args: summarizeArgs(rawArgs), ok, ...(error ? { error } : {}) });
|
|
406
|
+
}
|
|
354
407
|
async function dispatchToolCall(name, rawArgs) {
|
|
355
408
|
const handler = exports.TOOL_HANDLERS[name];
|
|
356
409
|
if (!handler)
|
|
@@ -359,11 +412,17 @@ async function dispatchToolCall(name, rawArgs) {
|
|
|
359
412
|
return (0, envelopes_1.errorResult)('rate limit exceeded; slow down');
|
|
360
413
|
try {
|
|
361
414
|
const mgr = (0, manager_1.getManager)();
|
|
362
|
-
|
|
363
|
-
|
|
415
|
+
// Workspace-management tools run server-side and must work even with no
|
|
416
|
+
// browser paired, so they skip the executor readiness check.
|
|
417
|
+
const ex = NO_BACKEND_TOOLS.has(name) ? null : await mgr.ensureReady();
|
|
418
|
+
const result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
|
|
419
|
+
recordHistory(name, rawArgs, !result.isError);
|
|
420
|
+
return result;
|
|
364
421
|
}
|
|
365
422
|
catch (err) {
|
|
366
|
-
|
|
423
|
+
const message = errMessage(err);
|
|
424
|
+
recordHistory(name, rawArgs, false, message);
|
|
425
|
+
return (0, envelopes_1.errorResult)(message);
|
|
367
426
|
}
|
|
368
427
|
}
|
|
369
428
|
/** Assert the catalog and the dispatch table describe the same tool set. */
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
isConnected() {
|
|
45
45
|
return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
|
|
46
46
|
}
|
|
47
|
-
connect(port, token) {
|
|
47
|
+
connect(port, token, profile) {
|
|
48
48
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
type: "hello",
|
|
63
63
|
v: PROTOCOL_VERSION,
|
|
64
64
|
token,
|
|
65
|
-
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() }
|
|
65
|
+
ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
|
|
66
|
+
profile: profile && profile.trim() ? profile.trim() : void 0
|
|
66
67
|
};
|
|
67
68
|
ws2.send(JSON.stringify(hello));
|
|
68
69
|
};
|
|
@@ -424,6 +425,36 @@
|
|
|
424
425
|
this.code = code;
|
|
425
426
|
}
|
|
426
427
|
};
|
|
428
|
+
var DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
429
|
+
function waitForDownloadComplete(id) {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
let settled = false;
|
|
432
|
+
const settle = (fn) => {
|
|
433
|
+
if (settled) return;
|
|
434
|
+
settled = true;
|
|
435
|
+
chrome.downloads.onChanged.removeListener(onChanged);
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
fn();
|
|
438
|
+
};
|
|
439
|
+
const timer = setTimeout(
|
|
440
|
+
() => settle(() => reject(new CmdError("DOWNLOAD_FAILED", "download timed out"))),
|
|
441
|
+
DOWNLOAD_TIMEOUT_MS
|
|
442
|
+
);
|
|
443
|
+
const check = async () => {
|
|
444
|
+
const [item] = await chrome.downloads.search({ id });
|
|
445
|
+
if (!item) return;
|
|
446
|
+
if (item.state === "complete") settle(() => resolve(item));
|
|
447
|
+
else if (item.state === "interrupted") {
|
|
448
|
+
settle(() => reject(new CmdError("DOWNLOAD_FAILED", `download interrupted: ${item.error ?? "unknown"}`)));
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
const onChanged = (delta) => {
|
|
452
|
+
if (delta.id === id) void check();
|
|
453
|
+
};
|
|
454
|
+
chrome.downloads.onChanged.addListener(onChanged);
|
|
455
|
+
void check();
|
|
456
|
+
});
|
|
457
|
+
}
|
|
427
458
|
var SESSION = crypto.randomUUID();
|
|
428
459
|
var CONTENT_SCHEME = /^(https?|file):/i;
|
|
429
460
|
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -1023,7 +1054,9 @@
|
|
|
1023
1054
|
);
|
|
1024
1055
|
return { matched: matched === true, waitedMs: Date.now() - start };
|
|
1025
1056
|
}
|
|
1026
|
-
// -- download (user's Downloads dir
|
|
1057
|
+
// -- download (saved to the user's Downloads dir; the server then moves it
|
|
1058
|
+
// into the active task's downloads/). We wait for completion and report
|
|
1059
|
+
// the absolute on-disk path so the server can relocate it. --
|
|
1027
1060
|
case "download_file": {
|
|
1028
1061
|
const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
|
|
1029
1062
|
if (!url) throw new CmdError("DOWNLOAD_FAILED", "the extension download path requires a url");
|
|
@@ -1031,7 +1064,16 @@
|
|
|
1031
1064
|
typeof cmd.params.suggestedName === "string" ? cmd.params.suggestedName : void 0
|
|
1032
1065
|
);
|
|
1033
1066
|
const downloadId = await chrome.downloads.download({ url, filename: name });
|
|
1034
|
-
|
|
1067
|
+
const item = await waitForDownloadComplete(downloadId);
|
|
1068
|
+
const bytes = item.fileSize > 0 ? item.fileSize : item.bytesReceived;
|
|
1069
|
+
return {
|
|
1070
|
+
path: item.filename,
|
|
1071
|
+
sourcePath: item.filename,
|
|
1072
|
+
downloadId,
|
|
1073
|
+
backend: "extension",
|
|
1074
|
+
bytes,
|
|
1075
|
+
suggestedName: name
|
|
1076
|
+
};
|
|
1035
1077
|
}
|
|
1036
1078
|
// -- upload: set local file(s) on a file <input> via CDP DOM.setFileInputFiles --
|
|
1037
1079
|
case "upload_file": {
|
|
@@ -1117,9 +1159,10 @@
|
|
|
1117
1159
|
log: (m) => console.debug("[chrome-mcp]", m)
|
|
1118
1160
|
});
|
|
1119
1161
|
async function getConfig() {
|
|
1120
|
-
const { wsPort, token } = await chrome.storage.local.get(["wsPort", "token"]);
|
|
1162
|
+
const { wsPort, token, profile } = await chrome.storage.local.get(["wsPort", "token", "profile"]);
|
|
1121
1163
|
if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
|
|
1122
|
-
|
|
1164
|
+
const name = typeof profile === "string" && profile.trim() ? profile.trim() : "default";
|
|
1165
|
+
return { wsPort, token, profile: name };
|
|
1123
1166
|
}
|
|
1124
1167
|
return null;
|
|
1125
1168
|
}
|
|
@@ -1146,7 +1189,7 @@
|
|
|
1146
1189
|
if (ws.isConnected() || ws.state === "unauthorized") return;
|
|
1147
1190
|
const cfg = await getConfig();
|
|
1148
1191
|
if (!cfg) return;
|
|
1149
|
-
ws.connect(cfg.wsPort, cfg.token);
|
|
1192
|
+
ws.connect(cfg.wsPort, cfg.token, cfg.profile);
|
|
1150
1193
|
}
|
|
1151
1194
|
async function keepalivePulse() {
|
|
1152
1195
|
await chrome.storage.local.get("connState");
|
|
@@ -1158,8 +1201,9 @@
|
|
|
1158
1201
|
if (alarm.name === KEEPALIVE_ALARM) void keepalivePulse();
|
|
1159
1202
|
});
|
|
1160
1203
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
1161
|
-
if (area === "local" && (changes.wsPort || changes.token)) {
|
|
1204
|
+
if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
|
|
1162
1205
|
if (ws.state === "unauthorized") ws.state = "idle";
|
|
1206
|
+
if (changes.profile) ws.close();
|
|
1163
1207
|
void ensureConnected();
|
|
1164
1208
|
}
|
|
1165
1209
|
});
|
|
@@ -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.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",
|