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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-extension-tester-mcp",
3
- "version": "2.2.0",
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 existingWorkers = context.serviceWorkers();
90
- existingWorkers.forEach((sw) => {
91
- sw.on("console", (msg) =>
92
- state.swLogs.push(`[${new Date().toISOString()}] ${msg.type()}: ${msg.text()}`)
93
- );
94
- });
95
- context.on("serviceworker", (sw) => {
96
- sw.on("console", (msg) =>
97
- state.swLogs.push(`[${new Date().toISOString()}] ${msg.type()}: ${msg.text()}`)
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
- const extensionId = await connectToDebugPort(port);
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 — open chrome://extensions to find it"}`,
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
- const extensionId = await connectToDebugPort(port);
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
- const extensionId = await connectToDebugPort(port);
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 — open chrome://extensions to find it"}`,
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
- if (!state.browser) throw new Error("Browser not started. Call load_extension first.");
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 = state.browser.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 state.browser.newPage();
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 = state.browser.pages().length - 1;
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 = state.browser.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 = state.browser.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 = state.browser.pages();
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 = state.browser.pages();
78
+ const pages = ctx.pages();
78
79
  const closed = [];
79
80
  for (const p of pages) {
80
81
  const url = p.url();