@extension.dev/mcp 4.8.0 → 4.9.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": "4.8.0",
13
+ "version": "4.9.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": "4.8.0",
4
+ "version": "4.9.0",
5
5
  "author": {
6
6
  "name": "Cezar Augusto",
7
7
  "email": "boss@cezaraugusto.net",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.9.0
4
+
5
+ A second pass from the persona swarm, closing the gaps 4.8.0 left and the top
6
+ new blockers it surfaced.
7
+
8
+ - **Honest `extension_manifest_validate`.** It now scans the project source for
9
+ permission-gated `chrome.*`/`browser.*` calls and flags any the manifest does
10
+ not declare — an API used without its permission is `undefined` at runtime and
11
+ crashes the context, the exact case where validate used to report `valid:true`.
12
+ The headline is now honest (`valid:false` + `buildBlocking:true` on any error),
13
+ and it accepts singular `browser` as an alias for `browsers`.
14
+ - **`extension_open` can navigate a tab.** Pass a `url` (Chromium, via CDP) to
15
+ drive a content-script test page, a `webNavigation` target, or the popup as a
16
+ page (`chrome-extension://<id>/popup.html`) — the loop the surface-only open
17
+ could not do. `target` is accepted as an alias for `surface`.
18
+ - **`extension_stop` actually reaps the session.** It now terminates the dev CLI
19
+ and both browser families (gecko profile + chromium `--load-extension`, under
20
+ the project's dist) and refuses to report `stopped:true` while any survive.
21
+ - **`extension_wait` won't lie about a dead session.** A `ready.json` whose pid
22
+ is dead now returns `status:"stale"` instead of `ready`, so you don't walk into
23
+ a reload/eval that fails with a misleading control-channel error.
24
+ - **Dropped-channel errors name the real cause.** A `1006` / "no control channel"
25
+ now detects an exited dev server (stale ready.json + dead pid) and says so,
26
+ instead of asking "is the session started with allowControl?" when it was.
27
+ - **`extension_doctor`** surfaces recent error-level logs as a `runtime-errors`
28
+ check (so a background throwing on every event isn't `healthy:true`), keeps the
29
+ project-local engine version in project mode, and flags when that engine
30
+ differs from a pinned `EXTENSION_MCP_CLI_VERSION`.
31
+ - **`extension_build`** lists declared entrypoints in its success output, so a
32
+ content script no longer reads as "didn't build".
33
+ - **`extension_create`** forces non-interactive git (`GIT_TERMINAL_PROMPT=0`) so a
34
+ credential prompt can't hang the template download, retries once on a transient
35
+ network/timeout failure (cleaning the partial dir first), reports a download
36
+ failure as such instead of "choose a valid template name", and warns when the
37
+ scaffold's `extension@latest` pin will win over your pinned CLI.
38
+ - Eval/inspect error guidance now speaks MCP JSON args (`context`, `tab`, `url`)
39
+ instead of CLI flags.
40
+
3
41
  ## 4.8.0
4
42
 
5
43
  Dev-session ergonomics hardened from a 30-persona agent walk of the toolchain.
package/dist/module.js CHANGED
@@ -199,7 +199,7 @@ __webpack_require__.d(whoami_namespaceObject, {
199
199
  handler: ()=>whoami_handler,
200
200
  schema: ()=>whoami_schema
201
201
  });
202
- var package_namespaceObject = JSON.parse('{"rE":"4.7.0","El":{"OP":"^4.0.11"}}');
202
+ var package_namespaceObject = JSON.parse('{"rE":"4.8.0","El":{"OP":"^4.0.11"}}');
203
203
  function detectPackageManager(projectPath) {
204
204
  const byLockfile = [
205
205
  [
@@ -259,14 +259,27 @@ const create_schema = {
259
259
  async function create_handler(args) {
260
260
  const start = Date.now();
261
261
  const projectInput = args.parentDir ? node_path.resolve(args.parentDir, args.projectName) : args.projectName;
262
+ process.env.GIT_TERMINAL_PROMPT = "0";
263
+ if (void 0 === process.env.GIT_ASKPASS) process.env.GIT_ASKPASS = "";
262
264
  const logLines = [];
263
265
  const capture = (stream)=>(...parts)=>{
264
266
  const line = parts.map((p)=>"string" == typeof p ? p : String(p)).join(" ").trim();
265
267
  if (line) logLines.push("error" === stream ? `[error] ${line}` : line);
266
268
  };
267
269
  const logTail = (max = 20)=>logLines.slice(-max);
268
- try {
269
- const result = await extensionCreate(projectInput, {
270
+ const looksTransient = ()=>{
271
+ const blob = logLines.join("\n").toLowerCase();
272
+ return /timed out|timeout|etimedout|econnreset|rate limit|\b429\b|network|could not resolve host|terminal prompts disabled|authentication failed|early eof|rpc failed|remote end hung up/.test(blob);
273
+ };
274
+ const cleanPartial = ()=>{
275
+ try {
276
+ if (args.parentDir && node_fs.existsSync(projectInput)) node_fs.rmSync(projectInput, {
277
+ recursive: true,
278
+ force: true
279
+ });
280
+ } catch {}
281
+ };
282
+ const attempt = ()=>extensionCreate(projectInput, {
270
283
  template: args.template ?? "typescript",
271
284
  install: args.install ?? true,
272
285
  logger: {
@@ -274,34 +287,53 @@ async function create_handler(args) {
274
287
  error: capture("error")
275
288
  }
276
289
  });
277
- const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
278
- const runDev = `${packageManager} run dev`;
279
- return JSON.stringify({
280
- projectPath: result.projectPath,
281
- projectName: result.projectName,
282
- template: result.template,
283
- depsInstalled: result.depsInstalled,
284
- packageManager: result.depsInstalled ? packageManager : null,
285
- duration: Date.now() - start,
286
- nextSteps: result.depsInstalled ? [
287
- `cd ${result.projectPath}`,
288
- runDev
289
- ] : [
290
- `cd ${result.projectPath}`,
291
- "npm install",
292
- "npm run dev"
293
- ],
294
- ...result.depsInstalled ? {} : {
295
- warnings: logTail()
296
- }
297
- });
298
- } catch (err) {
299
- return JSON.stringify({
300
- error: err instanceof Error ? err.message : String(err),
290
+ const failure = (err, transient)=>JSON.stringify({
291
+ error: transient ? "Template download failed (network/timeout/rate-limit) — this is not a bad template name. Retry, or check connectivity/GitHub rate limits." : err instanceof Error ? err.message : String(err),
292
+ ...transient ? {
293
+ cause: err instanceof Error ? err.message : String(err)
294
+ } : {},
301
295
  duration: Date.now() - start,
302
296
  log: logTail()
303
297
  });
298
+ let result;
299
+ try {
300
+ result = await attempt();
301
+ } catch (err1) {
302
+ if (!looksTransient()) return failure(err1, false);
303
+ logLines.push("[retry] transient template-download failure; retrying once");
304
+ cleanPartial();
305
+ try {
306
+ result = await attempt();
307
+ } catch (err2) {
308
+ return failure(err2, looksTransient());
309
+ }
304
310
  }
311
+ const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
312
+ const runDev = `${packageManager} run dev`;
313
+ const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
314
+ const engineWarning = pin && "latest" !== pin ? `The scaffold pins "extension": "latest"; the project-local engine wins over EXTENSION_MCP_CLI_VERSION=${pin}. Run \`(cd ${result.projectPath} && npm i -D extension@${pin})\` to match the pinned engine.` : void 0;
315
+ return JSON.stringify({
316
+ projectPath: result.projectPath,
317
+ projectName: result.projectName,
318
+ template: result.template,
319
+ depsInstalled: result.depsInstalled,
320
+ packageManager: result.depsInstalled ? packageManager : null,
321
+ duration: Date.now() - start,
322
+ nextSteps: result.depsInstalled ? [
323
+ `cd ${result.projectPath}`,
324
+ runDev
325
+ ] : [
326
+ `cd ${result.projectPath}`,
327
+ "npm install",
328
+ "npm run dev"
329
+ ],
330
+ ...engineWarning ? {
331
+ engineWarning
332
+ } : {},
333
+ ...result.depsInstalled ? {} : {
334
+ warnings: logTail()
335
+ }
336
+ });
305
337
  }
306
338
  const CACHE_DIR = node_path.join(node_os.homedir(), ".cache", "extension-js");
307
339
  const CACHE_FILE = node_path.join(CACHE_DIR, "templates-meta.json");
@@ -535,6 +567,34 @@ function spawnExtensionCli(args, options) {
535
567
  child.unref();
536
568
  return child;
537
569
  }
570
+ function builtEntrypoints(distDir) {
571
+ let manifest;
572
+ try {
573
+ manifest = JSON.parse(node_fs.readFileSync(node_path.join(distDir, "manifest.json"), "utf8"));
574
+ } catch {
575
+ return [];
576
+ }
577
+ const out = [];
578
+ const add = (role, ref)=>{
579
+ if ("string" != typeof ref) return;
580
+ out.push({
581
+ role,
582
+ path: ref,
583
+ present: node_fs.existsSync(node_path.join(distDir, ref.replace(/^\.?\//, "")))
584
+ });
585
+ };
586
+ const bg = manifest.background;
587
+ if (bg?.service_worker) add("background.service_worker", bg.service_worker);
588
+ if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>add("background.scripts", s));
589
+ const action = manifest.action || manifest.browser_action;
590
+ if (action?.default_popup) add("action.default_popup", action.default_popup);
591
+ const cs = manifest.content_scripts;
592
+ if (Array.isArray(cs)) cs.forEach((c, i)=>{
593
+ if (Array.isArray(c.js)) c.js.forEach((j)=>add(`content_scripts[${i}].js`, j));
594
+ if (Array.isArray(c.css)) c.css.forEach((s)=>add(`content_scripts[${i}].css`, s));
595
+ });
596
+ return out;
597
+ }
538
598
  const build_schema = {
539
599
  name: "extension_build",
540
600
  description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
@@ -632,6 +692,7 @@ async function build_handler(args) {
632
692
  if (0 === code) {
633
693
  const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
634
694
  const status = out.match(/Build Status:\s*(\w+)/)?.[1];
695
+ const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
635
696
  return JSON.stringify({
636
697
  success: true,
637
698
  browser,
@@ -641,6 +702,9 @@ async function build_handler(args) {
641
702
  ...status ? {
642
703
  status
643
704
  } : {},
705
+ ...entrypoints.length ? {
706
+ entrypoints
707
+ } : {},
644
708
  zip: args.zip ?? false,
645
709
  duration,
646
710
  output: lastLines(out, 12)
@@ -1050,6 +1114,13 @@ function knownSessionBrowsers(projectPath) {
1050
1114
  for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1051
1115
  return Array.from(new Set(browsers));
1052
1116
  }
1117
+ function deadReadySession(projectPath) {
1118
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1119
+ browser: sighting.browser,
1120
+ pid: sighting.pid
1121
+ };
1122
+ return null;
1123
+ }
1053
1124
  function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
1054
1125
  if (explicit) return {
1055
1126
  browser: explicit,
@@ -1094,12 +1165,11 @@ const stop_schema = {
1094
1165
  required: []
1095
1166
  }
1096
1167
  };
1097
- function profileProcessPids(projectPath, browser) {
1098
- const marker = node_path.resolve(projectPath, "dist", `extension-profile-${browser}`);
1168
+ function pgrepPids(pattern) {
1099
1169
  try {
1100
1170
  const out = execFileSync("pgrep", [
1101
1171
  "-f",
1102
- marker
1172
+ pattern
1103
1173
  ], {
1104
1174
  encoding: "utf8"
1105
1175
  });
@@ -1108,8 +1178,19 @@ function profileProcessPids(projectPath, browser) {
1108
1178
  return [];
1109
1179
  }
1110
1180
  }
1111
- function reapProfileProcesses(projectPath, browser) {
1112
- const pids = profileProcessPids(projectPath, browser);
1181
+ function sessionProcessPids(projectPath) {
1182
+ const resolved = node_path.resolve(projectPath);
1183
+ const pids = new Set();
1184
+ for (const marker of [
1185
+ `extension dev ${resolved}`,
1186
+ node_path.join(resolved, "dist")
1187
+ ])for (const pid of pgrepPids(marker))pids.add(pid);
1188
+ return [
1189
+ ...pids
1190
+ ];
1191
+ }
1192
+ function reapSessionProcesses(projectPath) {
1193
+ const pids = sessionProcessPids(projectPath);
1113
1194
  for (const pid of pids)try {
1114
1195
  process.kill(pid, "SIGKILL");
1115
1196
  } catch {}
@@ -1152,7 +1233,7 @@ async function stopOne(projectPath, browser) {
1152
1233
  const session = getSession(projectPath, browser);
1153
1234
  const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
1154
1235
  if (null == pid) {
1155
- const reaped = reapProfileProcesses(projectPath, browser);
1236
+ const reaped = reapSessionProcesses(projectPath);
1156
1237
  return {
1157
1238
  projectPath,
1158
1239
  browser,
@@ -1172,14 +1253,14 @@ async function stopOne(projectPath, browser) {
1172
1253
  }
1173
1254
  detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
1174
1255
  } else detail = "Process was already gone; cleaned up session records.";
1175
- const reaped = reapProfileProcesses(projectPath, browser);
1256
+ const reaped = reapSessionProcesses(projectPath);
1176
1257
  removeSession(projectPath, browser);
1177
1258
  try {
1178
1259
  node_fs.rmSync(readyJsonPath(projectPath, browser), {
1179
1260
  force: true
1180
1261
  });
1181
1262
  } catch {}
1182
- const survivors = profileProcessPids(projectPath, browser);
1263
+ const survivors = sessionProcessPids(projectPath);
1183
1264
  const stopped = !isAlive(pid) && 0 === survivors.length;
1184
1265
  if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
1185
1266
  else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
@@ -1413,6 +1494,10 @@ const manifest_validate_schema = {
1413
1494
  "firefox"
1414
1495
  ],
1415
1496
  description: "Browsers to validate against"
1497
+ },
1498
+ browser: {
1499
+ type: "string",
1500
+ description: "Single browser to validate against; alias for browsers:[browser] to match the other tools."
1416
1501
  }
1417
1502
  },
1418
1503
  required: []
@@ -1473,7 +1558,90 @@ function findManifest(projectPath) {
1473
1558
  }
1474
1559
  return null;
1475
1560
  }
1561
+ const API_PERMISSION = {
1562
+ storage: "storage",
1563
+ webNavigation: "webNavigation",
1564
+ history: "history",
1565
+ cookies: "cookies",
1566
+ bookmarks: "bookmarks",
1567
+ alarms: "alarms",
1568
+ contextMenus: "contextMenus",
1569
+ notifications: "notifications",
1570
+ downloads: "downloads",
1571
+ webRequest: "webRequest",
1572
+ tabGroups: "tabGroups",
1573
+ topSites: "topSites",
1574
+ idle: "idle",
1575
+ management: "management",
1576
+ scripting: "scripting",
1577
+ declarativeNetRequest: "declarativeNetRequest",
1578
+ sessions: "sessions",
1579
+ proxy: "proxy",
1580
+ tts: "tts",
1581
+ pageCapture: "pageCapture",
1582
+ desktopCapture: "desktopCapture",
1583
+ debugger: "debugger",
1584
+ geolocation: "geolocation"
1585
+ };
1586
+ const HARD_APIS = new Set([
1587
+ "history",
1588
+ "cookies",
1589
+ "bookmarks",
1590
+ "webNavigation",
1591
+ "downloads",
1592
+ "webRequest",
1593
+ "topSites",
1594
+ "management",
1595
+ "tabGroups",
1596
+ "sessions",
1597
+ "proxy",
1598
+ "debugger",
1599
+ "pageCapture",
1600
+ "desktopCapture"
1601
+ ]);
1602
+ function scanApiUsage(roots) {
1603
+ const used = new Set();
1604
+ let filesRead = 0;
1605
+ const walk = (dir, depth)=>{
1606
+ if (depth > 6 || filesRead > 300) return;
1607
+ let entries;
1608
+ try {
1609
+ entries = node_fs.readdirSync(dir, {
1610
+ withFileTypes: true
1611
+ });
1612
+ } catch {
1613
+ return;
1614
+ }
1615
+ for (const e of entries){
1616
+ if ("node_modules" === e.name || "dist" === e.name || e.name.startsWith(".")) continue;
1617
+ const full = node_path.join(dir, e.name);
1618
+ if (e.isDirectory()) {
1619
+ walk(full, depth + 1);
1620
+ continue;
1621
+ }
1622
+ if (!/\.(js|mjs|cjs|ts|tsx|jsx|svelte|vue)$/.test(e.name)) continue;
1623
+ if (filesRead++ > 300) return;
1624
+ let src;
1625
+ try {
1626
+ src = node_fs.readFileSync(full, "utf8");
1627
+ } catch {
1628
+ continue;
1629
+ }
1630
+ const re = /\b(?:chrome|browser)\.(\w+)/g;
1631
+ let m;
1632
+ while(m = re.exec(src))if (API_PERMISSION[m[1]]) used.add(m[1]);
1633
+ }
1634
+ };
1635
+ for (const root of new Set(roots))walk(root, 0);
1636
+ return used;
1637
+ }
1476
1638
  async function manifest_validate_handler(args) {
1639
+ if (!args.browsers && "string" == typeof args.browser) args = {
1640
+ ...args,
1641
+ browsers: [
1642
+ args.browser
1643
+ ]
1644
+ };
1477
1645
  const browsers = args.browsers ?? [
1478
1646
  "chrome",
1479
1647
  "firefox"
@@ -1522,6 +1690,17 @@ async function manifest_validate_handler(args) {
1522
1690
  ] : []
1523
1691
  ];
1524
1692
  for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.warnings.push(`Referenced file "${ref}" was not found near the manifest — this is the kind of dangling reference extension_build fails on. Verify with extension_build.`);
1693
+ const declaredPermSet = new Set([
1694
+ ...chromiumManifest.permissions ?? [],
1695
+ ...chromiumManifest.optional_permissions ?? []
1696
+ ].filter((p)=>"string" == typeof p));
1697
+ for (const api of scanApiUsage(roots)){
1698
+ const perm = API_PERMISSION[api];
1699
+ if (declaredPermSet.has(perm)) continue;
1700
+ const base = `Code calls chrome.${api} but "${perm}" is not in permissions`;
1701
+ if (HARD_APIS.has(api)) result.errors.push(`${base} — chrome.${api} is undefined without it and will crash the context at runtime.`);
1702
+ else result.warnings.push(`${base}; it may be undefined at runtime — add "${perm}" if you use it.`);
1703
+ }
1525
1704
  if (!chromiumManifest.manifest_version) result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1526
1705
  const declaredPerms = [
1527
1706
  ...chromiumManifest.permissions ?? [],
@@ -1580,8 +1759,11 @@ async function manifest_validate_handler(args) {
1580
1759
  surfaces: t.surfaces
1581
1760
  }));
1582
1761
  } catch {}
1583
- if (0 === result.errors.length) result.valid = Object.values(result.browserSupport).every((b)=>b.supported);
1584
- return JSON.stringify(result);
1762
+ result.valid = 0 === result.errors.length && Object.values(result.browserSupport).every((b)=>b.supported);
1763
+ return JSON.stringify({
1764
+ ...result,
1765
+ buildBlocking: result.errors.length > 0
1766
+ });
1585
1767
  }
1586
1768
  const inspect_schema = {
1587
1769
  name: "extension_inspect",
@@ -2787,10 +2969,13 @@ async function logs_handler(args) {
2787
2969
  return readFromFile(args, browser, limit);
2788
2970
  }
2789
2971
  function toMcpSpeak(text) {
2790
- 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(/--browser[= ]([\w-]+)/g, 'browser: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev");
2972
+ 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(/--context[= ]([\w-]+)/g, 'context: "$1"').replace(/--tab[= ](\d+)/g, "tab: $1").replace(/--url[= ](\S+)/g, 'url: "$1"').replace(/--browser[= ]([\w-]+)/g, 'browser: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev");
2791
2973
  }
2792
2974
  function withSessionContext(message, projectPath) {
2793
- if (!/no active control channel/i.test(message)) return message;
2975
+ const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
2976
+ if (!isControlError) return message;
2977
+ const dead = deadReadySession(projectPath);
2978
+ 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.`;
2794
2979
  const running = knownSessionBrowsers(projectPath);
2795
2980
  if (0 === running.length) return message;
2796
2981
  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.`;
@@ -3031,6 +3216,64 @@ async function reload_handler(args) {
3031
3216
  })
3032
3217
  ], args.projectPath, args.timeout);
3033
3218
  }
3219
+ async function navigateToUrl(projectPath, browser, url) {
3220
+ if (!isChromiumFamily(browser)) return JSON.stringify({
3221
+ ok: false,
3222
+ error: {
3223
+ name: "Unsupported",
3224
+ 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.`
3225
+ }
3226
+ });
3227
+ const resolved = await resolveCdpPort(projectPath, browser);
3228
+ if (!resolved) return JSON.stringify({
3229
+ ok: false,
3230
+ error: {
3231
+ name: "NoSession",
3232
+ message: `No active dev session / CDP port for ${browser}. Start extension_dev and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
3233
+ }
3234
+ });
3235
+ const cdp = new CDPClient();
3236
+ try {
3237
+ const targets = await CDPClient.discoverTargets(resolved.port);
3238
+ const pageTargets = targets.filter((t)=>"page" === t.type && !String(t.url || "").startsWith("devtools://"));
3239
+ if (0 === pageTargets.length) return JSON.stringify({
3240
+ ok: false,
3241
+ error: {
3242
+ name: "NoTab",
3243
+ message: "The dev browser has no open page tab to navigate. Trigger one (e.g. extension_open surface, or open the extension) first."
3244
+ }
3245
+ });
3246
+ const target = pageTargets[0];
3247
+ const browserWsUrl = await CDPClient.discoverBrowserWsUrl(resolved.port);
3248
+ await cdp.connect(browserWsUrl);
3249
+ const sessionId = await cdp.attachToTarget(String(target.id));
3250
+ await cdp.navigate(sessionId, url);
3251
+ await new Promise((r)=>setTimeout(r, 1200));
3252
+ const meta = await cdp.getPageMeta(sessionId).catch(()=>({}));
3253
+ return JSON.stringify({
3254
+ ok: true,
3255
+ navigated: url,
3256
+ tab: {
3257
+ id: target.id,
3258
+ title: meta.title,
3259
+ url: meta.url || url
3260
+ },
3261
+ hint: "Inspect it with extension_dom_inspect or extension_source_inspect (context: 'page')."
3262
+ });
3263
+ } catch (e) {
3264
+ return JSON.stringify({
3265
+ ok: false,
3266
+ error: {
3267
+ name: "NavigateError",
3268
+ message: e instanceof Error ? e.message : String(e)
3269
+ }
3270
+ });
3271
+ } finally{
3272
+ try {
3273
+ cdp.disconnect();
3274
+ } catch {}
3275
+ }
3276
+ }
3034
3277
  const open_schema = {
3035
3278
  name: "extension_open",
3036
3279
  description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
@@ -3056,6 +3299,10 @@ const open_schema = {
3056
3299
  type: "string",
3057
3300
  description: "For surface 'command': the chrome.commands name to trigger."
3058
3301
  },
3302
+ url: {
3303
+ type: "string",
3304
+ 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`."
3305
+ },
3059
3306
  browser: {
3060
3307
  type: "string",
3061
3308
  description: "Browser session to target. Defaults to the active dev session's browser for this project."
@@ -3066,13 +3313,20 @@ const open_schema = {
3066
3313
  }
3067
3314
  },
3068
3315
  required: [
3069
- "projectPath",
3070
- "surface"
3316
+ "projectPath"
3071
3317
  ]
3072
3318
  }
3073
3319
  };
3074
3320
  async function open_handler(args) {
3075
3321
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3322
+ if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
3323
+ if (!args.surface) return JSON.stringify({
3324
+ ok: false,
3325
+ error: {
3326
+ name: "BadRequest",
3327
+ message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
3328
+ }
3329
+ });
3076
3330
  const cli = [
3077
3331
  "open",
3078
3332
  args.surface,
@@ -3679,6 +3933,14 @@ async function deploy_handler(args) {
3679
3933
  });
3680
3934
  }
3681
3935
  const SAFE_CEILING_MS = 50000;
3936
+ function wait_isAlive(pid) {
3937
+ try {
3938
+ process.kill(pid, 0);
3939
+ return true;
3940
+ } catch {
3941
+ return false;
3942
+ }
3943
+ }
3682
3944
  const wait_schema = {
3683
3945
  name: "extension_wait",
3684
3946
  description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status. A single call is bounded to ~50s to stay under the MCP client request timeout; if it returns status:'timeout', call it again to keep waiting (polling resumes on the same contract).",
@@ -3716,18 +3978,27 @@ async function wait_handler(args) {
3716
3978
  try {
3717
3979
  const raw = node_fs.readFileSync(readyPath, "utf8");
3718
3980
  const contract = JSON.parse(raw);
3719
- if ("ready" === contract.status) return JSON.stringify({
3720
- status: "ready",
3721
- command: contract.command,
3722
- browser: contract.browser,
3723
- port: contract.port,
3724
- pid: contract.pid,
3725
- distPath: contract.distPath,
3726
- manifestPath: contract.manifestPath,
3727
- compiledAt: contract.compiledAt,
3728
- startedAt: contract.startedAt,
3729
- waitDuration: Date.now() - start
3730
- });
3981
+ if ("ready" === contract.status) {
3982
+ if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
3983
+ status: "stale",
3984
+ message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead — the session exited. Restart with extension_dev; extension_doctor will confirm.`,
3985
+ browser: contract.browser,
3986
+ pid: contract.pid,
3987
+ waitDuration: Date.now() - start
3988
+ });
3989
+ return JSON.stringify({
3990
+ status: "ready",
3991
+ command: contract.command,
3992
+ browser: contract.browser,
3993
+ port: contract.port,
3994
+ pid: contract.pid,
3995
+ distPath: contract.distPath,
3996
+ manifestPath: contract.manifestPath,
3997
+ compiledAt: contract.compiledAt,
3998
+ startedAt: contract.startedAt,
3999
+ waitDuration: Date.now() - start
4000
+ });
4001
+ }
3731
4002
  if ("error" === contract.status) return JSON.stringify({
3732
4003
  status: "error",
3733
4004
  message: contract.message,
@@ -4925,6 +5196,37 @@ async function environmentPreflight() {
4925
5196
  hint: "Pass projectPath to diagnose a live dev session end-to-end."
4926
5197
  });
4927
5198
  }
5199
+ function recentErrorLogs(projectPath, browser, max = 5) {
5200
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
5201
+ let lines;
5202
+ try {
5203
+ lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
5204
+ } catch {
5205
+ return [];
5206
+ }
5207
+ const errs = [];
5208
+ for (const line of lines){
5209
+ let ev;
5210
+ try {
5211
+ ev = JSON.parse(line);
5212
+ } catch {
5213
+ continue;
5214
+ }
5215
+ if (ev && "error" === ev.level) {
5216
+ const msg = Array.isArray(ev.args) ? ev.args.join(" ") : ev.message || ev.text || "";
5217
+ if (msg) errs.push(String(msg).slice(0, 300));
5218
+ }
5219
+ }
5220
+ return errs.slice(-max);
5221
+ }
5222
+ function projectEngineVersion(projectPath) {
5223
+ try {
5224
+ const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
5225
+ return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
5226
+ } catch {
5227
+ return null;
5228
+ }
5229
+ }
4928
5230
  async function doctor_handler(args) {
4929
5231
  if (!args.projectPath) return environmentPreflight();
4930
5232
  const projectPath = args.projectPath;
@@ -4958,9 +5260,36 @@ async function doctor_handler(args) {
4958
5260
  detail: toMcpSpeak(detail),
4959
5261
  remediation: "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
4960
5262
  });
5263
+ } else {
5264
+ const errs = recentErrorLogs(projectPath, browser);
5265
+ if (errs.length) {
5266
+ healthy = false;
5267
+ checks.push({
5268
+ check: "runtime-errors",
5269
+ status: "fail",
5270
+ detail: `Recent error-level logs: ${errs.join(" | ")}`,
5271
+ remediation: "The extension is throwing at runtime. Inspect with extension_logs; a missing permission for a called chrome.* API is a common cause (extension_manifest_validate now checks this)."
5272
+ });
5273
+ }
5274
+ }
5275
+ const engineVersion = projectEngineVersion(projectPath);
5276
+ if (engineVersion) {
5277
+ const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
5278
+ const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
5279
+ checks.push({
5280
+ check: "project-engine",
5281
+ status: mismatch ? "warn" : "pass",
5282
+ detail: `project-local extension@${engineVersion}${mismatch ? ` — but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
5283
+ ...mismatch ? {
5284
+ remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
5285
+ } : {}
5286
+ });
4961
5287
  }
4962
5288
  return JSON.stringify({
4963
5289
  browser,
5290
+ ...engineVersion ? {
5291
+ engineVersion
5292
+ } : {},
4964
5293
  healthy,
4965
5294
  checks
4966
5295
  });
@@ -5035,7 +5364,8 @@ const ARG_ALIASES = {
5035
5364
  "manifest"
5036
5365
  ],
5037
5366
  surface: [
5038
- "view"
5367
+ "view",
5368
+ "target"
5039
5369
  ]
5040
5370
  };
5041
5371
  function normalizeArgAliases(inputSchema, args) {
@@ -3,4 +3,8 @@ export interface ResolvedBrowser {
3
3
  source: "explicit" | "session" | "contract" | "fallback";
4
4
  }
5
5
  export declare function knownSessionBrowsers(projectPath: string): string[];
6
+ export declare function deadReadySession(projectPath: string): {
7
+ browser: string;
8
+ pid: number;
9
+ } | null;
6
10
  export declare function resolveSessionBrowser(projectPath: string, explicit: string | undefined, fallback?: string): ResolvedBrowser;
@@ -20,6 +20,10 @@ export declare const schema: {
20
20
  default: string[];
21
21
  description: string;
22
22
  };
23
+ browser: {
24
+ type: string;
25
+ description: string;
26
+ };
23
27
  };
24
28
  required: never[];
25
29
  };
@@ -27,5 +31,6 @@ export declare const schema: {
27
31
  export declare function handler(args: {
28
32
  manifestPath?: string;
29
33
  projectPath?: string;
34
+ browser?: string;
30
35
  browsers?: string[];
31
36
  }): Promise<string>;
@@ -18,6 +18,10 @@ export declare const schema: {
18
18
  type: string;
19
19
  description: string;
20
20
  };
21
+ url: {
22
+ type: string;
23
+ description: string;
24
+ };
21
25
  browser: {
22
26
  type: string;
23
27
  description: string;
@@ -31,6 +35,7 @@ export declare const schema: {
31
35
  };
32
36
  };
33
37
  export declare function handler(args: ActArgs & {
34
- surface: string;
38
+ surface?: string;
35
39
  name?: string;
40
+ url?: string;
36
41
  }): Promise<string>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "4.8.0",
4
+ "version": "4.9.0",
5
5
  "description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 31 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.",
6
6
  "mcpName": "io.github.extensiondev/mcp",
7
7
  "license": "MIT",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://extension.dev",
10
- "version": "4.8.0",
10
+ "version": "4.9.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": "4.8.0",
16
+ "version": "4.9.0",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"