@extenshi/mcp 0.7.0 → 0.8.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.
Files changed (46) hide show
  1. package/README.md +4 -3
  2. package/dist/bff.d.ts +5 -0
  3. package/dist/bff.d.ts.map +1 -1
  4. package/dist/bff.js +1 -0
  5. package/dist/bff.js.map +1 -1
  6. package/dist/development-guide.d.ts +108 -78
  7. package/dist/development-guide.d.ts.map +1 -1
  8. package/dist/development-guide.js +123 -44
  9. package/dist/development-guide.js.map +1 -1
  10. package/dist/docs.d.ts +45 -4
  11. package/dist/docs.d.ts.map +1 -1
  12. package/dist/docs.js +162 -17
  13. package/dist/docs.js.map +1 -1
  14. package/dist/icon-workflow.d.ts +5 -4
  15. package/dist/icon-workflow.d.ts.map +1 -1
  16. package/dist/icon-workflow.js +33 -32
  17. package/dist/icon-workflow.js.map +1 -1
  18. package/dist/localize-workflow.d.ts +6 -1
  19. package/dist/localize-workflow.d.ts.map +1 -1
  20. package/dist/localize-workflow.js +65 -47
  21. package/dist/localize-workflow.js.map +1 -1
  22. package/dist/pay.d.ts +1 -1
  23. package/dist/pay.d.ts.map +1 -1
  24. package/dist/pay.js +11 -11
  25. package/dist/pay.js.map +1 -1
  26. package/dist/search-filters.js +1 -1
  27. package/dist/search-filters.js.map +1 -1
  28. package/dist/shape.d.ts +34 -0
  29. package/dist/shape.d.ts.map +1 -1
  30. package/dist/shape.js +150 -3
  31. package/dist/shape.js.map +1 -1
  32. package/dist/templates.js +5 -5
  33. package/dist/templates.js.map +1 -1
  34. package/dist/tools.d.ts +1 -0
  35. package/dist/tools.d.ts.map +1 -1
  36. package/dist/tools.js +199 -111
  37. package/dist/tools.js.map +1 -1
  38. package/dist/welcome-agent-brief.d.ts +9 -9
  39. package/dist/welcome-agent-brief.d.ts.map +1 -1
  40. package/dist/welcome-agent-brief.js +32 -32
  41. package/dist/welcome-agent-brief.js.map +1 -1
  42. package/dist/welcome-workflow.d.ts +5 -5
  43. package/dist/welcome-workflow.d.ts.map +1 -1
  44. package/dist/welcome-workflow.js +13 -14
  45. package/dist/welcome-workflow.js.map +1 -1
  46. package/package.json +3 -2
package/dist/tools.js CHANGED
@@ -35,8 +35,9 @@
35
35
  import { agentEvidenceSchema, importManifestSchema, workspacePatchSchema } from '@extenshi/contracts';
36
36
  import { UserError } from 'fastmcp';
37
37
  import { z } from 'zod';
38
- import { buildDevelopmentGuide } from './development-guide.js';
39
- import { DocsError, getDocsIndex, searchDocs } from './docs.js';
38
+ import { zodToJsonSchema } from 'zod-to-json-schema';
39
+ import { buildDevelopmentGuide, GUIDE_SECTIONS } from './development-guide.js';
40
+ import { DEFAULT_EXCERPT_CHARS, DocsError, getDocsIndex, MAX_EXCERPT_CHARS, MIN_EXCERPT_CHARS, searchDocs, } from './docs.js';
40
41
  import { renderIconWorkflow } from './icon-workflow.js';
41
42
  import { renderLocalizeWorkflow } from './localize-workflow.js';
42
43
  import { PAY_OPERATIONS, paySchemas } from './pay.js';
@@ -44,7 +45,7 @@ import { PublishSetupError, publishArtifact, readStoreCredentials, validateStore
44
45
  import { checkPublishAccess } from './publish-access.js';
45
46
  import { ScanError, scanArtifact } from './scan.js';
46
47
  import { describeStoreConstraints, validateSearchFilters } from './search-filters.js';
47
- import { shapeExtension, shapeReviews, shapeSearch, shapeSecurity, shapeStoreRiskBatch } from './shape.js';
48
+ import { DEFAULT_MARKET_TOP_N, MARKET_FACETS, MAX_MARKET_TOP_N, shapeExtension, shapeMarketOverview, shapeReviews, shapeSearch, shapeSecurity, shapeStoreRiskBatch, } from './shape.js';
48
49
  import { captureError, captureEvent, classifyError } from './telemetry.js';
49
50
  import { renderExtensionTemplates } from './templates.js';
50
51
  import { renderCatalogPayload } from './untrusted.js';
@@ -53,6 +54,10 @@ import { renderWelcomeWorkflow } from './welcome-workflow.js';
53
54
  export const KEY_PAGE = 'https://dojo.extenshi.io/api-keys';
54
55
  export const SIGNUP_PAGE = 'https://auth.extenshi.io/signup';
55
56
  export const BILLING_PAGE = 'https://dojo.extenshi.io/billing';
57
+ export const HOSTED_PAGES_DOCS = 'https://docs.extenshi.io/developers/project-sync#hosted-pages-from-the-api';
58
+ // Every workspace write carries provenance; saying so up front saves the agent a
59
+ // failed first call on `provenance.toolVersion: Required`.
60
+ const PROVENANCE_HINT = ' `provenance` is required: {source: "agent", observedAt: ISO-8601 timestamp, toolVersion: your client name and version}.';
56
61
  export const MISSING_KEY_MESSAGE = "This tool needs an Extenshi API key, and you don't have one set up yet — " +
57
62
  "here's how to get going.\n\n" +
58
63
  'Getting started is free: no credit card required. Every account includes a ' +
@@ -238,12 +243,34 @@ function attribution(session) {
238
243
  * strongly typed as before.
239
244
  * Fail-soft: telemetry never alters the tool's result or its thrown error.
240
245
  */
246
+ /**
247
+ * Publish every tool's input schema with all `$ref`s inlined.
248
+ *
249
+ * fastmcp converts `parameters` through xsschema, which runs zod-to-json-schema
250
+ * with its default `$refStrategy: 'root'`: every zod instance used twice in one
251
+ * schema (the shared `digest`, `provenanceSchema` strings, …) becomes a bare
252
+ * `{"$ref": "#/properties/…"}` with no `type`. The Claude connector directory
253
+ * rejects those ("Add a type to this parameter: inputDigest"), and weaker MCP
254
+ * clients cannot resolve them either. xsschema prefers a Standard JSON Schema
255
+ * `jsonSchema` hook when one is present, so we supply an inlined conversion
256
+ * there; validation still goes through the original zod `~standard.validate`.
257
+ */
258
+ function withInlinedJsonSchema(parameters) {
259
+ const input = () => zodToJsonSchema(parameters, { $refStrategy: 'none' });
260
+ return Object.create(parameters, {
261
+ '~standard': { value: { ...parameters['~standard'], jsonSchema: { input, output: input } } },
262
+ });
263
+ }
241
264
  function instrument(tool) {
242
265
  const { name } = tool;
243
266
  const original = tool.execute;
244
267
  return {
245
268
  ...tool,
246
269
  execute: async (args, context) => {
270
+ // The hosted connector lets a user decline analytics on the consent
271
+ // screen; honour it for every event, including error reports.
272
+ if (context.session?.telemetry === false)
273
+ return original(args, context);
247
274
  const startedAt = Date.now();
248
275
  const { userId, sessionId } = attribution(context.session);
249
276
  const props = { tool: name, ...(sessionId ? { mcp_session_id: sessionId } : {}) };
@@ -290,8 +317,8 @@ const EXTENSION_REF_SHAPE = {
290
317
  .max(100)
291
318
  .optional()
292
319
  .describe('Alternative to extension_id: the extension’s id in its store URL (e.g. Chrome ' +
293
- '"cjpalhdlnbpafiamejdnhcphjbkeiagm"). For a Chrome/Edge id (32 letters a–p) you MUST also ' +
294
- 'pass `store` — both stores share that format. Firefox ids (slug / GUID / email) are ' +
320
+ '"cjpalhdlnbpafiamejdnhcphjbkeiagm"). A Chrome/Edge id (32 letters a–p) also requires ' +
321
+ '`store`: both stores share that format. Firefox ids (slug / GUID / email) are ' +
295
322
  'unambiguous. Resolving a store_id is free; only the read that follows costs a credit.'),
296
323
  store: z
297
324
  .enum(['CHROME', 'FIREFOX', 'EDGE'])
@@ -511,6 +538,12 @@ const TOOL_ANNOTATIONS = {
511
538
  idempotentHint: true,
512
539
  openWorldHint: true,
513
540
  },
541
+ remove_hosted_page: {
542
+ title: 'Remove a hosted page registration',
543
+ readOnlyHint: false,
544
+ destructiveHint: true,
545
+ openWorldHint: true,
546
+ },
514
547
  };
515
548
  /**
516
549
  * Register the Extenshi tools once on a FastMCP server.
@@ -530,7 +563,8 @@ export function registerTools(server, deps) {
530
563
  ...tool.annotations,
531
564
  title: tool.annotations?.title ?? TOOL_ANNOTATIONS[tool.name]?.title ?? tool.name.replaceAll('_', ' '),
532
565
  };
533
- server.addTool(instrument({ ...tool, annotations }));
566
+ const parameters = tool.parameters && withInlinedJsonSchema(tool.parameters);
567
+ server.addTool(instrument({ ...tool, annotations, ...(parameters ? { parameters } : {}) }));
534
568
  registeredTools.push({ name: tool.name, description: tool.description ?? '', annotations });
535
569
  }
536
570
  // Per-call helpers bound to the injected deps.
@@ -545,7 +579,7 @@ export function registerTools(server, deps) {
545
579
  for (const [method, name, , , mutation, description] of PAY_OPERATIONS) {
546
580
  add({
547
581
  name,
548
- description: `${description} Requires ${mutation ? 'pay.write' : 'pay.read'} permission. Never put developer API keys or Stripe secrets in extension code.`,
582
+ description: `${description} Requires ${mutation ? 'pay.write' : 'pay.read'} permission.`,
549
583
  parameters: paySchemas[method],
550
584
  annotations: {
551
585
  readOnlyHint: !mutation,
@@ -562,13 +596,22 @@ export function registerTools(server, deps) {
562
596
  if (err instanceof UserError && /^MCP_SCOPE_REQUIRED:pay\.(read|write)\./.test(err.message))
563
597
  throw err;
564
598
  const status = err?.data?.httpStatus;
599
+ // Only OUR authored backend messages are matched, never echoed, so a
600
+ // provider string can't leak through; they pick the precise guidance.
601
+ const backendMessage = err instanceof Error ? err.message : '';
565
602
  const message = status === 403
566
603
  ? 'Pay access denied. Grant pay.read/pay.write explicitly and verify application ownership.'
567
- : status === 412
568
- ? 'Pay prerequisites are incomplete. Read get_pay_readiness; the author completes legal acceptance and Stripe KYC in the browser.'
569
- : status === 401
570
- ? 'Pay authentication failed. Reconnect or configure a developer API key with explicit Pay permissions.'
571
- : 'Pay request failed. Check authentication, application readiness and backend support before retrying a mutation.';
604
+ : status === 412 && backendMessage.includes('Pay application is archived')
605
+ ? 'This Pay application is archived. Use an active application from list_pay_apps, or create a new one.'
606
+ : status === 412
607
+ ? 'Pay prerequisites are incomplete. Read get_pay_readiness; the author completes legal acceptance and Stripe KYC in the browser.'
608
+ : status === 404 && backendMessage.includes('No payment gateway connected')
609
+ ? 'No payment provider is connected to this Pay application yet. Call connect_pay_seller and have the author finish onboarding in the browser, then retry.'
610
+ : status === 404
611
+ ? 'Pay application or resource not found. Check the appId with list_pay_apps.'
612
+ : status === 401
613
+ ? 'Pay authentication failed. Reconnect or configure a developer API key with explicit Pay permissions.'
614
+ : 'Pay request failed. Check authentication, application readiness and backend support before retrying a mutation.';
572
615
  // Provider failures may contain customer data or credentials; never echo them.
573
616
  throw userErrorFrom(message, err);
574
617
  }
@@ -577,7 +620,8 @@ export function registerTools(server, deps) {
577
620
  }
578
621
  add({
579
622
  name: 'import_manifest',
580
- description: 'Import an existing manifest.json into the Dojo manifest editor and project labels. Pass parsed JSON and optional default-locale messages. Source import fills supported fields and preserves all other JSON; built imports remain separate observations. Default dryRun=true returns changes and expectedStateHash; apply with dryRun=false and that hash. Never infers data collection, prices, or publication. Requires project.write.',
623
+ description: 'Import an existing manifest.json into the Dojo manifest editor and project labels. Input: parsed JSON and optional default-locale messages. Source import fills supported fields and preserves all other JSON; built imports remain separate observations. Default dryRun=true returns changes and expectedStateHash without writing; dryRun=false with that hash applies them. Does not infer data collection, prices, or publication. Requires project.write.' +
624
+ PROVENANCE_HINT,
581
625
  parameters: importManifestSchema,
582
626
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
583
627
  execute: async (args, context) => {
@@ -597,7 +641,10 @@ export function registerTools(server, deps) {
597
641
  ],
598
642
  [
599
643
  'get_release_readiness',
600
- 'Explain readiness for the recorded browser and artifact by locale. Local or agent reports never become CI attestations. Stale evidence does not pass. Requires a Pro project.',
644
+ 'Explain readiness for the recorded browser and artifact by locale. Local or agent reports are not treated as CI attestations. Stale evidence does not pass. Requires a Pro project. ' +
645
+ 'Readiness is measured against the release recorded in the workspace: until apply_project_patch sets patch.release ' +
646
+ '(browser, version, artifactDigest, manifestDigest, locales, paymentRequired, and commit or dirtyTreeDigest) it reports ' +
647
+ 'RELEASE_NOT_RECORDED; after that, evidence counts only when it matches that release artifactDigest.',
601
648
  'getReleaseReadiness',
602
649
  ],
603
650
  ];
@@ -629,7 +676,7 @@ export function registerTools(server, deps) {
629
676
  });
630
677
  add({
631
678
  name: 'connection_diagnostics',
632
- description: 'Verify authentication, granted scopes, supported workspace contracts and actual write permissions (incl. hosted.write, evidence uploads and, for a projectId, CI ingest configuration). Includes credit balances; never returns credentials or secrets. On auth failures run `extenshi login --recover` (CLI) or reissue the API key from dojo.extenshi.io/api-keys.',
679
+ description: 'Verify authentication, granted scopes, supported workspace contracts and actual write permissions (incl. hosted.write, evidence uploads and, for a projectId, CI ingest configuration). Includes credit balances; credentials and secrets are not included. Auth failures are recoverable with `extenshi login --recover` (CLI) or a reissued API key from dojo.extenshi.io/api-keys.',
633
680
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
634
681
  parameters: z.object({}),
635
682
  execute: async (_args, context) => {
@@ -653,8 +700,8 @@ export function registerTools(server, deps) {
653
700
  add({
654
701
  name,
655
702
  description: dryRun
656
- ? 'Preview a three-way metadata diff against a server-held base revision. No writes.'
657
- : 'Apply a typed project metadata patch with expectedRevision and idempotencyKey. Preserve source and built manifests separately, including unknown keys. Missing fields never delete; tombstones explicitly delete. No source upload or scaffold overwrite. Requires project.write OAuth scope.',
703
+ ? `Preview a three-way metadata diff against a server-held base revision. No writes.${PROVENANCE_HINT}`
704
+ : `Apply a typed project metadata patch with expectedRevision and idempotencyKey. Source and built manifests are stored separately, including unknown keys. Omitted fields are left unchanged; tombstones explicitly delete. No source upload or scaffold overwrite. patch.release records the release that get_release_readiness measures against. Requires project.write OAuth scope.${PROVENANCE_HINT}`,
658
705
  annotations: {
659
706
  readOnlyHint: dryRun,
660
707
  idempotentHint: true,
@@ -673,7 +720,7 @@ export function registerTools(server, deps) {
673
720
  });
674
721
  add({
675
722
  name: 'create_ci_ingest_secret',
676
- description: 'Create or rotate the per-project secret the extenshi-evidence-action uses to POST CI evidence (POST /projects/:id/evidence/ingest). The secret is displayed ONCE — put it into the repository GitHub Actions secret EXTENSHI_EVIDENCE_SECRET. Creating again invalidates the previous secret. Requires project.write.',
723
+ description: 'Create or rotate the per-project secret the extenshi-evidence-action uses to POST CI evidence (POST /projects/:id/evidence/ingest). The secret is returned only once; the action reads it from the repository GitHub Actions secret EXTENSHI_EVIDENCE_SECRET. Not idempotent: creating again invalidates the previous secret. Requires project.write.',
677
724
  annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, openWorldHint: true },
678
725
  parameters: z.object({ projectId: z.string().uuid() }),
679
726
  execute: async (args, context) => {
@@ -687,7 +734,7 @@ export function registerTools(server, deps) {
687
734
  });
688
735
  add({
689
736
  name: 'upsert_hosted_page',
690
- description: "Register the project's homepage or support page URL (HTTPS only, must be publicly reachable; the server hashes the body). The registered homepage URL feeds HOMEPAGE_URL when the integration config file is regenerated. Requires hosted.write.",
737
+ description: `Register the project's homepage or support page URL (HTTPS only; the server fetches the public page and hashes the body, so the URL has to be publicly reachable). The registered homepage URL feeds HOMEPAGE_URL when the integration config file is regenerated. Requires hosted.write. API reference: ${HOSTED_PAGES_DOCS}`,
691
738
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
692
739
  parameters: z.object({
693
740
  projectId: z.string().uuid(),
@@ -705,7 +752,7 @@ export function registerTools(server, deps) {
705
752
  });
706
753
  add({
707
754
  name: 'verify_hosted_artifact',
708
- description: "Verify a hosted page actually serves what its record says: re-fetches and compares content. kind=homepage|support compares against the registered body hash (verified | changed | unreachable); kind=privacy checks the published policy version's HTML is what the public page really serves. Requires hosted.write.",
755
+ description: `Verify a hosted page actually serves what its record says: re-fetches and compares content. kind=homepage|support compares against the registered body hash (verified | changed | unreachable); kind=privacy checks the published policy version's HTML is what the public page really serves. Requires hosted.write. API reference: ${HOSTED_PAGES_DOCS}`,
709
756
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
710
757
  parameters: z.object({
711
758
  projectId: z.string().uuid(),
@@ -720,6 +767,23 @@ export function registerTools(server, deps) {
720
767
  }
721
768
  },
722
769
  });
770
+ add({
771
+ name: 'remove_hosted_page',
772
+ description: `Remove the project's registered homepage or support page — for a project moving off a URL or a mis-registered address. The public page itself keeps working; only the project record is forgotten. Requires hosted.write. API reference: ${HOSTED_PAGES_DOCS}`,
773
+ annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
774
+ parameters: z.object({
775
+ projectId: z.string().uuid(),
776
+ kind: z.enum(['homepage', 'support']),
777
+ }),
778
+ execute: async (args, context) => {
779
+ try {
780
+ return JSON.stringify(await bff(context).removeHostedPage(args));
781
+ }
782
+ catch (err) {
783
+ return readError(err, missingKeyMessage);
784
+ }
785
+ },
786
+ });
723
787
  add({
724
788
  name: 'list_hosted_pages',
725
789
  description: 'List the project registered hosted pages (homepage/support) with their verification status, content hash and last check time.',
@@ -736,7 +800,8 @@ export function registerTools(server, deps) {
736
800
  });
737
801
  add({
738
802
  name: 'record_project_evidence',
739
- description: 'Record test/scan/listing/privacy/payment evidence metadata bound to a browser, artifact hash and input hash. Source is local or agent, never CI — CI evidence is written by the verified ingest endpoint only. Attachment files upload via the CLI (extenshi evidence push --attach); this tool records metadata only. Requires evidence.write OAuth scope.',
803
+ description: 'Record test/scan/listing/privacy/payment evidence metadata bound to a browser, artifact hash and input hash. Source is local or agent; CI evidence is written only by the verified ingest endpoint. Attachment files upload via the CLI (extenshi evidence push --attach); this tool records metadata only. Requires evidence.write OAuth scope.' +
804
+ PROVENANCE_HINT,
740
805
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
741
806
  parameters: agentEvidenceSchema,
742
807
  execute: async (args, context) => {
@@ -751,10 +816,10 @@ export function registerTools(server, deps) {
751
816
  add({
752
817
  name: 'search_extensions',
753
818
  description: 'Search the cross-store extension catalog (Chrome, Firefox, Edge) with hybrid relevance. ' +
754
- 'ALL filters are applied server-side by the catalog database — always narrow with the ' +
755
- 'parameters below (rating/users/reviews thresholds, store, category, pricing, risk, ' +
756
- 'permissions, freshness, manifest version, trader status) rather than fetching a broad ' +
757
- 'list and filtering the results yourself. Use `skip` to page through large result sets. ' +
819
+ 'All filters (rating/reviews thresholds, store, category, pricing, risk, permissions, ' +
820
+ 'freshness, manifest version, trader status) are applied server-side by the catalog ' +
821
+ 'database, so a filtered request returns only the matching subset. `skip` pages through ' +
822
+ 'large result sets. ' +
758
823
  `${describeStoreConstraints()} ` +
759
824
  'Returns a compact list for market research and competitive analysis.',
760
825
  parameters: z.object({
@@ -762,7 +827,7 @@ export function registerTools(server, deps) {
762
827
  stores: z
763
828
  .array(z.enum(['CHROME', 'FIREFOX', 'EDGE']))
764
829
  .optional()
765
- .describe('Limit to stores: CHROME, FIREFOX, and/or EDGE.'),
830
+ .describe('Stores to search: CHROME, FIREFOX and/or EDGE.'),
766
831
  categories: z.array(z.string()).optional().describe('Catalog category slugs to include.'),
767
832
  pricing: z
768
833
  .array(z.enum(['FREE', 'FREEMIUM', 'IN_APP_PURCHASES', 'SUBSCRIPTION']))
@@ -779,9 +844,9 @@ export function registerTools(server, deps) {
779
844
  .number()
780
845
  .min(0)
781
846
  .optional()
782
- .describe('Minimum weekly downloads — FIREFOX-ONLY metric (Chrome/Edge do not report it). ' +
783
- 'For Chrome/Edge popularity use sortBy:"popular" instead. Conflicts with a ' +
784
- 'Chrome/Edge-only `stores` filter and will be rejected.'),
847
+ .describe('Minimum weekly downloads — FIREFOX-ONLY metric (not reported by Chrome/Edge). ' +
848
+ 'Chrome/Edge popularity is available through sortBy:"popular". A request combining ' +
849
+ 'it with a Chrome/Edge-only `stores` filter is rejected.'),
785
850
  minReviews: z.number().min(0).optional().describe('Minimum number of store reviews/ratings.'),
786
851
  updatedWithin: z
787
852
  .enum(['30d', '90d', '1y', 'stale'])
@@ -806,7 +871,7 @@ export function registerTools(server, deps) {
806
871
  includeDelisted: z
807
872
  .boolean()
808
873
  .optional()
809
- .describe('Include extensions whose store listing was removed (default: hidden).'),
874
+ .describe('Whether extensions whose store listing was removed are included (default: excluded).'),
810
875
  sortBy: z
811
876
  .enum(['relevance', 'popular', 'rating', 'recent', 'name', 'safety', 'trader', 'size'])
812
877
  .optional()
@@ -880,10 +945,10 @@ export function registerTools(server, deps) {
880
945
  'reviews link). Chrome Web Store review rows are NOT returned (their text cannot be redistributed) — ' +
881
946
  'for a Chrome extension the `aggregate` (with a link to the store reviews tab) is the only public ' +
882
947
  'review content. Reviewer identity is intentionally omitted. ' +
883
- 'Paginated newest-first by default — pass `cursor` (the `nextCursor` from a previous ' +
884
- 'call) to fetch the next page, or sort by highest rating. Cursors are sort-specific: ' +
885
- 'keep the same `sort` while paging, and start over if you change it. Reads existing ' +
886
- 'scraped reviews; use `min_rating` to see only positive or only critical feedback.',
948
+ 'Paginated newest-first by default (or by highest rating); `cursor` takes the `nextCursor` ' +
949
+ 'from a previous call. Cursors are sort-specific: a cursor is valid only with the `sort` ' +
950
+ 'it was issued for. Reads existing scraped reviews; `min_rating` narrows to positive or ' +
951
+ 'critical feedback.',
887
952
  parameters: z.object({
888
953
  ...EXTENSION_REF_SHAPE,
889
954
  limit: z.number().int().min(1).max(50).default(20).describe('Max reviews to return (1–50).'),
@@ -891,7 +956,7 @@ export function registerTools(server, deps) {
891
956
  .number()
892
957
  .int()
893
958
  .optional()
894
- .describe('Pagination cursor — pass the `nextCursor` returned by a previous call.'),
959
+ .describe('Pagination cursor: the `nextCursor` value returned by a previous call.'),
895
960
  language_id: z.number().int().optional().describe('Only reviews in this catalog language id.'),
896
961
  min_rating: z
897
962
  .number()
@@ -930,9 +995,8 @@ export function registerTools(server, deps) {
930
995
  description: 'Get the security analysis for an extension: safety score (0–100, higher = safer — the ' +
931
996
  'same coefficient the website shows), risk category, finding counts by ' +
932
997
  'severity, and the top grouped findings (scanner, rule, severity, count). Also returns ' +
933
- 'the install-dialog preview — exactly what Chrome/Firefox show users in the permission ' +
934
- 'prompt at install (consolidated + deduped from the manifest), available even for ' +
935
- 'unscanned extensions. Reads existing scan results — does not trigger a new scan. ' +
998
+ 'an approximate install-dialog preview computed from the store-listed permissions ' +
999
+ '(consolidated + deduped), available even for unscanned extensions. Reads existing scan results — does not trigger a new scan. ' +
936
1000
  'Identify the extension by numeric `extension_id` OR `store_id` (+`store` for Chrome/Edge). ' +
937
1001
  'NB: this tool costs 3 read credits (it fetches security, risk, and install-preview data).',
938
1002
  parameters: z.object({ ...EXTENSION_REF_SHAPE }),
@@ -963,15 +1027,15 @@ export function registerTools(server, deps) {
963
1027
  description: 'Look up the safety score and risk category for MANY extensions at once, addressed by their ' +
964
1028
  'STORE ids (the id in the store URL) — the inventory case: you have a list of what a user has ' +
965
1029
  `installed and need risk for all of it. Up to ${MAX_BATCH_EXTENSIONS} extensions per call, and ` +
966
- 'the WHOLE call costs 1 read credit (not 1 per extension), so prefer this over calling ' +
967
- 'get_security in a loop — that would cost 3 credits each. Returns safety score (0–100, higher ' +
1030
+ 'the WHOLE call costs 1 read credit (not 1 per extension; get_security costs 3 credits ' +
1031
+ 'per extension). Returns safety score (0–100, higher ' +
968
1032
  '= safer, the same number the website shows), risk category, severity counts, last scan date ' +
969
1033
  'and the catalog URL per extension. Each store id is resolved through its cross-store cluster ' +
970
1034
  'to the same listing the extension page renders, so these answers agree with the website. ' +
971
1035
  '`store` is REQUIRED per entry — Chrome and Edge ids share one format. An extension with no ' +
972
1036
  'score has not been scanned yet (`scanned: false`); ids with no catalog listing come back ' +
973
- 'under `notInCatalog`. Neither means "safe". For the full findings list of ONE extension, use ' +
974
- 'get_security instead.',
1037
+ 'under `notInCatalog`. Neither means "safe". The full findings list of ONE extension comes ' +
1038
+ 'from get_security.',
975
1039
  parameters: z.object({
976
1040
  extensions: z
977
1041
  .array(z.object({
@@ -986,8 +1050,8 @@ export function registerTools(server, deps) {
986
1050
  }))
987
1051
  .min(1)
988
1052
  .max(MAX_BATCH_EXTENSIONS)
989
- .describe(`The extensions to look up (max ${MAX_BATCH_EXTENSIONS}). Split a longer list into ` +
990
- `batches of ${MAX_BATCH_EXTENSIONS}; each batch costs 1 read credit.`),
1053
+ .describe(`The extensions to look up (max ${MAX_BATCH_EXTENSIONS}). A longer list takes several ` +
1054
+ `calls of up to ${MAX_BATCH_EXTENSIONS}; each call costs 1 read credit.`),
991
1055
  }),
992
1056
  execute: async (args, context) => {
993
1057
  try {
@@ -1011,10 +1075,22 @@ export function registerTools(server, deps) {
1011
1075
  query: z.string().optional().describe('Optional query to scope facets to a search.'),
1012
1076
  stores: z.array(z.enum(['CHROME', 'FIREFOX', 'EDGE'])).optional(),
1013
1077
  categories: z.array(z.string()).optional(),
1078
+ facets: z
1079
+ .array(z.enum(MARKET_FACETS))
1080
+ .optional()
1081
+ .describe('Facet groups to include: stats, stores, categories, extended, questionnaire, downloads; all groups when omitted.'),
1082
+ top_n: z
1083
+ .number()
1084
+ .int()
1085
+ .min(1)
1086
+ .max(MAX_MARKET_TOP_N)
1087
+ .optional()
1088
+ .describe(`Entries kept per open-ended list (category tree levels, permissions, target sites), largest first. Default ${DEFAULT_MARKET_TOP_N}; \`truncated\` reports what was cut.`),
1014
1089
  }),
1015
1090
  execute: async (args, context) => {
1016
1091
  try {
1017
1092
  const client = bff(context);
1093
+ const shapeOpts = { facets: args.facets, topN: args.top_n };
1018
1094
  const scoped = Boolean(args.query?.trim() || args.stores?.length || args.categories?.length);
1019
1095
  // Search-scoped: progressive-narrowing facets over the match set.
1020
1096
  if (scoped) {
@@ -1024,11 +1100,7 @@ export function registerTools(server, deps) {
1024
1100
  .getSearchFacets({ query: args.query, stores: args.stores, categories: args.categories })
1025
1101
  .catch(() => null),
1026
1102
  ]);
1027
- return renderCatalogPayload({
1028
- scope: 'search',
1029
- stats: stats ?? undefined,
1030
- facets: facets ?? undefined,
1031
- });
1103
+ return renderCatalogPayload(shapeMarketOverview({ scope: 'search', stats: stats ?? undefined, facets: facets ?? undefined }, shapeOpts));
1032
1104
  }
1033
1105
  // Catalog-wide: the unscoped search facets are all-zero by design, so build the
1034
1106
  // overview from the dedicated catalog-wide procedures (cached server-side).
@@ -1049,7 +1121,7 @@ export function registerTools(server, deps) {
1049
1121
  count: r.count,
1050
1122
  label: STORE_LABELS[r.store] ?? r.store,
1051
1123
  }));
1052
- return renderCatalogPayload({
1124
+ return renderCatalogPayload(shapeMarketOverview({
1053
1125
  scope: 'catalog-wide',
1054
1126
  stats: stats ?? undefined,
1055
1127
  facets: {
@@ -1057,8 +1129,8 @@ export function registerTools(server, deps) {
1057
1129
  categoryTree: categoryTree ?? undefined,
1058
1130
  extended: extended ?? undefined,
1059
1131
  },
1060
- note: 'Monetization and download-volume facets are only computed when scoped to a query — pass `query` to get those.',
1061
- });
1132
+ note: 'Monetization and download-volume facets are computed only for a query-scoped overview.',
1133
+ }, shapeOpts));
1062
1134
  }
1063
1135
  catch (err) {
1064
1136
  return readError(err, missingKeyMessage);
@@ -1067,15 +1139,15 @@ export function registerTools(server, deps) {
1067
1139
  });
1068
1140
  add({
1069
1141
  name: 'get_credit_balance',
1070
- description: 'Check your remaining Extenshi credits BEFORE running a batch, so you never guess whether a ' +
1071
- 'large request is safe. Returns every credit pool for your API key: `read` (spent one-per-call by ' +
1142
+ description: 'Report your remaining Extenshi credits — for checking whether a large request fits the ' +
1143
+ 'balance before running it. Returns every credit pool for your API key: `read` (spent one-per-call by ' +
1072
1144
  'search_extensions / get_extension / get_reviews / market_overview / get_risk_by_store_ids — the ' +
1073
1145
  'last one covers up to 40 extensions for that single credit; get_security costs 3) and ' +
1074
1146
  '`scan` (spent by scan_extension), plus `icon` and `inventory`. Each pool reports `remaining` — the ' +
1075
1147
  'number that actually gates calls (one-time free grant + purchased credits) — and `freeRemaining` ' +
1076
- '(how much of the never-renewing signup grant is left). FREE to call: checking your balance never ' +
1077
- 'spends a credit. Rule of thumb: to fetch N extensions you need read.remaining ≥ N (≥ 3N if you ' +
1078
- 'also call get_security on each).',
1148
+ '(how much of the one-time, non-renewing signup grant is left). FREE to call: checking the balance ' +
1149
+ 'spends no credit. Fetching N extensions takes read.remaining ≥ N (≥ 3N with get_security ' +
1150
+ 'on each).',
1079
1151
  parameters: z.object({}),
1080
1152
  execute: async (_args, context) => {
1081
1153
  try {
@@ -1098,9 +1170,9 @@ export function registerTools(server, deps) {
1098
1170
  name: 'list_my_projects',
1099
1171
  description: "List the extension projects owned by this API key — the developer's own workspaces on " +
1100
1172
  'extenshi.io. Returns id, name, status, target browsers, the bound GitHub repo (if any) and ' +
1101
- 'the claimed store listing (if any). Start here when the developer says "my extension" or ' +
1102
- '"my project": the id you get back is what get_project_state takes. FREE — reading your own ' +
1103
- 'projects never spends a credit.',
1173
+ 'the claimed store listing (if any). Applies when the developer refers to "my extension" or ' +
1174
+ '"my project": the returned id is what get_project_state takes. FREE — reading your own ' +
1175
+ 'projects spends no credit.',
1104
1176
  parameters: z.object({}),
1105
1177
  execute: async (_args, context) => {
1106
1178
  try {
@@ -1117,22 +1189,22 @@ export function registerTools(server, deps) {
1117
1189
  'they picked (popup / side panel / page enhancer / in-page assistant), the required ' +
1118
1190
  'permissions those types force, the exact manifest.json the project produces for each target ' +
1119
1191
  'browser, the files that type needs, live hosted URLs (uninstall survey), and an index of ' +
1120
- 'every saved tool state with its size. Use it before writing code so the manifest you ship ' +
1121
- 'matches what the developer configured on the site — and re-read it after they change ' +
1122
- 'something rather than assuming. The additive workspace envelope holds revisioned local observations separately from these scaffold drafts. It also returns the integration contract: review local changes before writing ' +
1123
- '`integration.file` to `integration.path` VERBATIM to wire the extension to this project — ' +
1124
- "those bytes carry a fingerprint extenshi.io uses to tell the developer's edits from its " +
1125
- 'own, so an equivalent file you assemble yourself makes the site stop managing every value ' +
1126
- 'in it. `integration.unwired` names the links this project has not set up yet — offer them ' +
1127
- 'rather than inventing URLs. Tool-state payloads are NOT inlined by default (a single ' +
1128
- 'row can be 256 KiB); name the keys you actually need in includeToolStates. FREE — never ' +
1129
- 'spends a credit. Combine with list_extension_templates for what each type means.',
1192
+ 'every saved tool state with its size. It reflects the site configuration at the time of the ' +
1193
+ 'call; later changes on the site appear in a later read. The additive workspace envelope ' +
1194
+ 'holds revisioned local observations separately from these scaffold drafts. It also returns ' +
1195
+ 'the integration contract: `integration.file` is the exact content for `integration.path` ' +
1196
+ 'that wires the extension to this project. Those bytes carry a fingerprint extenshi.io uses ' +
1197
+ "to tell the developer's edits from its own; a reassembled equivalent file lacks it, and the " +
1198
+ 'site then stops managing the values in it. `integration.unwired` lists the links this ' +
1199
+ 'project has not set up yet (no URLs exist for them). Tool-state payloads are NOT inlined by ' +
1200
+ 'default (a single row can be 256 KiB); includeToolStates selects which keys to inline. ' +
1201
+ 'FREE — spends no credit. Type definitions are in list_extension_templates.',
1130
1202
  parameters: z.object({
1131
1203
  projectId: z.string().describe('Project id from list_my_projects.'),
1132
1204
  includeToolStates: z
1133
1205
  .array(z.string())
1134
1206
  .optional()
1135
- .describe('Tool keys whose saved payload to inline, e.g. ["manifest-generator", "privacy-policy-generator"]. Omit to get the index only.'),
1207
+ .describe('Tool keys whose saved payload to inline, e.g. ["manifest-generator", "privacy-policy-generator"]. When omitted, only the index is returned.'),
1136
1208
  }),
1137
1209
  execute: async (args, context) => {
1138
1210
  try {
@@ -1152,18 +1224,18 @@ export function registerTools(server, deps) {
1152
1224
  description: "The starter extension one of the developer's own projects produces, as FILES ready to " +
1153
1225
  'write: manifest.json for the target browser, the background worker, the panel or ' +
1154
1226
  "content-script files that project's types need, placeholder icons, and " +
1155
- 'src/extenshi.config.js. Write the files VERBATIM — the config carries a fingerprint ' +
1156
- "that lets extenshi.io keep recognising the repository as this project's, and rebuilding " +
1157
- 'it by hand makes the site stop managing those values. Use this for a NEW extension; never overwrite an existing repository. Prefer this over writing a manifest ' +
1158
- 'yourself: it is the same set the site commits, so what you write and what the site ' +
1159
- "expects cannot disagree. One browser per call (default: the project's first target). " +
1160
- 'FREE — never spends a credit.',
1227
+ 'src/extenshi.config.js. The config carries a fingerprint that lets extenshi.io recognise ' +
1228
+ "the repository as this project's; a hand-rebuilt config lacks it, and the site then stops " +
1229
+ 'managing those values. Intended for a NEW extension: it is a complete starter set whose ' +
1230
+ 'files would replace same-named files in an existing repository. It is the same file set ' +
1231
+ "the site commits. One browser per call (default: the project's first target). " +
1232
+ 'FREE — spends no credit.',
1161
1233
  parameters: z.object({
1162
1234
  projectId: z.string().describe('Project id from list_my_projects.'),
1163
1235
  browser: z
1164
1236
  .enum(['chrome', 'firefox', 'edge'])
1165
1237
  .optional()
1166
- .describe("Target browser. Omit for the project's first declared target."),
1238
+ .describe("Target browser; defaults to the project's first declared target."),
1167
1239
  }),
1168
1240
  execute: async (args, context) => {
1169
1241
  try {
@@ -1198,8 +1270,8 @@ export function registerTools(server, deps) {
1198
1270
  });
1199
1271
  add({
1200
1272
  name: 'get_privacy_policy_version',
1201
- description: 'Read the markdown (and HTML) of one hosted privacy-policy version. Use list_privacy_policy_versions ' +
1202
- 'first. Pro only. Does not spend a credit.',
1273
+ description: 'Read the markdown (and HTML) of one hosted privacy-policy version; version numbers come from ' +
1274
+ 'list_privacy_policy_versions. Pro only. Does not spend a credit.',
1203
1275
  parameters: z.object({
1204
1276
  projectId: z.string().describe('Project id from list_my_projects.'),
1205
1277
  versionNumber: z
@@ -1219,17 +1291,17 @@ export function registerTools(server, deps) {
1219
1291
  });
1220
1292
  add({
1221
1293
  name: 'publish_privacy_policy',
1222
- description: 'Publish a hosted privacy policy for a Pro project. Omit bodyMarkdown to generate version 1 from ' +
1223
- 'the saved form and manifest (always the first version). Pass bodyMarkdown to publish an edited ' +
1224
- '(or AI-updated) draft — that goes live at the same public URL. After a successful publish, ' +
1225
- 're-read get_project_state so PRIVACY_POLICY_URL in integration.file is the hosted URL. ' +
1226
- 'Show the author the URL before telling them it is live. Pro only.',
1294
+ description: 'Publish a hosted privacy policy for a Pro project. Changes the live public policy page. ' +
1295
+ 'Without bodyMarkdown it generates the policy from the saved form and manifest (the first ' +
1296
+ 'version is produced this way); with bodyMarkdown it publishes that edited (or AI-updated) ' +
1297
+ 'draft at the same public URL. Once published, PRIVACY_POLICY_URL in the integration.file ' +
1298
+ 'from get_project_state is the hosted URL. Pro only.',
1227
1299
  parameters: z.object({
1228
1300
  projectId: z.string().describe('Project id from list_my_projects.'),
1229
1301
  bodyMarkdown: z
1230
1302
  .string()
1231
1303
  .optional()
1232
- .describe('Full policy markdown. Omit to generate from current form + manifest.'),
1304
+ .describe('Full policy markdown. When omitted, the policy is generated from the saved form and manifest.'),
1233
1305
  kind: z
1234
1306
  .enum(['generated', 'edited', 'ai_updated', 'reverted'])
1235
1307
  .optional()
@@ -1247,8 +1319,8 @@ export function registerTools(server, deps) {
1247
1319
  add({
1248
1320
  name: 'update_privacy_policy_with_ai',
1249
1321
  description: "Propose an updated privacy policy that keeps the author's custom wording and adds sections " +
1250
- 'required by new permissions or data practices. Does NOT go live — show proposedMarkdown to the ' +
1251
- 'author, then call publish_privacy_policy with that markdown (kind ai_updated). Requires a ' +
1322
+ 'required by new permissions or data practices. Returns proposedMarkdown only; nothing goes ' +
1323
+ 'live. Publishing it is a separate step (publish_privacy_policy, kind ai_updated). Requires a ' +
1252
1324
  'policy that is already published. Pro only.',
1253
1325
  parameters: z.object({
1254
1326
  projectId: z.string().describe('Project id from list_my_projects.'),
@@ -1267,40 +1339,55 @@ export function registerTools(server, deps) {
1267
1339
  if (caps.has('docs')) {
1268
1340
  add({
1269
1341
  name: 'get_development_guide',
1270
- description: 'Start here when developing an extension: get the complete tool inventory for THIS connection, ' +
1342
+ description: 'For extension-development tasks: returns the complete tool inventory for THIS connection, ' +
1271
1343
  'the Extenshi service directory with access requirements and docs links, GitHub/code placement ' +
1272
1344
  'guidance, and the ordered workflow from idea and repository through implementation, assets, ' +
1273
1345
  'privacy, tests/CI, store submission and maintenance. Distinguishes MCP tools from cabinet, ' +
1274
1346
  'CLI and external steps. Free; no key, account lookup or network request.',
1275
- parameters: z.object({}),
1276
- execute: async () => JSON.stringify(buildDevelopmentGuide(registeredTools), null, 2),
1347
+ parameters: z.object({
1348
+ sections: z
1349
+ .array(z.enum(['all', ...GUIDE_SECTIONS]))
1350
+ .optional()
1351
+ .describe('Full guide sections to include: tools, localActions, services, repository, workflow, handoff, or all. ' +
1352
+ 'Omitted: a compact overview with tool names, workflow stages and a table of contents.'),
1353
+ }),
1354
+ execute: async (args) => JSON.stringify(buildDevelopmentGuide(registeredTools, args?.sections), null, 2),
1277
1355
  });
1278
1356
  add({
1279
1357
  name: 'search_docs',
1280
1358
  description: 'Search the official Extenshi documentation (docs.extenshi.io) — product guides plus the ' +
1281
- 'full @extenshi/cli command reference (scan, review-risk, publish, login). Use it to answer ' +
1282
- '"how do I…" questions and to give developers exact CLI commands and flags instead of ' +
1283
- 'guessing. Free: reads public docs, no API key or quota required. Omit the query to list ' +
1284
- 'every available documentation page.',
1359
+ 'full @extenshi/cli command reference (scan, review-risk, publish, login). Applies to ' +
1360
+ '"how do I…" questions and to exact CLI commands and flags. Free: reads public docs, no ' +
1361
+ 'API key or quota required. Without a query it lists every available documentation page.',
1285
1362
  parameters: z.object({
1286
1363
  query: z
1287
1364
  .string()
1288
1365
  .optional()
1289
- .describe('What to look up, e.g. "scan a zip in CI", "review-risk flags", "publish to edge", "get an API key". Omit to list every page.'),
1366
+ .describe('What to look up, e.g. "scan a zip in CI", "review-risk flags", "publish to edge", "get an API key". When omitted, every page is listed.'),
1290
1367
  limit: z
1291
1368
  .number()
1292
1369
  .int()
1293
1370
  .min(1)
1294
1371
  .max(10)
1295
1372
  .default(4)
1296
- .describe('Max documentation sections to return (1–10).'),
1373
+ .describe('Max documentation passages (page subsections) to return (1–10).'),
1374
+ max_chars: z
1375
+ .number()
1376
+ .int()
1377
+ .min(MIN_EXCERPT_CHARS)
1378
+ .max(MAX_EXCERPT_CHARS)
1379
+ .optional()
1380
+ .describe(`Max characters per passage excerpt (${MIN_EXCERPT_CHARS}–${MAX_EXCERPT_CHARS}, default ${DEFAULT_EXCERPT_CHARS}). Longer passages are cut with a link to the full page.`),
1297
1381
  }),
1298
1382
  execute: async (args) => {
1299
1383
  try {
1300
1384
  const query = args.query?.trim();
1301
1385
  if (!query)
1302
1386
  return await getDocsIndex(deps.cfg.docsUrl);
1303
- return await searchDocs(deps.cfg.docsUrl, query, args.limit ?? 4);
1387
+ return await searchDocs(deps.cfg.docsUrl, query, {
1388
+ limit: args.limit ?? 4,
1389
+ maxChars: args.max_chars,
1390
+ });
1304
1391
  }
1305
1392
  catch (err) {
1306
1393
  // Both branches keep the origin as `cause` — a docs outage is a
@@ -1341,11 +1428,11 @@ export function registerTools(server, deps) {
1341
1428
  add({
1342
1429
  name: 'generate_welcome_page_workflow',
1343
1430
  description: 'Get the design brief for a browser-extension welcome page — the page a user lands on ' +
1344
- 'right after installing. Returns the one action the page must drive (pin the extension, ' +
1431
+ 'right after installing. Returns the one action the page is built to drive (pin the extension, ' +
1345
1432
  'keep the new-tab change, use it on a site), exactly which illustrations to produce for ' +
1346
1433
  'that goal, how to capture and crop them, how to place numbered/arrow markers showing ' +
1347
- 'where to click, the limits enforced on save, and the block JSON to hand back. Pair with ' +
1348
- 'get_extension to reuse the store screenshots Extenshi already hosts. Static content: no ' +
1434
+ 'where to click, the limits enforced on save, and the block JSON format. Store screenshots ' +
1435
+ 'Extenshi already hosts (from get_extension) can serve as illustrations. Static content: no ' +
1349
1436
  'API key, no network, no credits.',
1350
1437
  parameters: z.object({
1351
1438
  extension_name: z.string().max(120).optional().describe('Extension display name.'),
@@ -1397,16 +1484,16 @@ export function registerTools(server, deps) {
1397
1484
  description: 'The kinds of browser extension you can build, with the permissions each one REQUIRES: ' +
1398
1485
  'Popup (toolbar window), Side panel (docked beside the page), Page enhancer (runs on sites ' +
1399
1486
  'you list), In-page assistant (your own UI over any site). Types combine, and the required ' +
1400
- 'permissions are the union. Call this BEFORE writing a manifest so you request the minimum ' +
1401
- 'a shape actually needs instead of guessing — padded permissions are what store review ' +
1402
- 'pushes back on. Also states the cross-browser rules (Chromium side_panel vs Firefox ' +
1403
- 'sidebar_action) and the rule that a manifest must never name a file the package lacks. ' +
1404
- "FREE: no API key, no quota. For a specific project's chosen types use get_project_state.",
1487
+ 'permissions are the union. Applies when choosing manifest permissions: it gives the minimum ' +
1488
+ 'each shape needs (store review flags padded permissions). Also states the cross-browser ' +
1489
+ 'rules (Chromium side_panel vs Firefox sidebar_action) and the rule that a manifest may not ' +
1490
+ 'name a file the package lacks. ' +
1491
+ "FREE: no API key, no quota. A specific project's chosen types are in get_project_state.",
1405
1492
  parameters: z.object({
1406
1493
  types: z
1407
1494
  .array(z.string())
1408
1495
  .optional()
1409
- .describe('Limit to these type ids (popup, sidepanel, content, overlay). Omit for all four.'),
1496
+ .describe('Type ids to include (popup, sidepanel, content, overlay); all four when omitted.'),
1410
1497
  }),
1411
1498
  execute: async (args) => renderExtensionTemplates(args.types),
1412
1499
  });
@@ -1470,14 +1557,15 @@ export function registerTools(server, deps) {
1470
1557
  if (caps.has('publish')) {
1471
1558
  add({
1472
1559
  name: 'publish_extension',
1473
- description: 'Publish an extension artifact (.zip/.crx/.xpi) to Chrome Web Store, Firefox AMO, and/or Edge Add-ons. ' +
1560
+ description: 'Publish an extension artifact (.zip/.crx/.xpi) to Chrome Web Store, Firefox AMO, and/or Edge Add-ons: ' +
1561
+ 'uploads and submits a new version to each selected store. ' +
1474
1562
  'FREE and fully local: the upload goes from this machine straight to the store APIs using store ' +
1475
1563
  'credentials from the MCP server environment (CHROME_APP_ID/CHROME_CLIENT_ID/CHROME_CLIENT_SECRET/' +
1476
1564
  'CHROME_REFRESH_TOKEN, FIREFOX_ADDON_GUID/FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET, ' +
1477
1565
  'EDGE_PRODUCT_ID/EDGE_CLIENT_ID/EDGE_CLIENT_SECRET/EDGE_TENANT_ID). The upload itself is local, but ' +
1478
- 'publishing is in an active testing phase: a quick Extenshi access check runs first (set EXTENSHI_API_KEY ' +
1479
- 'so it can recognize your account). Edge submissions are polled to a terminal status. ' +
1480
- 'Recommended flow: scan_extension first, then publish.',
1566
+ 'publishing is in an active testing phase: a quick Extenshi access check runs first (it identifies your ' +
1567
+ 'account by EXTENSHI_API_KEY). Edge submissions are polled to a terminal status. The artifact is ' +
1568
+ 'not security-scanned by this tool (scanning is scan_extension).',
1481
1569
  parameters: z.object({
1482
1570
  artifact_path: z
1483
1571
  .string()