@extension.dev/mcp 5.5.1 → 5.5.2

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.
@@ -10,7 +10,7 @@
10
10
  "name": "extension-mcp",
11
11
  "source": "./",
12
12
  "description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox.",
13
- "version": "5.5.1",
13
+ "version": "5.5.2",
14
14
  "category": "development",
15
15
  "author": {
16
16
  "name": "Cezar Augusto"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "extension-mcp",
3
3
  "description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox. Ships /extension, /extension-add, /extension-debug, and /extension-publish commands.",
4
- "version": "5.5.1",
4
+ "version": "5.5.2",
5
5
  "author": {
6
6
  "name": "Cezar Augusto",
7
7
  "email": "hello@extension.dev",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.5.2
4
+
5
+ Honest browser support wording ahead of the Safari lane landing.
6
+
7
+ ### Changed
8
+
9
+ - The package description and README now say Safari is coming next
10
+ instead of listing it alongside the browsers that are store-ready
11
+ today. Chrome, Edge, Firefox, and every Chromium- or Gecko-based
12
+ browser remain fully supported; nothing changes functionally.
13
+
3
14
  ## 5.5.1
4
15
 
5
16
  The store journey stops being write-only after submit, and the token
package/README.md CHANGED
@@ -26,7 +26,7 @@ Extensions fail silently: content scripts that never inject, panels that never o
26
26
  These tools give agents eyes on the live browser, so they debug from evidence instead of guessing:
27
27
 
28
28
  - **Scaffold** from the 60+ template catalog behind [templates.extension.dev](https://templates.extension.dev), or add a popup, sidebar, or content script to an existing project
29
- - **Run** the dev server with HMR in Chrome, Edge, Firefox, Safari, Brave, Opera, Vivaldi, Yandex, Waterfox, LibreWolf, or any Chromium- or Gecko-based binary, no build config
29
+ - **Run** the dev server with HMR in Chrome, Edge, Firefox, Brave, Opera, Vivaldi, Yandex, Waterfox, LibreWolf, or any Chromium- or Gecko-based binary (Safari coming next), no build config
30
30
  - **See** the live DOM, unified logs from every extension context, `chrome.storage` contents, and the loaded-extension list
31
31
  - **Act**: evaluate code in any context, trigger the action button and commands, reload the extension, replay events
32
32
  - **Ship**: validate the manifest cross-browser, build for production, publish a shareable preview, and promote builds to release channels headlessly
package/dist/module.js CHANGED
@@ -219,7 +219,7 @@ __webpack_require__.d(whoami_namespaceObject, {
219
219
  handler: ()=>whoami_handler,
220
220
  schema: ()=>whoami_schema
221
221
  });
222
- var package_namespaceObject = JSON.parse('{"rE":"5.5.1","El":{"OP":"^4.0.13"}}');
222
+ var package_namespaceObject = JSON.parse('{"rE":"5.5.2","El":{"OP":"^4.0.13"}}');
223
223
  function scaffoldEnginePin(projectPath) {
224
224
  try {
225
225
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -3012,9 +3012,178 @@ function isCdpEndpoint(port) {
3012
3012
  });
3013
3013
  });
3014
3014
  }
3015
+ function toMcpSpeak(text) {
3016
+ return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
3017
+ }
3018
+ function withSessionContext(message, projectPath) {
3019
+ const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
3020
+ if (!isControlError) return message;
3021
+ const dead = deadReadySession(projectPath);
3022
+ if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
3023
+ const running = knownSessionBrowsers(projectPath);
3024
+ if (0 === running.length) return message;
3025
+ return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
3026
+ }
3027
+ function translateFrame(frame, projectPath) {
3028
+ if (!frame || false !== frame.ok) return frame;
3029
+ if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath);
3030
+ if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
3031
+ if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
3032
+ return frame;
3033
+ }
3034
+ async function runActVerb(args, projectPath, timeoutMs) {
3035
+ const { code, stdout, stderr } = await runExtensionCli([
3036
+ ...args,
3037
+ "--output",
3038
+ "json"
3039
+ ], {
3040
+ cwd: projectPath,
3041
+ timeoutMs
3042
+ });
3043
+ const out = stdout.trim();
3044
+ if (out) try {
3045
+ const frame = JSON.parse(out);
3046
+ if (frame && false === frame.ok) return JSON.stringify(translateFrame(frame, projectPath));
3047
+ return out;
3048
+ } catch {}
3049
+ const message = stderr.trim() || `extension exited with code ${code}`;
3050
+ return JSON.stringify({
3051
+ ok: false,
3052
+ error: {
3053
+ name: "CliError",
3054
+ message: withSessionContext(toMcpSpeak(message), projectPath)
3055
+ }
3056
+ });
3057
+ }
3058
+ function commonFlags(args) {
3059
+ const flags = [];
3060
+ if (args.context) flags.push("--context", args.context);
3061
+ if (args.url) flags.push("--url", args.url);
3062
+ if (null != args.tab) flags.push("--tab", String(args.tab));
3063
+ if (args.browser) flags.push("--browser", args.browser);
3064
+ if (null != args.timeout) flags.push("--timeout", String(args.timeout));
3065
+ return flags;
3066
+ }
3067
+ async function listBridgeTabs(projectPath, browser, timeout) {
3068
+ const raw = await runActVerb([
3069
+ "inspect",
3070
+ projectPath,
3071
+ "--list-tabs",
3072
+ "--browser",
3073
+ browser,
3074
+ ...null != timeout ? [
3075
+ "--timeout",
3076
+ String(timeout)
3077
+ ] : []
3078
+ ], projectPath, timeout);
3079
+ let parsed;
3080
+ try {
3081
+ parsed = JSON.parse(raw);
3082
+ } catch {
3083
+ return {
3084
+ error: raw
3085
+ };
3086
+ }
3087
+ if (parsed?.ok === false) return {
3088
+ error: raw
3089
+ };
3090
+ const list = Array.isArray(parsed?.tabs) ? parsed.tabs : Array.isArray(parsed?.value) ? parsed.value : Array.isArray(parsed?.value?.tabs) ? parsed.value.tabs : null;
3091
+ if (!list) return {
3092
+ error: raw
3093
+ };
3094
+ return {
3095
+ tabs: list.map((t)=>({
3096
+ tabId: "number" == typeof t?.tabId ? t.tabId : "number" == typeof t?.id ? t.id : null,
3097
+ url: String(t?.url ?? ""),
3098
+ title: String(t?.title ?? "")
3099
+ }))
3100
+ };
3101
+ }
3102
+ function matchTabsByUrl(tabs, needle) {
3103
+ const wanted = needle.toLowerCase();
3104
+ const byUrl = tabs.filter((t)=>t.url.toLowerCase().includes(wanted));
3105
+ if (byUrl.length > 0) return byUrl;
3106
+ return tabs.filter((t)=>t.title.toLowerCase().includes(wanted));
3107
+ }
3108
+ async function pollForBridgeTab(projectPath, browser, url, budgetMs) {
3109
+ const deadline = Date.now() + budgetMs;
3110
+ const wanted = url.replace(/#.*$/, "");
3111
+ for(;;){
3112
+ const listed = await listBridgeTabs(projectPath, browser);
3113
+ if ("tabs" in listed) {
3114
+ for (const t of listed.tabs)if (t.url === wanted || t.url.startsWith(wanted)) return t;
3115
+ }
3116
+ if (Date.now() >= deadline) return null;
3117
+ await new Promise((r)=>setTimeout(r, 250));
3118
+ }
3119
+ }
3120
+ async function navigateToUrlViaBridge(projectPath, browser, url, timeout) {
3121
+ const expression = `(async () => { const api = typeof browser !== "undefined" ? browser : chrome; const tabs = await api.tabs.query({ active: true, currentWindow: true }); const active = tabs && tabs[0]; const tab = active && active.id != null ? await api.tabs.update(active.id, { url: ${JSON.stringify(url)} }) : await api.tabs.create({ url: ${JSON.stringify(url)} }); return { tabId: tab && tab.id != null ? tab.id : null }; })()`;
3122
+ const raw = await runActVerb([
3123
+ "eval",
3124
+ expression,
3125
+ projectPath,
3126
+ "--context",
3127
+ "background",
3128
+ "--browser",
3129
+ browser,
3130
+ ...null != timeout ? [
3131
+ "--timeout",
3132
+ String(timeout)
3133
+ ] : []
3134
+ ], projectPath, timeout);
3135
+ try {
3136
+ const parsed = JSON.parse(raw);
3137
+ if (parsed?.ok === false) {
3138
+ if (!parsed.hint) parsed.hint = "On this browser family URL navigation rides the agent bridge (a background eval of tabs.update), so the dev session must be started with allowEval: true (extension_dev).";
3139
+ return JSON.stringify(parsed);
3140
+ }
3141
+ } catch {
3142
+ return raw;
3143
+ }
3144
+ const settled = await pollForBridgeTab(projectPath, browser, url, null != timeout ? Math.min(timeout, 6000) : 6000);
3145
+ if (!settled) return JSON.stringify({
3146
+ ok: false,
3147
+ error: {
3148
+ name: "NavigateFailed",
3149
+ message: `Navigation to ${url} did not produce a tab reporting that URL. The URL may not exist, or the browser refused the navigation (Firefox rejects privileged about:/chrome: URLs and other extensions' moz-extension: pages).`
3150
+ },
3151
+ hint: "Confirm the URL, or discover open tabs with extension_dom_inspect listTabs: true. For an extension page, the path must match the BUILT manifest."
3152
+ });
3153
+ return JSON.stringify({
3154
+ ok: true,
3155
+ navigated: url,
3156
+ tab: {
3157
+ tabId: settled.tabId,
3158
+ url: settled.url,
3159
+ title: settled.title
3160
+ },
3161
+ hint: "Inspect it with extension_dom_inspect or extension_eval using url or this numeric tab id (context: 'page'/'content')."
3162
+ });
3163
+ }
3164
+ async function resolveBridgeBaseUrl(projectPath, browser, timeout) {
3165
+ const raw = await runActVerb([
3166
+ "eval",
3167
+ '(typeof browser !== "undefined" ? browser : chrome).runtime.getURL("")',
3168
+ projectPath,
3169
+ "--context",
3170
+ "background",
3171
+ "--browser",
3172
+ browser,
3173
+ ...null != timeout ? [
3174
+ "--timeout",
3175
+ String(timeout)
3176
+ ] : []
3177
+ ], projectPath, timeout);
3178
+ try {
3179
+ const parsed = JSON.parse(raw);
3180
+ if (parsed?.ok && "string" == typeof parsed.value && parsed.value) return parsed.value.endsWith("/") ? parsed.value : `${parsed.value}/`;
3181
+ } catch {}
3182
+ return null;
3183
+ }
3015
3184
  const source_inspect_schema = {
3016
3185
  name: "extension_source_inspect",
3017
- description: "Inspect a running extension's live state via Chrome DevTools Protocol: full HTML (with shadow DOM), DOM structure, content script injection, console messages, and CSS selector queries. Requires an active dev or start session.",
3186
+ description: "Inspect a running extension's live state: full HTML (with shadow DOM), DOM structure, content script injection, console messages, and CSS selector queries. Chromium sessions ride Chrome DevTools Protocol; Firefox sessions ride the agent bridge (needs allowEval: true) and cover summary/meta/html/probes, while dom_snapshot, extension_roots, console, and deepDom stay CDP-only. Requires an active dev or start session.",
3018
3187
  inputSchema: {
3019
3188
  type: "object",
3020
3189
  properties: {
@@ -3081,10 +3250,7 @@ async function source_inspect_handler(args) {
3081
3250
  "console"
3082
3251
  ]);
3083
3252
  const maxBytes = args.maxBytes ?? 262144;
3084
- if (!isChromiumFamily(browser)) return JSON.stringify({
3085
- error: `Source inspection reads the live DOM over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. This is a capability limit, not a missing session.`,
3086
- hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval / extension_dom_inspect (content/page, an open surface like popup/options/sidebar, or an override page like newtab) which work against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
3087
- });
3253
+ if (!isChromiumFamily(browser)) return inspectViaBridge(args, browser, include, maxBytes);
3088
3254
  const resolved = await resolveCdpPort(args.projectPath, browser);
3089
3255
  if (!resolved) return JSON.stringify({
3090
3256
  error: "No active dev session found. Cannot connect to Chrome DevTools Protocol.",
@@ -3186,6 +3352,117 @@ async function source_inspect_handler(args) {
3186
3352
  cdp.disconnect();
3187
3353
  }
3188
3354
  }
3355
+ function buildBridgeInspectExpression(opts) {
3356
+ const parts = [
3357
+ "const out = {};"
3358
+ ];
3359
+ if (opts.meta) parts.push("try { out.meta = { url: location.href, title: document.title, readyState: document.readyState }; } catch (e) {}");
3360
+ if (opts.summary) parts.push(`try {
3361
+ const roots = document.querySelectorAll('#extension-root,[data-extension-root]:not([data-extension-root="extension-js-devtools"])');
3362
+ out.summary = {
3363
+ htmlLength: document.documentElement.outerHTML.length,
3364
+ scriptCount: document.querySelectorAll('script').length,
3365
+ styleCount: document.querySelectorAll('style').length,
3366
+ linkCount: document.querySelectorAll('link').length,
3367
+ extensionRootCount: roots.length,
3368
+ bodyChildCount: document.body ? document.body.children.length : 0
3369
+ };
3370
+ } catch (e) { out.summary = {}; }`);
3371
+ if (opts.html) parts.push(`try {
3372
+ const html = document.documentElement.outerHTML;
3373
+ const cap = ${JSON.stringify(opts.maxBytes)};
3374
+ out.htmlTruncated = cap > 0 && html.length > cap;
3375
+ out.html = out.htmlTruncated ? html.slice(0, cap) : html;
3376
+ } catch (e) {}`);
3377
+ if (opts.probes.length) parts.push(`out.probes = {};
3378
+ for (const sel of ${JSON.stringify(opts.probes)}) {
3379
+ try {
3380
+ const nodes = document.querySelectorAll(sel);
3381
+ const first = nodes[0];
3382
+ out.probes[sel] = { count: nodes.length, sample: first ? String(first.outerHTML || "").slice(0, 200) : null };
3383
+ } catch (e) { out.probes[sel] = { error: String((e && e.message) || e) }; }
3384
+ }`);
3385
+ parts.push("return out;");
3386
+ return `(() => { ${parts.join("\n")} })()`;
3387
+ }
3388
+ async function inspectViaBridge(args, browser, include, maxBytes) {
3389
+ const notes = [];
3390
+ const cdpOnly = [
3391
+ "dom_snapshot",
3392
+ "extension_roots"
3393
+ ].filter((k)=>include.has(k));
3394
+ if (cdpOnly.length) notes.push(`${cdpOnly.join(" and ")} require CDP and are Chromium-only; on ${browser}, extension_dom_inspect's summary reports extension roots and open shadow roots.`);
3395
+ if (args.deepDom) notes.push("deepDom (closed shadow roots) requires CDP and is Chromium-only.");
3396
+ if (include.has("console")) notes.push(`Console capture rides CDP; on ${browser} read extension_logs, where the engine streams console output.`);
3397
+ if (args.url) {
3398
+ const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
3399
+ if ("error" in listed) return listed.error;
3400
+ const already = listed.tabs.some((t)=>t.url.includes(args.url));
3401
+ if (!already) {
3402
+ const nav = await navigateToUrlViaBridge(args.projectPath, browser, args.url, args.timeout);
3403
+ try {
3404
+ if (JSON.parse(nav)?.ok !== true) return nav;
3405
+ } catch {
3406
+ return nav;
3407
+ }
3408
+ }
3409
+ }
3410
+ const expression = buildBridgeInspectExpression({
3411
+ summary: include.has("summary"),
3412
+ meta: true,
3413
+ html: include.has("html"),
3414
+ probes: args.probe ?? [],
3415
+ maxBytes
3416
+ });
3417
+ const raw = await runActVerb([
3418
+ "eval",
3419
+ expression,
3420
+ args.projectPath,
3421
+ "--context",
3422
+ "page",
3423
+ ...args.url ? [
3424
+ "--url",
3425
+ args.url
3426
+ ] : [],
3427
+ "--browser",
3428
+ browser,
3429
+ ...null != args.timeout ? [
3430
+ "--timeout",
3431
+ String(args.timeout)
3432
+ ] : []
3433
+ ], args.projectPath, args.timeout);
3434
+ let parsed;
3435
+ try {
3436
+ parsed = JSON.parse(raw);
3437
+ } catch {
3438
+ return raw;
3439
+ }
3440
+ if (parsed?.ok !== true) return raw;
3441
+ const value = parsed.value ?? {};
3442
+ const result = {
3443
+ browser,
3444
+ transport: "bridge"
3445
+ };
3446
+ if (value.meta) {
3447
+ result.target = {
3448
+ url: value.meta.url,
3449
+ title: value.meta.title
3450
+ };
3451
+ if (include.has("meta")) result.meta = value.meta;
3452
+ }
3453
+ if (include.has("summary") && value.summary) result.summary = value.summary;
3454
+ if (include.has("html") && "string" == typeof value.html) {
3455
+ result.html = value.html;
3456
+ if (value.htmlTruncated) result.htmlTruncated = true;
3457
+ }
3458
+ if (value.probes) {
3459
+ result.probes = value.probes;
3460
+ const jsLooking = (args.probe ?? []).filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
3461
+ if (jsLooking.length) result.probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
3462
+ }
3463
+ if (notes.length) result.notes = notes;
3464
+ return JSON.stringify(result);
3465
+ }
3189
3466
  const list_extensions_schema = {
3190
3467
  name: "extension_list_extensions",
3191
3468
  description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). The entry for THIS dev session's extension (the project being served) is flagged ownExtension: true, with name and version resolved from the session's ready contract even when the browser exposes no identity. Other extensions resolve via the read-only Extensions domain when available; their contexts are never attached to or evaluated in, so an id that the domain does not describe stays id-only with a note saying why. A dormant MV3 service worker with no open page may be absent until it wakes. Chromium only (Firefox uses RDP, not yet supported). Requires an active dev or start session.",
@@ -3665,58 +3942,6 @@ async function logs_handler(args) {
3665
3942
  if (args.follow) return readFromStream(args, browser, limit);
3666
3943
  return readFromFile(args, browser, limit);
3667
3944
  }
3668
- function toMcpSpeak(text) {
3669
- return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
3670
- }
3671
- function withSessionContext(message, projectPath) {
3672
- const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
3673
- if (!isControlError) return message;
3674
- const dead = deadReadySession(projectPath);
3675
- if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
3676
- const running = knownSessionBrowsers(projectPath);
3677
- if (0 === running.length) return message;
3678
- return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
3679
- }
3680
- function translateFrame(frame, projectPath) {
3681
- if (!frame || false !== frame.ok) return frame;
3682
- if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath);
3683
- if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
3684
- if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
3685
- return frame;
3686
- }
3687
- async function runActVerb(args, projectPath, timeoutMs) {
3688
- const { code, stdout, stderr } = await runExtensionCli([
3689
- ...args,
3690
- "--output",
3691
- "json"
3692
- ], {
3693
- cwd: projectPath,
3694
- timeoutMs
3695
- });
3696
- const out = stdout.trim();
3697
- if (out) try {
3698
- const frame = JSON.parse(out);
3699
- if (frame && false === frame.ok) return JSON.stringify(translateFrame(frame, projectPath));
3700
- return out;
3701
- } catch {}
3702
- const message = stderr.trim() || `extension exited with code ${code}`;
3703
- return JSON.stringify({
3704
- ok: false,
3705
- error: {
3706
- name: "CliError",
3707
- message: withSessionContext(toMcpSpeak(message), projectPath)
3708
- }
3709
- });
3710
- }
3711
- function commonFlags(args) {
3712
- const flags = [];
3713
- if (args.context) flags.push("--context", args.context);
3714
- if (args.url) flags.push("--url", args.url);
3715
- if (null != args.tab) flags.push("--tab", String(args.tab));
3716
- if (args.browser) flags.push("--browser", args.browser);
3717
- if (null != args.timeout) flags.push("--timeout", String(args.timeout));
3718
- return flags;
3719
- }
3720
3945
  const eval_schema = {
3721
3946
  name: "extension_eval",
3722
3947
  description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Context default: on a Chromium session whose manifest is MV3 (the default template) the default is `page` (the active tab), because the MV3 background is a service worker whose CSP blocks eval, so a background default would fail on the most common path; on Firefox/MV2 sessions the default stays `background`. Pass `context: \"background\"` explicitly to target the worker anyway (on Chromium MV3 that returns the CSP explanation). Targeting for context content/page: pass `url` to pick the matching tab, or omit both `url` and `tab` to use the ACTIVE tab; a numeric `tab` id is only needed to disambiguate. Extension surfaces (popup/options/sidebar/devtools) and override pages (newtab/history/bookmarks) evaluate over the in-bundle relay and need NO tab id; the surface must be OPEN (extension_open first; a closed surface returns an explicit error). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
@@ -3984,14 +4209,8 @@ async function pollForTarget(port, url, budgetMs) {
3984
4209
  await new Promise((r)=>setTimeout(r, 250));
3985
4210
  }
3986
4211
  }
3987
- async function navigateToUrl(projectPath, browser, url) {
3988
- if (!isChromiumFamily(browser)) return JSON.stringify({
3989
- ok: false,
3990
- error: {
3991
- name: "Unsupported",
3992
- message: `URL navigation drives a tab over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. On Firefox, drive the page via extension_eval (context: "page"/"content") or read extension_logs.`
3993
- }
3994
- });
4212
+ async function navigateToUrl(projectPath, browser, url, timeout) {
4213
+ if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
3995
4214
  const resolved = await resolveCdpPort(projectPath, browser);
3996
4215
  if (!resolved) return JSON.stringify({
3997
4216
  ok: false,
@@ -4234,16 +4453,32 @@ async function applyPopupBounds(projectPath, browser, targetId) {
4234
4453
  async function openSurfaceAsTab(projectPath, browser, surface) {
4235
4454
  const doc = surfaceDocument(projectPath, browser, surface);
4236
4455
  if (!doc) return missingSurfaceError(projectPath, browser, surface, "so there is no page to render as a tab");
4237
- const id = await resolveExtensionId(projectPath, browser);
4238
- if (!id) return JSON.stringify({
4239
- ok: false,
4240
- error: {
4241
- name: "NoExtensionId",
4242
- message: "Could not resolve the extension id from the live session's CDP targets."
4243
- },
4244
- hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
4245
- });
4246
- const url = `chrome-extension://${id}/${doc}`;
4456
+ let url;
4457
+ let extensionId = null;
4458
+ if (isChromiumFamily(browser)) {
4459
+ extensionId = await resolveExtensionId(projectPath, browser);
4460
+ if (!extensionId) return JSON.stringify({
4461
+ ok: false,
4462
+ error: {
4463
+ name: "NoExtensionId",
4464
+ message: "Could not resolve the extension id from the live session's CDP targets."
4465
+ },
4466
+ hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
4467
+ });
4468
+ url = `chrome-extension://${extensionId}/${doc}`;
4469
+ } else {
4470
+ const base = await resolveBridgeBaseUrl(projectPath, browser);
4471
+ if (!base) return JSON.stringify({
4472
+ ok: false,
4473
+ error: {
4474
+ name: "NoExtensionId",
4475
+ message: "Could not resolve the extension's moz-extension:// base URL from the live session (a background eval of runtime.getURL)."
4476
+ },
4477
+ hint: "Confirm the session is ready (extension_wait) and was started with allowEval: true (extension_dev)."
4478
+ });
4479
+ url = `${base}${doc}`;
4480
+ extensionId = base.replace(/^.*:\/\//, "").replace(/\/$/, "");
4481
+ }
4247
4482
  const raw = await navigateToUrl(projectPath, browser, url);
4248
4483
  try {
4249
4484
  const parsed = JSON.parse(raw);
@@ -4251,14 +4486,14 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4251
4486
  parsed.renderedAsTab = {
4252
4487
  surface,
4253
4488
  document: doc,
4254
- extensionId: id
4489
+ extensionId
4255
4490
  };
4256
4491
  let popupBounds = null;
4257
4492
  if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
4258
4493
  popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
4259
4494
  if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
4260
4495
  }
4261
- parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this chrome-extension:// url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
4496
+ parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this extension-page url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
4262
4497
  return JSON.stringify(parsed);
4263
4498
  }
4264
4499
  } catch {}
@@ -4294,7 +4529,7 @@ const open_schema = {
4294
4529
  },
4295
4530
  url: {
4296
4531
  type: "string",
4297
- description: "Navigate a real tab to this URL (Chromium only, via CDP) instead of opening a surface. Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
4532
+ description: "Navigate a real tab to this URL instead of opening a surface (Chromium via CDP; Firefox via the agent bridge, which needs allowEval: true). Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
4298
4533
  },
4299
4534
  asTab: {
4300
4535
  type: "boolean",
@@ -4317,7 +4552,7 @@ const open_schema = {
4317
4552
  };
4318
4553
  async function open_handler(args) {
4319
4554
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4320
- if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
4555
+ if (args.url) return navigateToUrl(args.projectPath, browser, args.url, args.timeout);
4321
4556
  const AS_TAB_SURFACES = [
4322
4557
  "popup",
4323
4558
  "options",
@@ -4368,7 +4603,7 @@ async function open_handler(args) {
4368
4603
  const parsed = JSON.parse(raw);
4369
4604
  const msg = String(parsed?.error?.message ?? "");
4370
4605
  if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
4371
- if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
4606
+ if (AS_TAB_SURFACES.includes(args.surface)) {
4372
4607
  const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
4373
4608
  try {
4374
4609
  const parsedFallback = JSON.parse(fallback);
@@ -4422,7 +4657,7 @@ const dom_inspect_schema = {
4422
4657
  },
4423
4658
  tabUrl: {
4424
4659
  type: "string",
4425
- description: "Target the tab whose URL contains this substring (case-insensitive; titles are checked only when no url matches). Resolved against the live browser's CDP page targets BEFORE inspecting: exactly one match proceeds; zero or several matches return the candidate targets (targetId/url/title) so you can narrow, never a guess. Chromium sessions only; on Firefox use `url`/`tab`. Alternative to `url`."
4660
+ description: "Target the tab whose URL contains this substring (case-insensitive; titles are checked only when no url matches). Resolved against the live browser BEFORE inspecting (Chromium: CDP page targets; Firefox: the agent bridge tab list): exactly one match proceeds; zero or several matches return the candidates so you can narrow, never a guess. Alternative to `url`."
4426
4661
  },
4427
4662
  listTargets: {
4428
4663
  type: "boolean",
@@ -4550,6 +4785,7 @@ async function dom_inspect_handler(args) {
4550
4785
  ] : []
4551
4786
  ], args.projectPath, args.timeout);
4552
4787
  let targetUrl = args.url;
4788
+ let targetTab = args.tab;
4553
4789
  let resolvedTarget = null;
4554
4790
  if (args.tabUrl) {
4555
4791
  if (null != args.tab || args.url) return JSON.stringify({
@@ -4560,48 +4796,79 @@ async function dom_inspect_handler(args) {
4560
4796
  }
4561
4797
  });
4562
4798
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4563
- const cdp = await cdpPortOrError(args.projectPath, browser, "tabUrl");
4564
- if ("error" in cdp) return cdp.error;
4565
- let targets;
4566
- try {
4567
- targets = await listPageTargets(cdp.port);
4568
- } catch (e) {
4569
- return JSON.stringify({
4799
+ if (isChromiumFamily(browser)) {
4800
+ const cdp = await cdpPortOrError(args.projectPath, browser, "tabUrl");
4801
+ if ("error" in cdp) return cdp.error;
4802
+ let targets;
4803
+ try {
4804
+ targets = await listPageTargets(cdp.port);
4805
+ } catch (e) {
4806
+ return JSON.stringify({
4807
+ ok: false,
4808
+ error: {
4809
+ name: "CdpError",
4810
+ message: `Could not list page targets to resolve tabUrl: ${e instanceof Error ? e.message : String(e)}`
4811
+ },
4812
+ hint: "Confirm the session is ready (extension_wait), then retry, or target with `url`/`tab` instead."
4813
+ });
4814
+ }
4815
+ const matches = matchTargetsByUrl(targets, args.tabUrl);
4816
+ if (0 === matches.length) return JSON.stringify({
4570
4817
  ok: false,
4571
4818
  error: {
4572
- name: "CdpError",
4573
- message: `Could not list page targets to resolve tabUrl: ${e instanceof Error ? e.message : String(e)}`
4819
+ name: "NoMatchingTarget",
4820
+ message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
4574
4821
  },
4575
- hint: "Confirm the session is ready (extension_wait), then retry, or target with `url`/`tab` instead."
4822
+ availableTargets: targets,
4823
+ hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
4576
4824
  });
4825
+ if (matches.length > 1) return JSON.stringify({
4826
+ ok: false,
4827
+ error: {
4828
+ name: "AmbiguousTabUrl",
4829
+ message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
4830
+ },
4831
+ matchingTargets: matches,
4832
+ hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
4833
+ });
4834
+ resolvedTarget = {
4835
+ ...matches[0]
4836
+ };
4837
+ targetUrl = matches[0].url;
4838
+ } else {
4839
+ const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
4840
+ if ("error" in listed) return listed.error;
4841
+ const matches = matchTabsByUrl(listed.tabs, args.tabUrl);
4842
+ if (0 === matches.length) return JSON.stringify({
4843
+ ok: false,
4844
+ error: {
4845
+ name: "NoMatchingTarget",
4846
+ message: `No open tab's url (or title) contains "${args.tabUrl}" (case-insensitive).`
4847
+ },
4848
+ availableTabs: listed.tabs,
4849
+ hint: "Pick one from availableTabs and retry with a `tabUrl` substring of its url, or open the page first (extension_open with `url`)."
4850
+ });
4851
+ if (matches.length > 1) return JSON.stringify({
4852
+ ok: false,
4853
+ error: {
4854
+ name: "AmbiguousTabUrl",
4855
+ message: `${matches.length} tabs match "${args.tabUrl}"; refusing to guess which tab you mean.`
4856
+ },
4857
+ matchingTabs: matches,
4858
+ hint: "Narrow `tabUrl` to a longer substring that matches exactly one url in matchingTabs, or pass its numeric tabId as `tab`."
4859
+ });
4860
+ resolvedTarget = {
4861
+ ...matches[0]
4862
+ };
4863
+ if (null != matches[0].tabId) targetTab = matches[0].tabId;
4864
+ else targetUrl = matches[0].url;
4577
4865
  }
4578
- const matches = matchTargetsByUrl(targets, args.tabUrl);
4579
- if (0 === matches.length) return JSON.stringify({
4580
- ok: false,
4581
- error: {
4582
- name: "NoMatchingTarget",
4583
- message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
4584
- },
4585
- availableTargets: targets,
4586
- hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
4587
- });
4588
- if (matches.length > 1) return JSON.stringify({
4589
- ok: false,
4590
- error: {
4591
- name: "AmbiguousTabUrl",
4592
- message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
4593
- },
4594
- matchingTargets: matches,
4595
- hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
4596
- });
4597
- resolvedTarget = matches[0];
4598
- targetUrl = resolvedTarget.url;
4599
4866
  }
4600
4867
  const cli = [
4601
4868
  "inspect",
4602
4869
  args.projectPath
4603
4870
  ];
4604
- if (null != args.tab) cli.push("--tab", String(args.tab));
4871
+ if (null != targetTab) cli.push("--tab", String(targetTab));
4605
4872
  if (targetUrl) cli.push("--url", targetUrl);
4606
4873
  if (args.context) cli.push("--context", args.context);
4607
4874
  if (args.include?.length) cli.push("--include", args.include.join(","));
@@ -0,0 +1,14 @@
1
+ export interface BridgeTab {
2
+ tabId: number | null;
3
+ url: string;
4
+ title: string;
5
+ }
6
+ export declare function listBridgeTabs(projectPath: string, browser: string, timeout?: number): Promise<{
7
+ tabs: BridgeTab[];
8
+ } | {
9
+ error: string;
10
+ }>;
11
+ export declare function matchTabsByUrl(tabs: BridgeTab[], needle: string): BridgeTab[];
12
+ export declare function pollForBridgeTab(projectPath: string, browser: string, url: string, budgetMs: number): Promise<BridgeTab | null>;
13
+ export declare function navigateToUrlViaBridge(projectPath: string, browser: string, url: string, timeout?: number): Promise<string>;
14
+ export declare function resolveBridgeBaseUrl(projectPath: string, browser: string, timeout?: number): Promise<string | null>;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "5.5.1",
5
- "description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 33 tools for scaffolding, live DOM inspection, log streaming, and store-ready builds across Chrome, Edge, Firefox, Safari, and every Chromium- or Gecko-based browser (Brave, Opera, Vivaldi, Yandex, Waterfox, LibreWolf). Powered by extension.dev and Extension.js.",
4
+ "version": "5.5.2",
5
+ "description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 33 tools for scaffolding, live DOM inspection, log streaming, and store-ready builds across Chrome, Edge, Firefox, and every Chromium- or Gecko-based browser (Brave, Opera, Vivaldi, Yandex, Waterfox, LibreWolf), with Safari coming next. Powered by extension.dev and Extension.js.",
6
6
  "mcpName": "io.github.extensiondev/mcp",
7
7
  "license": "MIT",
8
8
  "author": {
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://extension.dev",
10
- "version": "5.5.1",
10
+ "version": "5.5.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@extension.dev/mcp",
16
- "version": "5.5.1",
16
+ "version": "5.5.2",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"