chrome-extension-tester-mcp 2.2.0 → 2.3.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/package.json +1 -1
- package/src/state.js +12 -0
- package/src/tools/connect-browser.js +108 -20
- package/src/tools/tabs.js +9 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-extension-tester-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "MCP server for interactive Chrome extension testing via Playwright — load, interact, assert, inspect storage, network, badges, messaging, tabs, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/state.js
CHANGED
|
@@ -77,6 +77,18 @@ export async function getServiceWorker() {
|
|
|
77
77
|
const ctx = state.context || state.browser;
|
|
78
78
|
if (!ctx) throw new Error("No browser connected. Call load_extension or connect_browser first.");
|
|
79
79
|
const workers = ctx.serviceWorkers();
|
|
80
|
+
|
|
81
|
+
if (state.extensionId) {
|
|
82
|
+
const targetWorker = workers.find((w) => w.url().includes(state.extensionId));
|
|
83
|
+
if (targetWorker) return targetWorker;
|
|
84
|
+
const workerList = workers.map((w) => ` ${w.url()}`).join("\n") || " (none)";
|
|
85
|
+
throw new Error(
|
|
86
|
+
`No service worker found for extension ${state.extensionId}.\n` +
|
|
87
|
+
`Active workers:\n${workerList}\n` +
|
|
88
|
+
`Re-run connect_browser with the correct extension_id to retarget.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
80
92
|
if (!workers.length) throw new Error("No service worker found. Extension may not have a background service worker.");
|
|
81
93
|
return workers[0];
|
|
82
94
|
}
|
|
@@ -14,19 +14,59 @@ const KNOWN_BROWSERS = [
|
|
|
14
14
|
name: "Brave",
|
|
15
15
|
executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
16
16
|
userDataDir: `${os.homedir()}/Library/Application Support/BraveSoftware/Brave-Browser`,
|
|
17
|
+
extensionsDir: `${os.homedir()}/Library/Application Support/BraveSoftware/Brave-Browser/Default/Extensions`,
|
|
17
18
|
},
|
|
18
19
|
{
|
|
19
20
|
name: "Chrome",
|
|
20
21
|
executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
21
22
|
userDataDir: `${os.homedir()}/Library/Application Support/Google/Chrome`,
|
|
23
|
+
extensionsDir: `${os.homedir()}/Library/Application Support/Google/Chrome/Default/Extensions`,
|
|
22
24
|
},
|
|
23
25
|
{
|
|
24
26
|
name: "Chromium",
|
|
25
27
|
executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
26
28
|
userDataDir: `${os.homedir()}/Library/Application Support/Chromium`,
|
|
29
|
+
extensionsDir: `${os.homedir()}/Library/Application Support/Chromium/Default/Extensions`,
|
|
27
30
|
},
|
|
28
31
|
];
|
|
29
32
|
|
|
33
|
+
function readExtensionManifest(extensionsDir, extensionId) {
|
|
34
|
+
try {
|
|
35
|
+
const versionDirs = fs.readdirSync(`${extensionsDir}/${extensionId}`);
|
|
36
|
+
for (const version of versionDirs) {
|
|
37
|
+
const manifestPath = `${extensionsDir}/${extensionId}/${version}/manifest.json`;
|
|
38
|
+
if (fs.existsSync(manifestPath)) {
|
|
39
|
+
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function listInstalledExtensions(extensionsDir) {
|
|
47
|
+
if (!fs.existsSync(extensionsDir)) return [];
|
|
48
|
+
const ids = fs.readdirSync(extensionsDir).filter((d) => /^[a-z]{32}$/.test(d));
|
|
49
|
+
return ids.map((id) => {
|
|
50
|
+
const manifest = readExtensionManifest(extensionsDir, id);
|
|
51
|
+
const name = manifest?.name || "(unknown)";
|
|
52
|
+
return { id, name };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Accepts a 32-char extension ID or a name substring; returns the resolved ID or throws on ambiguity.
|
|
57
|
+
function resolveExtensionId(nameOrId, extensionsDir) {
|
|
58
|
+
if (/^[a-z]{32}$/.test(nameOrId)) return nameOrId;
|
|
59
|
+
const extensions = listInstalledExtensions(extensionsDir);
|
|
60
|
+
const needle = nameOrId.toLowerCase();
|
|
61
|
+
const matches = extensions.filter((e) => e.name.toLowerCase().includes(needle));
|
|
62
|
+
if (matches.length === 0) return null;
|
|
63
|
+
if (matches.length > 1) {
|
|
64
|
+
const list = matches.map((e) => ` ${e.id} ${e.name}`).join("\n");
|
|
65
|
+
throw new Error(`"${nameOrId}" matched ${matches.length} extensions — pass the full ID instead:\n${list}`);
|
|
66
|
+
}
|
|
67
|
+
return matches[0].id;
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
async function fetchCdpVersionInfo(port) {
|
|
31
71
|
try {
|
|
32
72
|
const controller = new AbortController();
|
|
@@ -86,17 +126,18 @@ async function findExtensionIdFromTargets(port) {
|
|
|
86
126
|
}
|
|
87
127
|
|
|
88
128
|
function attachSwLogListeners(context) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
const attachToWorker = (sw) => {
|
|
130
|
+
sw.on("console", (msg) => {
|
|
131
|
+
// Check state.extensionId at capture time so retargeting takes effect immediately.
|
|
132
|
+
const workerExtId = extractExtensionIdFromUrl(sw.url());
|
|
133
|
+
if (!state.extensionId || workerExtId === state.extensionId) {
|
|
134
|
+
state.swLogs.push(`[${new Date().toISOString()}] ${msg.type()}: ${msg.text()}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
context.serviceWorkers().forEach(attachToWorker);
|
|
140
|
+
context.on("serviceworker", attachToWorker);
|
|
100
141
|
}
|
|
101
142
|
|
|
102
143
|
export async function teardownExistingConnection() {
|
|
@@ -184,11 +225,12 @@ export const definition = {
|
|
|
184
225
|
properties: {
|
|
185
226
|
action: {
|
|
186
227
|
type: "string",
|
|
187
|
-
enum: ["scan", "connect", "launch"],
|
|
228
|
+
enum: ["scan", "connect", "launch", "list_extensions"],
|
|
188
229
|
description:
|
|
189
230
|
"'scan' — list running debuggable browsers and installed browsers. " +
|
|
190
231
|
"'connect' — attach to a browser already running with --remote-debugging-port. " +
|
|
191
|
-
"'launch' — start an installed browser with your real profile and remote debugging, then connect."
|
|
232
|
+
"'launch' — start an installed browser with your real profile and remote debugging, then connect. " +
|
|
233
|
+
"'list_extensions' — list all installed extensions with their IDs and names.",
|
|
192
234
|
},
|
|
193
235
|
port: {
|
|
194
236
|
type: "number",
|
|
@@ -203,14 +245,44 @@ export const definition = {
|
|
|
203
245
|
type: "number",
|
|
204
246
|
description: "Port to use for remote debugging when launching (for 'launch' action). Defaults to 9222.",
|
|
205
247
|
},
|
|
248
|
+
extension_id: {
|
|
249
|
+
type: "string",
|
|
250
|
+
description: "Extension ID (32 lowercase chars) or name substring to target (for 'connect' and 'launch' actions). Overrides auto-detection — pass the name (e.g. 'AudiTex') or full ID to pick the right extension when multiple are installed.",
|
|
251
|
+
},
|
|
252
|
+
browser_name_for_extensions: {
|
|
253
|
+
type: "string",
|
|
254
|
+
enum: ["Brave", "Chrome", "Chromium"],
|
|
255
|
+
description: "Which browser's extensions directory to scan (for 'list_extensions' action). Defaults to 'Brave'.",
|
|
256
|
+
},
|
|
206
257
|
},
|
|
207
258
|
required: ["action"],
|
|
208
259
|
},
|
|
209
260
|
};
|
|
210
261
|
|
|
262
|
+
function getExtensionsDirForBrowser(browserName) {
|
|
263
|
+
const name = browserName || "Brave";
|
|
264
|
+
const config = KNOWN_BROWSERS.find((b) => b.name === name);
|
|
265
|
+
return config?.extensionsDir || null;
|
|
266
|
+
}
|
|
267
|
+
|
|
211
268
|
export async function handler(args) {
|
|
212
269
|
const { action } = args;
|
|
213
270
|
|
|
271
|
+
if (action === "list_extensions") {
|
|
272
|
+
const extensionsDir = getExtensionsDirForBrowser(args.browser_name_for_extensions);
|
|
273
|
+
if (!extensionsDir || !fs.existsSync(extensionsDir)) {
|
|
274
|
+
throw new Error(`Extensions directory not found: ${extensionsDir}`);
|
|
275
|
+
}
|
|
276
|
+
const extensions = listInstalledExtensions(extensionsDir);
|
|
277
|
+
const lines = extensions.map((e) => ` ${e.id} ${e.name}`);
|
|
278
|
+
return {
|
|
279
|
+
content: [{
|
|
280
|
+
type: "text",
|
|
281
|
+
text: `Installed extensions (${extensions.length}):\n${lines.join("\n")}\n\nPass the ID or name substring as extension_id when connecting.`,
|
|
282
|
+
}],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
214
286
|
if (action === "scan") {
|
|
215
287
|
const runningBrowsers = await scanRunningBrowsers();
|
|
216
288
|
const installedBrowsers = detectInstalledBrowsers();
|
|
@@ -261,7 +333,14 @@ export async function handler(args) {
|
|
|
261
333
|
}
|
|
262
334
|
|
|
263
335
|
await teardownExistingConnection();
|
|
264
|
-
|
|
336
|
+
await connectToDebugPort(port);
|
|
337
|
+
|
|
338
|
+
if (args.extension_id) {
|
|
339
|
+
const extensionsDir = getExtensionsDirForBrowser("Brave");
|
|
340
|
+
const resolved = extensionsDir ? resolveExtensionId(args.extension_id, extensionsDir) : null;
|
|
341
|
+
if (!resolved) throw new Error(`No installed extension found matching "${args.extension_id}". Run action:"list_extensions" to see available extensions.`);
|
|
342
|
+
state.extensionId = resolved;
|
|
343
|
+
}
|
|
265
344
|
|
|
266
345
|
return {
|
|
267
346
|
content: [
|
|
@@ -269,7 +348,7 @@ export async function handler(args) {
|
|
|
269
348
|
type: "text",
|
|
270
349
|
text: [
|
|
271
350
|
`Connected to ${versionInfo.Browser} on port ${port}.`,
|
|
272
|
-
`Extension ID: ${extensionId || "not detected —
|
|
351
|
+
`Extension ID: ${state.extensionId || "not detected — run list_extensions to find it"}`,
|
|
273
352
|
"",
|
|
274
353
|
"Your existing tabs and logged-in sessions are preserved.",
|
|
275
354
|
"All other tools (interact_with_popup, inspect_dom, etc.) will now use this browser.",
|
|
@@ -292,18 +371,26 @@ export async function handler(args) {
|
|
|
292
371
|
throw new Error(`${browserName} not found at: ${browserConfig.executable}`);
|
|
293
372
|
}
|
|
294
373
|
|
|
374
|
+
const resolveTarget = (nameOrId) => {
|
|
375
|
+
if (!nameOrId) return null;
|
|
376
|
+
const resolved = resolveExtensionId(nameOrId, browserConfig.extensionsDir);
|
|
377
|
+
if (!resolved) throw new Error(`No installed extension found matching "${nameOrId}". Run action:"list_extensions" to see available extensions.`);
|
|
378
|
+
return resolved;
|
|
379
|
+
};
|
|
380
|
+
|
|
295
381
|
// If a browser is already debugging on the target port, just connect to it.
|
|
296
382
|
const alreadyRunning = await fetchCdpVersionInfo(port);
|
|
297
383
|
if (alreadyRunning) {
|
|
298
384
|
await teardownExistingConnection();
|
|
299
|
-
|
|
385
|
+
await connectToDebugPort(port);
|
|
386
|
+
if (args.extension_id) state.extensionId = resolveTarget(args.extension_id);
|
|
300
387
|
return {
|
|
301
388
|
content: [
|
|
302
389
|
{
|
|
303
390
|
type: "text",
|
|
304
391
|
text: [
|
|
305
392
|
`Browser already running with debugging on port ${port}. Connected to ${alreadyRunning.Browser}.`,
|
|
306
|
-
`Extension ID: ${extensionId || "not detected"}`,
|
|
393
|
+
`Extension ID: ${state.extensionId || "not detected"}`,
|
|
307
394
|
].join("\n"),
|
|
308
395
|
},
|
|
309
396
|
],
|
|
@@ -312,7 +399,8 @@ export async function handler(args) {
|
|
|
312
399
|
|
|
313
400
|
await teardownExistingConnection();
|
|
314
401
|
await launchBrowserProcess(browserConfig, port);
|
|
315
|
-
|
|
402
|
+
await connectToDebugPort(port);
|
|
403
|
+
if (args.extension_id) state.extensionId = resolveTarget(args.extension_id);
|
|
316
404
|
|
|
317
405
|
return {
|
|
318
406
|
content: [
|
|
@@ -321,7 +409,7 @@ export async function handler(args) {
|
|
|
321
409
|
text: [
|
|
322
410
|
`Launched ${browserName} with remote debugging on port ${port}.`,
|
|
323
411
|
`Profile: ${browserConfig.userDataDir}`,
|
|
324
|
-
`Extension ID: ${extensionId || "not detected —
|
|
412
|
+
`Extension ID: ${state.extensionId || "not detected — run list_extensions to find it"}`,
|
|
325
413
|
"",
|
|
326
414
|
"All your existing logins are available.",
|
|
327
415
|
"All other tools (interact_with_popup, inspect_dom, etc.) will now use this browser.",
|
|
@@ -331,5 +419,5 @@ export async function handler(args) {
|
|
|
331
419
|
};
|
|
332
420
|
}
|
|
333
421
|
|
|
334
|
-
throw new Error(`Unknown action "${action}". Valid actions: "scan", "connect", "launch".`);
|
|
422
|
+
throw new Error(`Unknown action "${action}". Valid actions: "scan", "connect", "launch", "list_extensions".`);
|
|
335
423
|
}
|
package/src/tools/tabs.js
CHANGED
|
@@ -25,10 +25,11 @@ export const definition = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export async function handler(args) {
|
|
28
|
-
|
|
28
|
+
const ctx = state.context || state.browser;
|
|
29
|
+
if (!ctx) throw new Error("No browser connected. Call load_extension or connect_browser first.");
|
|
29
30
|
|
|
30
31
|
if (args.action === "list") {
|
|
31
|
-
const pages =
|
|
32
|
+
const pages = ctx.pages();
|
|
32
33
|
const list = await Promise.all(
|
|
33
34
|
pages.map(async (p, i) => {
|
|
34
35
|
const title = await p.title().catch(() => "(no title)");
|
|
@@ -41,15 +42,15 @@ export async function handler(args) {
|
|
|
41
42
|
|
|
42
43
|
if (args.action === "open") {
|
|
43
44
|
const url = args.url || "about:blank";
|
|
44
|
-
const newPage = await
|
|
45
|
+
const newPage = await ctx.newPage();
|
|
45
46
|
if (args.url) await newPage.goto(url, { waitUntil: "domcontentloaded" });
|
|
46
47
|
state.page = newPage;
|
|
47
|
-
const index =
|
|
48
|
+
const index = ctx.pages().length - 1;
|
|
48
49
|
return { content: [{ type: "text", text: `Opened new tab [${index}]: ${url}` }] };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
if (args.action === "switch") {
|
|
52
|
-
const pages =
|
|
53
|
+
const pages = ctx.pages();
|
|
53
54
|
if (args.tab_index === undefined || args.tab_index < 0 || args.tab_index >= pages.length) {
|
|
54
55
|
return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }], isError: true };
|
|
55
56
|
}
|
|
@@ -59,7 +60,7 @@ export async function handler(args) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
if (args.action === "close") {
|
|
62
|
-
const pages =
|
|
63
|
+
const pages = ctx.pages();
|
|
63
64
|
if (args.tab_index === undefined || args.tab_index < 0 || args.tab_index >= pages.length) {
|
|
64
65
|
return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }], isError: true };
|
|
65
66
|
}
|
|
@@ -67,14 +68,14 @@ export async function handler(args) {
|
|
|
67
68
|
const closedUrl = toClose.url();
|
|
68
69
|
await toClose.close();
|
|
69
70
|
if (state.page === toClose || state.page?.isClosed()) {
|
|
70
|
-
const remaining =
|
|
71
|
+
const remaining = ctx.pages();
|
|
71
72
|
state.page = remaining.length ? remaining[remaining.length - 1] : null;
|
|
72
73
|
}
|
|
73
74
|
return { content: [{ type: "text", text: `Closed tab [${args.tab_index}]: ${closedUrl}` }] };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
if (args.action === "close_all") {
|
|
77
|
-
const pages =
|
|
78
|
+
const pages = ctx.pages();
|
|
78
79
|
const closed = [];
|
|
79
80
|
for (const p of pages) {
|
|
80
81
|
const url = p.url();
|