@extension.dev/mcp 5.3.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "extension-mcp",
11
11
  "source": "./",
12
12
  "description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox.",
13
- "version": "5.3.1",
13
+ "version": "5.4.0",
14
14
  "category": "development",
15
15
  "author": {
16
16
  "name": "Cezar Augusto"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "extension-mcp",
3
3
  "description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox. Ships /extension, /extension-add, /extension-debug, and /extension-publish commands.",
4
- "version": "5.3.1",
4
+ "version": "5.4.0",
5
5
  "author": {
6
6
  "name": "Cezar Augusto",
7
7
  "email": "boss@cezaraugusto.net",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.4.0
4
+
5
+ The DevX swarm's non-blocker friction clusters, cleared: waiting is
6
+ narrated, ports tell the truth, artifacts say where they are, and error
7
+ prose explains the extension instead of the engine.
8
+
9
+ ### Added
10
+
11
+ - `extension_wait` narrates its budget. `timeoutMs` is a documented
12
+ argument (default 45000, clamped 1000 to 50000; the legacy `timeout`
13
+ spelling stays as a deprecated alias), every result carries `budgetMs`
14
+ and `elapsedMs`, and a timeout says what WAS observed plus a
15
+ call-again hint instead of an opaque failure. The status splits
16
+ `compiled` from `browserAttached`, and a `noBrowser` build-only
17
+ session returns immediately with `buildOnly: true` and a plain
18
+ statement that no browser will ever attach.
19
+ - `extension_build` with `zip: true` returns `zipPath`, the absolute
20
+ path of the file the engine actually wrote (its name sanitizer strips
21
+ punctuation, so the filename rarely matches the project name), and
22
+ `zipSourcePath` for `zipSource: true`. When a zip cannot be located
23
+ after a successful build the result says so in `zipPathNote` instead
24
+ of omitting the field silently.
25
+
26
+ ### Fixed
27
+
28
+ - `extension_dev` reports the actually-bound port. The engine's
29
+ ready.json carries the bound port from its first stamp, so dev reads
30
+ it after the health window and re-registers the session with the true
31
+ port; when the stamp has not landed yet, dev claims no port at all
32
+ and says `extension_wait` reports the bound one. dev and wait now
33
+ share one source of truth and cannot disagree about the same session.
34
+ - `extension_build` warns when it writes over a live dev session's
35
+ dist: the dev browser may serve the production artifact until the
36
+ next recompile. The build is never blocked; the clobber is named.
37
+ - `extension_inspect` classifies `.zip` files as archives and excludes
38
+ them from `shippableSize` and the 10MB store gate (the package is not
39
+ payload), with an `archiveNote` explaining the exclusion.
40
+ - `extension_whoami` anchors its identity to the stored token that
41
+ `extension_login` minted: it does not follow the current working
42
+ directory, and the wording now says so.
43
+ - `extension_open` explains a popup-less extension instead of relaying
44
+ the engine error: a manifest pre-check reports that nothing sets
45
+ `action.default_popup`, lists the surfaces the manifest DOES declare,
46
+ and points at the right next verb. Headless popup errors state the
47
+ exact headed path (`extension_dev` with `replace: true` and
48
+ `EXTENSION_HEADLESS=0`).
49
+ - `extension_dev`'s `earlyOutput` drops V8 asm.js verdict lines (pure
50
+ noise); real errors are preserved.
51
+
3
52
  ## 5.3.1
4
53
 
5
54
  ### Added
package/dist/module.js CHANGED
@@ -210,7 +210,7 @@ __webpack_require__.d(whoami_namespaceObject, {
210
210
  handler: ()=>whoami_handler,
211
211
  schema: ()=>whoami_schema
212
212
  });
213
- var package_namespaceObject = JSON.parse('{"rE":"5.3.1","El":{"OP":"^4.0.13"}}');
213
+ var package_namespaceObject = JSON.parse('{"rE":"5.4.0","El":{"OP":"^4.0.13"}}');
214
214
  function scaffoldEnginePin(projectPath) {
215
215
  try {
216
216
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -623,6 +623,185 @@ function spawnExtensionCli(args, options) {
623
623
  }
624
624
  };
625
625
  }
626
+ const sessions = new Map();
627
+ function sessionKey(projectPath, browser) {
628
+ return `${node_path.resolve(projectPath)}::${browser}`;
629
+ }
630
+ function markerDir() {
631
+ return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
632
+ }
633
+ function markerPath(projectPath, browser) {
634
+ const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
635
+ return node_path.join(markerDir(), `${digest}.json`);
636
+ }
637
+ function removeSessionMarker(projectPath, browser) {
638
+ try {
639
+ node_fs.rmSync(markerPath(projectPath, browser), {
640
+ force: true
641
+ });
642
+ } catch {}
643
+ }
644
+ function listSessionMarkers() {
645
+ let files;
646
+ try {
647
+ files = node_fs.readdirSync(markerDir());
648
+ } catch {
649
+ return [];
650
+ }
651
+ const out = [];
652
+ for (const file of files)if (file.endsWith(".json")) try {
653
+ const parsed = JSON.parse(node_fs.readFileSync(node_path.join(markerDir(), file), "utf8"));
654
+ if ("string" == typeof parsed?.projectPath && "string" == typeof parsed?.browser) out.push(parsed);
655
+ } catch {}
656
+ return out;
657
+ }
658
+ function registerSession(info) {
659
+ sessions.set(sessionKey(info.projectPath, info.browser), info);
660
+ try {
661
+ node_fs.mkdirSync(markerDir(), {
662
+ recursive: true
663
+ });
664
+ node_fs.writeFileSync(markerPath(info.projectPath, info.browser), JSON.stringify({
665
+ ...info,
666
+ projectPath: node_path.resolve(info.projectPath),
667
+ registeredAt: new Date().toISOString()
668
+ }));
669
+ } catch {}
670
+ }
671
+ function getSession(projectPath, browser) {
672
+ return sessions.get(sessionKey(projectPath, browser));
673
+ }
674
+ function findSessionInfo(projectPath, browser) {
675
+ const inMemory = sessions.get(sessionKey(projectPath, browser));
676
+ if (inMemory) return inMemory;
677
+ const resolved = node_path.resolve(projectPath);
678
+ for (const marker of listSessionMarkers())if (node_path.resolve(marker.projectPath) === resolved && marker.browser === browser) return marker;
679
+ }
680
+ function removeSession(projectPath, browser) {
681
+ sessions.delete(sessionKey(projectPath, browser));
682
+ }
683
+ function listSessions() {
684
+ return Array.from(sessions.values());
685
+ }
686
+ function contractSightings(projectPath) {
687
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
688
+ let dirs;
689
+ try {
690
+ dirs = node_fs.readdirSync(root);
691
+ } catch {
692
+ return [];
693
+ }
694
+ const sightings = [];
695
+ for (const dir of dirs){
696
+ const readyPath = node_path.join(root, dir, "ready.json");
697
+ try {
698
+ const stat = node_fs.statSync(readyPath);
699
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
700
+ if (contract?.status !== "ready") continue;
701
+ sightings.push({
702
+ browser: dir,
703
+ mtimeMs: stat.mtimeMs,
704
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
705
+ });
706
+ } catch {}
707
+ }
708
+ return sightings;
709
+ }
710
+ function pidAlive(pid) {
711
+ try {
712
+ process.kill(pid, 0);
713
+ return true;
714
+ } catch {
715
+ return false;
716
+ }
717
+ }
718
+ function knownSessionBrowsers(projectPath) {
719
+ const resolved = node_path.resolve(projectPath);
720
+ const browsers = [];
721
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
722
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
723
+ return Array.from(new Set(browsers));
724
+ }
725
+ function liveProjectSessions(projectPath) {
726
+ const resolved = node_path.resolve(projectPath);
727
+ const out = new Map();
728
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) {
729
+ if (pidAlive(session.pid)) out.set(session.browser, {
730
+ browser: session.browser,
731
+ pid: session.pid,
732
+ source: "registry"
733
+ });
734
+ }
735
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && pidAlive(sighting.pid)) {
736
+ if (!out.has(sighting.browser)) out.set(sighting.browser, {
737
+ browser: sighting.browser,
738
+ pid: sighting.pid,
739
+ source: "contract"
740
+ });
741
+ }
742
+ return [
743
+ ...out.values()
744
+ ];
745
+ }
746
+ function deadReadySession(projectPath) {
747
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
748
+ browser: sighting.browser,
749
+ pid: sighting.pid
750
+ };
751
+ return null;
752
+ }
753
+ function browserExitStamp(projectPath, browser, since) {
754
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
755
+ try {
756
+ const stat = node_fs.statSync(readyPath);
757
+ if (stat.mtimeMs < since) return null;
758
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
759
+ const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
760
+ if (contract?.status === "error" && exited) return {
761
+ code: contract.code,
762
+ browserExitCode: contract.browserExitCode ?? null,
763
+ browserExitedAt: contract.browserExitedAt
764
+ };
765
+ } catch {}
766
+ return null;
767
+ }
768
+ function contractBoundPort(projectPath, browser, since) {
769
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
770
+ try {
771
+ const stat = node_fs.statSync(readyPath);
772
+ if (stat.mtimeMs < since) return null;
773
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
774
+ return "number" == typeof contract?.port && Number.isFinite(contract.port) ? contract.port : null;
775
+ } catch {
776
+ return null;
777
+ }
778
+ }
779
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
780
+ if (explicit) return {
781
+ browser: explicit,
782
+ source: "explicit"
783
+ };
784
+ const resolved = node_path.resolve(projectPath);
785
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
786
+ if (mine.length > 0) return {
787
+ browser: mine[mine.length - 1].browser,
788
+ source: "session"
789
+ };
790
+ const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
791
+ const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
792
+ if (live.length > 0) return {
793
+ browser: live[0].browser,
794
+ source: "contract"
795
+ };
796
+ if (sightings.length > 0) return {
797
+ browser: sightings[0].browser,
798
+ source: "stale"
799
+ };
800
+ return {
801
+ browser: fallback,
802
+ source: "fallback"
803
+ };
804
+ }
626
805
  function readBuildSummary(projectPath, browser, since) {
627
806
  const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
628
807
  try {
@@ -678,6 +857,46 @@ function builtEntrypoints(distDir) {
678
857
  });
679
858
  return out;
680
859
  }
860
+ function engineSanitize(input) {
861
+ return input.toLowerCase().replace(/[^a-z0-9 ]/gi, "").trim().replace(/\s+/g, "-");
862
+ }
863
+ function newestZip(dir, since, match) {
864
+ try {
865
+ const fresh = node_fs.readdirSync(dir).filter((name)=>name.endsWith(".zip") && (!match || match(name))).map((name)=>{
866
+ const full = node_path.join(dir, name);
867
+ return {
868
+ full,
869
+ mtimeMs: node_fs.statSync(full).mtimeMs
870
+ };
871
+ }).filter((entry)=>entry.mtimeMs >= since).sort((a, b)=>b.mtimeMs - a.mtimeMs);
872
+ return fresh[0]?.full ?? null;
873
+ } catch {
874
+ return null;
875
+ }
876
+ }
877
+ function engineZipBase(distDir, projectPath) {
878
+ let manifest = {};
879
+ try {
880
+ manifest = JSON.parse(node_fs.readFileSync(node_path.join(distDir, "manifest.json"), "utf8"));
881
+ } catch {}
882
+ const rawName = "string" != typeof manifest.name || /^__MSG_.+__$/.test(manifest.name) ? node_path.basename(node_path.resolve(projectPath)) : manifest.name;
883
+ const version = "string" == typeof manifest.version && manifest.version ? manifest.version : "0.0.0";
884
+ return `${engineSanitize(rawName)}-${version}`;
885
+ }
886
+ function locateDistZip(projectPath, browser, zipFilename, since) {
887
+ const distDir = node_path.resolve(projectPath, "dist", browser);
888
+ const base = zipFilename ? engineSanitize(zipFilename) : engineZipBase(distDir, projectPath);
889
+ const expected = node_path.join(distDir, `${base}.zip`);
890
+ if (node_fs.existsSync(expected)) return expected;
891
+ return newestZip(distDir, since);
892
+ }
893
+ function locateSourceZip(projectPath, browser, since) {
894
+ const distDir = node_path.resolve(projectPath, "dist", browser);
895
+ const distRoot = node_path.resolve(projectPath, "dist");
896
+ const expected = node_path.join(distRoot, `${engineZipBase(distDir, projectPath)}-source.zip`);
897
+ if (node_fs.existsSync(expected)) return expected;
898
+ return newestZip(distRoot, since, (name)=>name.endsWith("-source.zip"));
899
+ }
681
900
  const build_schema = {
682
901
  name: "extension_build",
683
902
  description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
@@ -816,6 +1035,8 @@ async function build_handler(args) {
816
1035
  duration: Date.now() - start,
817
1036
  hint: "Fix the errors above, then build again. Run extension_manifest_validate for the full report. To build anyway (for example to inspect the broken output), pass skipValidation: true."
818
1037
  });
1038
+ const clobberedSessions = liveProjectSessions(args.projectPath).filter((session)=>session.browser === browser);
1039
+ const warnings = clobberedSessions.map((session)=>`A live dev session (pid ${session.pid}) is running on this project for ${browser}, and this build wrote over its dist/${browser} output. The dev browser may now serve the production artifact instead of the dev build until the next recompile. Run extension_stop, or let dev recompile on the next source change, to resolve it.`);
819
1040
  const cliArgs = [
820
1041
  "build",
821
1042
  args.projectPath,
@@ -858,6 +1079,9 @@ async function build_handler(args) {
858
1079
  manifestWarnings: preflight.warnings
859
1080
  } : {},
860
1081
  ...buildWarnings,
1082
+ ...warnings.length ? {
1083
+ warnings
1084
+ } : {},
861
1085
  duration,
862
1086
  output: lastLines(out, 12),
863
1087
  hint: "The bundler exited 0 but did not emit these files. Check that the manifest paths match what the build produces, and that nothing references a file outside the source tree."
@@ -884,7 +1108,26 @@ async function build_handler(args) {
884
1108
  productionDivergence: divergence
885
1109
  } : {};
886
1110
  })(),
1111
+ ...warnings.length ? {
1112
+ warnings
1113
+ } : {},
887
1114
  zip: args.zip ?? false,
1115
+ ...args.zip ? (()=>{
1116
+ const zipPath = locateDistZip(args.projectPath, browser, args.zipFilename, start);
1117
+ return zipPath ? {
1118
+ zipPath
1119
+ } : {
1120
+ zipPathNote: `zip: true was requested and the build succeeded, but no .zip file could be located in dist/${browser}. The engine may not have packaged it; check the build output below.`
1121
+ };
1122
+ })() : {},
1123
+ ...args.zipSource ? (()=>{
1124
+ const zipSourcePath = locateSourceZip(args.projectPath, browser, start);
1125
+ return zipSourcePath ? {
1126
+ zipSourcePath
1127
+ } : {
1128
+ zipSourcePathNote: "zipSource: true was requested and the build succeeded, but no *-source.zip file could be located in dist/. The engine may not have packaged it; check the build output below."
1129
+ };
1130
+ })() : {},
888
1131
  duration,
889
1132
  output: lastLines(out, 12)
890
1133
  });
@@ -894,172 +1137,13 @@ async function build_handler(args) {
894
1137
  success: false,
895
1138
  browser,
896
1139
  error: message.slice(0, 1200),
1140
+ ...warnings.length ? {
1141
+ warnings
1142
+ } : {},
897
1143
  duration,
898
1144
  hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
899
1145
  });
900
1146
  }
901
- const sessions = new Map();
902
- function sessionKey(projectPath, browser) {
903
- return `${node_path.resolve(projectPath)}::${browser}`;
904
- }
905
- function markerDir() {
906
- return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
907
- }
908
- function markerPath(projectPath, browser) {
909
- const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
910
- return node_path.join(markerDir(), `${digest}.json`);
911
- }
912
- function removeSessionMarker(projectPath, browser) {
913
- try {
914
- node_fs.rmSync(markerPath(projectPath, browser), {
915
- force: true
916
- });
917
- } catch {}
918
- }
919
- function listSessionMarkers() {
920
- let files;
921
- try {
922
- files = node_fs.readdirSync(markerDir());
923
- } catch {
924
- return [];
925
- }
926
- const out = [];
927
- for (const file of files)if (file.endsWith(".json")) try {
928
- const parsed = JSON.parse(node_fs.readFileSync(node_path.join(markerDir(), file), "utf8"));
929
- if ("string" == typeof parsed?.projectPath && "string" == typeof parsed?.browser) out.push(parsed);
930
- } catch {}
931
- return out;
932
- }
933
- function registerSession(info) {
934
- sessions.set(sessionKey(info.projectPath, info.browser), info);
935
- try {
936
- node_fs.mkdirSync(markerDir(), {
937
- recursive: true
938
- });
939
- node_fs.writeFileSync(markerPath(info.projectPath, info.browser), JSON.stringify({
940
- ...info,
941
- projectPath: node_path.resolve(info.projectPath),
942
- registeredAt: new Date().toISOString()
943
- }));
944
- } catch {}
945
- }
946
- function getSession(projectPath, browser) {
947
- return sessions.get(sessionKey(projectPath, browser));
948
- }
949
- function removeSession(projectPath, browser) {
950
- sessions.delete(sessionKey(projectPath, browser));
951
- }
952
- function listSessions() {
953
- return Array.from(sessions.values());
954
- }
955
- function contractSightings(projectPath) {
956
- const root = node_path.resolve(projectPath, "dist", "extension-js");
957
- let dirs;
958
- try {
959
- dirs = node_fs.readdirSync(root);
960
- } catch {
961
- return [];
962
- }
963
- const sightings = [];
964
- for (const dir of dirs){
965
- const readyPath = node_path.join(root, dir, "ready.json");
966
- try {
967
- const stat = node_fs.statSync(readyPath);
968
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
969
- if (contract?.status !== "ready") continue;
970
- sightings.push({
971
- browser: dir,
972
- mtimeMs: stat.mtimeMs,
973
- pid: "number" == typeof contract.pid ? contract.pid : void 0
974
- });
975
- } catch {}
976
- }
977
- return sightings;
978
- }
979
- function pidAlive(pid) {
980
- try {
981
- process.kill(pid, 0);
982
- return true;
983
- } catch {
984
- return false;
985
- }
986
- }
987
- function knownSessionBrowsers(projectPath) {
988
- const resolved = node_path.resolve(projectPath);
989
- const browsers = [];
990
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
991
- for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
992
- return Array.from(new Set(browsers));
993
- }
994
- function liveProjectSessions(projectPath) {
995
- const resolved = node_path.resolve(projectPath);
996
- const out = new Map();
997
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) {
998
- if (pidAlive(session.pid)) out.set(session.browser, {
999
- browser: session.browser,
1000
- pid: session.pid,
1001
- source: "registry"
1002
- });
1003
- }
1004
- for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && pidAlive(sighting.pid)) {
1005
- if (!out.has(sighting.browser)) out.set(sighting.browser, {
1006
- browser: sighting.browser,
1007
- pid: sighting.pid,
1008
- source: "contract"
1009
- });
1010
- }
1011
- return [
1012
- ...out.values()
1013
- ];
1014
- }
1015
- function deadReadySession(projectPath) {
1016
- for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1017
- browser: sighting.browser,
1018
- pid: sighting.pid
1019
- };
1020
- return null;
1021
- }
1022
- function browserExitStamp(projectPath, browser, since) {
1023
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1024
- try {
1025
- const stat = node_fs.statSync(readyPath);
1026
- if (stat.mtimeMs < since) return null;
1027
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1028
- const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1029
- if (contract?.status === "error" && exited) return {
1030
- code: contract.code,
1031
- browserExitCode: contract.browserExitCode ?? null,
1032
- browserExitedAt: contract.browserExitedAt
1033
- };
1034
- } catch {}
1035
- return null;
1036
- }
1037
- function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1038
- if (explicit) return {
1039
- browser: explicit,
1040
- source: "explicit"
1041
- };
1042
- const resolved = node_path.resolve(projectPath);
1043
- const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1044
- if (mine.length > 0) return {
1045
- browser: mine[mine.length - 1].browser,
1046
- source: "session"
1047
- };
1048
- const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1049
- const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
1050
- if (live.length > 0) return {
1051
- browser: live[0].browser,
1052
- source: "contract"
1053
- };
1054
- if (sightings.length > 0) return {
1055
- browser: sightings[0].browser,
1056
- source: "stale"
1057
- };
1058
- return {
1059
- browser: fallback,
1060
- source: "fallback"
1061
- };
1062
- }
1063
1147
  const stop_schema = {
1064
1148
  name: "extension_stop",
1065
1149
  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.",
@@ -1380,7 +1464,8 @@ async function dev_handler(args) {
1380
1464
  browser,
1381
1465
  port: args.port,
1382
1466
  projectPath: args.projectPath,
1383
- command: "dev"
1467
+ command: "dev",
1468
+ noBrowser: Boolean(args.noBrowser)
1384
1469
  });
1385
1470
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1386
1471
  await new Promise((resolve)=>setTimeout(resolve, 3000));
@@ -1437,11 +1522,30 @@ async function dev_handler(args) {
1437
1522
  allowEval: Boolean(args.allowEval),
1438
1523
  unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
1439
1524
  };
1525
+ const boundPort = contractBoundPort(args.projectPath, browser, spawnedAt);
1526
+ if (null !== boundPort && boundPort !== args.port) registerSession({
1527
+ pid,
1528
+ browser,
1529
+ port: boundPort,
1530
+ projectPath: args.projectPath,
1531
+ command: "dev",
1532
+ noBrowser: Boolean(args.noBrowser)
1533
+ });
1534
+ const portReport = null !== boundPort ? {
1535
+ port: boundPort,
1536
+ ...void 0 !== args.port && args.port !== boundPort ? {
1537
+ requestedPort: args.port,
1538
+ portNote: `Requested port ${args.port} was not available; the dev server bound ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).`
1539
+ } : {}
1540
+ } : {
1541
+ requestedPort: args.port ?? 8080,
1542
+ portNote: "The engine has not stamped its ready.json contract yet, so the bound port is not known at response time (a taken port makes the server bind the next free one). extension_wait reports the bound port from that contract once it lands; requestedPort above is only what was asked for."
1543
+ };
1440
1544
  return JSON.stringify({
1441
1545
  ok: true,
1442
1546
  pid,
1443
1547
  browser,
1444
- port: args.port ?? 8080,
1548
+ ...portReport,
1445
1549
  projectPath: args.projectPath,
1446
1550
  status: "started",
1447
1551
  ...replaced.length > 0 ? {
@@ -1451,7 +1555,7 @@ async function dev_handler(args) {
1451
1555
  } : {}
1452
1556
  } : {},
1453
1557
  capabilities,
1454
- 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). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1558
+ hint: args.noBrowser ? "Build-only session (noBrowser: true): no browser will launch, so no runtime will ever attach. extension_wait returns as soon as the first compile lands (compiled: true, browserAttached: false) instead of waiting out its budget; do not wait for a browser. The control verbs (storage/reload/open/dom_inspect/eval) need a live browser and will not work against this session. When you are done, call extension_stop to shut down the dev server." : "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). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1455
1559
  earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1456
1560
  logPath
1457
1561
  });
@@ -1463,7 +1567,10 @@ function denoiseEarlyOutput(raw) {
1463
1567
  /^npm warn config/i,
1464
1568
  /V8: .*Invalid asm\.js/i,
1465
1569
  /^\(node:\d+\) V8:/i,
1466
- /Use `node --trace-warnings/i
1570
+ /Use `node --trace-warnings/i,
1571
+ /Invalid asm\.js:/i,
1572
+ /Linking failure in asm\.js/i,
1573
+ /Successfully compiled asm\.js/i
1467
1574
  ];
1468
1575
  return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
1469
1576
  }
@@ -2307,6 +2414,9 @@ function walkDir(dir, base = "") {
2307
2414
  else if ([
2308
2415
  ".map"
2309
2416
  ].includes(ext)) type = "sourcemap";
2417
+ else if ([
2418
+ ".zip"
2419
+ ].includes(ext)) type = "archive";
2310
2420
  entries.push({
2311
2421
  path: rel,
2312
2422
  size: stat.size,
@@ -2347,7 +2457,8 @@ async function inspect_handler(args) {
2347
2457
  }
2348
2458
  const sourcemapSize = byType.sourcemap?.size ?? 0;
2349
2459
  const buildType = sourcemapSize > 0 ? "development" : "production";
2350
- const shippableSize = totalSize - sourcemapSize;
2460
+ const archiveSize = byType.archive?.size ?? 0;
2461
+ const shippableSize = totalSize - sourcemapSize - archiveSize;
2351
2462
  const sizeByPath = new Map(files.map((f)=>[
2352
2463
  f.path,
2353
2464
  f.size
@@ -2377,7 +2488,7 @@ async function inspect_handler(args) {
2377
2488
  });
2378
2489
  const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
2379
2490
  const sizeWarnings = [];
2380
- for (const f of files)if ("sourcemap" !== f.type) {
2491
+ for (const f of files)if ("sourcemap" !== f.type && "archive" !== f.type) {
2381
2492
  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.`);
2382
2493
  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.`);
2383
2494
  }
@@ -2397,6 +2508,9 @@ async function inspect_handler(args) {
2397
2508
  ..."development" === buildType ? {
2398
2509
  note: `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.`
2399
2510
  } : {},
2511
+ ...archiveSize > 0 ? {
2512
+ archiveNote: `This dist contains ${formatBytes(archiveSize)} of .zip archive(s) (store packaging output, written into dist by zip builds). shippableSize excludes them so the packaged copy does not double-count the files it contains.`
2513
+ } : {},
2400
2514
  manifest: {
2401
2515
  name: manifest.name,
2402
2516
  version: manifest.version,
@@ -2429,7 +2543,7 @@ async function inspect_handler(args) {
2429
2543
  has128Icon: "string" == typeof manifest.icons?.["128"],
2430
2544
  noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
2431
2545
  noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
2432
- under10MB: totalSize < 10485760
2546
+ under10MB: totalSize - archiveSize < 10485760
2433
2547
  }
2434
2548
  };
2435
2549
  return JSON.stringify(result);
@@ -3909,6 +4023,57 @@ function surfaceDocument(projectPath, browser, surface) {
3909
4023
  }
3910
4024
  return null;
3911
4025
  }
4026
+ const SURFACE_MANIFEST_KEYS = {
4027
+ popup: "action.default_popup",
4028
+ options: "options_ui.page (or options_page)",
4029
+ sidebar: "side_panel.default_path (or sidebar_action.default_panel)",
4030
+ newtab: "chrome_url_overrides.newtab",
4031
+ history: "chrome_url_overrides.history",
4032
+ bookmarks: "chrome_url_overrides.bookmarks"
4033
+ };
4034
+ function declaredSurfaces(projectPath, browser) {
4035
+ const candidates = [
4036
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
4037
+ node_path.join(projectPath, "dist", "manifest.json"),
4038
+ node_path.join(projectPath, "src", "manifest.json"),
4039
+ node_path.join(projectPath, "manifest.json")
4040
+ ];
4041
+ const readable = candidates.some((file)=>{
4042
+ try {
4043
+ JSON.parse(node_fs.readFileSync(file, "utf8"));
4044
+ return true;
4045
+ } catch {
4046
+ return false;
4047
+ }
4048
+ });
4049
+ if (!readable) return null;
4050
+ return Object.keys(SURFACE_MANIFEST_KEYS).filter((s)=>null !== surfaceDocument(projectPath, browser, s));
4051
+ }
4052
+ function missingSurfaceError(projectPath, browser, surface, consequence) {
4053
+ const declared = declaredSurfaces(projectPath, browser);
4054
+ if (null === declared) return JSON.stringify({
4055
+ ok: false,
4056
+ error: {
4057
+ name: "NoSurfaceDocument",
4058
+ message: `No readable manifest was found for this project (checked dist/${browser}, dist, src, and the project root), so the ${surface} document cannot be resolved.`
4059
+ },
4060
+ hint: "Check projectPath, or build the project first (extension_build)."
4061
+ });
4062
+ const key = SURFACE_MANIFEST_KEYS[surface] ?? surface;
4063
+ const others = declared.filter((s)=>s !== surface);
4064
+ const nextVerb = "popup" === surface ? 'To exercise the toolbar button of a popup-less extension, call extension_open with surface: "action", which replays chrome.action.onClicked. To give the extension a popup, set action.default_popup in the manifest and rebuild.' : `To add one, set ${key} in the manifest and rebuild.`;
4065
+ return JSON.stringify({
4066
+ ok: false,
4067
+ error: {
4068
+ name: "NoSurfaceDocument",
4069
+ message: `This extension declares no ${surface}: nothing in its manifest sets ${key}, ${consequence}. That is how the extension is built, not a failure of the session or the tooling.`
4070
+ },
4071
+ ...others.length ? {
4072
+ declaredSurfaces: others
4073
+ } : {},
4074
+ hint: (others.length ? `Surfaces this extension does declare: ${others.join(", ")}; extension_open can target those. ` : "The manifest declares no other UI surface documents either. ") + nextVerb
4075
+ });
4076
+ }
3912
4077
  const POPUP_MIN = 25;
3913
4078
  const POPUP_MAX_WIDTH = 800;
3914
4079
  const POPUP_MAX_HEIGHT = 600;
@@ -3967,14 +4132,7 @@ async function applyPopupBounds(projectPath, browser, targetId) {
3967
4132
  }
3968
4133
  async function openSurfaceAsTab(projectPath, browser, surface) {
3969
4134
  const doc = surfaceDocument(projectPath, browser, surface);
3970
- if (!doc) return JSON.stringify({
3971
- ok: false,
3972
- error: {
3973
- name: "NoSurfaceDocument",
3974
- message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
3975
- },
3976
- hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
3977
- });
4135
+ if (!doc) return missingSurfaceError(projectPath, browser, surface, "so there is no page to render as a tab");
3978
4136
  const id = await resolveExtensionId(projectPath, browser);
3979
4137
  if (!id) return JSON.stringify({
3980
4138
  ok: false,
@@ -4087,6 +4245,10 @@ async function open_handler(args) {
4087
4245
  hint: declared.length ? `Declared commands are: ${declared.join(", ")}. Check for a typo, or add "${args.name}" to the manifest.` : "This manifest declares no commands at all. Add a `commands` block, rebuild, then retry."
4088
4246
  });
4089
4247
  }
4248
+ if ("popup" === args.surface) {
4249
+ const declared = declaredSurfaces(args.projectPath, browser);
4250
+ if (declared && !declared.includes("popup")) return missingSurfaceError(args.projectPath, browser, "popup", "so there is no popup to open");
4251
+ }
4090
4252
  const cli = [
4091
4253
  "open",
4092
4254
  args.surface,
@@ -4110,12 +4272,12 @@ async function open_handler(args) {
4110
4272
  try {
4111
4273
  const parsedFallback = JSON.parse(fallback);
4112
4274
  if (parsedFallback?.ok) {
4113
- parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS), which has no window to attach a popup/sidebar to, so the surface was rendered as a tab instead. Pass asTab: false and relaunch headed for a real popup window.";
4275
+ parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS=1), and a real popup/sidebar window can only open in a headed session, so the surface was rendered as a tab instead. For the real window, start a headed session: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment, then open the surface again without asTab.";
4114
4276
  return JSON.stringify(parsedFallback);
4115
4277
  }
4116
4278
  } catch {}
4117
4279
  }
4118
- if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Retry with asTab: true to render the surface document in a tab, or relaunch a headed dev session for a real popup window.";
4280
+ if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : "The dev browser is running headless (EXTENSION_HEADLESS=1), and a popup/sidebar window needs a headed session. Retry with asTab: true to render the surface document in a tab, or start a headed session for the real window: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment.";
4119
4281
  return JSON.stringify(parsed);
4120
4282
  }
4121
4283
  } catch {}
@@ -5227,6 +5389,8 @@ async function doctor_handler(args) {
5227
5389
  }
5228
5390
  }
5229
5391
  const SAFE_CEILING_MS = 50000;
5392
+ const DEFAULT_TIMEOUT_MS = 45000;
5393
+ const MIN_TIMEOUT_MS = 1000;
5230
5394
  function wait_isAlive(pid) {
5231
5395
  try {
5232
5396
  process.kill(pid, 0);
@@ -5237,7 +5401,7 @@ function wait_isAlive(pid) {
5237
5401
  }
5238
5402
  const wait_schema = {
5239
5403
  name: "extension_wait",
5240
- 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).",
5404
+ description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status with two separate facts: compiled (the compiler finished) and browserAttached (the extension's runtime connected from a live browser). Every result reports budgetMs (this call's wait budget) and elapsedMs; on status:'timeout' call again to keep waiting (polling resumes on the same contract). In a noBrowser (build-only) session it returns as soon as the compile lands instead of waiting for a browser that will never attach. Ports in the result come from the ready contract, so they always match what the dev server actually bound.",
5241
5405
  inputSchema: {
5242
5406
  type: "object",
5243
5407
  properties: {
@@ -5249,10 +5413,14 @@ const wait_schema = {
5249
5413
  type: "string",
5250
5414
  description: "Browser to check readiness for. Defaults to the active dev session's browser for this project."
5251
5415
  },
5416
+ timeoutMs: {
5417
+ type: "number",
5418
+ default: DEFAULT_TIMEOUT_MS,
5419
+ description: `Wait budget in milliseconds for this call. Default ${DEFAULT_TIMEOUT_MS}; clamped to ${MIN_TIMEOUT_MS}-${SAFE_CEILING_MS} (the ceiling keeps one call under the MCP client's 60s request timeout). On timeout the result reports elapsedMs plus what was observed (compiled, browserAttached); call again to keep waiting, polling resumes on the same contract.`
5420
+ },
5252
5421
  timeout: {
5253
5422
  type: "number",
5254
- default: 45000,
5255
- description: "Requested wait in milliseconds. Clamped to ~50s per call so it never exceeds the MCP client request timeout; call again to keep waiting for slower builds."
5423
+ description: "Deprecated alias of timeoutMs, kept for callers that already pass it. timeoutMs wins when both are given."
5256
5424
  }
5257
5425
  },
5258
5426
  required: [
@@ -5262,26 +5430,47 @@ const wait_schema = {
5262
5430
  };
5263
5431
  async function wait_handler(args) {
5264
5432
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
5265
- const requested = args.timeout ?? 45000;
5266
- const timeout = Math.min(Math.max(requested, 1000), SAFE_CEILING_MS);
5433
+ const requested = args.timeoutMs ?? args.timeout ?? DEFAULT_TIMEOUT_MS;
5434
+ const budgetMs = Math.min(Math.max(requested, MIN_TIMEOUT_MS), SAFE_CEILING_MS);
5267
5435
  const clamped = requested > SAFE_CEILING_MS;
5268
5436
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
5437
+ const buildOnly = findSessionInfo(args.projectPath, browser)?.noBrowser === true;
5269
5438
  const start = Date.now();
5270
5439
  const pollInterval = 1000;
5271
5440
  let sawCompiledButUnattached = false;
5272
- while(Date.now() - start < timeout){
5441
+ let lastContractStatus = null;
5442
+ while(Date.now() - start < budgetMs){
5273
5443
  try {
5274
5444
  const raw = node_fs.readFileSync(readyPath, "utf8");
5275
5445
  const contract = JSON.parse(raw);
5446
+ lastContractStatus = contract.status;
5276
5447
  if ("ready" === contract.status) {
5277
5448
  if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
5278
5449
  status: "stale",
5279
5450
  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.`,
5280
5451
  browser: contract.browser,
5281
5452
  pid: contract.pid,
5282
- waitDuration: Date.now() - start
5453
+ budgetMs,
5454
+ elapsedMs: Date.now() - start
5283
5455
  });
5284
5456
  const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
5457
+ if (!attached && buildOnly) return JSON.stringify({
5458
+ status: "ready",
5459
+ buildOnly: true,
5460
+ compiled: true,
5461
+ browserAttached: false,
5462
+ message: "Build-only session (noBrowser): the extension compiled and the dev server is live, but no browser was launched, so browserAttached will never become true. Do not call extension_wait again to wait for a browser. The control verbs (storage/reload/open/dom_inspect/eval) need a live browser and will not work against this session.",
5463
+ command: contract.command,
5464
+ browser: contract.browser,
5465
+ port: contract.port,
5466
+ pid: contract.pid,
5467
+ distPath: contract.distPath,
5468
+ manifestPath: contract.manifestPath,
5469
+ compiledAt: contract.compiledAt,
5470
+ startedAt: contract.startedAt,
5471
+ budgetMs,
5472
+ elapsedMs: Date.now() - start
5473
+ });
5285
5474
  if (!attached) {
5286
5475
  await new Promise((r)=>setTimeout(r, pollInterval));
5287
5476
  sawCompiledButUnattached = true;
@@ -5290,6 +5479,8 @@ async function wait_handler(args) {
5290
5479
  const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
5291
5480
  return JSON.stringify({
5292
5481
  status: "ready",
5482
+ compiled: true,
5483
+ browserAttached: true,
5293
5484
  command: contract.command,
5294
5485
  browser: contract.browser,
5295
5486
  port: contract.port,
@@ -5298,7 +5489,8 @@ async function wait_handler(args) {
5298
5489
  manifestPath: contract.manifestPath,
5299
5490
  compiledAt: contract.compiledAt,
5300
5491
  startedAt: contract.startedAt,
5301
- waitDuration: Date.now() - start,
5492
+ budgetMs,
5493
+ elapsedMs: Date.now() - start,
5302
5494
  ...runtimeErrors.length ? {
5303
5495
  runtimeErrors,
5304
5496
  warning: `Compiled and attached, but the extension is throwing at runtime (${runtimeErrors.length} recent error event${1 === runtimeErrors.length ? "" : "s"} above). Check extension_logs (level: error) or extension_doctor before trusting this session.`
@@ -5311,23 +5503,30 @@ async function wait_handler(args) {
5311
5503
  errors: contract.errors,
5312
5504
  code: contract.code,
5313
5505
  browser: contract.browser,
5314
- waitDuration: Date.now() - start
5506
+ budgetMs,
5507
+ elapsedMs: Date.now() - start
5315
5508
  });
5316
5509
  } catch {}
5317
5510
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
5318
5511
  }
5319
5512
  if (sawCompiledButUnattached) return JSON.stringify({
5320
5513
  status: "compiled-not-attached",
5321
- message: `The extension compiled, but the runtime executor never attached within ${timeout}ms. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
5514
+ compiled: true,
5515
+ browserAttached: false,
5516
+ message: `The extension compiled, but the runtime executor never attached within this call's ${budgetMs}ms budget. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
5322
5517
  readyPath,
5323
- waitDuration: Date.now() - start,
5518
+ budgetMs,
5519
+ elapsedMs: Date.now() - start,
5324
5520
  hint: "This is usually transient: call extension_wait again. If it persists, stop and restart the session with extension_dev (a restart reliably reattaches); extension_doctor reports the executor leg."
5325
5521
  });
5326
5522
  return JSON.stringify({
5327
5523
  status: "timeout",
5328
- message: `Extension not ready after ${timeout}ms this call`,
5524
+ compiled: false,
5525
+ browserAttached: false,
5526
+ message: "starting" === lastContractStatus ? `Not ready after ${budgetMs}ms this call: the dev server stamped its contract (status: starting) but the first compile has not landed yet.` : `Not ready after ${budgetMs}ms this call: no ready contract was observed at ${readyPath}, so neither the compile nor a browser attach has been seen.`,
5329
5527
  readyPath,
5330
- waitDuration: Date.now() - start,
5528
+ budgetMs,
5529
+ elapsedMs: Date.now() - start,
5331
5530
  clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
5332
5531
  hint: "Still building, call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
5333
5532
  });
@@ -5936,7 +6135,7 @@ async function login_handler(args) {
5936
6135
  }
5937
6136
  const whoami_schema = {
5938
6137
  name: "extension_whoami",
5939
- description: "Report the locally stored extension.dev login (workspace/project and token expiry) without revealing the token. Returns logged-out status when no credentials are stored.",
6138
+ description: "Report the identity carried by the locally stored extension.dev token that extension_login minted (workspace/project scoped), plus its expiry, without revealing the token. The identity comes from that stored token alone; it does not change with the current working directory or whichever project folder you are in. Returns logged-out status when no credentials are stored.",
5940
6139
  inputSchema: {
5941
6140
  type: "object",
5942
6141
  properties: {}
@@ -5961,7 +6160,7 @@ async function whoami_handler() {
5961
6160
  expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
5962
6161
  expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
5963
6162
  expired,
5964
- message: expired ? "Stored token has expired. Run extension_login to refresh it." : `Logged in to ${creds.workspaceSlug}/${creds.projectSlug}.`
6163
+ message: expired ? "The stored token has expired. Run extension_login to refresh it." : `Logged in as ${creds.workspaceSlug}/${creds.projectSlug}, per the token extension_login stored on this machine. That token is what scopes the identity: it does not follow the current working directory or project folder.`
5965
6164
  });
5966
6165
  }
5967
6166
  const logout_schema = {
@@ -3,5 +3,6 @@ export declare function removeSessionMarker(projectPath: string, browser: string
3
3
  export declare function listSessionMarkers(): ProcessInfo[];
4
4
  export declare function registerSession(info: ProcessInfo): void;
5
5
  export declare function getSession(projectPath: string, browser: string): ProcessInfo | undefined;
6
+ export declare function findSessionInfo(projectPath: string, browser: string): ProcessInfo | undefined;
6
7
  export declare function removeSession(projectPath: string, browser: string): void;
7
8
  export declare function listSessions(): ProcessInfo[];
@@ -19,4 +19,5 @@ export interface BrowserExitStamp {
19
19
  browserExitedAt?: string;
20
20
  }
21
21
  export declare function browserExitStamp(projectPath: string, browser: string, since: number): BrowserExitStamp | null;
22
+ export declare function contractBoundPort(projectPath: string, browser: string, since: number): number | null;
22
23
  export declare function resolveSessionBrowser(projectPath: string, explicit: string | undefined, fallback?: string): ResolvedBrowser;
@@ -43,7 +43,7 @@ export interface TemplatesMetaV2 {
43
43
  templates: TemplateMeta[];
44
44
  }
45
45
  export interface ReadyContract {
46
- status: "ready" | "error" | "stopped";
46
+ status: "starting" | "ready" | "error" | "stopped";
47
47
  message?: string;
48
48
  errors?: string[];
49
49
  code?: string;
@@ -56,9 +56,13 @@ export interface ReadyContract {
56
56
  distPath?: string;
57
57
  manifestPath?: string;
58
58
  port?: number | null;
59
+ host?: string;
60
+ cdpPort?: number;
59
61
  pid?: number;
60
62
  ts?: string;
61
63
  compiledAt?: string | null;
64
+ executorAttachedAt?: string;
65
+ runtime?: string;
62
66
  }
63
67
  export interface ProcessInfo {
64
68
  pid: number;
@@ -66,5 +70,6 @@ export interface ProcessInfo {
66
70
  port?: number;
67
71
  projectPath: string;
68
72
  command: "dev" | "start" | "preview";
73
+ noBrowser?: boolean;
69
74
  }
70
75
  export type BrowserType = "chrome" | "edge" | "firefox" | "chromium-based" | "gecko-based";
@@ -12,11 +12,15 @@ export declare const schema: {
12
12
  type: string;
13
13
  description: string;
14
14
  };
15
- timeout: {
15
+ timeoutMs: {
16
16
  type: string;
17
17
  default: number;
18
18
  description: string;
19
19
  };
20
+ timeout: {
21
+ type: string;
22
+ description: string;
23
+ };
20
24
  };
21
25
  required: string[];
22
26
  };
@@ -24,5 +28,6 @@ export declare const schema: {
24
28
  export declare function handler(args: {
25
29
  projectPath: string;
26
30
  browser?: string;
31
+ timeoutMs?: number;
27
32
  timeout?: number;
28
33
  }): Promise<string>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "5.3.1",
4
+ "version": "5.4.0",
5
5
  "description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 32 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": "5.3.1",
10
+ "version": "5.4.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@extension.dev/mcp",
16
- "version": "5.3.1",
16
+ "version": "5.4.0",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"