@extension.dev/mcp 4.7.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.7.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.7.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,75 @@
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
+
41
+ ## 4.8.0
42
+
43
+ Dev-session ergonomics hardened from a 30-persona agent walk of the toolchain.
44
+
45
+ - **`allowEval` now implies `allowControl`.** Enabling eval on `extension_dev`
46
+ also opens the control channel, so a single `allowEval: true` unlocks
47
+ `extension_storage`/`reload`/`open`/`dom_inspect` too. `extension_dev` now
48
+ returns a `capabilities` block naming exactly which verbs the session unlocked,
49
+ ending the stop-and-restart loop that hit agents who passed one flag and not
50
+ the other.
51
+ - **Session-aware browser default.** `extension_stop` (and the other
52
+ browser-scoped tools) resolve the browser from the one live session for the
53
+ project instead of assuming `chrome`. `extension_stop` also reaps the launched
54
+ browser's process tree and refuses to report `stopped: true` while a process
55
+ survives, fixing orphaned browsers (notably Firefox) after a stop.
56
+ - **Forgiving argument names.** Common synonyms are accepted and normalized:
57
+ `path`/`dir` for `projectPath`, `name` for `projectName`, `template` for
58
+ `slug`, `code` for `expression`, and more, so a reasonable first guess no
59
+ longer 400s.
60
+ - **`extension_manifest_validate`** accepts `projectPath` (it finds the
61
+ manifest) and probes path-valued fields (popup, service worker, icons, content
62
+ scripts) against disk, warning on dangling references instead of a false
63
+ all-clear.
64
+ - **`extension_doctor`** inlines the dev session's own recorded errors so a build
65
+ or load failure no longer reads as healthy.
66
+ - **`extension_inspect`** lists declared entrypoints (so a small content script
67
+ is not buried under assets) and warns when a store-listing promo image is
68
+ shipped inside the package.
69
+ - **`extension_source_inspect`** on a Gecko session now names the working
70
+ alternatives (`extension_logs`, `extension_eval`) instead of pointing back at
71
+ the tool that just refused.
72
+
3
73
  ## 4.7.0
4
74
 
5
75
  `extension_deploy` now submits **through** extension.dev instead of driving a
package/dist/module.js CHANGED
@@ -7,11 +7,11 @@ import node_path, { join } from "node:path";
7
7
  import { extensionCreate } from "extension-create";
8
8
  import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
+ import { execFile, execFileSync } from "node:child_process";
10
11
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
11
12
  import ws from "ws";
12
13
  import node_http from "node:http";
13
14
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
14
- import { execFile } from "node:child_process";
15
15
  import { promisify } from "node:util";
16
16
  var add_feature_namespaceObject = {};
17
17
  __webpack_require__.r(add_feature_namespaceObject);
@@ -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.6.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)
@@ -769,7 +833,7 @@ const dev_schema = {
769
833
  allowEval: {
770
834
  type: "boolean",
771
835
  default: false,
772
- description: "Additionally enable extension_eval (runs code in a context; writes a 0600 session token)"
836
+ description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect — you do not need to pass both."
773
837
  }
774
838
  },
775
839
  required: [
@@ -779,6 +843,7 @@ const dev_schema = {
779
843
  };
780
844
  async function dev_handler(args) {
781
845
  const browser = args.browser ?? "chrome";
846
+ const allowControl = Boolean(args.allowControl || args.allowEval);
782
847
  const cliArgs = [
783
848
  "dev",
784
849
  args.projectPath,
@@ -789,7 +854,7 @@ async function dev_handler(args) {
789
854
  if (args.noBrowser) cliArgs.push("--no-browser");
790
855
  if (false === args.polyfill) cliArgs.push("--polyfill", "false");
791
856
  cliArgs.push(...launchFlagArgs(args));
792
- if (args.allowControl) cliArgs.push("--allow-control");
857
+ if (allowControl) cliArgs.push("--allow-control");
793
858
  if (args.allowEval) cliArgs.push("--allow-eval");
794
859
  const child = spawnExtensionCli(cliArgs, {
795
860
  projectDir: args.projectPath
@@ -812,13 +877,20 @@ async function dev_handler(args) {
812
877
  await new Promise((resolve)=>setTimeout(resolve, 3000));
813
878
  child.stdout?.off("data", collector);
814
879
  child.stderr?.off("data", collector);
880
+ const controlVerbs = "storage, reload, open, dom_inspect";
881
+ const capabilities = {
882
+ allowControl,
883
+ allowEval: Boolean(args.allowEval),
884
+ unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
885
+ };
815
886
  return JSON.stringify({
816
887
  pid,
817
888
  browser,
818
889
  port: args.port ?? 8080,
819
890
  projectPath: args.projectPath,
820
891
  status: "started",
821
- hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. When you are done, call extension_stop to shut down the dev server and browser.",
892
+ capabilities,
893
+ hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). Restart extension_dev with the flag you need.") + " When you are done, call extension_stop to shut down the dev server and browser.",
822
894
  earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
823
895
  });
824
896
  }
@@ -1003,6 +1075,73 @@ async function preview_handler(args) {
1003
1075
  hint: "Call extension_stop when you are done to close the preview browser."
1004
1076
  });
1005
1077
  }
1078
+ function contractSightings(projectPath) {
1079
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
1080
+ let dirs;
1081
+ try {
1082
+ dirs = node_fs.readdirSync(root);
1083
+ } catch {
1084
+ return [];
1085
+ }
1086
+ const sightings = [];
1087
+ for (const dir of dirs){
1088
+ const readyPath = node_path.join(root, dir, "ready.json");
1089
+ try {
1090
+ const stat = node_fs.statSync(readyPath);
1091
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1092
+ if (contract?.status !== "ready") continue;
1093
+ sightings.push({
1094
+ browser: dir,
1095
+ mtimeMs: stat.mtimeMs,
1096
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
1097
+ });
1098
+ } catch {}
1099
+ }
1100
+ return sightings;
1101
+ }
1102
+ function pidAlive(pid) {
1103
+ try {
1104
+ process.kill(pid, 0);
1105
+ return true;
1106
+ } catch {
1107
+ return false;
1108
+ }
1109
+ }
1110
+ function knownSessionBrowsers(projectPath) {
1111
+ const resolved = node_path.resolve(projectPath);
1112
+ const browsers = [];
1113
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1114
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1115
+ return Array.from(new Set(browsers));
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
+ }
1124
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
1125
+ if (explicit) return {
1126
+ browser: explicit,
1127
+ source: "explicit"
1128
+ };
1129
+ const resolved = node_path.resolve(projectPath);
1130
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1131
+ if (mine.length > 0) return {
1132
+ browser: mine[mine.length - 1].browser,
1133
+ source: "session"
1134
+ };
1135
+ const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1136
+ if (sightings.length > 0) return {
1137
+ browser: sightings[0].browser,
1138
+ source: "contract"
1139
+ };
1140
+ return {
1141
+ browser: fallback,
1142
+ source: "fallback"
1143
+ };
1144
+ }
1006
1145
  const stop_schema = {
1007
1146
  name: "extension_stop",
1008
1147
  description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
@@ -1015,8 +1154,7 @@ const stop_schema = {
1015
1154
  },
1016
1155
  browser: {
1017
1156
  type: "string",
1018
- default: "chrome",
1019
- description: "Browser of the session to stop (matches the browser passed to extension_dev/extension_start)"
1157
+ description: "Browser of the session to stop (matches the browser passed to extension_dev/extension_start). Defaults to the one live session for this project when omitted, instead of assuming chrome."
1020
1158
  },
1021
1159
  all: {
1022
1160
  type: "boolean",
@@ -1027,6 +1165,37 @@ const stop_schema = {
1027
1165
  required: []
1028
1166
  }
1029
1167
  };
1168
+ function pgrepPids(pattern) {
1169
+ try {
1170
+ const out = execFileSync("pgrep", [
1171
+ "-f",
1172
+ pattern
1173
+ ], {
1174
+ encoding: "utf8"
1175
+ });
1176
+ return out.split("\n").map((s)=>parseInt(s.trim(), 10)).filter((n)=>Number.isInteger(n) && n > 0 && n !== process.pid);
1177
+ } catch {
1178
+ return [];
1179
+ }
1180
+ }
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);
1194
+ for (const pid of pids)try {
1195
+ process.kill(pid, "SIGKILL");
1196
+ } catch {}
1197
+ return pids;
1198
+ }
1030
1199
  function isAlive(pid) {
1031
1200
  try {
1032
1201
  process.kill(pid, 0);
@@ -1063,13 +1232,17 @@ function pidFromReadyContract(projectPath, browser) {
1063
1232
  async function stopOne(projectPath, browser) {
1064
1233
  const session = getSession(projectPath, browser);
1065
1234
  const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
1066
- if (null == pid) return {
1067
- projectPath,
1068
- browser,
1069
- pid: null,
1070
- stopped: false,
1071
- detail: "No known session for this project/browser (nothing registered in this server and no ready.json contract found)."
1072
- };
1235
+ if (null == pid) {
1236
+ const reaped = reapSessionProcesses(projectPath);
1237
+ return {
1238
+ projectPath,
1239
+ browser,
1240
+ pid: null,
1241
+ stopped: 0 !== reaped.length,
1242
+ reaped,
1243
+ detail: 0 === reaped.length ? "No known session for this project/browser (nothing registered in this server and no ready.json contract found)." : `No dev pid on record, but reaped ${reaped.length} orphaned browser process(es) from the profile dir.`
1244
+ };
1245
+ }
1073
1246
  let detail;
1074
1247
  if (isAlive(pid)) {
1075
1248
  signal(pid, "SIGTERM");
@@ -1080,17 +1253,23 @@ async function stopOne(projectPath, browser) {
1080
1253
  }
1081
1254
  detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
1082
1255
  } else detail = "Process was already gone; cleaned up session records.";
1256
+ const reaped = reapSessionProcesses(projectPath);
1083
1257
  removeSession(projectPath, browser);
1084
1258
  try {
1085
1259
  node_fs.rmSync(readyJsonPath(projectPath, browser), {
1086
1260
  force: true
1087
1261
  });
1088
1262
  } catch {}
1263
+ const survivors = sessionProcessPids(projectPath);
1264
+ const stopped = !isAlive(pid) && 0 === survivors.length;
1265
+ if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
1266
+ else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
1089
1267
  return {
1090
1268
  projectPath,
1091
1269
  browser,
1092
1270
  pid,
1093
- stopped: !isAlive(pid),
1271
+ stopped,
1272
+ reaped,
1094
1273
  detail
1095
1274
  };
1096
1275
  }
@@ -1110,7 +1289,8 @@ async function stop_handler(args) {
1110
1289
  if (!args.projectPath) return JSON.stringify({
1111
1290
  error: "projectPath is required unless all=true. Pass the same projectPath used with extension_dev/extension_start."
1112
1291
  });
1113
- const outcome = await stopOne(args.projectPath, args.browser ?? "chrome");
1292
+ const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
1293
+ const outcome = await stopOne(args.projectPath, browser);
1114
1294
  return JSON.stringify(outcome);
1115
1295
  }
1116
1296
  const RAW_BASE = "https://raw.githubusercontent.com/extension-js/examples/main/examples";
@@ -1298,7 +1478,11 @@ const manifest_validate_schema = {
1298
1478
  properties: {
1299
1479
  manifestPath: {
1300
1480
  type: "string",
1301
- description: "Path to manifest.json"
1481
+ description: "Path to manifest.json. Or pass projectPath and the manifest is located for you."
1482
+ },
1483
+ projectPath: {
1484
+ type: "string",
1485
+ description: "Path to the extension project root; manifest.json is resolved from it (root or src/). Accepted in place of manifestPath."
1302
1486
  },
1303
1487
  browsers: {
1304
1488
  type: "array",
@@ -1310,14 +1494,154 @@ const manifest_validate_schema = {
1310
1494
  "firefox"
1311
1495
  ],
1312
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."
1313
1501
  }
1314
1502
  },
1315
- required: [
1316
- "manifestPath"
1317
- ]
1503
+ required: []
1504
+ }
1505
+ };
1506
+ function collectPathRefs(m) {
1507
+ const refs = [];
1508
+ const push = (v)=>{
1509
+ if ("string" == typeof v) refs.push(v);
1510
+ };
1511
+ const action = m.action || m.browser_action;
1512
+ if (action) {
1513
+ push(action.default_popup);
1514
+ if ("string" == typeof action.default_icon) push(action.default_icon);
1515
+ else if (action.default_icon) Object.values(action.default_icon).forEach(push);
1516
+ }
1517
+ const bg = m.background;
1518
+ if (bg) {
1519
+ push(bg.service_worker);
1520
+ push(bg.page);
1521
+ if (Array.isArray(bg.scripts)) bg.scripts.forEach(push);
1318
1522
  }
1523
+ if (m.icons) Object.values(m.icons).forEach(push);
1524
+ const cs = m.content_scripts;
1525
+ if (Array.isArray(cs)) for (const c of cs){
1526
+ if (Array.isArray(c.js)) c.js.forEach(push);
1527
+ if (Array.isArray(c.css)) c.css.forEach(push);
1528
+ }
1529
+ push(m.options_page);
1530
+ const oui = m.options_ui;
1531
+ if (oui) push(oui.page);
1532
+ const sp = m.side_panel;
1533
+ if (sp) push(sp.default_path);
1534
+ const sa = m.sidebar_action;
1535
+ if (sa) push(sa.default_panel);
1536
+ const cuo = m.chrome_url_overrides;
1537
+ if (cuo) Object.values(cuo).forEach(push);
1538
+ return refs;
1539
+ }
1540
+ function fileResolvesSomewhere(ref, roots) {
1541
+ if (!ref || ref.includes("*") || /^(https?:|data:)/i.test(ref)) return true;
1542
+ const clean = ref.replace(/^\.?\//, "");
1543
+ return roots.some((root)=>{
1544
+ try {
1545
+ return node_fs.existsSync(node_path.resolve(root, clean));
1546
+ } catch {
1547
+ return false;
1548
+ }
1549
+ });
1550
+ }
1551
+ function findManifest(projectPath) {
1552
+ for (const rel of [
1553
+ "manifest.json",
1554
+ node_path.join("src", "manifest.json")
1555
+ ]){
1556
+ const candidate = node_path.resolve(projectPath, rel);
1557
+ if (node_fs.existsSync(candidate)) return candidate;
1558
+ }
1559
+ return null;
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"
1319
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
+ }
1320
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
+ };
1321
1645
  const browsers = args.browsers ?? [
1322
1646
  "chrome",
1323
1647
  "firefox"
@@ -1329,9 +1653,20 @@ async function manifest_validate_handler(args) {
1329
1653
  browserSupport: {},
1330
1654
  similarTemplates: []
1331
1655
  };
1656
+ const manifestPath = args.manifestPath ?? (args.projectPath ? findManifest(args.projectPath) : null);
1657
+ if (!manifestPath) return JSON.stringify({
1658
+ valid: false,
1659
+ errors: [
1660
+ args.projectPath ? `No manifest.json found under ${args.projectPath} (looked in the root and src/).` : "Pass manifestPath (path to manifest.json) or projectPath (project root)."
1661
+ ],
1662
+ warnings: [],
1663
+ browserSupport: {},
1664
+ similarTemplates: []
1665
+ });
1666
+ const manifestDir = node_path.dirname(node_path.resolve(manifestPath));
1332
1667
  let manifest;
1333
1668
  try {
1334
- const raw = node_fs.readFileSync(node_path.resolve(args.manifestPath), "utf8");
1669
+ const raw = node_fs.readFileSync(node_path.resolve(manifestPath), "utf8");
1335
1670
  manifest = JSON.parse(raw);
1336
1671
  } catch (err) {
1337
1672
  return JSON.stringify({
@@ -1347,6 +1682,25 @@ async function manifest_validate_handler(args) {
1347
1682
  if (!manifest.name) result.errors.push("Missing required field: name");
1348
1683
  if (!manifest.version) result.warnings.push("Missing field: version (required for store submission)");
1349
1684
  const chromiumManifest = filterKeysForThisBrowser(manifest, "chrome");
1685
+ const roots = [
1686
+ manifestDir,
1687
+ node_path.join(manifestDir, "src"),
1688
+ ..."src" === node_path.basename(manifestDir) ? [
1689
+ node_path.dirname(manifestDir)
1690
+ ] : []
1691
+ ];
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
+ }
1350
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.');
1351
1705
  const declaredPerms = [
1352
1706
  ...chromiumManifest.permissions ?? [],
@@ -1405,8 +1759,11 @@ async function manifest_validate_handler(args) {
1405
1759
  surfaces: t.surfaces
1406
1760
  }));
1407
1761
  } catch {}
1408
- if (0 === result.errors.length) result.valid = Object.values(result.browserSupport).every((b)=>b.supported);
1409
- 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
+ });
1410
1767
  }
1411
1768
  const inspect_schema = {
1412
1769
  name: "extension_inspect",
@@ -1525,9 +1882,46 @@ async function inspect_handler(args) {
1525
1882
  const sourcemapSize = byType.sourcemap?.size ?? 0;
1526
1883
  const buildType = sourcemapSize > 0 ? "development" : "production";
1527
1884
  const shippableSize = totalSize - sourcemapSize;
1885
+ const sizeByPath = new Map(files.map((f)=>[
1886
+ f.path,
1887
+ f.size
1888
+ ]));
1889
+ const entrypoints = [];
1890
+ const addEntry = (role, ref)=>{
1891
+ if ("string" != typeof ref) return;
1892
+ const size = sizeByPath.get(ref.replace(/^\.?\//, ""));
1893
+ entrypoints.push({
1894
+ role,
1895
+ path: ref,
1896
+ present: void 0 !== size,
1897
+ ...void 0 !== size ? {
1898
+ sizeFormatted: formatBytes(size)
1899
+ } : {}
1900
+ });
1901
+ };
1902
+ const bg = manifest.background;
1903
+ if (bg?.service_worker) addEntry("background.service_worker", bg.service_worker);
1904
+ if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>addEntry("background.scripts", s));
1905
+ const actionField = manifest.action || manifest.browser_action;
1906
+ if (actionField?.default_popup) addEntry("action.default_popup", actionField.default_popup);
1907
+ const contentScripts = manifest.content_scripts;
1908
+ if (Array.isArray(contentScripts)) contentScripts.forEach((c, i)=>{
1909
+ if (Array.isArray(c.js)) c.js.forEach((j)=>addEntry(`content_scripts[${i}].js`, j));
1910
+ if (Array.isArray(c.css)) c.css.forEach((s)=>addEntry(`content_scripts[${i}].css`, s));
1911
+ });
1912
+ const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
1913
+ const sizeWarnings = [];
1914
+ for (const f of files)if ("sourcemap" !== f.type) {
1915
+ if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package — move it out of the bundled sources so it does not inflate the store zip.`);
1916
+ else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle — unusually large for a shipped asset.`);
1917
+ }
1528
1918
  const result = {
1529
1919
  browser,
1530
1920
  distPath,
1921
+ entrypoints,
1922
+ ...sizeWarnings.length ? {
1923
+ sizeWarnings
1924
+ } : {},
1531
1925
  buildType,
1532
1926
  totalSize,
1533
1927
  totalSizeFormatted: formatBytes(totalSize),
@@ -1567,6 +1961,7 @@ async function inspect_handler(args) {
1567
1961
  hasManifest: node_fs.existsSync(manifestPath),
1568
1962
  hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
1569
1963
  noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
1964
+ noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
1570
1965
  under10MB: totalSize < 10485760
1571
1966
  }
1572
1967
  };
@@ -2027,66 +2422,6 @@ function isCdpEndpoint(port) {
2027
2422
  });
2028
2423
  });
2029
2424
  }
2030
- function contractSightings(projectPath) {
2031
- const root = node_path.resolve(projectPath, "dist", "extension-js");
2032
- let dirs;
2033
- try {
2034
- dirs = node_fs.readdirSync(root);
2035
- } catch {
2036
- return [];
2037
- }
2038
- const sightings = [];
2039
- for (const dir of dirs){
2040
- const readyPath = node_path.join(root, dir, "ready.json");
2041
- try {
2042
- const stat = node_fs.statSync(readyPath);
2043
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
2044
- if (contract?.status !== "ready") continue;
2045
- sightings.push({
2046
- browser: dir,
2047
- mtimeMs: stat.mtimeMs,
2048
- pid: "number" == typeof contract.pid ? contract.pid : void 0
2049
- });
2050
- } catch {}
2051
- }
2052
- return sightings;
2053
- }
2054
- function pidAlive(pid) {
2055
- try {
2056
- process.kill(pid, 0);
2057
- return true;
2058
- } catch {
2059
- return false;
2060
- }
2061
- }
2062
- function knownSessionBrowsers(projectPath) {
2063
- const resolved = node_path.resolve(projectPath);
2064
- const browsers = [];
2065
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
2066
- for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
2067
- return Array.from(new Set(browsers));
2068
- }
2069
- function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
2070
- if (explicit) return {
2071
- browser: explicit,
2072
- source: "explicit"
2073
- };
2074
- const resolved = node_path.resolve(projectPath);
2075
- const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
2076
- if (mine.length > 0) return {
2077
- browser: mine[mine.length - 1].browser,
2078
- source: "session"
2079
- };
2080
- const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
2081
- if (sightings.length > 0) return {
2082
- browser: sightings[0].browser,
2083
- source: "contract"
2084
- };
2085
- return {
2086
- browser: fallback,
2087
- source: "fallback"
2088
- };
2089
- }
2090
2425
  const source_inspect_schema = {
2091
2426
  name: "extension_source_inspect",
2092
2427
  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.",
@@ -2157,8 +2492,8 @@ async function source_inspect_handler(args) {
2157
2492
  ]);
2158
2493
  const maxBytes = args.maxBytes ?? 262144;
2159
2494
  if (!isChromiumFamily(browser)) return JSON.stringify({
2160
- error: `Source inspection for ${browser} uses RDP (Remote Debug Protocol). Currently only Chromium CDP is supported.`,
2161
- hint: 'Pass browser: "chrome" (against a Chromium-family dev session).'
2495
+ 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.`,
2496
+ hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval (context: "content"/"page") which works against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
2162
2497
  });
2163
2498
  const resolved = await resolveCdpPort(args.projectPath, browser);
2164
2499
  if (!resolved) return JSON.stringify({
@@ -2634,10 +2969,13 @@ async function logs_handler(args) {
2634
2969
  return readFromFile(args, browser, limit);
2635
2970
  }
2636
2971
  function toMcpSpeak(text) {
2637
- 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");
2638
2973
  }
2639
2974
  function withSessionContext(message, projectPath) {
2640
- 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.`;
2641
2979
  const running = knownSessionBrowsers(projectPath);
2642
2980
  if (0 === running.length) return message;
2643
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.`;
@@ -2878,6 +3216,64 @@ async function reload_handler(args) {
2878
3216
  })
2879
3217
  ], args.projectPath, args.timeout);
2880
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
+ }
2881
3277
  const open_schema = {
2882
3278
  name: "extension_open",
2883
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`.",
@@ -2903,6 +3299,10 @@ const open_schema = {
2903
3299
  type: "string",
2904
3300
  description: "For surface 'command': the chrome.commands name to trigger."
2905
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
+ },
2906
3306
  browser: {
2907
3307
  type: "string",
2908
3308
  description: "Browser session to target. Defaults to the active dev session's browser for this project."
@@ -2913,13 +3313,20 @@ const open_schema = {
2913
3313
  }
2914
3314
  },
2915
3315
  required: [
2916
- "projectPath",
2917
- "surface"
3316
+ "projectPath"
2918
3317
  ]
2919
3318
  }
2920
3319
  };
2921
3320
  async function open_handler(args) {
2922
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
+ });
2923
3330
  const cli = [
2924
3331
  "open",
2925
3332
  args.surface,
@@ -3526,6 +3933,14 @@ async function deploy_handler(args) {
3526
3933
  });
3527
3934
  }
3528
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
+ }
3529
3944
  const wait_schema = {
3530
3945
  name: "extension_wait",
3531
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).",
@@ -3563,18 +3978,27 @@ async function wait_handler(args) {
3563
3978
  try {
3564
3979
  const raw = node_fs.readFileSync(readyPath, "utf8");
3565
3980
  const contract = JSON.parse(raw);
3566
- if ("ready" === contract.status) return JSON.stringify({
3567
- status: "ready",
3568
- command: contract.command,
3569
- browser: contract.browser,
3570
- port: contract.port,
3571
- pid: contract.pid,
3572
- distPath: contract.distPath,
3573
- manifestPath: contract.manifestPath,
3574
- compiledAt: contract.compiledAt,
3575
- startedAt: contract.startedAt,
3576
- waitDuration: Date.now() - start
3577
- });
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
+ }
3578
4002
  if ("error" === contract.status) return JSON.stringify({
3579
4003
  status: "error",
3580
4004
  message: contract.message,
@@ -4711,6 +5135,14 @@ async function detect_browsers_handler(args) {
4711
5135
  hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_install_browser to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
4712
5136
  });
4713
5137
  }
5138
+ function doctor_readReadyContract(projectPath, browser) {
5139
+ try {
5140
+ const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
5141
+ return JSON.parse(raw);
5142
+ } catch {
5143
+ return null;
5144
+ }
5145
+ }
4714
5146
  const doctor_schema = {
4715
5147
  name: "extension_doctor",
4716
5148
  description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order — a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
@@ -4764,6 +5196,37 @@ async function environmentPreflight() {
4764
5196
  hint: "Pass projectPath to diagnose a live dev session end-to-end."
4765
5197
  });
4766
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
+ }
4767
5230
  async function doctor_handler(args) {
4768
5231
  if (!args.projectPath) return environmentPreflight();
4769
5232
  const projectPath = args.projectPath;
@@ -4786,9 +5249,48 @@ async function doctor_handler(args) {
4786
5249
  if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
4787
5250
  if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
4788
5251
  }
5252
+ let healthy = 0 === code;
5253
+ const contract = doctor_readReadyContract(projectPath, browser);
5254
+ if (contract?.status === "error") {
5255
+ healthy = false;
5256
+ const detail = contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
5257
+ checks.push({
5258
+ check: "runtime-errors",
5259
+ status: "fail",
5260
+ detail: toMcpSpeak(detail),
5261
+ remediation: "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
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
+ });
5287
+ }
4789
5288
  return JSON.stringify({
4790
5289
  browser,
4791
- healthy: 0 === code,
5290
+ ...engineVersion ? {
5291
+ engineVersion
5292
+ } : {},
5293
+ healthy,
4792
5294
  checks
4793
5295
  });
4794
5296
  } catch {
@@ -4835,6 +5337,56 @@ function checkPrimitive(path, value, schema, issues) {
4835
5337
  });
4836
5338
  }
4837
5339
  }
5340
+ const ARG_ALIASES = {
5341
+ projectPath: [
5342
+ "path",
5343
+ "dir",
5344
+ "projectDir",
5345
+ "cwd"
5346
+ ],
5347
+ projectName: [
5348
+ "name"
5349
+ ],
5350
+ parentDir: [
5351
+ "parent",
5352
+ "into"
5353
+ ],
5354
+ slug: [
5355
+ "template",
5356
+ "templateSlug"
5357
+ ],
5358
+ expression: [
5359
+ "code",
5360
+ "js",
5361
+ "script"
5362
+ ],
5363
+ manifestPath: [
5364
+ "manifest"
5365
+ ],
5366
+ surface: [
5367
+ "view",
5368
+ "target"
5369
+ ]
5370
+ };
5371
+ function normalizeArgAliases(inputSchema, args) {
5372
+ const schema = inputSchema;
5373
+ const props = schema.properties ?? {};
5374
+ const out = {
5375
+ ...args
5376
+ };
5377
+ for (const [canonical, aliases] of Object.entries(ARG_ALIASES))if (canonical in props) {
5378
+ if (void 0 === out[canonical]) {
5379
+ for (const alias of aliases)if (!(alias in props)) {
5380
+ if (void 0 !== out[alias]) {
5381
+ out[canonical] = out[alias];
5382
+ delete out[alias];
5383
+ break;
5384
+ }
5385
+ }
5386
+ }
5387
+ }
5388
+ return out;
5389
+ }
4838
5390
  function validateToolInput(inputSchema, args) {
4839
5391
  const schema = inputSchema;
4840
5392
  const issues = [];
@@ -4933,7 +5485,8 @@ async function startServer() {
4933
5485
  ],
4934
5486
  isError: true
4935
5487
  };
4936
- const issues = validateToolInput(tool.schema.inputSchema, args ?? {});
5488
+ const normalizedArgs = normalizeArgAliases(tool.schema.inputSchema, args ?? {});
5489
+ const issues = validateToolInput(tool.schema.inputSchema, normalizedArgs);
4937
5490
  if (issues.length) return {
4938
5491
  content: [
4939
5492
  {
@@ -4944,7 +5497,7 @@ async function startServer() {
4944
5497
  isError: true
4945
5498
  };
4946
5499
  try {
4947
- const result = await tool.handler(args ?? {});
5500
+ const result = await tool.handler(normalizedArgs);
4948
5501
  return {
4949
5502
  content: [
4950
5503
  {
@@ -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;
@@ -2,5 +2,6 @@ export interface InputIssue {
2
2
  path: string;
3
3
  message: string;
4
4
  }
5
+ export declare function normalizeArgAliases(inputSchema: Record<string, unknown>, args: Record<string, unknown>): Record<string, unknown>;
5
6
  export declare function validateToolInput(inputSchema: Record<string, unknown>, args: Record<string, unknown>): InputIssue[];
6
7
  export declare function inputValidationError(toolName: string, issues: InputIssue[]): string;
@@ -8,6 +8,10 @@ export declare const schema: {
8
8
  type: string;
9
9
  description: string;
10
10
  };
11
+ projectPath: {
12
+ type: string;
13
+ description: string;
14
+ };
11
15
  browsers: {
12
16
  type: string;
13
17
  items: {
@@ -16,11 +20,17 @@ export declare const schema: {
16
20
  default: string[];
17
21
  description: string;
18
22
  };
23
+ browser: {
24
+ type: string;
25
+ description: string;
26
+ };
19
27
  };
20
- required: string[];
28
+ required: never[];
21
29
  };
22
30
  };
23
31
  export declare function handler(args: {
24
- manifestPath: string;
32
+ manifestPath?: string;
33
+ projectPath?: string;
34
+ browser?: string;
25
35
  browsers?: string[];
26
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>;
@@ -10,7 +10,6 @@ export declare const schema: {
10
10
  };
11
11
  browser: {
12
12
  type: string;
13
- default: string;
14
13
  description: string;
15
14
  };
16
15
  all: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "4.7.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.7.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.7.0",
16
+ "version": "4.9.0",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"