@extension.dev/mcp 6.4.0 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "extension-mcp",
11
11
  "source": "./",
12
12
  "description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox.",
13
- "version": "6.4.0",
13
+ "version": "6.6.0",
14
14
  "category": "development",
15
15
  "author": {
16
16
  "name": "Cezar Augusto"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "extension-mcp",
3
3
  "description": "MCP tools for browser extension development: scaffold from 60+ templates, run the dev server with HMR, inspect the live DOM and logs, and publish store-ready builds for Chrome, Edge, and Firefox. Ships /extension, /extension-add, /extension-debug, and /extension-publish commands.",
4
- "version": "6.4.0",
4
+ "version": "6.6.0",
5
5
  "author": {
6
6
  "name": "Cezar Augusto",
7
7
  "email": "hello@extension.dev",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,101 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.6.0
4
+
5
+ A shared build belongs to the project that owns it, not to whoever happened to
6
+ press publish. `extension_shares` now says which of the two it is looking at,
7
+ and it names the publisher without ever inventing one.
8
+
9
+ ### Added
10
+
11
+ - **Every listed share carries its owner and its publisher.** The platform now
12
+ returns `owner` and `sharedBy` on each row and both come through untouched,
13
+ alongside an `attribution` block that reads them. `attribution.ownership` is
14
+ `"project"` when the owning workspace holds the share, `"personal"` when one
15
+ person holds it alone, and `"unknown"` when the platform disclosed no owner.
16
+ `attribution.ownerPath` gives the owning `workspace/project` for a project
17
+ share. Ownership is read off `owner` and never off the publisher, because the
18
+ owner is what decides who may revoke a share and the publisher is only who
19
+ made it.
20
+ - **`attribution.revocableBy` says who can actually pull the link back.** A
21
+ project share is revocable by any member of the owning workspace and by any
22
+ token scoped to the owning project. A personal share belongs to one person,
23
+ so nobody else can see it or revoke it and a project token cannot touch it.
24
+ Knowing which of the two you are holding is the difference between a revoke
25
+ that will work and a 404 that reads like a bug.
26
+ - **`server.ownership` counts the listed shares by owner.** Project, personal
27
+ and unknown, so a list can be reasoned about without walking every row.
28
+
29
+ ### Changed
30
+
31
+ - **A publisher is never guessed.** `attribution.credit` is the GitHub login
32
+ when the platform resolved one. When a share was made by a CLI token whose
33
+ issuer could not be resolved it reads `CLI token <id>`, which is exactly what
34
+ the credential itself proves, and a share made before attribution existed
35
+ reads as not recorded. The workspace slug, the project slug and the owner are
36
+ never substituted for a name, because naming a team where a human is expected
37
+ attributes the share to whoever the reader takes that team to be.
38
+ `attribution.creditSource` says which of the three it was.
39
+ - **Attribution is stated as attribution.** The response spells out that
40
+ `sharedBy` records who published a share and grants and restricts nothing, so
41
+ it is not read as a permission.
42
+ - **A truncated list is more explicit about why.** `truncated` also goes true
43
+ when the platform spends its budget working out which shares the caller is
44
+ entitled to see, so the note now says `matched` is a floor rather than a
45
+ total.
46
+ - **Revoking a share the token does not own explains the personal case.** The
47
+ 404 message already covered a different project and an already dead link; it
48
+ now also names a teammate's personal share, which no project token can
49
+ revoke.
50
+
51
+ ## 6.5.0
52
+
53
+ A link you shared is no longer only as findable as the response that created
54
+ it. The shares you have made are now listable, and revocable, from the tool
55
+ that made them.
56
+
57
+ ### Added
58
+
59
+ - **`extension_shares` lists and revokes the links you have shared.**
60
+ `share:true` hands back a public link, an `expiresAt` and a `revokeUrl`, and
61
+ until now the only copy of that `revokeUrl` was the tool response and the
62
+ project's own `.extension.dev/shared-previews.json`. Neither could say what
63
+ was actually still live, and neither existed at all for a link shared from
64
+ another machine. `action:"list"` (the default) asks the platform for every
65
+ artifact the logged-in project owns and returns each one's `artifactId`,
66
+ name, version, live or dead state, `createdAt`, `expiresAt`, `revokedAt`,
67
+ size, and its `previewUrl`, `zipUrl` and `revokeUrl`. `previewUrl` and
68
+ `zipUrl` come back null for anything no longer live, because a revoked or
69
+ expired address resolves for nobody and echoing it back would invite passing
70
+ on a dead link; `revokeUrl` stays on every row.
71
+ - **`action:"revoke"` kills a link from where it was made.** It takes an
72
+ `artifactId` or, just as well, any URL of the share (`previewUrl`, `zipUrl`,
73
+ `viewUrl`, or `revokeUrl`), so the link you sent someone is enough to pull it
74
+ back without going to find a `curl` command. Revocation is permanent: the zip
75
+ is deleted and the id is burned, so a revoked link can never resolve again
76
+ and sharing the same build later mints a different one. The response says so
77
+ rather than reading like an undoable delete.
78
+ - **The platform's answer is reconciled with the project's record.** Pass
79
+ `projectPath` and every listed share is matched against
80
+ `.extension.dev/shared-previews.json`: a share the project recorded carries
81
+ its local `sharedAt`, `browser` and `distDir`, a share the platform knows
82
+ about but the project does not is flagged `remoteOnly` (shared from another
83
+ machine or another checkout), and a local record with no artifact behind it
84
+ is reported under `localOnly`. The tool only ever reads that file, never
85
+ rewrites it, so its append-only history stays intact.
86
+ - **A cut list is never passed off as the whole set.** When the platform
87
+ answers `truncated:true`, the response carries the count that came back
88
+ against the count that matched, says to raise `limit` or narrow with
89
+ `status:"live"`, and refuses to call a `localOnly` record dead, because a
90
+ share missing from a truncated list has not been shown to be gone.
91
+
92
+ ### Changed
93
+
94
+ - **A successful share now says where to find the link again.** The `share`
95
+ note and the `extension_preview_web` tool description both point at
96
+ `extension_shares`, at the moment the link exists and the question of how to
97
+ reach it later is about to come up.
98
+
3
99
  ## 6.4.0
4
100
 
5
101
  A build on your machine can now become a link someone else can open, and the
package/README.md CHANGED
@@ -133,6 +133,7 @@ cp node_modules/@extension.dev/mcp/claude/commands/*.md ~/my-extension/.claude/c
133
133
  | platform | `extension_whoami` | Show the stored login (never the token) |
134
134
  | platform | `extension_logout` | Remove stored credentials |
135
135
  | platform | `extension_preview_web` | Render a build in the web emulator, and share it as a link |
136
+ | platform | `extension_shares` | List every link you have shared, and revoke one permanently |
136
137
  | platform | `extension_publish` | Publish a shareable preview to extension.dev |
137
138
  | platform | `extension_release_promote` | Promote a build to a release channel, headless |
138
139
  | platform | `extension_deploy` | Submit to the Chrome, Firefox, and Edge stores through extension.dev |
@@ -143,6 +144,8 @@ Browser-launching tools (`dev`, `start`, `preview`) shell out to the `extension`
143
144
 
144
145
  An unpacked extension is unusually hard to hand to someone: the only way to look at a colleague's work-in-progress has been to take their zip and run untrusted code with real browser permissions on your own machine. `extension_preview_web` with `share: true` uploads the `dist/` it just built and returns a link that renders those exact bytes in the emulator. Whoever opens it installs nothing and signs in to nothing, which is what lets a designer, a PM, or a reviewer into the loop at all. Sharing needs auth (`extension_login` or `EXTENSION_DEV_TOKEN`), the link expires, and `DELETE`ing the returned `revokeUrl` with the same token kills it early. Revocation is permanent and re-sharing mints a new link, so that `revokeUrl` is the only handle to the link you just made; every share is also appended to `.extension.dev/shared-previews.json` in the project (gitignored) so it survives losing the tool output. Without `share`, the tool returns a local-only deep link and uploads nothing.
145
146
 
147
+ `extension_shares` is the other half of that: it lists every link the token has shared, live and dead, with the `previewUrl` and `revokeUrl` of each, and revokes one by `artifactId` or by pasting any of its URLs. Pass `projectPath` and it reconciles the platform's answer with the project's own record, so a link shared from another machine shows up as `remoteOnly` and a record with nothing behind it any more shows up under `localOnly`. It never rewrites the local file.
148
+
146
149
  That is a different job from shipping. Use `share` for the build you are holding right now; use `extension_publish` and `extension_release_promote` below for builds your CI has released.
147
150
 
148
151
  ## From preview to store
package/dist/module.js CHANGED
@@ -566,6 +566,12 @@ __webpack_require__.d(reload_namespaceObject, {
566
566
  handler: ()=>reload_handler,
567
567
  schema: ()=>reload_schema
568
568
  });
569
+ var shares_namespaceObject = {};
570
+ __webpack_require__.r(shares_namespaceObject);
571
+ __webpack_require__.d(shares_namespaceObject, {
572
+ handler: ()=>shares_handler,
573
+ schema: ()=>shares_schema
574
+ });
569
575
  var source_inspect_namespaceObject = {};
570
576
  __webpack_require__.r(source_inspect_namespaceObject);
571
577
  __webpack_require__.d(source_inspect_namespaceObject, {
@@ -623,7 +629,7 @@ __webpack_require__.d(whoami_namespaceObject, {
623
629
  handler: ()=>whoami_handler,
624
630
  schema: ()=>whoami_schema
625
631
  });
626
- var package_namespaceObject = JSON.parse('{"rE":"6.4.0","El":{"OP":"4.0.16-canary.1784889479.74e12044"}}');
632
+ var package_namespaceObject = JSON.parse('{"rE":"6.5.0","El":{"OP":"4.0.16-canary.1784889479.74e12044"}}');
627
633
  function credentialsPath() {
628
634
  if ("win32" === process.platform) {
629
635
  const base = process.env.APPDATA || process.env.LOCALAPPDATA || node_path.join(node_os.homedir(), "AppData", "Roaming");
@@ -4499,6 +4505,22 @@ function loadExisting(file) {
4499
4505
  unreadable: true
4500
4506
  };
4501
4507
  }
4508
+ function readSharedPreviews(projectPath) {
4509
+ const file = sharedPreviewsPath(projectPath);
4510
+ if (!node_fs.existsSync(file)) return {
4511
+ path: file,
4512
+ exists: false,
4513
+ unreadable: false,
4514
+ entries: []
4515
+ };
4516
+ const existing = loadExisting(file);
4517
+ return {
4518
+ path: file,
4519
+ exists: true,
4520
+ unreadable: existing.unreadable,
4521
+ entries: existing.entries.filter((entry)=>entry && "string" == typeof entry.artifactId)
4522
+ };
4523
+ }
4502
4524
  function recordSharedPreview(projectPath, entry) {
4503
4525
  const file = sharedPreviewsPath(projectPath);
4504
4526
  let preserved;
@@ -4629,7 +4651,7 @@ async function buildShare(projectPath, distDir, manifest, browser) {
4629
4651
  serves: "uploaded-local-build",
4630
4652
  localBuildUploaded: true,
4631
4653
  record,
4632
- 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 only handle that pulls this link early and re-sharing mints 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}`) + (record.warning ? ` ${record.warning}` : "")
4654
+ 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 only handle that pulls this link early and re-sharing mints 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}` : "")
4633
4655
  };
4634
4656
  }
4635
4657
  function detectSurfaces(manifest) {
@@ -4652,7 +4674,7 @@ function detectSurfaces(manifest) {
4652
4674
  }
4653
4675
  const preview_web_schema = {
4654
4676
  name: "extension_preview_web",
4655
- description: "Preview an in-progress extension in the web emulator (no real browser). Builds the project (unless build:false), points preview.extension.dev at the dist directory over the dev-only preview://build scheme, and returns a deep link plus a loadability check against its dev server. preview is the author's front door: it renders YOUR build and carries the Emulated/Real lane toggle and the Trace tab. Pass surface:\"inspect\" to render in inspect.extension.dev instead (the evaluator's door, for fixture and forensic work). Pass share:true to UPLOAD the dist you just built and get back a public link (share.previewUrl) that renders those exact bytes for anyone who opens it, with no install and no dev server, and that also serves the whole build as a downloadable zip. Sharing needs a token scoped to an existing extension.dev workspace and project (extension_login or EXTENSION_DEV_TOKEN, valid up to 7 days), so a local folder with no project on the platform cannot share. Use share:true whenever the build has to reach someone who is not at this machine, since the plain deepLink only resolves locally.",
4677
+ description: "Preview an in-progress extension in the web emulator (no real browser). Builds the project (unless build:false), points preview.extension.dev at the dist directory over the dev-only preview://build scheme, and returns a deep link plus a loadability check against its dev server. preview is the author's front door: it renders YOUR build and carries the Emulated/Real lane toggle and the Trace tab. Pass surface:\"inspect\" to render in inspect.extension.dev instead (the evaluator's door, for fixture and forensic work). Pass share:true to UPLOAD the dist you just built and get back a public link (share.previewUrl) that renders those exact bytes for anyone who opens it, with no install and no dev server, and that also serves the whole build as a downloadable zip. Sharing needs a token scoped to an existing extension.dev workspace and project (extension_login or EXTENSION_DEV_TOKEN, valid up to 7 days), so a local folder with no project on the platform cannot share. Use share:true whenever the build has to reach someone who is not at this machine, since the plain deepLink only resolves locally. Shared links do not disappear when this response scrolls away: extension_shares lists every link this token has shared and revokes any of them.",
4656
4678
  inputSchema: {
4657
4679
  type: "object",
4658
4680
  properties: {
@@ -4734,7 +4756,7 @@ const preview_web_schema = {
4734
4756
  share: {
4735
4757
  type: "boolean",
4736
4758
  default: false,
4737
- description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes in the emulator for anyone who opens it: no install, no sign-in, no dev server. The link also serves the whole build as a downloadable zip (share.zipUrl), so sharing it hands over the built code. Needs a token scoped to an existing extension.dev workspace and project (extension_login or EXTENSION_DEV_TOKEN, valid up to 7 days); without one this returns a login hint and never fails the local preview. The link stays live until share.expiresAt, and DELETEing share.revokeUrl with the same token kills it sooner; a revoked link stays dead, and re-sharing the same build returns a new link. Because re-sharing never reproduces the old link, every successful share is also appended to .extension.dev/shared-previews.json in the project (gitignored) so the revoke handle survives losing this response."
4759
+ description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes in the emulator for anyone who opens it: no install, no sign-in, no dev server. The link also serves the whole build as a downloadable zip (share.zipUrl), so sharing it hands over the built code. Needs a token scoped to an existing extension.dev workspace and project (extension_login or EXTENSION_DEV_TOKEN, valid up to 7 days); without one this returns a login hint and never fails the local preview. The link stays live until share.expiresAt, and DELETEing share.revokeUrl with the same token kills it sooner; a revoked link stays dead, and re-sharing the same build returns a new link. Because re-sharing never reproduces the old link, every successful share is also appended to .extension.dev/shared-previews.json in the project (gitignored) so the revoke handle survives losing this response, and extension_shares lists what is actually live on the platform and revokes any of it by id or by URL."
4738
4760
  }
4739
4761
  },
4740
4762
  required: [
@@ -4869,6 +4891,458 @@ async function preview_web_handler(args) {
4869
4891
  });
4870
4892
  }
4871
4893
  }
4894
+ const ARTIFACT_ID = /^gen_[0-9a-f]{32}$/;
4895
+ const ARTIFACT_ID_ANYWHERE = /gen_[0-9a-f]{32}/;
4896
+ function parseArtifactRef(input) {
4897
+ const raw = String(input ?? "").trim();
4898
+ if (!raw) return null;
4899
+ if (ARTIFACT_ID.test(raw)) return raw;
4900
+ let parsed = null;
4901
+ try {
4902
+ parsed = new URL(raw);
4903
+ } catch {
4904
+ parsed = null;
4905
+ }
4906
+ if (parsed) {
4907
+ const fromQuery = parsed.searchParams.get("preview");
4908
+ if (fromQuery && ARTIFACT_ID.test(fromQuery.trim())) return fromQuery.trim();
4909
+ const segments = parsed.pathname.split("/").filter(Boolean);
4910
+ for(let i = segments.length - 1; i >= 0; i -= 1){
4911
+ const segment = decodeURIComponent(segments[i]);
4912
+ if (ARTIFACT_ID.test(segment)) return segment;
4913
+ }
4914
+ }
4915
+ const loose = ARTIFACT_ID_ANYWHERE.exec(raw);
4916
+ return loose ? loose[0] : null;
4917
+ }
4918
+ function authError(name) {
4919
+ return {
4920
+ ok: false,
4921
+ error: {
4922
+ name,
4923
+ message: "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
4924
+ }
4925
+ };
4926
+ }
4927
+ async function readBody(res) {
4928
+ const text = await res.text();
4929
+ try {
4930
+ return JSON.parse(text);
4931
+ } catch {
4932
+ return {
4933
+ message: text
4934
+ };
4935
+ }
4936
+ }
4937
+ async function listArtifacts(options = {}) {
4938
+ const token = options.token ?? resolveToken();
4939
+ if (!token) return authError("SharesAuthError");
4940
+ const apiCheck = safeApiBase(resolveApiBase(options.api));
4941
+ if (!apiCheck.ok) return {
4942
+ ok: false,
4943
+ error: {
4944
+ name: "SharesConfigError",
4945
+ message: apiCheck.message
4946
+ }
4947
+ };
4948
+ const url = new URL(`${apiCheck.base}/api/artifacts`);
4949
+ if (null != options.limit) url.searchParams.set("limit", String(options.limit));
4950
+ if (options.liveOnly) url.searchParams.set("status", "live");
4951
+ const doFetch = options.fetchImpl ?? fetch;
4952
+ let res;
4953
+ try {
4954
+ res = await doFetch(url.toString(), {
4955
+ headers: {
4956
+ authorization: `Bearer ${token}`,
4957
+ accept: "application/json"
4958
+ }
4959
+ });
4960
+ } catch (err) {
4961
+ return {
4962
+ ok: false,
4963
+ error: {
4964
+ name: "SharesNetworkError",
4965
+ message: `Could not reach ${url.toString()}: ${err?.message || err}`
4966
+ }
4967
+ };
4968
+ }
4969
+ const data = await readBody(res);
4970
+ if (401 === res.status) return authError("SharesAuthError");
4971
+ if (!res.ok) return {
4972
+ ok: false,
4973
+ error: {
4974
+ name: "SharesListError",
4975
+ status: res.status,
4976
+ message: `Listing shares failed (${res.status}): ${data?.message || "unknown error"}`
4977
+ }
4978
+ };
4979
+ const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
4980
+ const num = (value, fallback)=>"number" == typeof value && Number.isFinite(value) ? value : fallback;
4981
+ return {
4982
+ ok: true,
4983
+ data: {
4984
+ artifacts,
4985
+ count: num(data.count, artifacts.length),
4986
+ matched: num(data.matched, artifacts.length),
4987
+ limit: num(data.limit, artifacts.length),
4988
+ truncated: true === data.truncated,
4989
+ scanned: num(data.scanned, 0)
4990
+ }
4991
+ };
4992
+ }
4993
+ async function revokeArtifact(options) {
4994
+ const token = options.token ?? resolveToken();
4995
+ if (!token) return authError("SharesAuthError");
4996
+ const apiCheck = safeApiBase(resolveApiBase(options.api));
4997
+ if (!apiCheck.ok) return {
4998
+ ok: false,
4999
+ error: {
5000
+ name: "SharesConfigError",
5001
+ message: apiCheck.message
5002
+ }
5003
+ };
5004
+ const url = `${apiCheck.base}/api/artifacts/${encodeURIComponent(options.artifactId)}`;
5005
+ const doFetch = options.fetchImpl ?? fetch;
5006
+ let res;
5007
+ try {
5008
+ res = await doFetch(url, {
5009
+ method: "DELETE",
5010
+ headers: {
5011
+ authorization: `Bearer ${token}`,
5012
+ accept: "application/json"
5013
+ }
5014
+ });
5015
+ } catch (err) {
5016
+ return {
5017
+ ok: false,
5018
+ error: {
5019
+ name: "SharesNetworkError",
5020
+ message: `Could not reach ${url}: ${err?.message || err}`
5021
+ }
5022
+ };
5023
+ }
5024
+ const data = await readBody(res);
5025
+ if (401 === res.status) return authError("SharesAuthError");
5026
+ if (404 === res.status) return {
5027
+ ok: false,
5028
+ error: {
5029
+ name: "SharesNotFoundError",
5030
+ status: 404,
5031
+ message: `The platform has no live share ${options.artifactId} for this token. It may already be revoked, already expired, owned by a different project than the one this token is scoped to, or a teammate's personal share, which belongs to that person alone and no project token can revoke.`
5032
+ }
5033
+ };
5034
+ if (!res.ok) return {
5035
+ ok: false,
5036
+ error: {
5037
+ name: "SharesRevokeError",
5038
+ status: res.status,
5039
+ message: `Revoking ${options.artifactId} failed (${res.status}): ${data?.message || "unknown error"}`
5040
+ }
5041
+ };
5042
+ return {
5043
+ ok: true,
5044
+ data: {
5045
+ artifactId: options.artifactId,
5046
+ revoked: true === data.revoked,
5047
+ ..."string" == typeof data.revokedAt ? {
5048
+ revokedAt: data.revokedAt
5049
+ } : {}
5050
+ }
5051
+ };
5052
+ }
5053
+ const LOGIN_HINT = "Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).";
5054
+ const shares_schema = {
5055
+ name: "extension_shares",
5056
+ description: "List and revoke the public preview links this token has shared (the links extension_preview_web share:true hands out). action:\"list\" (default) asks the platform for every artifact owned by the logged-in project and returns each one's artifactId, name, version, live/dead state, createdAt, expiresAt, revokedAt, size, and its previewUrl, zipUrl and revokeUrl, so a link you lost the response for is findable again. Every row also carries owner and sharedBy as the platform returned them, plus an attribution block: attribution.ownership is \"project\" when the owning workspace holds the share and any member can revoke it, \"personal\" when one person holds it alone and nobody else can, and \"unknown\" when the platform disclosed no owner. attribution.credit names the publisher for credit only and never decides access; it reads \"CLI token <id>\" when the token's issuer could not be resolved and \"not recorded\" for a share made before attribution existed, and neither is a person's name. action:\"revoke\" kills one by artifactId or by pasting any of its URLs; revocation is PERMANENT (the id is burned and re-sharing mints a different link), so it cannot be undone. Pass projectPath to reconcile the platform's answer with .extension.dev/shared-previews.json, the project's own append-only record: a share made on another machine shows up as remoteOnly, and a record with no live artifact behind it shows up under localOnly. Needs the same token as sharing (extension_login or EXTENSION_DEV_TOKEN); without one, listing still returns the local record with a login hint instead of failing. Read-only for the local file: this tool never rewrites shared-previews.json.",
5057
+ inputSchema: {
5058
+ type: "object",
5059
+ properties: {
5060
+ action: {
5061
+ type: "string",
5062
+ enum: [
5063
+ "list",
5064
+ "revoke"
5065
+ ],
5066
+ default: "list",
5067
+ description: "list reads every share this token owns; revoke permanently kills one and cannot be undone."
5068
+ },
5069
+ artifactId: {
5070
+ type: "string",
5071
+ description: "Which share to revoke (the gen_... id from a share response or from action:\"list\"). Required for revoke unless url is given."
5072
+ },
5073
+ url: {
5074
+ type: "string",
5075
+ description: "Any URL of the share to revoke (previewUrl, zipUrl, viewUrl, or revokeUrl). The artifact id is read out of it, so the link you sent someone is enough to pull it back."
5076
+ },
5077
+ projectPath: {
5078
+ type: "string",
5079
+ description: "Path to the extension project root. Reconciles the platform's answer against this project's .extension.dev/shared-previews.json record. Read-only."
5080
+ },
5081
+ status: {
5082
+ type: "string",
5083
+ enum: [
5084
+ "all",
5085
+ "live"
5086
+ ],
5087
+ default: "all",
5088
+ description: "all (default) includes expired and revoked shares, which is what makes a dead link explainable; live returns only links that still resolve."
5089
+ },
5090
+ limit: {
5091
+ type: "number",
5092
+ description: "How many shares to return, 1 to 200 (platform default 100). A cut list comes back with truncated:true."
5093
+ },
5094
+ api: {
5095
+ type: "string",
5096
+ description: "Platform base URL (defaults to https://www.extension.dev or EXTENSION_DEV_API_URL)."
5097
+ }
5098
+ },
5099
+ required: []
5100
+ }
5101
+ };
5102
+ const REVOCABLE_BY = {
5103
+ project: "Any member of the owning workspace, and any token scoped to the owning project, can revoke this share.",
5104
+ personal: "This is one person's personal share. Only that person can see it and revoke it, so a project token cannot pull it back.",
5105
+ unknown: "The platform did not disclose an owner for this share, so who may revoke it cannot be stated here."
5106
+ };
5107
+ function shares_text(value) {
5108
+ return "string" == typeof value ? value.trim() : "";
5109
+ }
5110
+ function ownershipOf(owner) {
5111
+ const kind = shares_text(owner?.kind);
5112
+ if ("project" === kind) return "project";
5113
+ if ("user" === kind) return "personal";
5114
+ return "unknown";
5115
+ }
5116
+ function creditOf(sharedBy) {
5117
+ const login = shares_text(sharedBy?.login);
5118
+ if (login) return {
5119
+ credit: login,
5120
+ creditSource: "login"
5121
+ };
5122
+ const tokenId = shares_text(sharedBy?.tokenId);
5123
+ if (tokenId) return {
5124
+ credit: `CLI token ${tokenId}`,
5125
+ creditSource: "tokenId"
5126
+ };
5127
+ return {
5128
+ credit: sharedBy ? "not recorded: the platform could not resolve who published this share" : "not recorded: this share predates publisher attribution",
5129
+ creditSource: "none"
5130
+ };
5131
+ }
5132
+ function attributionOf(artifact) {
5133
+ const ownership = ownershipOf(artifact.owner);
5134
+ const owner = artifact.owner;
5135
+ const ownerPath = "project" === ownership && owner && "project" === owner.kind ? [
5136
+ shares_text(owner.workspace),
5137
+ shares_text(owner.project)
5138
+ ].filter(Boolean).join("/") : "";
5139
+ return {
5140
+ ownership,
5141
+ ...ownerPath ? {
5142
+ ownerPath
5143
+ } : {},
5144
+ ...creditOf(artifact.sharedBy),
5145
+ revocableBy: REVOCABLE_BY[ownership]
5146
+ };
5147
+ }
5148
+ function localIndex(entries) {
5149
+ const index = new Map();
5150
+ for (const entry of entries){
5151
+ const existing = index.get(entry.artifactId);
5152
+ if (!existing || String(entry.sharedAt) > String(existing.sharedAt)) index.set(entry.artifactId, entry);
5153
+ }
5154
+ return index;
5155
+ }
5156
+ function localOnlyStatus(entry, truncated, now) {
5157
+ if (truncated) return "unknown: the platform list was cut short, so this share may simply be past the returned window.";
5158
+ const expiresAt = entry.expiresAt ? Date.parse(entry.expiresAt) : NaN;
5159
+ if (Number.isFinite(expiresAt) && expiresAt <= now) return "expired: its own expiresAt has passed, and the platform no longer lists it.";
5160
+ return "not owned by this token: expired and pruned, revoked long ago, or shared while logged in to a different project.";
5161
+ }
5162
+ async function listShares(args) {
5163
+ const local = args.projectPath ? readSharedPreviews(args.projectPath) : void 0;
5164
+ const localRecord = local ? {
5165
+ path: local.path,
5166
+ exists: local.exists,
5167
+ entries: local.entries.length,
5168
+ ...local.unreadable ? {
5169
+ unreadable: "The file exists but could not be parsed, so no local reconciliation was possible."
5170
+ } : {}
5171
+ } : {
5172
+ path: null,
5173
+ note: "Pass projectPath to reconcile this list against the project's own share record."
5174
+ };
5175
+ const listing = await listArtifacts({
5176
+ liveOnly: "live" === args.status,
5177
+ ...null != args.limit ? {
5178
+ limit: args.limit
5179
+ } : {},
5180
+ ...args.api ? {
5181
+ api: args.api
5182
+ } : {}
5183
+ });
5184
+ if (!listing.ok) {
5185
+ const isAuth = "SharesAuthError" === listing.error.name;
5186
+ return JSON.stringify({
5187
+ ok: true,
5188
+ action: "list",
5189
+ server: {
5190
+ listed: false,
5191
+ errorName: listing.error.name,
5192
+ reason: listing.error.message,
5193
+ ...isAuth ? {
5194
+ loginHint: LOGIN_HINT
5195
+ } : {}
5196
+ },
5197
+ shares: [],
5198
+ localOnly: (local?.entries ?? []).map((entry)=>({
5199
+ ...entry
5200
+ })),
5201
+ localRecord,
5202
+ note: isAuth ? `The platform was not asked, so live and dead cannot be told apart here. ${LOGIN_HINT} localOnly is this project's own record of every link it ever shared, revoke handles included.` : "The platform could not be reached, so localOnly below is this project's own record and not a statement about what is still live."
5203
+ });
5204
+ }
5205
+ const now = Date.now();
5206
+ const byId = localIndex(local?.entries ?? []);
5207
+ const seen = new Set();
5208
+ const shares = listing.data.artifacts.map((artifact)=>{
5209
+ seen.add(artifact.artifactId);
5210
+ const entry = byId.get(artifact.artifactId);
5211
+ return {
5212
+ ...artifact,
5213
+ owner: artifact.owner ?? null,
5214
+ sharedBy: artifact.sharedBy ?? null,
5215
+ attribution: attributionOf(artifact),
5216
+ recordedLocally: Boolean(entry),
5217
+ ...entry ? {
5218
+ sharedAt: entry.sharedAt,
5219
+ ...entry.browser ? {
5220
+ browser: entry.browser
5221
+ } : {},
5222
+ ...entry.distDir ? {
5223
+ distDir: entry.distDir
5224
+ } : {}
5225
+ } : local ? {
5226
+ remoteOnly: "Not in this project's record: shared from another machine or another checkout of the project."
5227
+ } : {}
5228
+ };
5229
+ });
5230
+ const localOnly = (local?.entries ?? []).filter((entry)=>!seen.has(entry.artifactId)).map((entry)=>({
5231
+ ...entry,
5232
+ status: localOnlyStatus(entry, listing.data.truncated, now)
5233
+ }));
5234
+ const liveCount = shares.filter((share)=>share.live).length;
5235
+ const ownership = {
5236
+ project: shares.filter((s)=>"project" === s.attribution.ownership).length,
5237
+ personal: shares.filter((s)=>"personal" === s.attribution.ownership).length,
5238
+ unknown: shares.filter((s)=>"unknown" === s.attribution.ownership).length
5239
+ };
5240
+ return JSON.stringify({
5241
+ ok: true,
5242
+ action: "list",
5243
+ server: {
5244
+ listed: true,
5245
+ count: listing.data.count,
5246
+ matched: listing.data.matched,
5247
+ limit: listing.data.limit,
5248
+ truncated: listing.data.truncated,
5249
+ scanned: listing.data.scanned,
5250
+ ownership,
5251
+ ...listing.data.truncated ? {
5252
+ truncatedNote: `This is not the whole set: ${listing.data.count} of ${listing.data.matched} matched shares came back at limit ${listing.data.limit}. truncated also goes true when the server spent its budget working out which shares you are entitled to see, so matched is a floor and not a total. Raise limit (max 200) or pass status:"live" to narrow it, and do not read a missing share as revoked.`
5253
+ } : {}
5254
+ },
5255
+ shares,
5256
+ ...local ? {
5257
+ localOnly
5258
+ } : {},
5259
+ localRecord,
5260
+ message: `${liveCount} of ${shares.length} listed shares still resolve${local ? `; ${localOnly.length} local record ${1 === localOnly.length ? "entry has" : "entries have"} no artifact behind them` : ""}. Revoke one with action:"revoke" and its artifactId or any of its URLs.`,
5261
+ note: "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 the same build mints a different link.",
5262
+ attributionNote: "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."
5263
+ });
5264
+ }
5265
+ async function revokeShare(args) {
5266
+ const ref = parseArtifactRef(args.artifactId || args.url || "");
5267
+ if (!ref) return JSON.stringify({
5268
+ ok: false,
5269
+ action: "revoke",
5270
+ error: {
5271
+ name: "SharesInputError",
5272
+ message: "Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action:\"list\" to see both."
5273
+ }
5274
+ });
5275
+ const result = await revokeArtifact({
5276
+ artifactId: ref,
5277
+ ...args.api ? {
5278
+ api: args.api
5279
+ } : {}
5280
+ });
5281
+ const local = args.projectPath ? readSharedPreviews(args.projectPath) : void 0;
5282
+ const entry = local ? localIndex(local.entries).get(ref) : void 0;
5283
+ const recordNote = local ? entry ? `${local.path} still lists this share as its own append-only history and was not rewritten, so the entry stays with its original sharedAt. The platform is the truth for whether a link resolves.` : `${local.path} has no entry for this share, so it was made from another machine or another checkout.` : void 0;
5284
+ if (!result.ok) {
5285
+ const isAuth = "SharesAuthError" === result.error.name;
5286
+ return JSON.stringify({
5287
+ ok: false,
5288
+ action: "revoke",
5289
+ artifactId: ref,
5290
+ error: {
5291
+ name: result.error.name,
5292
+ message: result.error.message
5293
+ },
5294
+ ...isAuth ? {
5295
+ loginHint: LOGIN_HINT
5296
+ } : {},
5297
+ ...recordNote ? {
5298
+ recordNote
5299
+ } : {}
5300
+ });
5301
+ }
5302
+ return JSON.stringify({
5303
+ ok: true,
5304
+ action: "revoke",
5305
+ artifactId: ref,
5306
+ revoked: result.data.revoked,
5307
+ ...result.data.revokedAt ? {
5308
+ revokedAt: result.data.revokedAt
5309
+ } : {},
5310
+ ...recordNote ? {
5311
+ recordNote
5312
+ } : {},
5313
+ note: "The link is dead for everyone, permanently: the zip is deleted and the id is burned, so it can never resolve again. Sharing the same build later returns a different link, and anyone holding the old one gets nothing."
5314
+ });
5315
+ }
5316
+ async function shares_handler(args) {
5317
+ if ("revoke" === args.action) return revokeShare({
5318
+ ...args.artifactId ? {
5319
+ artifactId: args.artifactId
5320
+ } : {},
5321
+ ...args.url ? {
5322
+ url: args.url
5323
+ } : {},
5324
+ ...args.projectPath ? {
5325
+ projectPath: args.projectPath
5326
+ } : {},
5327
+ ...args.api ? {
5328
+ api: args.api
5329
+ } : {}
5330
+ });
5331
+ return listShares({
5332
+ ...args.projectPath ? {
5333
+ projectPath: args.projectPath
5334
+ } : {},
5335
+ ...args.status ? {
5336
+ status: args.status
5337
+ } : {},
5338
+ ...null != args.limit ? {
5339
+ limit: args.limit
5340
+ } : {},
5341
+ ...args.api ? {
5342
+ api: args.api
5343
+ } : {}
5344
+ });
5345
+ }
4872
5346
  const get_template_source_schema = {
4873
5347
  name: "extension_get_template_source",
4874
5348
  description: "Read source files from a template in the extension.dev template catalog. Use this to learn implementation patterns before building something similar.",
@@ -10652,6 +11126,7 @@ const tools = [
10652
11126
  start_namespaceObject,
10653
11127
  preview_namespaceObject,
10654
11128
  preview_web_namespaceObject,
11129
+ shares_namespaceObject,
10655
11130
  stop_namespaceObject,
10656
11131
  get_template_source_namespaceObject,
10657
11132
  manifest_validate_namespaceObject,
@@ -0,0 +1,72 @@
1
+ type FetchImpl = typeof fetch;
2
+ export type ArtifactOwner = {
3
+ kind: "project";
4
+ workspace: string;
5
+ project: string;
6
+ } | {
7
+ kind: "user";
8
+ };
9
+ export interface ArtifactPublisher {
10
+ via: "token" | "session";
11
+ login: string | null;
12
+ workspace: string | null;
13
+ project: string | null;
14
+ tokenId: string | null;
15
+ }
16
+ export interface ListedArtifact {
17
+ artifactId: string;
18
+ kind?: string;
19
+ name?: string;
20
+ slug?: string;
21
+ version?: string;
22
+ live: boolean;
23
+ createdAt?: string;
24
+ expiresAt?: string | null;
25
+ revokedAt?: string | null;
26
+ sizeBytes?: number | null;
27
+ previewUrl?: string | null;
28
+ viewUrl?: string | null;
29
+ zipUrl?: string | null;
30
+ revokeUrl?: string;
31
+ owner?: ArtifactOwner | null;
32
+ sharedBy?: ArtifactPublisher | null;
33
+ }
34
+ export interface ArtifactListing {
35
+ artifacts: ListedArtifact[];
36
+ count: number;
37
+ matched: number;
38
+ limit: number;
39
+ truncated: boolean;
40
+ scanned: number;
41
+ }
42
+ export interface ArtifactRevocation {
43
+ artifactId: string;
44
+ revoked: boolean;
45
+ revokedAt?: string;
46
+ }
47
+ export type ArtifactsOutcome<T> = {
48
+ ok: true;
49
+ data: T;
50
+ } | {
51
+ ok: false;
52
+ error: {
53
+ name: string;
54
+ message: string;
55
+ status?: number;
56
+ };
57
+ };
58
+ export declare function parseArtifactRef(input: string): string | null;
59
+ export declare function listArtifacts(options?: {
60
+ limit?: number;
61
+ liveOnly?: boolean;
62
+ api?: string;
63
+ token?: string;
64
+ fetchImpl?: FetchImpl;
65
+ }): Promise<ArtifactsOutcome<ArtifactListing>>;
66
+ export declare function revokeArtifact(options: {
67
+ artifactId: string;
68
+ api?: string;
69
+ token?: string;
70
+ fetchImpl?: FetchImpl;
71
+ }): Promise<ArtifactsOutcome<ArtifactRevocation>>;
72
+ export {};
@@ -23,4 +23,11 @@ export interface ShareRecordOutcome {
23
23
  warning?: string;
24
24
  }
25
25
  export declare function sharedPreviewsPath(projectPath: string): string;
26
+ export interface SharedPreviewsFile {
27
+ path: string;
28
+ exists: boolean;
29
+ unreadable: boolean;
30
+ entries: SharedPreviewEntry[];
31
+ }
32
+ export declare function readSharedPreviews(projectPath: string): SharedPreviewsFile;
26
33
  export declare function recordSharedPreview(projectPath: string, entry: SharedPreviewEntry): ShareRecordOutcome;
@@ -0,0 +1,51 @@
1
+ export declare const schema: {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: "object";
6
+ properties: {
7
+ action: {
8
+ type: string;
9
+ enum: string[];
10
+ default: string;
11
+ description: string;
12
+ };
13
+ artifactId: {
14
+ type: string;
15
+ description: string;
16
+ };
17
+ url: {
18
+ type: string;
19
+ description: string;
20
+ };
21
+ projectPath: {
22
+ type: string;
23
+ description: string;
24
+ };
25
+ status: {
26
+ type: string;
27
+ enum: string[];
28
+ default: string;
29
+ description: string;
30
+ };
31
+ limit: {
32
+ type: string;
33
+ description: string;
34
+ };
35
+ api: {
36
+ type: string;
37
+ description: string;
38
+ };
39
+ };
40
+ required: never[];
41
+ };
42
+ };
43
+ export declare function handler(args: {
44
+ action?: string;
45
+ artifactId?: string;
46
+ url?: string;
47
+ projectPath?: string;
48
+ status?: string;
49
+ limit?: number;
50
+ api?: string;
51
+ }): Promise<string>;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "6.4.0",
5
- "description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 35 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.",
4
+ "version": "6.6.0",
5
+ "description": "MCP server that lets AI agents (Claude Code, Claude Desktop, Cursor, Copilot, Codex) build, run, inspect, and publish browser extensions. 36 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",
8
8
  "author": {
package/server.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
3
3
  "name": "io.github.extensiondev/mcp",
4
- "description": "Build, run, inspect, and publish browser extensions from any MCP client. 35 tools, 11 browsers.",
4
+ "description": "Build, run, inspect, and publish browser extensions from any MCP client. 36 tools, 11 browsers.",
5
5
  "repository": {
6
6
  "url": "https://github.com/extensiondev/mcp",
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://extension.dev",
10
- "version": "6.4.0",
10
+ "version": "6.6.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@extension.dev/mcp",
16
- "version": "6.4.0",
16
+ "version": "6.6.0",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"