@extension.dev/mcp 10.2.0 → 10.3.1

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 50+ 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": "10.2.0",
13
+ "version": "10.3.1",
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 50+ 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": "10.2.0",
4
+ "version": "10.3.1",
5
5
  "author": {
6
6
  "name": "Cezar Augusto",
7
7
  "email": "hello@extension.dev",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 10.3.1
4
+
5
+ The 10.3.0 tarball shipped two comments a stranger could read: an HTML
6
+ comment in the popup markup naming a private package, and an eight line
7
+ internal roadmap comment plus two inline script comments in the live
8
+ preview sandbox page. Both were removed at source right after that
9
+ release. This release exists to take them off the registry, because a
10
+ published tarball cannot be edited in place.
11
+
12
+ - The popup markup no longer names any private package.
13
+ - `extensions/live-preview/chromium/sandbox/page-0.html` ships exactly
14
+ the markup it runs, with no comments at all.
15
+
16
+ ## 10.3.0
17
+
18
+ Four fixes for places where a tool answered confidently about something it
19
+ had not actually checked: a login refusal that never said where the slugs
20
+ come from, a doctor report labelled with a browser that had no session, a
21
+ preview probe that certified whatever was listening on the port, and a
22
+ release gate that passed on a stale bundle.
23
+
24
+ - `extension_auth login` refused a malformed `project` without saying
25
+ where the two slugs come from. Both the refusal and the input schema now
26
+ name the console address bar as the source: an existing project's page is
27
+ `console.extension.dev/<workspace>/<project>`, and a project that does
28
+ not exist yet is created at extension.dev/new first.
29
+ - `extension_doctor` diagnoses the session that exists, not a hardcoded
30
+ default. The shared session resolver only counts contracts marked
31
+ "ready", which is exactly wrong for the tool you reach for when a session
32
+ failed: doctor fell back to chrome, labelled the report with it, read the
33
+ absent contract, and returned a healthy verdict over a session that had
34
+ errored. When there is no ready session, the browser with a contract on
35
+ disk wins, newest first and whatever its status.
36
+ - `extension_preview_web` certifies the probe against the build the call
37
+ just minted instead of against the port. Anything can be listening on the
38
+ local preview port, and the old probe read `hostReachable` and
39
+ `previewLoadable` as true off whatever JSON came back, then echoed that
40
+ server's `identifier`, name and version into the envelope unmarked, which
41
+ made any local server a text channel into the agent's context. The name
42
+ and version must now match the `dist` manifest read off disk, and the
43
+ identifier is echoed only when it matches the derivation the real
44
+ middleware uses. A mismatch returns the status
45
+ `host-serving-different-artifact` with `previewLoadable: false`, and
46
+ anything the host claimed that could not be confirmed locally travels
47
+ clipped under `probe.hostReported`, named as the host's claim.
48
+ - The release gate asserts the version the built bundle exports rather than
49
+ grepping the bundle for the version string. The grep greenwashed: the
50
+ bundle carries the pinned engine version and fifty-two `"version":"1.0.0"`
51
+ strings from the template corpus, so a release cut at any of those
52
+ numbers passed the gate over a stale `dist`. `dist/module.js` now exports
53
+ the manifest version it inlined at build time, and the gate reads it back
54
+ and compares.
55
+
3
56
  ## 10.2.0
4
57
 
5
58
  A production walk of the full share-and-publish loop, then a fix for every
package/README.md CHANGED
@@ -127,7 +127,7 @@ cp node_modules/@extension.dev/mcp/claude/commands/*.md ~/my-extension/.claude/c
127
127
  | platform | `extension_shares` | List every link you have shared, and revoke one permanently |
128
128
  | platform | `extension_publish` | Publish a shareable preview to extension.dev |
129
129
  | platform | `extension_release_promote` | Promote a build to a release channel, headless |
130
- | platform | `extension_submit` | Submit for store review: Chrome, Firefox, Edge, Safari, through extension.dev |
130
+ | platform | `extension_submit` | Submit for store review: Chrome, Firefox and Edge, through extension.dev |
131
131
  | platform | `extension_release_status` | Read release channels, recent builds, and store submission and review state |
132
132
 
133
133
  Browser-launching tools (`dev`, `start`) shell out to the `extension` CLI, the project's own `node_modules/.bin/extension` when present, otherwise `npx extension@<pinned>` at the version this package is verified against; everything else runs in-process.
@@ -142,7 +142,7 @@ That is a different job from shipping. Use `share` for the build you are holding
142
142
 
143
143
  ## From preview to store
144
144
 
145
- The platform tools connect agents to [extension.dev](https://extension.dev): `extension_auth` runs extension.dev's own device flow (you approve the code at [extension.dev/device](https://extension.dev/device), and GitHub is federated server-side, so no GitHub token ever reaches your machine) and stores a project-scoped token locally (never returned to the agent), `extension_publish` turns a build your project has already published into a shareable URL, and `extension_release_promote` promotes a tested build to a release channel from CI or an agent session, no browser required. `extension_submit` submits a built extension to the Chrome Web Store, Edge Add-ons, Firefox AMO, and the App Store for Safari through extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI, it defaults to a dry run and store credentials are never tool arguments. The two verbs are not interchangeable: `extension_publish` pushes to the extension.dev platform, `extension_submit` sends the build into a store's review queue, which is irreversible. After a real submission, `extension_release_status` reads the recorded outcome, per-store credential health, and review state from the project's public registry, so agents and CI can answer "was it approved?" without a console visit. Access tokens live at most 7 days; CI pipelines re-mint them from the console's Access tokens page.
145
+ The platform tools connect agents to [extension.dev](https://extension.dev): `extension_auth` runs extension.dev's own device flow (you approve the code at [extension.dev/device](https://extension.dev/device), and GitHub is federated server-side, so no GitHub token ever reaches your machine) and stores a project-scoped token locally (never returned to the agent), `extension_publish` turns a build your project has already published into a shareable URL, and `extension_release_promote` promotes a tested build to a release channel from CI or an agent session, no browser required. `extension_submit` submits a built extension to the Chrome Web Store, Edge Add-ons, and Firefox AMO through extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI, it defaults to a dry run and store credentials are never tool arguments. Safari and the App Store are one paid lane on the platform, so a free workspace is refused there and the other three stores are unaffected. The two verbs are not interchangeable: `extension_publish` pushes to the extension.dev platform, `extension_submit` sends the build into a store's review queue, which is irreversible. After a real submission, `extension_release_status` reads the recorded outcome, per-store credential health, and review state from the project's public registry, so agents and CI can answer "was it approved?" without a console visit. Access tokens live at most 7 days; CI pipelines re-mint them from the console's Access tokens page.
146
146
 
147
147
  ## The extension.dev stack
148
148
 
package/dist/module.d.ts CHANGED
@@ -1 +1 @@
1
- export { startServer, runCli } from "./src/index";
1
+ export { startServer, runCli, version } from "./src/index";
package/dist/module.js CHANGED
@@ -582,7 +582,7 @@ __webpack_require__.d(wait_namespaceObject, {
582
582
  handler: ()=>wait_handler,
583
583
  schema: ()=>wait_schema
584
584
  });
585
- var package_namespaceObject = JSON.parse('{"rE":"10.2.0","El":{"OP":"4.0.20"}}');
585
+ var package_namespaceObject = JSON.parse('{"rE":"10.3.1","El":{"OP":"4.0.20"}}');
586
586
  const ENVELOPE_SCHEMA = 1;
587
587
  const collectWarnings = (warnings)=>{
588
588
  if (!warnings) return [];
@@ -3530,6 +3530,26 @@ async function pollBootVerdict(projectPath, browser, options) {
3530
3530
  warnings: []
3531
3531
  };
3532
3532
  }
3533
+ const SYSTEM_PROFILE_ARG = "false";
3534
+ function holdsState(dir) {
3535
+ try {
3536
+ return node_fs.readdirSync(dir).length > 0;
3537
+ } catch {
3538
+ return false;
3539
+ }
3540
+ }
3541
+ function profileCarriesTabsOver(projectPath, browser, profileArg) {
3542
+ const raw = "string" == typeof profileArg ? profileArg.trim() : "";
3543
+ if (raw === SYSTEM_PROFILE_ARG) return true;
3544
+ if (raw) return holdsState(node_path.resolve(projectPath, raw));
3545
+ return holdsState(node_path.join(browserProfileRootDir(projectPath, browser), PERSISTED_PROFILE_DIR_NAME));
3546
+ }
3547
+ function sessionProfileReused(projectPath, browser) {
3548
+ return findSessionInfo(projectPath, browser)?.profileReused === true;
3549
+ }
3550
+ function restoredTabWarning(url) {
3551
+ return `No target was given and this session runs on a browser profile that already held a previous session's state, so ${url} may be a tab restored from that previous session rather than anything this run opened. Nothing here proves the page belongs to the extension you just started: pass url, or open a surface with extension_open, and inspect again.`;
3552
+ }
3533
3553
  function sweepCarriers(projectPaths) {
3534
3554
  const out = [];
3535
3555
  const seen = new Set();
@@ -3870,6 +3890,7 @@ function launchFlagArgs(args) {
3870
3890
  if (args.extensions?.length) cli.push("--extensions", args.extensions.join(","));
3871
3891
  return cli;
3872
3892
  }
3893
+ const REUSED_PROFILE_NOTE = "This session reuses a browser profile that already held a previous session's state, so the browser may restore that session's tabs alongside the extension. extension_inspect with no target ranks whatever is open and can land on one of them; it flags this session as profileReused for that reason. Pass url, or open the surface you mean with extension_open, before reading anything.";
3873
3894
  const dev_schema = {
3874
3895
  name: "extension_dev",
3875
3896
  description: "Run the extension while you edit it: dev build, hot module replacement, and a browser with the extension loaded. Reach for this first when the ask is \"run my extension\". ONLY this tool unlocks the control channel that extension_storage, extension_reload, extension_open and extension_dom_snapshot need (allowControl:true) and the eval channel that extension_eval needs (allowEval:true, which implies allowControl, so you never need to pass both). Use extension_start instead to run the production build in a browser. The result carries the process info that extension_wait and extension_inspect need.",
@@ -3954,6 +3975,7 @@ async function dev_handler(args) {
3954
3975
  }
3955
3976
  const carrier = args.carrier ? materializeCarrier(args.projectPath, browser) : null;
3956
3977
  const allowControl = Boolean(args.allowControl || args.allowEval);
3978
+ const profileReused = profileCarriesTabsOver(args.projectPath, browser, args.profile);
3957
3979
  const cliArgs = [
3958
3980
  "dev",
3959
3981
  args.projectPath,
@@ -3979,7 +4001,8 @@ async function dev_handler(args) {
3979
4001
  port: args.port,
3980
4002
  projectPath: args.projectPath,
3981
4003
  command: "dev",
3982
- noBrowser: Boolean(args.noBrowser)
4004
+ noBrowser: Boolean(args.noBrowser),
4005
+ profileReused
3983
4006
  });
3984
4007
  child.on("exit", ()=>{
3985
4008
  removeSession(args.projectPath, browser, pid);
@@ -4104,7 +4127,8 @@ async function dev_handler(args) {
4104
4127
  port: boundPort,
4105
4128
  projectPath: args.projectPath,
4106
4129
  command: "dev",
4107
- noBrowser: Boolean(args.noBrowser)
4130
+ noBrowser: Boolean(args.noBrowser),
4131
+ profileReused
4108
4132
  });
4109
4133
  const portReport = null !== boundPort ? {
4110
4134
  port: boundPort,
@@ -4138,6 +4162,7 @@ async function dev_handler(args) {
4138
4162
  },
4139
4163
  warnings: [
4140
4164
  portNote,
4165
+ profileReused && !args.noBrowser ? REUSED_PROFILE_NOTE : null,
4141
4166
  ...boot.warnings
4142
4167
  ],
4143
4168
  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_snapshot/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_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_snapshot 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."
@@ -4192,6 +4217,7 @@ async function start_handler(args) {
4192
4217
  if (args.noBrowser) cliArgs.push("--no-browser");
4193
4218
  cliArgs.push(...launchFlagArgs(args));
4194
4219
  const stale = removeCarrier(args.projectPath);
4220
+ const profileReused = profileCarriesTabsOver(args.projectPath, browser, args.profile);
4195
4221
  const spawnedAt = Date.now();
4196
4222
  const spawned = spawnExtensionCli(cliArgs, {
4197
4223
  projectDir: args.projectPath
@@ -4203,7 +4229,8 @@ async function start_handler(args) {
4203
4229
  pid,
4204
4230
  browser,
4205
4231
  projectPath: args.projectPath,
4206
- command
4232
+ command,
4233
+ profileReused
4207
4234
  });
4208
4235
  child.on("exit", ()=>{
4209
4236
  removeSession(args.projectPath, browser, pid);
@@ -5742,6 +5769,7 @@ async function publish(options = {}) {
5742
5769
  }
5743
5770
  const ARTIFACT_ID = /^gen_(?:[0-9a-f]{32}|[0-9a-f]{64})$/;
5744
5771
  const ARTIFACT_ID_CANDIDATE = /gen_[0-9a-f]+/;
5772
+ const ZIP_URL_REDIRECT_NOTE = "zipUrl does not serve the archive itself: it answers 302 with a short-lived presigned storage URL in Location. Follow redirects when you fetch it (curl -L; fetch and most HTTP clients already do), because a client that does not follow them reads 0 bytes and reports the share as empty when it is not.";
5745
5773
  function parseArtifactRef(input) {
5746
5774
  const raw = String(input ?? "").trim();
5747
5775
  if (!raw) return null;
@@ -6305,6 +6333,14 @@ function safeHostBase(raw) {
6305
6333
  base: trimmed
6306
6334
  };
6307
6335
  }
6336
+ const HOST_CLAIM_MAX_CHARS = 120;
6337
+ function clipHostClaim(value) {
6338
+ return value.length > HOST_CLAIM_MAX_CHARS ? value.slice(0, HOST_CLAIM_MAX_CHARS) : value;
6339
+ }
6340
+ function expectedPreviewIdentifier(name, distDir) {
6341
+ const base = (name ?? node_path.basename(distDir)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "extension";
6342
+ return base.startsWith("local-") ? base : `local-${base}`;
6343
+ }
6308
6344
  const SURFACE = {
6309
6345
  defaultOrigin: DEFAULT_PREVIEW_DEV_URL,
6310
6346
  scheme: (encoded)=>`preview://build/${encoded}`,
@@ -6434,7 +6470,7 @@ async function buildShare(projectPath, distDir, manifest, browser, verifyInBrows
6434
6470
  browserLoadable: null
6435
6471
  },
6436
6472
  record,
6437
- note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the handle that pulls this link early. Re-sharing an unchanged build returns this same link rather than a second one, and only a revoked link is replaced by a different one, so " + (record.recorded ? `it was also written to ${record.path} (record.path), which lists every share from this project.` : `keep it: ${record.note}`) + " To find this link again later, or to pull it back once it has left this conversation, run extension_shares: it lists every link this token has shared with its live or dead state, and revokes one by artifactId or by pasting any of its URLs." + (record.warning ? ` ${record.warning}` : "")
6473
+ note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the handle that pulls this link early. Re-sharing an unchanged build returns this same link rather than a second one, and only a revoked link is replaced by a different one, so " + (record.recorded ? `it was also written to ${record.path} (record.path), which lists every share from this project.` : `keep it: ${record.note}`) + " To find this link again later, or to pull it back once it has left this conversation, run extension_shares: it lists every link this token has shared with its live or dead state, and revokes one by artifactId or by pasting any of its URLs." + (result.data.zipUrl ? ` ${ZIP_URL_REDIRECT_NOTE}` : "") + (record.warning ? ` ${record.warning}` : "")
6438
6474
  };
6439
6475
  }
6440
6476
  function detectSurfaces(manifest) {
@@ -6651,7 +6687,14 @@ async function preview_web_handler(args) {
6651
6687
  }
6652
6688
  });
6653
6689
  const contentType = res.headers.get("content-type") ?? "";
6654
- if (!res.ok || !contentType.includes("application/json")) return envelope_envelope({
6690
+ let raw = null;
6691
+ if (res.ok && contentType.includes("application/json")) try {
6692
+ raw = await res.json();
6693
+ } catch {
6694
+ raw = null;
6695
+ }
6696
+ const payload = raw && "object" == typeof raw && !Array.isArray(raw) ? raw : null;
6697
+ if (!payload) return envelope_envelope({
6655
6698
  ok: true,
6656
6699
  command: preview_web_COMMAND,
6657
6700
  status: "host-not-serving-preview",
@@ -6670,7 +6713,62 @@ async function preview_web_handler(args) {
6670
6713
  `${SURFACE.label} answered but not with a preview payload. On the deployed host ${SURFACE.fetchPath} does not exist, because it is dev-only. ${remedy}`
6671
6714
  ]
6672
6715
  });
6673
- const payload = await res.json();
6716
+ const localName = "string" == typeof manifest.name ? manifest.name : null;
6717
+ const localVersion = "string" == typeof manifest.version ? manifest.version : null;
6718
+ const remoteName = "string" == typeof payload.manifest?.name ? payload.manifest.name : null;
6719
+ const remoteVersion = "string" == typeof payload.version ? payload.version : null;
6720
+ const remoteIdentifier = "string" == typeof payload.identifier ? payload.identifier : null;
6721
+ const fileCount = Array.isArray(payload.files) ? payload.files.length : 0;
6722
+ const matchesDist = (null === localName || remoteName === localName) && (null === localVersion || remoteVersion === localVersion);
6723
+ if (!matchesDist) {
6724
+ const hostReported = {
6725
+ ...null !== remoteIdentifier ? {
6726
+ identifier: clipHostClaim(remoteIdentifier)
6727
+ } : {},
6728
+ ...null !== remoteName ? {
6729
+ name: clipHostClaim(remoteName)
6730
+ } : {},
6731
+ ...null !== remoteVersion ? {
6732
+ version: clipHostClaim(remoteVersion)
6733
+ } : {}
6734
+ };
6735
+ return envelope_envelope({
6736
+ ok: true,
6737
+ command: preview_web_COMMAND,
6738
+ status: "host-serving-different-artifact",
6739
+ value: {
6740
+ ...result,
6741
+ hostReachable: true,
6742
+ previewLoadable: false,
6743
+ probe: {
6744
+ method: "server-fetch",
6745
+ provesBrowserLoad: false,
6746
+ matchesDist: false,
6747
+ fileCount,
6748
+ ...Object.keys(hostReported).length ? {
6749
+ hostReported
6750
+ } : {}
6751
+ }
6752
+ },
6753
+ hint,
6754
+ warnings: [
6755
+ ...previewWarnings,
6756
+ `Something answered at ${hostBase}${SURFACE.fetchPath} but described a different artifact than the build this call minted, so previewLoadable stays false. probe.hostReported carries that host's own claims, clipped and unverified, not facts about your build; another local server on this port is the usual cause. ${remedy}`
6757
+ ]
6758
+ });
6759
+ }
6760
+ const identifierConfirmed = null !== remoteIdentifier && remoteIdentifier === expectedPreviewIdentifier(localName, distDir);
6761
+ const unconfirmed = {
6762
+ ...!identifierConfirmed && null !== remoteIdentifier ? {
6763
+ identifier: clipHostClaim(remoteIdentifier)
6764
+ } : {},
6765
+ ...null === localName && null !== remoteName ? {
6766
+ name: clipHostClaim(remoteName)
6767
+ } : {},
6768
+ ...null === localVersion && null !== remoteVersion ? {
6769
+ version: clipHostClaim(remoteVersion)
6770
+ } : {}
6771
+ };
6674
6772
  return envelope_envelope({
6675
6773
  ok: true,
6676
6774
  command: preview_web_COMMAND,
@@ -6681,12 +6779,22 @@ async function preview_web_handler(args) {
6681
6779
  previewLoadable: true,
6682
6780
  previewLoadableLane: "local-dev-host",
6683
6781
  probe: {
6684
- identifier: payload.identifier,
6685
- loadedName: payload.manifest?.name,
6686
- loadedVersion: payload.version,
6687
- fileCount: Array.isArray(payload.files) ? payload.files.length : 0,
6782
+ ...identifierConfirmed ? {
6783
+ identifier: remoteIdentifier
6784
+ } : {},
6785
+ ...null !== localName ? {
6786
+ loadedName: localName
6787
+ } : {},
6788
+ ...null !== localVersion ? {
6789
+ loadedVersion: localVersion
6790
+ } : {},
6791
+ fileCount,
6688
6792
  method: "server-fetch",
6689
- provesBrowserLoad: false
6793
+ provesBrowserLoad: false,
6794
+ matchesDist: true,
6795
+ ...Object.keys(unconfirmed).length ? {
6796
+ hostReported: unconfirmed
6797
+ } : {}
6690
6798
  }
6691
6799
  },
6692
6800
  hint,
@@ -6930,6 +7038,7 @@ async function listShares(args) {
6930
7038
  } : {},
6931
7039
  status: localOnlyStatus(entry, completeness, liveFiltered, now)
6932
7040
  }));
7041
+ const handsOutAZip = shares.some((share)=>Boolean(share.zipUrl)) || localOnly.some((entry)=>Boolean(entry.zipUrl));
6933
7042
  const liveCount = shares.filter((share)=>share.live).length;
6934
7043
  const ownership = {
6935
7044
  project: shares.filter((s)=>"project" === s.attribution.ownership).length,
@@ -6966,6 +7075,7 @@ async function listShares(args) {
6966
7075
  warnings: [
6967
7076
  "previewUrl and zipUrl are null for a share that is no longer live, because a revoked or expired link cannot resolve for anyone. revokeUrl stays on every row. Revocation is permanent: a revoked id is burned, and re-sharing that build mints a different link. Re-sharing a build that was NOT revoked returns its existing link instead.",
6968
7077
  "attribution.ownership says who the share belongs to and therefore who may revoke it: project means the owning workspace holds it and any member can pull it back, personal means one person holds it alone. attribution.credit names the publisher and is attribution only, granting and restricting nothing. A credit of \"CLI token ...\" means the platform could not resolve which human minted that token, and a credit of \"not recorded\" means it never knew; neither is a name, and neither should be reported as one.",
7078
+ handsOutAZip ? ZIP_URL_REDIRECT_NOTE : null,
6969
7079
  truncatedNote
6970
7080
  ]
6971
7081
  });
@@ -9172,6 +9282,10 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
9172
9282
  if (jsLooking.length) probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
9173
9283
  }
9174
9284
  const urlFilter = args.url ?? ("string" == typeof value.meta?.url ? value.meta.url : void 0);
9285
+ if (!args.url && sessionProfileReused(args.projectPath, browser)) {
9286
+ result.profileReused = true;
9287
+ notes.push(restoredTabWarning(urlFilter ?? "the tab this read landed on"));
9288
+ }
9175
9289
  if (include.has("console")) await collectGeckoConsole(args, browser, urlFilter, result, notes);
9176
9290
  if (args.deepDom) {
9177
9291
  const cap = maxBytes > 0 ? maxBytes : 65536;
@@ -9271,9 +9385,13 @@ async function inspect_handler(args) {
9271
9385
  }
9272
9386
  toolchainWarning = isEngineCompanionUrl(documentUrl) || isEngineCompanionUrl(target.url) ? `Inspected ${target.url}, which is rendered by the Extension.js toolchain's own companion extension, not by this project. Pass url, or open one of the extension's surfaces first (extension_open), then inspect again.` : `No url was given and only override pages were open, so this inspected ${target.url}. Unless this extension provides that override itself, this is the toolchain's welcome surface, not your extension: pass url or open a surface with extension_open.`;
9273
9387
  }
9388
+ const profileReused = !args.url && sessionProfileReused(args.projectPath, browser);
9274
9389
  const result = {
9275
9390
  cdpPort,
9276
9391
  browser,
9392
+ ...profileReused ? {
9393
+ profileReused: true
9394
+ } : {},
9277
9395
  target: {
9278
9396
  id: target.id,
9279
9397
  url: target.url,
@@ -9335,7 +9453,8 @@ async function inspect_handler(args) {
9335
9453
  value: result,
9336
9454
  warnings: [
9337
9455
  "string" == typeof probeWarning ? probeWarning : null,
9338
- toolchainWarning
9456
+ toolchainWarning,
9457
+ profileReused ? restoredTabWarning(documentUrl || target.url) : null
9339
9458
  ]
9340
9459
  });
9341
9460
  } catch (err) {
@@ -11456,9 +11575,26 @@ function readContractForDiagnosis(projectPath, browser) {
11456
11575
  return null;
11457
11576
  }
11458
11577
  }
11578
+ function sightedContractBrowser(projectPath) {
11579
+ let dirs;
11580
+ try {
11581
+ dirs = node_fs.readdirSync(sessionArtifactsRootDir(projectPath));
11582
+ } catch {
11583
+ return null;
11584
+ }
11585
+ let best = null;
11586
+ for (const dir of dirs)try {
11587
+ const stat = node_fs.statSync(bridge_readyContractPath(projectPath, dir));
11588
+ if (!best || stat.mtimeMs > best.mtimeMs) best = {
11589
+ browser: dir,
11590
+ mtimeMs: stat.mtimeMs
11591
+ };
11592
+ } catch {}
11593
+ return best ? best.browser : null;
11594
+ }
11459
11595
  const doctor_schema = {
11460
11596
  name: "extension_doctor",
11461
- description: "Diagnose a dev session end to end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, browser liveness. This returns one {check, status, detail, remediation?} per leg, in dependency order. Read a 'skip' as blocked, not as a pass: it names the check that blocked it. Run this first when any act tool (storage, reload, eval, open) errors unexpectedly. Call it with no projectPath for a pre-flight environment check (node, the Extension.js CLI, the template cache) before any project exists.",
11597
+ description: "Diagnose a dev session end to end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, browser liveness. This returns one {check, status, detail, remediation?} per leg, in dependency order. Read a 'skip' as blocked, not as a pass: it names the check that blocked it. A session started without allowControl comes back ok:true with status 'read-only', not as an error: its control channel is off by choice. Run this first when any act tool (storage, reload, eval, open) errors unexpectedly. Call it with no projectPath for a pre-flight environment check (node, the Extension.js CLI, the template cache) before any project exists.",
11462
11598
  inputSchema: {
11463
11599
  type: "object",
11464
11600
  properties: {
@@ -11546,12 +11682,20 @@ function projectEngineVersion(projectPath) {
11546
11682
  }
11547
11683
  }
11548
11684
  function capabilityProbeChecks(parsed) {
11549
- return isEnvelope(parsed) ? parsed.value?.checks : parsed;
11685
+ if (!isEnvelope(parsed)) return parsed;
11686
+ const value = parsed.value;
11687
+ if (Array.isArray(value)) return value;
11688
+ return value?.checks;
11689
+ }
11690
+ const CONTROL_OFF_BY_CHOICE = /\bwas not started with --allow-control\b/i;
11691
+ function controlOffByChoiceLeg(checks) {
11692
+ return checks.find((leg)=>"control-channel" === leg.check && "fail" === leg.status && "string" == typeof leg.detail && CONTROL_OFF_BY_CHOICE.test(leg.detail)) ?? null;
11550
11693
  }
11551
11694
  async function doctor_handler(args) {
11552
11695
  if (!args.projectPath) return environmentPreflight();
11553
11696
  const projectPath = args.projectPath;
11554
- const { browser } = resolveSessionBrowser(projectPath, args.browser);
11697
+ const resolved = resolveSessionBrowser(projectPath, args.browser);
11698
+ const browser = "fallback" === resolved.source ? sightedContractBrowser(projectPath) ?? resolved.browser : resolved.browser;
11555
11699
  const { code, stdout, stderr } = await runExtensionCli([
11556
11700
  "doctor",
11557
11701
  projectPath,
@@ -11576,8 +11720,10 @@ async function doctor_handler(args) {
11576
11720
  const out = stdout.trim();
11577
11721
  try {
11578
11722
  const parsed = JSON.parse(out);
11579
- const checks = capabilityProbeChecks(parsed);
11580
- if (!Array.isArray(checks)) throw new Error("not a check array");
11723
+ const probed = capabilityProbeChecks(parsed);
11724
+ if (!Array.isArray(probed)) throw new Error("not a check array");
11725
+ const checks = probed;
11726
+ const readOnlyLeg = controlOffByChoiceLeg(checks);
11581
11727
  for (const check of checks){
11582
11728
  if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
11583
11729
  if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
@@ -11619,17 +11765,30 @@ async function doctor_handler(args) {
11619
11765
  } : {}
11620
11766
  });
11621
11767
  }
11768
+ const failures = checks.filter((leg)=>"fail" === leg.status);
11769
+ const readOnly = null !== readOnlyLeg && 1 === failures.length && failures[0] === readOnlyLeg;
11770
+ if (readOnly && readOnlyLeg) {
11771
+ readOnlyLeg.status = "warn";
11772
+ readOnlyLeg.detail = `read-only by choice: ${readOnlyLeg.detail}`;
11773
+ readOnlyLeg.remediation = "Nothing failed. To unlock the control verbs, call extension_dev again with allowControl: true (or allowEval: true) plus replace: true, which stops this session first; a plain second call is refused so the session does not fork.";
11774
+ }
11622
11775
  return envelope_envelope({
11623
- ok: healthy,
11776
+ ok: healthy || readOnly,
11624
11777
  command: doctor_schema.name,
11625
- status: healthy ? "healthy" : "unhealthy",
11778
+ status: healthy ? "healthy" : readOnly ? "read-only" : "unhealthy",
11626
11779
  value: {
11627
11780
  browser,
11628
11781
  ...engineVersion ? {
11629
11782
  engineVersion
11630
11783
  } : {},
11784
+ ...readOnly ? {
11785
+ readOnly: true
11786
+ } : {},
11631
11787
  checks
11632
- }
11788
+ },
11789
+ ...readOnly ? {
11790
+ hint: "This session is read-only because it was started without allowControl, not because anything is wrong: logs, inspect, wait and doctor all work, while storage, reload, open, dom_snapshot and eval stay locked."
11791
+ } : {}
11633
11792
  });
11634
11793
  } catch {
11635
11794
  const message = stderr.trim() || `extension exited with code ${code}`;
@@ -12321,7 +12480,7 @@ function resumePending(deviceCode, verificationUri) {
12321
12480
  }
12322
12481
  async function loginToProject(args) {
12323
12482
  const project = String(args.project || "").trim();
12324
- if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
12483
+ if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. If the project does not exist yet, create it at extension.dev/new, then log in with the slugs the console shows.", "bad-request", "E_BAD_REQUEST");
12325
12484
  const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
12326
12485
  if (!apiCheck.ok) return login_fail("LoginConfigError", apiCheck.message, "login-failed", "E_AUTH_FAILED");
12327
12486
  const apiBase = apiCheck.base;
@@ -12562,7 +12721,7 @@ const auth_schema = {
12562
12721
  },
12563
12722
  project: {
12564
12723
  type: "string",
12565
- description: "login: target project as '<workspace>/<project>'; the token is scoped to it."
12724
+ description: "login: target project as '<workspace>/<project>'; the token is scoped to it. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. Create one at extension.dev/new if none exists yet."
12566
12725
  },
12567
12726
  deviceCode: {
12568
12727
  type: "string",
@@ -13461,4 +13620,5 @@ async function runCli(cmd, args) {
13461
13620
  log(`Unknown command: ${cmd}. Expected one of: login, logout, whoami, release.`);
13462
13621
  return 1;
13463
13622
  }
13464
- export { runCli, startServer };
13623
+ var version_0 = package_namespaceObject.rE;
13624
+ export { runCli, startServer, version_0 as version };
@@ -1,3 +1,5 @@
1
+ import { version } from "../package.json";
2
+ export { version };
1
3
  export interface ToolModule {
2
4
  schema: {
3
5
  name: string;
@@ -13,6 +13,7 @@ export interface ArtifactPublisher {
13
13
  project: string | null;
14
14
  tokenId: string | null;
15
15
  }
16
+ export declare const ZIP_URL_REDIRECT_NOTE = "zipUrl does not serve the archive itself: it answers 302 with a short-lived presigned storage URL in Location. Follow redirects when you fetch it (curl -L; fetch and most HTTP clients already do), because a client that does not follow them reads 0 bytes and reports the share as empty when it is not.";
16
17
  export interface ListedArtifact {
17
18
  artifactId: string;
18
19
  kind?: string;
@@ -0,0 +1,4 @@
1
+ export declare const SYSTEM_PROFILE_ARG = "false";
2
+ export declare function profileCarriesTabsOver(projectPath: string, browser: string, profileArg?: string): boolean;
3
+ export declare function sessionProfileReused(projectPath: string, browser: string): boolean;
4
+ export declare function restoredTabWarning(url: string): string;
@@ -71,5 +71,6 @@ export interface ProcessInfo {
71
71
  projectPath: string;
72
72
  command: "dev" | "start" | "preview";
73
73
  noBrowser?: boolean;
74
+ profileReused?: boolean;
74
75
  }
75
76
  export type BrowserType = "chrome" | "edge" | "firefox" | "chromium-based" | "gecko-based";
@@ -1,9 +1,6 @@
1
1
  <!DOCTYPE html><html lang="en"><head>
2
2
  <meta charset="utf-8">
3
3
  <meta name="viewport" content="width=device-width, initial-scale=1">
4
- <!-- Dark-only surface: signals the popup chrome up front so there is no
5
- white flash before styles.css loads. The brand tokens live in
6
- ./styles.css (synced with @extensiondev/ui), not inline here. -->
7
4
  <meta name="color-scheme" content="dark">
8
5
  <title>Extension.dev Live Preview</title>
9
6
  <link rel="stylesheet" href="/action/index.css"></head>
@@ -3,19 +3,8 @@
3
3
  <title>Extension preview host</title>
4
4
  </head>
5
5
  <body>
6
- <!--
7
- Sandbox execution surface for guest extension code.
8
- Declared in manifest.json under chromium:sandbox.pages with a CSP
9
- that allows unsafe-eval / blob: so guest code can run for real inside this
10
- installed extension. This file exists so the sandbox reference resolves and
11
- the build has a host page to mount; the full executor wiring (receiving a
12
- guest PreviewSession and running its surfaces here) is the next increment.
13
- Until then, guest surfaces still render through the creator-preview iframes.
14
- -->
15
6
  <div id="preview-host-root"></div>
16
7
  <script>
17
- // Signal the parent (the newtab runtime) that the sandbox host is mounted
18
- // and ready to receive a guest preview to execute.
19
8
  if (window.parent) {
20
9
  window.parent.postMessage(
21
10
  { type: "EXTENSION_PREVIEW_HOST_READY" },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "10.2.0",
4
+ "version": "10.3.1",
5
5
  "description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 28 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": "Apache-2.0",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://extension.dev",
10
- "version": "10.2.0",
10
+ "version": "10.3.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@extension.dev/mcp",
16
- "version": "10.2.0",
16
+ "version": "10.3.1",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"