@extension.dev/mcp 5.5.2 → 5.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.
@@ -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.2",
13
+ "version": "5.6.0",
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.2",
4
+ "version": "5.6.0",
5
5
  "author": {
6
6
  "name": "Cezar Augusto",
7
7
  "email": "hello@extension.dev",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.6.0
4
+
5
+ Firefox reaches full protocol parity: every formerly Chromium-only
6
+ feature now works on Gecko, over RDP or the agent bridge.
7
+
8
+ ### Added
9
+
10
+ - `extension_list_extensions` works on Firefox-family sessions. It rides
11
+ the Remote Debugging Protocol root actor's `listAddons` over the
12
+ `rdpPort` the engine stamps into ready.json from extension.js 4.0.15
13
+ on (upstream entry 78). Entries list INSTALLED add-ons regardless of
14
+ live contexts, `temporarilyInstalled` marks temporary loads, and the
15
+ dev session's extension is flagged `ownExtension` by matching the
16
+ ready contract's identity (with a lone temporary install as the
17
+ fallback signal). Add-on targets are never attached to or evaluated
18
+ in; system and hidden add-ons are filtered out. Older engines get a
19
+ hint naming the 4.0.15 requirement instead of a generic failure. A
20
+ minimal RDP client (`src/lib/rdp.ts`) carries the handshake; legacy
21
+ RDP was chosen over WebDriver BiDi on purpose, since BiDi is
22
+ single-session and would block attaching alongside other consumers.
23
+ - `extension_dom_inspect` listTargets works on Firefox: RDP tab
24
+ descriptors as `{actor,url,title,type}`, with the same two-id-space
25
+ warning as the CDP path (an actor id is not a chrome.tabs id).
26
+ Discovery therefore needs no allowControl on Gecko, unlike listTabs.
27
+ - `extension_source_inspect` closes its four Gecko gaps. dom_snapshot
28
+ and extension_roots ride the bridge eval, embedding the same CDP page
29
+ scripts Chromium uses (the bridge html path also gained the
30
+ shadow-aware serializer, so open extension-root shadow content is in
31
+ the markup now). console rides the RDP watcher's cached-resource
32
+ replay (`getWatcher` with server target switching, then
33
+ watchResources; verified live that `getCachedMessages` is a dead end
34
+ on current Firefox), summarized in the same shape as the CDP console
35
+ buffer. deepDom walks CLOSED shadow roots through
36
+ `tabs.executeScript`, where Firefox exposes
37
+ `Element.openOrClosedShadowRoot` to content scripts, so it needs an
38
+ MV2 session with host permissions for the target url; a failed walk
39
+ reports why in `notes` instead of silently dropping the field.
40
+ - MV2 page-eval fallback: the engine's page-context eval needs
41
+ chrome.scripting, an MV3-only API, so Firefox MV2 sessions reported
42
+ every bridge inspection as Unsupported. The inspect expression now
43
+ falls back to compiling in the tab's content-script sandbox via
44
+ `tabs.executeScript`, which reads the identical DOM; callers see the
45
+ same result shape on both paths.
46
+
47
+ ### Fixed
48
+
49
+ - Act-verb CLI output over ~8KB no longer truncates. The engine CLI
50
+ exits without draining stdout, and the socketpair pipe Node hands a
51
+ child buffers about 8KB, so any larger `--output json` frame (a DOM
52
+ snapshot, a big html capture) arrived cut mid-JSON and surfaced as
53
+ "CliError: extension exited with code 0". The MCP now hands the child
54
+ file descriptors and reads them after exit, which no pipe buffer can
55
+ truncate. Filed upstream as extension.js ledger entry 79; the
56
+ workaround stays until the engine flushes before exiting.
57
+
58
+ ### Known limitation
59
+
60
+ - Firefox MV3 sessions cannot use the bridge extras: the MV3 event page
61
+ CSP blocks the eval the control bridge dispatches through the
62
+ background, exactly like Chromium MV3 service workers. MV2 sessions
63
+ are fully covered via the executeScript fallback. The RDP paths
64
+ (list_extensions, listTargets, console) work on both manifest
65
+ versions.
66
+
3
67
  ## 5.5.2
4
68
 
5
69
  Honest browser support wording ahead of the Safari lane landing.
package/dist/module.js CHANGED
@@ -12,6 +12,7 @@ import { execFile, execFileSync } from "node:child_process";
12
12
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
13
13
  import ws_0 from "ws";
14
14
  import node_http from "node:http";
15
+ import node_net from "node:net";
15
16
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
16
17
  import { promisify } from "node:util";
17
18
  var add_feature_namespaceObject = {};
@@ -219,7 +220,7 @@ __webpack_require__.d(whoami_namespaceObject, {
219
220
  handler: ()=>whoami_handler,
220
221
  schema: ()=>whoami_schema
221
222
  });
222
- var package_namespaceObject = JSON.parse('{"rE":"5.5.2","El":{"OP":"^4.0.13"}}');
223
+ var package_namespaceObject = JSON.parse('{"rE":"5.6.0","El":{"OP":"^4.0.15"}}');
223
224
  function scaffoldEnginePin(projectPath) {
224
225
  try {
225
226
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -563,6 +564,42 @@ function resolveExtensionInvocation(projectDir) {
563
564
  function runExtensionCli(args, options) {
564
565
  const { command, prefixArgs } = resolveExtensionInvocation(options?.cwd);
565
566
  return new Promise((resolve)=>{
567
+ let ioDir;
568
+ let outFd;
569
+ let errFd;
570
+ try {
571
+ ioDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-io-"));
572
+ outFd = node_fs.openSync(node_path.join(ioDir, "stdout"), "a");
573
+ errFd = node_fs.openSync(node_path.join(ioDir, "stderr"), "a");
574
+ } catch (err) {
575
+ resolve({
576
+ code: 1,
577
+ stdout: "",
578
+ stderr: String(err)
579
+ });
580
+ return;
581
+ }
582
+ const readAll = (name)=>{
583
+ try {
584
+ return node_fs.readFileSync(node_path.join(ioDir, name), "utf8");
585
+ } catch {
586
+ return "";
587
+ }
588
+ };
589
+ const cleanup = ()=>{
590
+ for (const fd of [
591
+ outFd,
592
+ errFd
593
+ ])try {
594
+ node_fs.closeSync(fd);
595
+ } catch {}
596
+ try {
597
+ node_fs.rmSync(ioDir, {
598
+ recursive: true,
599
+ force: true
600
+ });
601
+ } catch {}
602
+ };
566
603
  const child = cross_spawn(command, [
567
604
  ...prefixArgs,
568
605
  ...args
@@ -570,8 +607,8 @@ function runExtensionCli(args, options) {
570
607
  cwd: options?.cwd,
571
608
  stdio: [
572
609
  "ignore",
573
- "pipe",
574
- "pipe"
610
+ outFd,
611
+ errFd
575
612
  ],
576
613
  env: {
577
614
  ...process.env,
@@ -580,20 +617,26 @@ function runExtensionCli(args, options) {
580
617
  },
581
618
  timeout: options?.timeoutMs ?? 30000
582
619
  });
583
- let stdout = "";
584
- let stderr = "";
585
- child.stdout?.on("data", (d)=>stdout += d.toString());
586
- child.stderr?.on("data", (d)=>stderr += d.toString());
587
- child.on("close", (code)=>resolve({
620
+ child.on("close", (code)=>{
621
+ const stdout = readAll("stdout");
622
+ const stderr = readAll("stderr");
623
+ cleanup();
624
+ resolve({
588
625
  code,
589
626
  stdout,
590
627
  stderr
591
- }));
592
- child.on("error", (err)=>resolve({
628
+ });
629
+ });
630
+ child.on("error", (err)=>{
631
+ const stdout = readAll("stdout");
632
+ const stderr = readAll("stderr");
633
+ cleanup();
634
+ resolve({
593
635
  code: 1,
594
636
  stdout,
595
637
  stderr: stderr || String(err)
596
- }));
638
+ });
639
+ });
597
640
  });
598
641
  }
599
642
  function spawnExtensionCli(args, options) {
@@ -2557,6 +2600,32 @@ async function inspect_handler(args) {
2557
2600
  };
2558
2601
  return JSON.stringify(result);
2559
2602
  }
2603
+ function summarizeConsoleMessages(messages) {
2604
+ const counts = {};
2605
+ const uniqueByLevel = {};
2606
+ for (const msg of messages){
2607
+ counts[msg.level] = (counts[msg.level] ?? 0) + 1;
2608
+ if (!uniqueByLevel[msg.level]) uniqueByLevel[msg.level] = new Map();
2609
+ const key = msg.text.slice(0, 200);
2610
+ uniqueByLevel[msg.level].set(key, (uniqueByLevel[msg.level].get(key) ?? 0) + 1);
2611
+ }
2612
+ const topMessages = [];
2613
+ for (const [level, msgs] of Object.entries(uniqueByLevel)){
2614
+ const sorted = [
2615
+ ...msgs.entries()
2616
+ ].sort((a, b)=>b[1] - a[1]);
2617
+ for (const [text, count] of sorted.slice(0, 5))topMessages.push({
2618
+ level,
2619
+ text,
2620
+ count
2621
+ });
2622
+ }
2623
+ return {
2624
+ total: messages.length,
2625
+ counts,
2626
+ topMessages: topMessages.sort((a, b)=>b.count - a.count).slice(0, 10)
2627
+ };
2628
+ }
2560
2629
  function _define_property(obj, key, value) {
2561
2630
  if (key in obj) Object.defineProperty(obj, key, {
2562
2631
  value: value,
@@ -2666,30 +2735,7 @@ class CDPConnection {
2666
2735
  ];
2667
2736
  }
2668
2737
  getConsoleSummary() {
2669
- const counts = {};
2670
- const uniqueByLevel = {};
2671
- for (const msg of this.consoleMessages){
2672
- counts[msg.level] = (counts[msg.level] ?? 0) + 1;
2673
- if (!uniqueByLevel[msg.level]) uniqueByLevel[msg.level] = new Map();
2674
- const key = msg.text.slice(0, 200);
2675
- uniqueByLevel[msg.level].set(key, (uniqueByLevel[msg.level].get(key) ?? 0) + 1);
2676
- }
2677
- const topMessages = [];
2678
- for (const [level, msgs] of Object.entries(uniqueByLevel)){
2679
- const sorted = [
2680
- ...msgs.entries()
2681
- ].sort((a, b)=>b[1] - a[1]);
2682
- for (const [text, count] of sorted.slice(0, 5))topMessages.push({
2683
- level,
2684
- text,
2685
- count
2686
- });
2687
- }
2688
- return {
2689
- total: this.consoleMessages.length,
2690
- counts,
2691
- topMessages: topMessages.sort((a, b)=>b.count - a.count).slice(0, 10)
2692
- };
2738
+ return summarizeConsoleMessages(this.consoleMessages);
2693
2739
  }
2694
2740
  onEvent(handler) {
2695
2741
  this.eventListeners.add(handler);
@@ -2964,7 +3010,7 @@ class CDPClient extends CDPConnection {
2964
3010
  return result ?? null;
2965
3011
  }
2966
3012
  }
2967
- async function resolveCdpPort(projectPath, browser, options) {
3013
+ async function resolveContractPort(projectPath, browser, field, options) {
2968
3014
  const waitMs = options?.waitMs ?? 20000;
2969
3015
  const graceMs = options?.graceMs ?? 2500;
2970
3016
  const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
@@ -2976,9 +3022,9 @@ async function resolveCdpPort(projectPath, browser, options) {
2976
3022
  const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
2977
3023
  contractSeen = true;
2978
3024
  if (null == contractSeenAt) contractSeenAt = Date.now();
2979
- if ("number" == typeof contract.cdpPort) return {
2980
- port: contract.cdpPort,
2981
- source: "contract"
3025
+ if ("number" == typeof contract[field]) return {
3026
+ port: contract[field],
3027
+ contractSeen
2982
3028
  };
2983
3029
  } catch {
2984
3030
  if (!contractSeen) break;
@@ -2987,13 +3033,32 @@ async function resolveCdpPort(projectPath, browser, options) {
2987
3033
  if (Date.now() >= effectiveDeadline) break;
2988
3034
  await new Promise((resolve)=>setTimeout(resolve, 500));
2989
3035
  }
3036
+ return {
3037
+ port: null,
3038
+ contractSeen
3039
+ };
3040
+ }
3041
+ async function resolveCdpPort(projectPath, browser, options) {
3042
+ const { port, contractSeen } = await resolveContractPort(projectPath, browser, "cdpPort", options);
3043
+ if (null != port) return {
3044
+ port,
3045
+ source: "contract"
3046
+ };
2990
3047
  if (!contractSeen && await isCdpEndpoint(9222)) return {
2991
3048
  port: 9222,
2992
3049
  source: "default-probe"
2993
3050
  };
2994
3051
  return null;
2995
3052
  }
3053
+ async function resolveRdpPort(projectPath, browser, options) {
3054
+ const { port } = await resolveContractPort(projectPath, browser, "rdpPort", options);
3055
+ return null != port ? {
3056
+ port,
3057
+ source: "contract"
3058
+ } : null;
3059
+ }
2996
3060
  const CDP_PORT_MISSING_HINT = "The session's ready contract has no CDP port (the browser may still be binding its debug port, or was launched without one). Confirm the session with extension_wait, give it a moment, and retry.";
3061
+ const RDP_PORT_MISSING_HINT = "The session's ready contract has no rdpPort. Firefox sessions publish one from extension.js 4.0.15 on; upgrade the project's extension dependency (or remove the local install so the MCP's pinned CLI drives the session) and restart the dev session.";
2997
3062
  function isCdpEndpoint(port) {
2998
3063
  return new Promise((resolve)=>{
2999
3064
  const req = node_http.get({
@@ -3181,9 +3246,471 @@ async function resolveBridgeBaseUrl(projectPath, browser, timeout) {
3181
3246
  } catch {}
3182
3247
  return null;
3183
3248
  }
3249
+ function rdp_define_property(obj, key, value) {
3250
+ if (key in obj) Object.defineProperty(obj, key, {
3251
+ value: value,
3252
+ enumerable: true,
3253
+ configurable: true,
3254
+ writable: true
3255
+ });
3256
+ else obj[key] = value;
3257
+ return obj;
3258
+ }
3259
+ function encodeRdpPacket(packet) {
3260
+ const json = Buffer.from(JSON.stringify(packet), "utf8");
3261
+ return Buffer.concat([
3262
+ Buffer.from(`${json.length}:`, "ascii"),
3263
+ json
3264
+ ]);
3265
+ }
3266
+ class RdpPacketDecoder {
3267
+ push(chunk) {
3268
+ this.buffer = Buffer.concat([
3269
+ this.buffer,
3270
+ chunk
3271
+ ]);
3272
+ const packets = [];
3273
+ for(;;){
3274
+ const colon = this.buffer.indexOf(0x3a);
3275
+ if (-1 === colon) {
3276
+ if (this.buffer.length > 16) throw new Error("RDP stream corrupt: no length prefix");
3277
+ break;
3278
+ }
3279
+ const prefix = this.buffer.subarray(0, colon).toString("ascii");
3280
+ if (!/^\d+$/.test(prefix)) throw new Error(`RDP stream corrupt: bad length prefix "${prefix}"`);
3281
+ const length = Number(prefix);
3282
+ if (this.buffer.length < colon + 1 + length) break;
3283
+ const json = this.buffer.subarray(colon + 1, colon + 1 + length);
3284
+ this.buffer = this.buffer.subarray(colon + 1 + length);
3285
+ packets.push(JSON.parse(json.toString("utf8")));
3286
+ }
3287
+ return packets;
3288
+ }
3289
+ constructor(){
3290
+ rdp_define_property(this, "buffer", Buffer.alloc(0));
3291
+ }
3292
+ }
3293
+ class RdpSession {
3294
+ static connect(port, timeoutMs = 10000) {
3295
+ return new Promise((resolve, reject)=>{
3296
+ const socket = node_net.connect({
3297
+ host: "127.0.0.1",
3298
+ port
3299
+ });
3300
+ const session = new RdpSession(socket);
3301
+ const decoder = new RdpPacketDecoder();
3302
+ let settledConnect = false;
3303
+ const timer = setTimeout(()=>{
3304
+ if (!settledConnect) {
3305
+ settledConnect = true;
3306
+ session.close();
3307
+ reject(new Error(`RDP connect timed out after ${timeoutMs}ms`));
3308
+ }
3309
+ }, timeoutMs);
3310
+ socket.on("data", (chunk)=>{
3311
+ let packets;
3312
+ try {
3313
+ packets = decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3314
+ } catch {
3315
+ session.close();
3316
+ return;
3317
+ }
3318
+ for (const packet of packets){
3319
+ if (!settledConnect && "root" === packet.from) {
3320
+ settledConnect = true;
3321
+ clearTimeout(timer);
3322
+ resolve(session);
3323
+ continue;
3324
+ }
3325
+ for (const tap of session.taps)tap(packet);
3326
+ for(let i = 0; i < session.waiters.length; i++)if (session.waiters[i].match(packet)) {
3327
+ session.waiters.splice(i, 1)[0].resolve(packet);
3328
+ break;
3329
+ }
3330
+ }
3331
+ });
3332
+ socket.on("error", (error)=>{
3333
+ if (!settledConnect) {
3334
+ settledConnect = true;
3335
+ clearTimeout(timer);
3336
+ reject(error);
3337
+ }
3338
+ });
3339
+ socket.on("close", ()=>{
3340
+ session.closed = true;
3341
+ for (const waiter of session.waiters.splice(0))waiter.reject(new Error("RDP connection closed before a reply arrived"));
3342
+ });
3343
+ });
3344
+ }
3345
+ request(actor, packet, timeoutMs = 10000) {
3346
+ return new Promise((resolve, reject)=>{
3347
+ if (this.closed) return void reject(new Error("RDP connection is closed"));
3348
+ const waiter = {
3349
+ match: (p)=>p.from === actor && void 0 === p.type,
3350
+ resolve: (p)=>{
3351
+ clearTimeout(timer);
3352
+ if ("string" == typeof p.error) reject(new Error(`RDP actor error: ${p.error}${"string" == typeof p.message ? ` (${p.message})` : ""}`));
3353
+ else resolve(p);
3354
+ },
3355
+ reject: (error)=>{
3356
+ clearTimeout(timer);
3357
+ reject(error);
3358
+ }
3359
+ };
3360
+ const timer = setTimeout(()=>{
3361
+ const at = this.waiters.indexOf(waiter);
3362
+ if (-1 !== at) this.waiters.splice(at, 1);
3363
+ reject(new Error(`RDP request ${String(packet.type)} to ${actor} timed out after ${timeoutMs}ms`));
3364
+ }, timeoutMs);
3365
+ this.waiters.push(waiter);
3366
+ this.socket.write(encodeRdpPacket({
3367
+ to: actor,
3368
+ ...packet
3369
+ }));
3370
+ });
3371
+ }
3372
+ tap(handler) {
3373
+ this.taps.add(handler);
3374
+ return ()=>this.taps.delete(handler);
3375
+ }
3376
+ close() {
3377
+ this.closed = true;
3378
+ this.socket.destroy();
3379
+ }
3380
+ constructor(socket){
3381
+ rdp_define_property(this, "socket", void 0);
3382
+ rdp_define_property(this, "waiters", void 0);
3383
+ rdp_define_property(this, "taps", void 0);
3384
+ rdp_define_property(this, "closed", void 0);
3385
+ this.socket = socket;
3386
+ this.waiters = [];
3387
+ this.taps = new Set();
3388
+ this.closed = false;
3389
+ }
3390
+ }
3391
+ async function withSession(port, timeoutMs, work) {
3392
+ const session = await RdpSession.connect(port, timeoutMs);
3393
+ try {
3394
+ return await work(session);
3395
+ } finally{
3396
+ session.close();
3397
+ }
3398
+ }
3399
+ async function rdpListAddons(port, options) {
3400
+ const timeoutMs = options?.timeoutMs ?? 10000;
3401
+ return withSession(port, timeoutMs, async (session)=>{
3402
+ const reply = await session.request("root", {
3403
+ type: "listAddons"
3404
+ }, timeoutMs);
3405
+ return reply.addons ?? [];
3406
+ });
3407
+ }
3408
+ async function rdpListTabs(port, options) {
3409
+ const timeoutMs = options?.timeoutMs ?? 10000;
3410
+ return withSession(port, timeoutMs, async (session)=>{
3411
+ const reply = await session.request("root", {
3412
+ type: "listTabs"
3413
+ }, timeoutMs);
3414
+ return reply.tabs ?? [];
3415
+ });
3416
+ }
3417
+ function formatConsoleArg(arg) {
3418
+ if (null == arg) return String(arg);
3419
+ if ("object" == typeof arg) {
3420
+ const cls = arg.class;
3421
+ return "string" == typeof cls ? `[${cls}]` : "[object]";
3422
+ }
3423
+ return String(arg);
3424
+ }
3425
+ async function rdpCollectConsoleMessages(port, options) {
3426
+ const timeoutMs = options?.timeoutMs ?? 10000;
3427
+ const settleMs = options?.settleMs ?? 1000;
3428
+ return withSession(port, timeoutMs, async (session)=>{
3429
+ const tabsReply = await session.request("root", {
3430
+ type: "listTabs"
3431
+ }, timeoutMs);
3432
+ const tabs = tabsReply.tabs ?? [];
3433
+ const wanted = options?.urlFilter?.toLowerCase();
3434
+ const tab = wanted ? tabs.find((t)=>String(t.url ?? "").toLowerCase().includes(wanted)) : tabs.find((t)=>true === t.selected) ?? tabs[0];
3435
+ if (!tab?.actor) throw new Error(wanted ? `no open tab matches url: ${options?.urlFilter}` : "no open tabs");
3436
+ const watcherReply = await session.request(String(tab.actor), {
3437
+ type: "getWatcher",
3438
+ isServerTargetSwitchingEnabled: true
3439
+ }, timeoutMs);
3440
+ const watcherActor = String(watcherReply.actor ?? "");
3441
+ if (!watcherActor) throw new Error("tab descriptor returned no watcher");
3442
+ const messages = [];
3443
+ const untap = session.tap((packet)=>{
3444
+ if ("resources-available-array" !== packet.type) return;
3445
+ const array = Array.isArray(packet.array) ? packet.array : [];
3446
+ for (const entry of array){
3447
+ if (!Array.isArray(entry) || entry.length < 2) continue;
3448
+ const [resourceType, resources] = entry;
3449
+ if (Array.isArray(resources)) for (const raw of resources){
3450
+ const resource = raw;
3451
+ if ("console-message" === resourceType) {
3452
+ const args = Array.isArray(resource.arguments) ? resource.arguments : [];
3453
+ messages.push({
3454
+ level: String(resource.level ?? "log"),
3455
+ text: args.map(formatConsoleArg).join(" ")
3456
+ });
3457
+ } else if ("error-message" === resourceType) {
3458
+ const pageError = resource.pageError ?? {};
3459
+ messages.push({
3460
+ level: true === pageError.warning ? "warn" : "error",
3461
+ text: String(pageError.errorMessage ?? "")
3462
+ });
3463
+ }
3464
+ }
3465
+ }
3466
+ });
3467
+ try {
3468
+ await session.request(watcherActor, {
3469
+ type: "watchTargets",
3470
+ targetType: "frame"
3471
+ }, timeoutMs);
3472
+ await session.request(watcherActor, {
3473
+ type: "watchResources",
3474
+ resourceTypes: [
3475
+ "console-message",
3476
+ "error-message"
3477
+ ]
3478
+ }, timeoutMs);
3479
+ await new Promise((resolve)=>setTimeout(resolve, settleMs));
3480
+ } finally{
3481
+ untap();
3482
+ }
3483
+ return messages;
3484
+ });
3485
+ }
3486
+ function buildBridgeInspectExpression(opts) {
3487
+ const parts = [
3488
+ "const out = {};"
3489
+ ];
3490
+ if (opts.meta) parts.push("try { out.meta = { url: location.href, title: document.title, readyState: document.readyState }; } catch (e) {}");
3491
+ if (opts.summary) parts.push(`try {
3492
+ const roots = document.querySelectorAll('#extension-root,[data-extension-root]:not([data-extension-root="extension-js-devtools"])');
3493
+ out.summary = {
3494
+ htmlLength: document.documentElement.outerHTML.length,
3495
+ scriptCount: document.querySelectorAll('script').length,
3496
+ styleCount: document.querySelectorAll('style').length,
3497
+ linkCount: document.querySelectorAll('link').length,
3498
+ extensionRootCount: roots.length,
3499
+ bodyChildCount: document.body ? document.body.children.length : 0
3500
+ };
3501
+ } catch (e) { out.summary = {}; }`);
3502
+ if (opts.html) parts.push(`try {
3503
+ const html = ${PAGE_HTML_SCRIPT};
3504
+ const cap = ${JSON.stringify(opts.maxBytes)};
3505
+ out.htmlTruncated = cap > 0 && html.length > cap;
3506
+ out.html = out.htmlTruncated ? html.slice(0, cap) : html;
3507
+ } catch (e) {}`);
3508
+ if (opts.domSnapshot) parts.push(`try { out.domSnapshot = ${domSnapshotScript(500)}; } catch (e) {}`);
3509
+ if (opts.extensionRoots) parts.push(`try { out.extensionRoots = ${EXTENSION_ROOT_META_SCRIPT}; } catch (e) {}`);
3510
+ if (opts.probes.length) parts.push(`out.probes = {};
3511
+ for (const sel of ${JSON.stringify(opts.probes)}) {
3512
+ try {
3513
+ const nodes = document.querySelectorAll(sel);
3514
+ const first = nodes[0];
3515
+ out.probes[sel] = { count: nodes.length, sample: first ? String(first.outerHTML || "").slice(0, 200) : null };
3516
+ } catch (e) { out.probes[sel] = { error: String((e && e.message) || e) }; }
3517
+ }`);
3518
+ parts.push("return out;");
3519
+ return `(() => { ${parts.join("\n")} })()`;
3520
+ }
3521
+ function closedShadowWalkerCode(cap) {
3522
+ return `
3523
+ (function() {
3524
+ var out = { api: ("openOrClosedShadowRoot" in Element.prototype), closed: [] };
3525
+ function walk(node) {
3526
+ if (!node || node.nodeType !== 1) return;
3527
+ var sr = null;
3528
+ try { sr = node.openOrClosedShadowRoot || null; } catch (e) {}
3529
+ if (sr && sr.mode !== "open") out.closed.push({ host: node.tagName.toLowerCase(), html: String(sr.innerHTML).slice(0, ${cap}) });
3530
+ var kids = node.children;
3531
+ for (var i = 0; i < kids.length; i++) walk(kids[i]);
3532
+ if (sr) { var sk = sr.children; for (var j = 0; j < sk.length; j++) walk(sk[j]); }
3533
+ }
3534
+ walk(document.documentElement);
3535
+ return out;
3536
+ })();
3537
+ `;
3538
+ }
3539
+ function executeScriptExpression(urlFilter, code) {
3540
+ const pick = urlFilter ? `tabs.find(function (t) { return String(t.url || "").toLowerCase().indexOf(${JSON.stringify(urlFilter.toLowerCase())}) !== -1; })` : "(tabs.find(function (t) { return t.active; }) || tabs[0])";
3541
+ return `browser.tabs.query({}).then(function (tabs) {
3542
+ var tab = ${pick};
3543
+ if (!tab) return { error: "no matching tab" };
3544
+ return browser.tabs.executeScript(tab.id, { code: ${JSON.stringify(code)} }).then(
3545
+ function (results) { return { frames: results }; },
3546
+ function (err) { return { error: String((err && err.message) || err) }; }
3547
+ );
3548
+ })`;
3549
+ }
3550
+ async function collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes) {
3551
+ const raw = await runActVerb([
3552
+ "eval",
3553
+ executeScriptExpression(urlFilter, closedShadowWalkerCode(cap)),
3554
+ args.projectPath,
3555
+ "--context",
3556
+ "background",
3557
+ "--browser",
3558
+ browser,
3559
+ ...null != args.timeout ? [
3560
+ "--timeout",
3561
+ String(args.timeout)
3562
+ ] : []
3563
+ ], args.projectPath, args.timeout);
3564
+ let parsed;
3565
+ try {
3566
+ parsed = JSON.parse(raw);
3567
+ } catch {
3568
+ parsed = null;
3569
+ }
3570
+ const value = parsed?.ok === true ? parsed.value : null;
3571
+ const frame = Array.isArray(value?.frames) ? value.frames[0] : null;
3572
+ if (frame && Array.isArray(frame.closed)) {
3573
+ result.closedShadowRoots = frame.closed.map((c)=>({
3574
+ host: String(c.host ?? ""),
3575
+ type: "closed",
3576
+ html: String(c.html ?? "")
3577
+ }));
3578
+ result.deepDom = true;
3579
+ return;
3580
+ }
3581
+ const reason = value?.error ?? parsed?.error?.message ?? "the content-script walk returned nothing";
3582
+ notes.push(`deepDom failed on ${browser}: ${reason}. The walk runs via tabs.executeScript (MV2) and needs the extension to hold host permissions for the target url.`);
3583
+ }
3584
+ async function collectGeckoConsole(args, browser, urlFilter, result, notes) {
3585
+ const fallbackNote = `Console capture on ${browser} rides the RDP watcher replay and needs a session whose ready contract publishes rdpPort (extension.js 4.0.15+); extension_logs streams the extension's own console either way.`;
3586
+ const resolved = await resolveRdpPort(args.projectPath, browser, {
3587
+ waitMs: 5000
3588
+ });
3589
+ if (!resolved) return void notes.push(fallbackNote);
3590
+ try {
3591
+ const messages = await rdpCollectConsoleMessages(resolved.port, {
3592
+ urlFilter
3593
+ });
3594
+ result.console = summarizeConsoleMessages(messages);
3595
+ result.rdpPort = resolved.port;
3596
+ } catch (error) {
3597
+ notes.push(`Console capture over RDP failed: ${error.message}. ${fallbackNote}`);
3598
+ }
3599
+ }
3600
+ async function inspectViaBridge(args, browser, include, maxBytes) {
3601
+ const notes = [];
3602
+ if (args.url) {
3603
+ const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
3604
+ if ("error" in listed) return listed.error;
3605
+ const already = listed.tabs.some((t)=>t.url.includes(args.url));
3606
+ if (!already) {
3607
+ const nav = await navigateToUrlViaBridge(args.projectPath, browser, args.url, args.timeout);
3608
+ try {
3609
+ if (JSON.parse(nav)?.ok !== true) return nav;
3610
+ } catch {
3611
+ return nav;
3612
+ }
3613
+ }
3614
+ }
3615
+ const expression = buildBridgeInspectExpression({
3616
+ summary: include.has("summary"),
3617
+ meta: true,
3618
+ html: include.has("html"),
3619
+ domSnapshot: include.has("dom_snapshot"),
3620
+ extensionRoots: include.has("extension_roots"),
3621
+ probes: args.probe ?? [],
3622
+ maxBytes
3623
+ });
3624
+ let raw = await runActVerb([
3625
+ "eval",
3626
+ expression,
3627
+ args.projectPath,
3628
+ "--context",
3629
+ "page",
3630
+ ...args.url ? [
3631
+ "--url",
3632
+ args.url
3633
+ ] : [],
3634
+ "--browser",
3635
+ browser,
3636
+ ...null != args.timeout ? [
3637
+ "--timeout",
3638
+ String(args.timeout)
3639
+ ] : []
3640
+ ], args.projectPath, args.timeout);
3641
+ let parsed;
3642
+ try {
3643
+ parsed = JSON.parse(raw);
3644
+ } catch {
3645
+ return raw;
3646
+ }
3647
+ let value = parsed?.ok === true ? parsed.value ?? {} : null;
3648
+ if (null === value && /scripting is not available/i.test(String(parsed?.error?.message ?? ""))) {
3649
+ raw = await runActVerb([
3650
+ "eval",
3651
+ executeScriptExpression(args.url, expression),
3652
+ args.projectPath,
3653
+ "--context",
3654
+ "background",
3655
+ "--browser",
3656
+ browser,
3657
+ ...null != args.timeout ? [
3658
+ "--timeout",
3659
+ String(args.timeout)
3660
+ ] : []
3661
+ ], args.projectPath, args.timeout);
3662
+ try {
3663
+ parsed = JSON.parse(raw);
3664
+ } catch {
3665
+ return raw;
3666
+ }
3667
+ const frame = Array.isArray(parsed?.value?.frames) ? parsed.value.frames[0] : null;
3668
+ if (parsed?.ok === true && frame && "object" == typeof frame) value = frame;
3669
+ else if (parsed?.ok === true) return JSON.stringify({
3670
+ ok: false,
3671
+ error: {
3672
+ name: "InspectFailed",
3673
+ message: String(parsed?.value?.error ?? "the content-script inspect returned nothing")
3674
+ },
3675
+ hint: "The MV2 fallback inspects via tabs.executeScript, which needs the extension to hold host permissions for the target url."
3676
+ });
3677
+ }
3678
+ if (null === value) return raw;
3679
+ const result = {
3680
+ browser,
3681
+ transport: "bridge"
3682
+ };
3683
+ if (value.meta) {
3684
+ result.target = {
3685
+ url: value.meta.url,
3686
+ title: value.meta.title
3687
+ };
3688
+ if (include.has("meta")) result.meta = value.meta;
3689
+ }
3690
+ if (include.has("summary") && value.summary) result.summary = value.summary;
3691
+ if (include.has("html") && "string" == typeof value.html) {
3692
+ result.html = value.html;
3693
+ if (value.htmlTruncated) result.htmlTruncated = true;
3694
+ }
3695
+ if (include.has("dom_snapshot") && value.domSnapshot) result.domSnapshot = value.domSnapshot;
3696
+ if (include.has("extension_roots") && void 0 !== value.extensionRoots) result.extensionRoots = value.extensionRoots;
3697
+ if (value.probes) {
3698
+ result.probes = value.probes;
3699
+ const jsLooking = (args.probe ?? []).filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
3700
+ 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.`;
3701
+ }
3702
+ const urlFilter = args.url ?? ("string" == typeof value.meta?.url ? value.meta.url : void 0);
3703
+ if (include.has("console")) await collectGeckoConsole(args, browser, urlFilter, result, notes);
3704
+ if (args.deepDom) {
3705
+ const cap = maxBytes > 0 ? maxBytes : 65536;
3706
+ await collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes);
3707
+ }
3708
+ if (notes.length) result.notes = notes;
3709
+ return JSON.stringify(result);
3710
+ }
3184
3711
  const source_inspect_schema = {
3185
3712
  name: "extension_source_inspect",
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.",
3713
+ 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 are fully paired: summary/meta/html/dom_snapshot/extension_roots/probes ride the agent bridge (needs allowEval: true), console rides the RDP watcher replay (engine 4.0.15+), and deepDom walks closed shadow roots via a content-script eval (MV2 sessions with host permissions for the target url; Firefox MV3 background CSP blocks bridge evals entirely). Requires an active dev or start session.",
3187
3714
  inputSchema: {
3188
3715
  type: "object",
3189
3716
  properties: {
@@ -3234,7 +3761,7 @@ const source_inspect_schema = {
3234
3761
  deepDom: {
3235
3762
  type: "boolean",
3236
3763
  default: false,
3237
- description: "Pierce CLOSED shadow roots via CDP (Chromium only). The default path reads open shadow roots; closed ones need this escape hatch."
3764
+ description: "Pierce CLOSED shadow roots. The default path reads open shadow roots; closed ones need this escape hatch. Chromium: CDP DOM pierce. Firefox: a content-script walk via tabs.executeScript (MV2 sessions; the extension needs host permissions for the target url)."
3238
3765
  }
3239
3766
  },
3240
3767
  required: [
@@ -3352,120 +3879,9 @@ async function source_inspect_handler(args) {
3352
3879
  cdp.disconnect();
3353
3880
  }
3354
3881
  }
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
- }
3466
3882
  const list_extensions_schema = {
3467
3883
  name: "extension_list_extensions",
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.",
3884
+ description: "List the extensions in the running dev browser. Returns each extension's id, name, version, and (on Chromium) live contexts. 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. On Chromium this rides the Chrome DevTools Protocol: entries are extensions with at least one live context (a dormant MV3 service worker with no open page may be absent until it wakes), and other extensions resolve via the read-only Extensions domain when available. On Firefox this rides the Remote Debugging Protocol root actor (listAddons, engine 4.0.15+): entries are INSTALLED add-ons regardless of live contexts, with temporarilyInstalled marking temporary loads, and carry no contexts. Either way other extensions' contexts are never attached to or evaluated in. Requires an active dev or start session.",
3469
3885
  inputSchema: {
3470
3886
  type: "object",
3471
3887
  properties: {
@@ -3526,9 +3942,10 @@ function readOwnIdentity(projectPath, browser) {
3526
3942
  const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domain returned nothing for this id, and other extensions' contexts are never attached to or evaluated in to read a manifest.";
3527
3943
  async function list_extensions_handler(args) {
3528
3944
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
3945
+ if (isGeckoFamily(browser)) return listGeckoExtensions(args.projectPath, browser);
3529
3946
  if (!isChromiumFamily(browser)) return JSON.stringify({
3530
- error: `Listing extensions for ${browser} uses RDP (Remote Debug Protocol). Currently only Chromium CDP is supported.`,
3531
- hint: 'Pass browser: "chrome" (against a Chromium-family dev session).'
3947
+ error: `Listing extensions for ${browser} is not supported: no debugging-protocol pairing exists for this browser family.`,
3948
+ hint: "Target a Chromium-family (CDP) or Firefox-family (RDP) dev session."
3532
3949
  });
3533
3950
  const resolved = await resolveCdpPort(args.projectPath, browser);
3534
3951
  if (!resolved) return JSON.stringify({
@@ -3621,6 +4038,70 @@ async function list_extensions_handler(args) {
3621
4038
  cdp.disconnect();
3622
4039
  }
3623
4040
  }
4041
+ async function listGeckoExtensions(projectPath, browser) {
4042
+ const resolved = await resolveRdpPort(projectPath, browser);
4043
+ if (!resolved) return JSON.stringify({
4044
+ error: "No active dev session with a Firefox debugger server (RDP) found.",
4045
+ hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${RDP_PORT_MISSING_HINT}`
4046
+ });
4047
+ const rdpPort = resolved.port;
4048
+ try {
4049
+ let addons = null;
4050
+ let lastError = null;
4051
+ for(let attempt = 0; attempt < 3; attempt++)try {
4052
+ addons = await rdpListAddons(rdpPort);
4053
+ break;
4054
+ } catch (error) {
4055
+ lastError = error;
4056
+ if (attempt < 2) await new Promise((resolve)=>setTimeout(resolve, 800));
4057
+ }
4058
+ if (!addons) throw lastError;
4059
+ const own = readOwnIdentity(projectPath, browser);
4060
+ const extensions = addons.filter((addon)=>true === addon.isWebExtension && true !== addon.isSystem && true !== addon.hidden).map((addon)=>{
4061
+ const entry = {
4062
+ id: String(addon.id ?? addon.actor ?? ""),
4063
+ contexts: [],
4064
+ source: "rdp-root"
4065
+ };
4066
+ if ("string" == typeof addon.name) entry.name = addon.name;
4067
+ if ("string" == typeof addon.version) entry.version = addon.version;
4068
+ if (true === addon.temporarilyInstalled) entry.temporarilyInstalled = true;
4069
+ return entry;
4070
+ });
4071
+ if (own?.name) {
4072
+ const byName = extensions.filter((e)=>e.name === own.name);
4073
+ if (1 === byName.length) byName[0].ownExtension = true;
4074
+ }
4075
+ if (!extensions.some((e)=>e.ownExtension)) {
4076
+ const temporary = extensions.filter((e)=>e.temporarilyInstalled);
4077
+ if (1 === temporary.length) {
4078
+ temporary[0].ownExtension = true;
4079
+ if (void 0 === temporary[0].name && own?.name !== void 0) {
4080
+ temporary[0].name = own.name;
4081
+ if (void 0 !== own.version) temporary[0].version = own.version;
4082
+ temporary[0].source = "session-contract";
4083
+ }
4084
+ }
4085
+ }
4086
+ extensions.sort((a, b)=>{
4087
+ if ((a.ownExtension ?? false) !== (b.ownExtension ?? false)) return a.ownExtension ? -1 : 1;
4088
+ return (a.name ?? a.id).localeCompare(b.name ?? b.id);
4089
+ });
4090
+ const ownEntry = extensions.find((e)=>e.ownExtension);
4091
+ return JSON.stringify({
4092
+ rdpPort,
4093
+ browser,
4094
+ count: extensions.length,
4095
+ ownExtensionId: ownEntry?.id ?? null,
4096
+ extensions,
4097
+ note: "Lists INSTALLED add-ons via the RDP root actor (listAddons), regardless of whether a context is currently live, so entries carry no contexts. temporarilyInstalled marks temporary loads; ownExtension marks the extension this dev session serves, matched from the session's ready contract. Add-ons are never attached to or evaluated in."
4098
+ });
4099
+ } catch (error) {
4100
+ return JSON.stringify({
4101
+ error: `Failed to list extensions over RDP: ${error.message}`
4102
+ });
4103
+ }
4104
+ }
3624
4105
  const CONTROL_WS_PATH = "/extjs-control";
3625
4106
  const LEVEL_ORDER = [
3626
4107
  "error",
@@ -4637,9 +5118,10 @@ function matchTargetsByUrl(targets, needle) {
4637
5118
  if (byUrl.length > 0) return byUrl;
4638
5119
  return targets.filter((t)=>t.title.toLowerCase().includes(wanted));
4639
5120
  }
5121
+ const RDP_ACTOR_NOTE = "actor is an RDP tab descriptor actor id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_inspect with listTabs: true.";
4640
5122
  const dom_inspect_schema = {
4641
5123
  name: "extension_dom_inspect",
4642
- description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Target a tab by `tabUrl` (case-insensitive URL substring resolved against the browser's live page targets; zero or several matches return the candidates instead of guessing), by `url`, or by numeric `tab`. Discover what is open with listTargets: true (CDP targetIds) or listTabs: true (numeric chrome.tabs ids). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
5124
+ description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Target a tab by `tabUrl` (case-insensitive URL substring resolved against the browser's live page targets; zero or several matches return the candidates instead of guessing), by `url`, or by numeric `tab`. Discover what is open with listTargets: true (Chromium CDP targetIds; Firefox RDP tab actors) or listTabs: true (numeric chrome.tabs ids). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
4643
5125
  inputSchema: {
4644
5126
  type: "object",
4645
5127
  properties: {
@@ -4662,7 +5144,7 @@ const dom_inspect_schema = {
4662
5144
  listTargets: {
4663
5145
  type: "boolean",
4664
5146
  default: false,
4665
- description: "Enumerate the browser's CDP page targets as {targetId,url,title,type} and return, ignoring the other args. The discovery path for `tabUrl`. targetId is a CDP target id, NOT a numeric chrome.tabs id (for those use listTabs)."
5147
+ description: "Enumerate the browser's live page targets and return, ignoring the other args. The discovery path for `tabUrl`. Chromium: CDP page targets as {targetId,url,title,type}. Firefox: RDP tab descriptors as {actor,url,title,type} (engine 4.0.15+). Neither id is a numeric chrome.tabs id (for those use listTabs)."
4666
5148
  },
4667
5149
  listTabs: {
4668
5150
  type: "boolean",
@@ -4752,6 +5234,43 @@ async function dom_inspect_handler(args) {
4752
5234
  const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
4753
5235
  if (args.listTargets) {
4754
5236
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
5237
+ if (isGeckoFamily(browser)) {
5238
+ const resolved = await resolveRdpPort(args.projectPath, browser);
5239
+ if (!resolved) return JSON.stringify({
5240
+ ok: false,
5241
+ error: {
5242
+ name: "NoSession",
5243
+ message: `No active dev session with a Firefox debugger server (RDP) for ${browser}, so listTargets has no browser to ask. Start extension_dev and extension_wait for ready. ${RDP_PORT_MISSING_HINT}`
5244
+ }
5245
+ });
5246
+ try {
5247
+ const tabs = await rdpListTabs(resolved.port);
5248
+ return JSON.stringify({
5249
+ ok: true,
5250
+ browser,
5251
+ transport: "rdp",
5252
+ targets: tabs.map((t)=>({
5253
+ actor: String(t.actor ?? ""),
5254
+ type: "tab",
5255
+ url: String(t.url ?? ""),
5256
+ title: String(t.title ?? ""),
5257
+ ...true === t.selected ? {
5258
+ selected: true
5259
+ } : {}
5260
+ })),
5261
+ note: RDP_ACTOR_NOTE
5262
+ });
5263
+ } catch (e) {
5264
+ return JSON.stringify({
5265
+ ok: false,
5266
+ error: {
5267
+ name: "RdpError",
5268
+ message: `Could not list tab targets over RDP: ${e instanceof Error ? e.message : String(e)}`
5269
+ },
5270
+ hint: "Confirm the session is ready (extension_wait), then retry. listTabs: true is the bridge alternative (needs allowControl)."
5271
+ });
5272
+ }
5273
+ }
4755
5274
  const cdp = await cdpPortOrError(args.projectPath, browser, "listTargets");
4756
5275
  if ("error" in cdp) return cdp.error;
4757
5276
  try {
@@ -5,4 +5,12 @@ export declare function resolveCdpPort(projectPath: string, browser: string, opt
5
5
  port: number;
6
6
  source: "contract" | "default-probe";
7
7
  } | null>;
8
+ export declare function resolveRdpPort(projectPath: string, browser: string, options?: {
9
+ waitMs?: number;
10
+ graceMs?: number;
11
+ }): Promise<{
12
+ port: number;
13
+ source: "contract";
14
+ } | null>;
8
15
  export declare const CDP_PORT_MISSING_HINT = "The session's ready contract has no CDP port (the browser may still be binding its debug port, or was launched without one). Confirm the session with extension_wait, give it a moment, and retry.";
16
+ export declare const RDP_PORT_MISSING_HINT = "The session's ready contract has no rdpPort. Firefox sessions publish one from extension.js 4.0.15 on; upgrade the project's extension dependency (or remove the local install so the MCP's pinned CLI drives the session) and restart the dev session.";
@@ -0,0 +1,5 @@
1
+ export interface ConsoleMessage {
2
+ level: string;
3
+ text: string;
4
+ }
5
+ export declare function summarizeConsoleMessages(messages: ConsoleMessage[]): Record<string, unknown>;
@@ -0,0 +1,50 @@
1
+ import type { ConsoleMessage } from "./console-summary";
2
+ export interface RdpAddon {
3
+ id?: string;
4
+ actor?: string;
5
+ name?: string;
6
+ version?: string;
7
+ url?: string;
8
+ temporarilyInstalled?: boolean;
9
+ isWebExtension?: boolean;
10
+ isSystem?: boolean;
11
+ hidden?: boolean;
12
+ [key: string]: unknown;
13
+ }
14
+ export interface RdpTab {
15
+ actor?: string;
16
+ url?: string;
17
+ title?: string;
18
+ selected?: boolean;
19
+ browserId?: number;
20
+ [key: string]: unknown;
21
+ }
22
+ export declare function encodeRdpPacket(packet: Record<string, unknown>): Buffer;
23
+ export declare class RdpPacketDecoder {
24
+ private buffer;
25
+ push(chunk: Buffer): Array<Record<string, unknown>>;
26
+ }
27
+ type RdpPacket = Record<string, unknown>;
28
+ export declare class RdpSession {
29
+ private socket;
30
+ private waiters;
31
+ private taps;
32
+ private closed;
33
+ private constructor();
34
+ static connect(port: number, timeoutMs?: number): Promise<RdpSession>;
35
+ request(actor: string, packet: RdpPacket, timeoutMs?: number): Promise<RdpPacket>;
36
+ tap(handler: (p: RdpPacket) => void): () => void;
37
+ close(): void;
38
+ }
39
+ export declare function rdpListAddons(port: number, options?: {
40
+ timeoutMs?: number;
41
+ }): Promise<RdpAddon[]>;
42
+ export declare function rdpListTabs(port: number, options?: {
43
+ timeoutMs?: number;
44
+ }): Promise<RdpTab[]>;
45
+ export declare function rdpCollectConsoleMessages(port: number, options?: {
46
+ urlFilter?: string;
47
+ timeoutMs?: number;
48
+ settleMs?: number;
49
+ }): Promise<ConsoleMessage[]>;
50
+ export {};
@@ -0,0 +1,8 @@
1
+ export declare function inspectViaBridge(args: {
2
+ projectPath: string;
3
+ url?: string;
4
+ probe?: string[];
5
+ include?: string[];
6
+ timeout?: number;
7
+ deepDom?: boolean;
8
+ }, browser: string, include: Set<string>, maxBytes: number): Promise<string>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "5.5.2",
4
+ "version": "5.6.0",
5
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",
@@ -88,9 +88,9 @@
88
88
  "@modelcontextprotocol/sdk": "^1.12.1",
89
89
  "browser-extension-manifest-fields": "^2.2.9",
90
90
  "cross-spawn": "^7.0.6",
91
- "extension-create": "^4.0.13",
92
- "extension-develop": "^4.0.13",
93
- "extension-install": "^4.0.13",
91
+ "extension-create": "^4.0.15",
92
+ "extension-develop": "^4.0.15",
93
+ "extension-install": "^4.0.15",
94
94
  "ws": "^8.20.0"
95
95
  },
96
96
  "devDependencies": {
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://extension.dev",
10
- "version": "5.5.2",
10
+ "version": "5.6.0",
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.2",
16
+ "version": "5.6.0",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"