@extenshi/mcp 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +9 -4
  2. package/dist/bff.d.ts +17 -0
  3. package/dist/bff.d.ts.map +1 -1
  4. package/dist/bff.js +4 -0
  5. package/dist/bff.js.map +1 -1
  6. package/dist/development-guide.d.ts +109 -79
  7. package/dist/development-guide.d.ts.map +1 -1
  8. package/dist/development-guide.js +133 -45
  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/json-ld.d.ts +89 -0
  19. package/dist/json-ld.d.ts.map +1 -0
  20. package/dist/json-ld.js +118 -0
  21. package/dist/json-ld.js.map +1 -0
  22. package/dist/landing-page.d.ts +123 -0
  23. package/dist/landing-page.d.ts.map +1 -0
  24. package/dist/landing-page.js +405 -0
  25. package/dist/landing-page.js.map +1 -0
  26. package/dist/landing-workflow.d.ts +49 -0
  27. package/dist/landing-workflow.d.ts.map +1 -0
  28. package/dist/landing-workflow.js +80 -0
  29. package/dist/landing-workflow.js.map +1 -0
  30. package/dist/localize-workflow.d.ts +6 -1
  31. package/dist/localize-workflow.d.ts.map +1 -1
  32. package/dist/localize-workflow.js +65 -47
  33. package/dist/localize-workflow.js.map +1 -1
  34. package/dist/pay.d.ts +1 -1
  35. package/dist/pay.d.ts.map +1 -1
  36. package/dist/pay.js +11 -11
  37. package/dist/pay.js.map +1 -1
  38. package/dist/privacy-ai-outcome.d.ts +21 -0
  39. package/dist/privacy-ai-outcome.d.ts.map +1 -0
  40. package/dist/privacy-ai-outcome.js +69 -0
  41. package/dist/privacy-ai-outcome.js.map +1 -0
  42. package/dist/scan.d.ts +7 -0
  43. package/dist/scan.d.ts.map +1 -1
  44. package/dist/scan.js +26 -2
  45. package/dist/scan.js.map +1 -1
  46. package/dist/search-filters.js +1 -1
  47. package/dist/search-filters.js.map +1 -1
  48. package/dist/shape.d.ts +34 -0
  49. package/dist/shape.d.ts.map +1 -1
  50. package/dist/shape.js +150 -3
  51. package/dist/shape.js.map +1 -1
  52. package/dist/telemetry.d.ts.map +1 -1
  53. package/dist/telemetry.js +3 -0
  54. package/dist/telemetry.js.map +1 -1
  55. package/dist/templates.js +5 -5
  56. package/dist/templates.js.map +1 -1
  57. package/dist/tools.d.ts +2 -1
  58. package/dist/tools.d.ts.map +1 -1
  59. package/dist/tools.js +398 -115
  60. package/dist/tools.js.map +1 -1
  61. package/dist/welcome-agent-brief.d.ts +9 -9
  62. package/dist/welcome-agent-brief.d.ts.map +1 -1
  63. package/dist/welcome-agent-brief.js +32 -32
  64. package/dist/welcome-agent-brief.js.map +1 -1
  65. package/dist/welcome-workflow.d.ts +5 -5
  66. package/dist/welcome-workflow.d.ts.map +1 -1
  67. package/dist/welcome-workflow.js +13 -14
  68. package/dist/welcome-workflow.js.map +1 -1
  69. package/package.json +3 -2
package/dist/tools.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * list_my_projects, get_project_state, get_project_scaffold,
27
27
  * list/get/publish/update privacy policy (hosted; Pro)
28
28
  * 'docs' → get_development_guide, search_docs, list_extension_templates, generate_icon_workflow,
29
- * generate_welcome_page_workflow, localize_workflow (free; no key)
29
+ * generate_welcome_page_workflow, generate_landing_page, localize_workflow (free; no key)
30
30
  * 'scan' → scan_extension (local artifact; stdio only)
31
31
  * 'publish' → publish_extension (local creds; stdio only)
32
32
  *
@@ -35,16 +35,20 @@
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';
42
+ import { LANDING_LIMITS } from './landing-page.js';
43
+ import { hostedLandingNextSteps, landingFormFromArgs, renderGenerateLandingPage } from './landing-workflow.js';
41
44
  import { renderLocalizeWorkflow } from './localize-workflow.js';
42
45
  import { PAY_OPERATIONS, paySchemas } from './pay.js';
46
+ import { describePrivacyAiUpdate } from './privacy-ai-outcome.js';
43
47
  import { PublishSetupError, publishArtifact, readStoreCredentials, validateStoreCredentials, } from './publish.js';
44
48
  import { checkPublishAccess } from './publish-access.js';
45
49
  import { ScanError, scanArtifact } from './scan.js';
46
50
  import { describeStoreConstraints, validateSearchFilters } from './search-filters.js';
47
- import { shapeExtension, shapeReviews, shapeSearch, shapeSecurity, shapeStoreRiskBatch } from './shape.js';
51
+ import { DEFAULT_MARKET_TOP_N, MARKET_FACETS, MAX_MARKET_TOP_N, shapeExtension, shapeMarketOverview, shapeReviews, shapeSearch, shapeSecurity, shapeStoreRiskBatch, } from './shape.js';
48
52
  import { captureError, captureEvent, classifyError } from './telemetry.js';
49
53
  import { renderExtensionTemplates } from './templates.js';
50
54
  import { renderCatalogPayload } from './untrusted.js';
@@ -53,6 +57,10 @@ import { renderWelcomeWorkflow } from './welcome-workflow.js';
53
57
  export const KEY_PAGE = 'https://dojo.extenshi.io/api-keys';
54
58
  export const SIGNUP_PAGE = 'https://auth.extenshi.io/signup';
55
59
  export const BILLING_PAGE = 'https://dojo.extenshi.io/billing';
60
+ export const HOSTED_PAGES_DOCS = 'https://docs.extenshi.io/developers/project-sync#hosted-pages-from-the-api';
61
+ // Every workspace write carries provenance; saying so up front saves the agent a
62
+ // failed first call on `provenance.toolVersion: Required`.
63
+ const PROVENANCE_HINT = ' `provenance` is required: {source: "agent", observedAt: ISO-8601 timestamp, toolVersion: your client name and version}.';
56
64
  export const MISSING_KEY_MESSAGE = "This tool needs an Extenshi API key, and you don't have one set up yet — " +
57
65
  "here's how to get going.\n\n" +
58
66
  'Getting started is free: no credit card required. Every account includes a ' +
@@ -238,12 +246,34 @@ function attribution(session) {
238
246
  * strongly typed as before.
239
247
  * Fail-soft: telemetry never alters the tool's result or its thrown error.
240
248
  */
249
+ /**
250
+ * Publish every tool's input schema with all `$ref`s inlined.
251
+ *
252
+ * fastmcp converts `parameters` through xsschema, which runs zod-to-json-schema
253
+ * with its default `$refStrategy: 'root'`: every zod instance used twice in one
254
+ * schema (the shared `digest`, `provenanceSchema` strings, …) becomes a bare
255
+ * `{"$ref": "#/properties/…"}` with no `type`. The Claude connector directory
256
+ * rejects those ("Add a type to this parameter: inputDigest"), and weaker MCP
257
+ * clients cannot resolve them either. xsschema prefers a Standard JSON Schema
258
+ * `jsonSchema` hook when one is present, so we supply an inlined conversion
259
+ * there; validation still goes through the original zod `~standard.validate`.
260
+ */
261
+ function withInlinedJsonSchema(parameters) {
262
+ const input = () => zodToJsonSchema(parameters, { $refStrategy: 'none' });
263
+ return Object.create(parameters, {
264
+ '~standard': { value: { ...parameters['~standard'], jsonSchema: { input, output: input } } },
265
+ });
266
+ }
241
267
  function instrument(tool) {
242
268
  const { name } = tool;
243
269
  const original = tool.execute;
244
270
  return {
245
271
  ...tool,
246
272
  execute: async (args, context) => {
273
+ // The hosted connector lets a user decline analytics on the consent
274
+ // screen; honour it for every event, including error reports.
275
+ if (context.session?.telemetry === false)
276
+ return original(args, context);
247
277
  const startedAt = Date.now();
248
278
  const { userId, sessionId } = attribution(context.session);
249
279
  const props = { tool: name, ...(sessionId ? { mcp_session_id: sessionId } : {}) };
@@ -290,8 +320,8 @@ const EXTENSION_REF_SHAPE = {
290
320
  .max(100)
291
321
  .optional()
292
322
  .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 ' +
323
+ '"cjpalhdlnbpafiamejdnhcphjbkeiagm"). A Chrome/Edge id (32 letters a–p) also requires ' +
324
+ '`store`: both stores share that format. Firefox ids (slug / GUID / email) are ' +
295
325
  'unambiguous. Resolving a store_id is free; only the read that follows costs a credit.'),
296
326
  store: z
297
327
  .enum(['CHROME', 'FIREFOX', 'EDGE'])
@@ -474,6 +504,12 @@ const TOOL_ANNOTATIONS = {
474
504
  idempotentHint: true,
475
505
  openWorldHint: false,
476
506
  },
507
+ generate_landing_page: {
508
+ title: 'Generate a static landing page',
509
+ readOnlyHint: true,
510
+ idempotentHint: true,
511
+ openWorldHint: false,
512
+ },
477
513
  scan_extension: {
478
514
  title: 'Scan an extension package',
479
515
  readOnlyHint: false,
@@ -511,6 +547,112 @@ const TOOL_ANNOTATIONS = {
511
547
  idempotentHint: true,
512
548
  openWorldHint: true,
513
549
  },
550
+ publish_landing_page: {
551
+ title: 'Publish a landing page on extenshi.io',
552
+ readOnlyHint: false,
553
+ idempotentHint: false,
554
+ destructiveHint: false,
555
+ openWorldHint: true,
556
+ },
557
+ get_landing_page: {
558
+ title: 'Read the hosted landing page',
559
+ readOnlyHint: true,
560
+ idempotentHint: true,
561
+ openWorldHint: true,
562
+ },
563
+ unpublish_landing_page: {
564
+ title: 'Unpublish the hosted landing page',
565
+ readOnlyHint: false,
566
+ idempotentHint: true,
567
+ destructiveHint: true,
568
+ openWorldHint: true,
569
+ },
570
+ remove_hosted_page: {
571
+ title: 'Remove a hosted page registration',
572
+ readOnlyHint: false,
573
+ destructiveHint: true,
574
+ openWorldHint: true,
575
+ },
576
+ };
577
+ /**
578
+ * LandingForm fields shared by generate_landing_page (offline HTML) and
579
+ * publish_landing_page (hosted on page.extenshi.io). Limits match the builder
580
+ * and the BFF, so a form valid for one is valid for the other.
581
+ */
582
+ const LANDING_FORM_SHAPE = {
583
+ extensionName: z.string().min(1).max(LANDING_LIMITS.extensionName).describe('Extension display name.'),
584
+ tagline: z.string().max(LANDING_LIMITS.tagline).optional().describe('One-line promise under the name.'),
585
+ description: z
586
+ .string()
587
+ .max(LANDING_LIMITS.description)
588
+ .optional()
589
+ .describe('Short paragraph on what the extension does; also the meta description.'),
590
+ accentColor: z
591
+ .string()
592
+ .max(9)
593
+ .optional()
594
+ .describe('Hex accent colour for the primary button and monogram (default #5e5ce6).'),
595
+ theme: z.enum(['light', 'dark']).optional().describe('Page colour scheme (default dark).'),
596
+ chromeUrl: z
597
+ .string()
598
+ .max(LANDING_LIMITS.url)
599
+ .optional()
600
+ .describe('Chrome Web Store listing URL. Empty or omitted hides the Chrome button.'),
601
+ firefoxUrl: z
602
+ .string()
603
+ .max(LANDING_LIMITS.url)
604
+ .optional()
605
+ .describe('Firefox Add-ons listing URL. Empty or omitted hides the Firefox button.'),
606
+ edgeUrl: z
607
+ .string()
608
+ .max(LANDING_LIMITS.url)
609
+ .optional()
610
+ .describe('Edge Add-ons listing URL. Empty or omitted hides the Edge button.'),
611
+ privacyPolicyUrl: z
612
+ .string()
613
+ .max(LANDING_LIMITS.url)
614
+ .optional()
615
+ .describe('Privacy policy URL for the footer (for example the hosted privacy.extenshi.io page).'),
616
+ supportUrl: z
617
+ .string()
618
+ .max(LANDING_LIMITS.url)
619
+ .optional()
620
+ .describe('Support link for the footer: an http(s) URL, or a single mailto: address (optionally ' +
621
+ '?subject= with a percent-encoded value).'),
622
+ homepageUrl: z
623
+ .string()
624
+ .max(LANDING_LIMITS.url)
625
+ .optional()
626
+ .describe('Public https URL the page will be served from: canonical link, og:url, JSON-LD url, and the ' +
627
+ 'base for relative screenshot paths in og:image.'),
628
+ features: z
629
+ .array(z.object({
630
+ title: z.string().max(LANDING_LIMITS.featureTitle),
631
+ description: z.string().max(LANDING_LIMITS.featureDescription).optional(),
632
+ }))
633
+ .max(LANDING_LIMITS.features)
634
+ .optional()
635
+ .describe('Feature cards; entries with an empty title are skipped.'),
636
+ screenshots: z
637
+ .array(z.object({
638
+ url: z.string().max(LANDING_LIMITS.url),
639
+ alt: z.string().max(LANDING_LIMITS.screenshotAlt).optional(),
640
+ }))
641
+ .max(LANDING_LIMITS.screenshots)
642
+ .optional()
643
+ .describe('Screenshots as https URLs (for example store screenshots from get_extension) or paths ' +
644
+ 'relative to index.html, each with alt text.'),
645
+ logoSvg: z
646
+ .string()
647
+ .max(LANDING_LIMITS.logoSvgBytes)
648
+ .optional()
649
+ .describe('Inline SVG logo source. Refused (monogram shown, reason in warnings) if it contains ' +
650
+ 'scripts, event handlers, foreignObject, animation, entity references or non-fragment hrefs.'),
651
+ logoUrl: z
652
+ .string()
653
+ .max(LANDING_LIMITS.logoUrl)
654
+ .optional()
655
+ .describe('Logo image as an https URL, a relative path, or a data:image/(png|jpeg|gif|webp);base64 URL.'),
514
656
  };
515
657
  /**
516
658
  * Register the Extenshi tools once on a FastMCP server.
@@ -530,7 +672,8 @@ export function registerTools(server, deps) {
530
672
  ...tool.annotations,
531
673
  title: tool.annotations?.title ?? TOOL_ANNOTATIONS[tool.name]?.title ?? tool.name.replaceAll('_', ' '),
532
674
  };
533
- server.addTool(instrument({ ...tool, annotations }));
675
+ const parameters = tool.parameters && withInlinedJsonSchema(tool.parameters);
676
+ server.addTool(instrument({ ...tool, annotations, ...(parameters ? { parameters } : {}) }));
534
677
  registeredTools.push({ name: tool.name, description: tool.description ?? '', annotations });
535
678
  }
536
679
  // Per-call helpers bound to the injected deps.
@@ -545,7 +688,7 @@ export function registerTools(server, deps) {
545
688
  for (const [method, name, , , mutation, description] of PAY_OPERATIONS) {
546
689
  add({
547
690
  name,
548
- description: `${description} Requires ${mutation ? 'pay.write' : 'pay.read'} permission. Never put developer API keys or Stripe secrets in extension code.`,
691
+ description: `${description} Requires ${mutation ? 'pay.write' : 'pay.read'} permission.`,
549
692
  parameters: paySchemas[method],
550
693
  annotations: {
551
694
  readOnlyHint: !mutation,
@@ -562,13 +705,22 @@ export function registerTools(server, deps) {
562
705
  if (err instanceof UserError && /^MCP_SCOPE_REQUIRED:pay\.(read|write)\./.test(err.message))
563
706
  throw err;
564
707
  const status = err?.data?.httpStatus;
708
+ // Only OUR authored backend messages are matched, never echoed, so a
709
+ // provider string can't leak through; they pick the precise guidance.
710
+ const backendMessage = err instanceof Error ? err.message : '';
565
711
  const message = status === 403
566
712
  ? '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.';
713
+ : status === 412 && backendMessage.includes('Pay application is archived')
714
+ ? 'This Pay application is archived. Use an active application from list_pay_apps, or create a new one.'
715
+ : status === 412
716
+ ? 'Pay prerequisites are incomplete. Read get_pay_readiness; the author completes legal acceptance and Stripe KYC in the browser.'
717
+ : status === 404 && backendMessage.includes('No payment gateway connected')
718
+ ? '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.'
719
+ : status === 404
720
+ ? 'Pay application or resource not found. Check the appId with list_pay_apps.'
721
+ : status === 401
722
+ ? 'Pay authentication failed. Reconnect or configure a developer API key with explicit Pay permissions.'
723
+ : 'Pay request failed. Check authentication, application readiness and backend support before retrying a mutation.';
572
724
  // Provider failures may contain customer data or credentials; never echo them.
573
725
  throw userErrorFrom(message, err);
574
726
  }
@@ -577,7 +729,8 @@ export function registerTools(server, deps) {
577
729
  }
578
730
  add({
579
731
  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.',
732
+ 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.' +
733
+ PROVENANCE_HINT,
581
734
  parameters: importManifestSchema,
582
735
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
583
736
  execute: async (args, context) => {
@@ -597,7 +750,10 @@ export function registerTools(server, deps) {
597
750
  ],
598
751
  [
599
752
  '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.',
753
+ '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. ' +
754
+ 'Readiness is measured against the release recorded in the workspace: until apply_project_patch sets patch.release ' +
755
+ '(browser, version, artifactDigest, manifestDigest, locales, paymentRequired, and commit or dirtyTreeDigest) it reports ' +
756
+ 'RELEASE_NOT_RECORDED; after that, evidence counts only when it matches that release artifactDigest.',
601
757
  'getReleaseReadiness',
602
758
  ],
603
759
  ];
@@ -629,7 +785,7 @@ export function registerTools(server, deps) {
629
785
  });
630
786
  add({
631
787
  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.',
788
+ 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
789
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
634
790
  parameters: z.object({}),
635
791
  execute: async (_args, context) => {
@@ -653,8 +809,8 @@ export function registerTools(server, deps) {
653
809
  add({
654
810
  name,
655
811
  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.',
812
+ ? `Preview a three-way metadata diff against a server-held base revision. No writes.${PROVENANCE_HINT}`
813
+ : `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
814
  annotations: {
659
815
  readOnlyHint: dryRun,
660
816
  idempotentHint: true,
@@ -673,7 +829,7 @@ export function registerTools(server, deps) {
673
829
  });
674
830
  add({
675
831
  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.',
832
+ 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
833
  annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, openWorldHint: true },
678
834
  parameters: z.object({ projectId: z.string().uuid() }),
679
835
  execute: async (args, context) => {
@@ -687,7 +843,7 @@ export function registerTools(server, deps) {
687
843
  });
688
844
  add({
689
845
  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.",
846
+ 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
847
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
692
848
  parameters: z.object({
693
849
  projectId: z.string().uuid(),
@@ -705,7 +861,7 @@ export function registerTools(server, deps) {
705
861
  });
706
862
  add({
707
863
  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.",
864
+ 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
865
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
710
866
  parameters: z.object({
711
867
  projectId: z.string().uuid(),
@@ -720,6 +876,86 @@ export function registerTools(server, deps) {
720
876
  }
721
877
  },
722
878
  });
879
+ add({
880
+ name: 'remove_hosted_page',
881
+ 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}`,
882
+ annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
883
+ parameters: z.object({
884
+ projectId: z.string().uuid(),
885
+ kind: z.enum(['homepage', 'support']),
886
+ }),
887
+ execute: async (args, context) => {
888
+ try {
889
+ return JSON.stringify(await bff(context).removeHostedPage(args));
890
+ }
891
+ catch (err) {
892
+ return readError(err, missingKeyMessage);
893
+ }
894
+ },
895
+ });
896
+ add({
897
+ name: 'publish_landing_page',
898
+ description: "Publish the project's landing page (homepage) on Extenshi hosting — the same page " +
899
+ 'generate_landing_page returns, rendered server-side from the form (no caller HTML is stored) and ' +
900
+ 'served at page.extenshi.io/{code}, or https://{custom-domain}/landing when the project has an ' +
901
+ 'active custom domain. The code is permanent: republishing creates a new version at the same URL. ' +
902
+ 'By default the URL is also registered as the project homepage, which feeds HOMEPAGE_URL. Hosted ' +
903
+ 'images are limited to store screenshot URLs (from get_extension) and images uploaded in the Dojo ' +
904
+ 'Page generator; the logo may be an inline SVG or a raster data: URL. Returns JSON {url, publicCode, ' +
905
+ 'versionNumber, contentHash, bytes, warnings, homepage, nextSteps}. Requires hosted.write. ' +
906
+ `API reference: ${HOSTED_PAGES_DOCS}`,
907
+ parameters: z.object({
908
+ projectId: z.string().uuid(),
909
+ registerAsHomepage: z
910
+ .boolean()
911
+ .optional()
912
+ .describe('Register the URL as the project homepage (default true). False keeps an existing homepage.'),
913
+ ...LANDING_FORM_SHAPE,
914
+ }),
915
+ execute: async (args, context) => {
916
+ const { projectId, registerAsHomepage, ...fields } = args;
917
+ try {
918
+ const result = (await bff(context).publishLandingPage({
919
+ projectId,
920
+ registerAsHomepage,
921
+ form: landingFormFromArgs(fields),
922
+ }));
923
+ return JSON.stringify({ ...result, nextSteps: hostedLandingNextSteps(result) });
924
+ }
925
+ catch (err) {
926
+ return readError(err, missingKeyMessage);
927
+ }
928
+ },
929
+ });
930
+ add({
931
+ name: 'get_landing_page',
932
+ description: "The project's hosted landing page: public code, live version, publish state, URL (null when " +
933
+ 'unpublished) and the form the live version was rendered from.',
934
+ parameters: z.object({ projectId: z.string().uuid() }),
935
+ execute: async (args, context) => {
936
+ try {
937
+ return JSON.stringify(await bff(context).getLandingPage(args));
938
+ }
939
+ catch (err) {
940
+ return readError(err, missingKeyMessage);
941
+ }
942
+ },
943
+ });
944
+ add({
945
+ name: 'unpublish_landing_page',
946
+ description: 'Take the hosted landing page offline (it answers 404). The public code and version history are ' +
947
+ 'kept, so publishing again restores the same URL. A homepage registration that points at this ' +
948
+ `page is removed with it. Requires hosted.write. API reference: ${HOSTED_PAGES_DOCS}`,
949
+ parameters: z.object({ projectId: z.string().uuid() }),
950
+ execute: async (args, context) => {
951
+ try {
952
+ return JSON.stringify(await bff(context).unpublishLandingPage(args));
953
+ }
954
+ catch (err) {
955
+ return readError(err, missingKeyMessage);
956
+ }
957
+ },
958
+ });
723
959
  add({
724
960
  name: 'list_hosted_pages',
725
961
  description: 'List the project registered hosted pages (homepage/support) with their verification status, content hash and last check time.',
@@ -736,7 +972,8 @@ export function registerTools(server, deps) {
736
972
  });
737
973
  add({
738
974
  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.',
975
+ 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.' +
976
+ PROVENANCE_HINT,
740
977
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
741
978
  parameters: agentEvidenceSchema,
742
979
  execute: async (args, context) => {
@@ -751,10 +988,10 @@ export function registerTools(server, deps) {
751
988
  add({
752
989
  name: 'search_extensions',
753
990
  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. ' +
991
+ 'All filters (rating/reviews thresholds, store, category, pricing, risk, permissions, ' +
992
+ 'freshness, manifest version, trader status) are applied server-side by the catalog ' +
993
+ 'database, so a filtered request returns only the matching subset. `skip` pages through ' +
994
+ 'large result sets. ' +
758
995
  `${describeStoreConstraints()} ` +
759
996
  'Returns a compact list for market research and competitive analysis.',
760
997
  parameters: z.object({
@@ -762,7 +999,7 @@ export function registerTools(server, deps) {
762
999
  stores: z
763
1000
  .array(z.enum(['CHROME', 'FIREFOX', 'EDGE']))
764
1001
  .optional()
765
- .describe('Limit to stores: CHROME, FIREFOX, and/or EDGE.'),
1002
+ .describe('Stores to search: CHROME, FIREFOX and/or EDGE.'),
766
1003
  categories: z.array(z.string()).optional().describe('Catalog category slugs to include.'),
767
1004
  pricing: z
768
1005
  .array(z.enum(['FREE', 'FREEMIUM', 'IN_APP_PURCHASES', 'SUBSCRIPTION']))
@@ -779,9 +1016,9 @@ export function registerTools(server, deps) {
779
1016
  .number()
780
1017
  .min(0)
781
1018
  .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.'),
1019
+ .describe('Minimum weekly downloads — FIREFOX-ONLY metric (not reported by Chrome/Edge). ' +
1020
+ 'Chrome/Edge popularity is available through sortBy:"popular". A request combining ' +
1021
+ 'it with a Chrome/Edge-only `stores` filter is rejected.'),
785
1022
  minReviews: z.number().min(0).optional().describe('Minimum number of store reviews/ratings.'),
786
1023
  updatedWithin: z
787
1024
  .enum(['30d', '90d', '1y', 'stale'])
@@ -806,7 +1043,7 @@ export function registerTools(server, deps) {
806
1043
  includeDelisted: z
807
1044
  .boolean()
808
1045
  .optional()
809
- .describe('Include extensions whose store listing was removed (default: hidden).'),
1046
+ .describe('Whether extensions whose store listing was removed are included (default: excluded).'),
810
1047
  sortBy: z
811
1048
  .enum(['relevance', 'popular', 'rating', 'recent', 'name', 'safety', 'trader', 'size'])
812
1049
  .optional()
@@ -880,10 +1117,10 @@ export function registerTools(server, deps) {
880
1117
  'reviews link). Chrome Web Store review rows are NOT returned (their text cannot be redistributed) — ' +
881
1118
  'for a Chrome extension the `aggregate` (with a link to the store reviews tab) is the only public ' +
882
1119
  '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.',
1120
+ 'Paginated newest-first by default (or by highest rating); `cursor` takes the `nextCursor` ' +
1121
+ 'from a previous call. Cursors are sort-specific: a cursor is valid only with the `sort` ' +
1122
+ 'it was issued for. Reads existing scraped reviews; `min_rating` narrows to positive or ' +
1123
+ 'critical feedback.',
887
1124
  parameters: z.object({
888
1125
  ...EXTENSION_REF_SHAPE,
889
1126
  limit: z.number().int().min(1).max(50).default(20).describe('Max reviews to return (1–50).'),
@@ -891,7 +1128,7 @@ export function registerTools(server, deps) {
891
1128
  .number()
892
1129
  .int()
893
1130
  .optional()
894
- .describe('Pagination cursor — pass the `nextCursor` returned by a previous call.'),
1131
+ .describe('Pagination cursor: the `nextCursor` value returned by a previous call.'),
895
1132
  language_id: z.number().int().optional().describe('Only reviews in this catalog language id.'),
896
1133
  min_rating: z
897
1134
  .number()
@@ -930,9 +1167,8 @@ export function registerTools(server, deps) {
930
1167
  description: 'Get the security analysis for an extension: safety score (0–100, higher = safer — the ' +
931
1168
  'same coefficient the website shows), risk category, finding counts by ' +
932
1169
  '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. ' +
1170
+ 'an approximate install-dialog preview computed from the store-listed permissions ' +
1171
+ '(consolidated + deduped), available even for unscanned extensions. Reads existing scan results — does not trigger a new scan. ' +
936
1172
  'Identify the extension by numeric `extension_id` OR `store_id` (+`store` for Chrome/Edge). ' +
937
1173
  'NB: this tool costs 3 read credits (it fetches security, risk, and install-preview data).',
938
1174
  parameters: z.object({ ...EXTENSION_REF_SHAPE }),
@@ -963,15 +1199,15 @@ export function registerTools(server, deps) {
963
1199
  description: 'Look up the safety score and risk category for MANY extensions at once, addressed by their ' +
964
1200
  'STORE ids (the id in the store URL) — the inventory case: you have a list of what a user has ' +
965
1201
  `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 ' +
1202
+ 'the WHOLE call costs 1 read credit (not 1 per extension; get_security costs 3 credits ' +
1203
+ 'per extension). Returns safety score (0–100, higher ' +
968
1204
  '= safer, the same number the website shows), risk category, severity counts, last scan date ' +
969
1205
  'and the catalog URL per extension. Each store id is resolved through its cross-store cluster ' +
970
1206
  'to the same listing the extension page renders, so these answers agree with the website. ' +
971
1207
  '`store` is REQUIRED per entry — Chrome and Edge ids share one format. An extension with no ' +
972
1208
  '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.',
1209
+ 'under `notInCatalog`. Neither means "safe". The full findings list of ONE extension comes ' +
1210
+ 'from get_security.',
975
1211
  parameters: z.object({
976
1212
  extensions: z
977
1213
  .array(z.object({
@@ -986,8 +1222,8 @@ export function registerTools(server, deps) {
986
1222
  }))
987
1223
  .min(1)
988
1224
  .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.`),
1225
+ .describe(`The extensions to look up (max ${MAX_BATCH_EXTENSIONS}). A longer list takes several ` +
1226
+ `calls of up to ${MAX_BATCH_EXTENSIONS}; each call costs 1 read credit.`),
991
1227
  }),
992
1228
  execute: async (args, context) => {
993
1229
  try {
@@ -1011,10 +1247,22 @@ export function registerTools(server, deps) {
1011
1247
  query: z.string().optional().describe('Optional query to scope facets to a search.'),
1012
1248
  stores: z.array(z.enum(['CHROME', 'FIREFOX', 'EDGE'])).optional(),
1013
1249
  categories: z.array(z.string()).optional(),
1250
+ facets: z
1251
+ .array(z.enum(MARKET_FACETS))
1252
+ .optional()
1253
+ .describe('Facet groups to include: stats, stores, categories, extended, questionnaire, downloads; all groups when omitted.'),
1254
+ top_n: z
1255
+ .number()
1256
+ .int()
1257
+ .min(1)
1258
+ .max(MAX_MARKET_TOP_N)
1259
+ .optional()
1260
+ .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
1261
  }),
1015
1262
  execute: async (args, context) => {
1016
1263
  try {
1017
1264
  const client = bff(context);
1265
+ const shapeOpts = { facets: args.facets, topN: args.top_n };
1018
1266
  const scoped = Boolean(args.query?.trim() || args.stores?.length || args.categories?.length);
1019
1267
  // Search-scoped: progressive-narrowing facets over the match set.
1020
1268
  if (scoped) {
@@ -1024,11 +1272,7 @@ export function registerTools(server, deps) {
1024
1272
  .getSearchFacets({ query: args.query, stores: args.stores, categories: args.categories })
1025
1273
  .catch(() => null),
1026
1274
  ]);
1027
- return renderCatalogPayload({
1028
- scope: 'search',
1029
- stats: stats ?? undefined,
1030
- facets: facets ?? undefined,
1031
- });
1275
+ return renderCatalogPayload(shapeMarketOverview({ scope: 'search', stats: stats ?? undefined, facets: facets ?? undefined }, shapeOpts));
1032
1276
  }
1033
1277
  // Catalog-wide: the unscoped search facets are all-zero by design, so build the
1034
1278
  // overview from the dedicated catalog-wide procedures (cached server-side).
@@ -1049,7 +1293,7 @@ export function registerTools(server, deps) {
1049
1293
  count: r.count,
1050
1294
  label: STORE_LABELS[r.store] ?? r.store,
1051
1295
  }));
1052
- return renderCatalogPayload({
1296
+ return renderCatalogPayload(shapeMarketOverview({
1053
1297
  scope: 'catalog-wide',
1054
1298
  stats: stats ?? undefined,
1055
1299
  facets: {
@@ -1057,8 +1301,8 @@ export function registerTools(server, deps) {
1057
1301
  categoryTree: categoryTree ?? undefined,
1058
1302
  extended: extended ?? undefined,
1059
1303
  },
1060
- note: 'Monetization and download-volume facets are only computed when scoped to a query — pass `query` to get those.',
1061
- });
1304
+ note: 'Monetization and download-volume facets are computed only for a query-scoped overview.',
1305
+ }, shapeOpts));
1062
1306
  }
1063
1307
  catch (err) {
1064
1308
  return readError(err, missingKeyMessage);
@@ -1067,15 +1311,15 @@ export function registerTools(server, deps) {
1067
1311
  });
1068
1312
  add({
1069
1313
  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 ' +
1314
+ description: 'Report your remaining Extenshi credits — for checking whether a large request fits the ' +
1315
+ 'balance before running it. Returns every credit pool for your API key: `read` (spent one-per-call by ' +
1072
1316
  'search_extensions / get_extension / get_reviews / market_overview / get_risk_by_store_ids — the ' +
1073
1317
  'last one covers up to 40 extensions for that single credit; get_security costs 3) and ' +
1074
1318
  '`scan` (spent by scan_extension), plus `icon` and `inventory`. Each pool reports `remaining` — the ' +
1075
1319
  '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).',
1320
+ '(how much of the one-time, non-renewing signup grant is left). FREE to call: checking the balance ' +
1321
+ 'spends no credit. Fetching N extensions takes read.remaining ≥ N (≥ 3N with get_security ' +
1322
+ 'on each).',
1079
1323
  parameters: z.object({}),
1080
1324
  execute: async (_args, context) => {
1081
1325
  try {
@@ -1098,9 +1342,9 @@ export function registerTools(server, deps) {
1098
1342
  name: 'list_my_projects',
1099
1343
  description: "List the extension projects owned by this API key — the developer's own workspaces on " +
1100
1344
  '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.',
1345
+ 'the claimed store listing (if any). Applies when the developer refers to "my extension" or ' +
1346
+ '"my project": the returned id is what get_project_state takes. FREE — reading your own ' +
1347
+ 'projects spends no credit.',
1104
1348
  parameters: z.object({}),
1105
1349
  execute: async (_args, context) => {
1106
1350
  try {
@@ -1117,22 +1361,22 @@ export function registerTools(server, deps) {
1117
1361
  'they picked (popup / side panel / page enhancer / in-page assistant), the required ' +
1118
1362
  'permissions those types force, the exact manifest.json the project produces for each target ' +
1119
1363
  '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.',
1364
+ 'every saved tool state with its size. It reflects the site configuration at the time of the ' +
1365
+ 'call; later changes on the site appear in a later read. The additive workspace envelope ' +
1366
+ 'holds revisioned local observations separately from these scaffold drafts. It also returns ' +
1367
+ 'the integration contract: `integration.file` is the exact content for `integration.path` ' +
1368
+ 'that wires the extension to this project. Those bytes carry a fingerprint extenshi.io uses ' +
1369
+ "to tell the developer's edits from its own; a reassembled equivalent file lacks it, and the " +
1370
+ 'site then stops managing the values in it. `integration.unwired` lists the links this ' +
1371
+ 'project has not set up yet (no URLs exist for them). Tool-state payloads are NOT inlined by ' +
1372
+ 'default (a single row can be 256 KiB); includeToolStates selects which keys to inline. ' +
1373
+ 'FREE — spends no credit. Type definitions are in list_extension_templates.',
1130
1374
  parameters: z.object({
1131
1375
  projectId: z.string().describe('Project id from list_my_projects.'),
1132
1376
  includeToolStates: z
1133
1377
  .array(z.string())
1134
1378
  .optional()
1135
- .describe('Tool keys whose saved payload to inline, e.g. ["manifest-generator", "privacy-policy-generator"]. Omit to get the index only.'),
1379
+ .describe('Tool keys whose saved payload to inline, e.g. ["manifest-generator", "privacy-policy-generator"]. When omitted, only the index is returned.'),
1136
1380
  }),
1137
1381
  execute: async (args, context) => {
1138
1382
  try {
@@ -1152,18 +1396,18 @@ export function registerTools(server, deps) {
1152
1396
  description: "The starter extension one of the developer's own projects produces, as FILES ready to " +
1153
1397
  'write: manifest.json for the target browser, the background worker, the panel or ' +
1154
1398
  "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.',
1399
+ 'src/extenshi.config.js. The config carries a fingerprint that lets extenshi.io recognise ' +
1400
+ "the repository as this project's; a hand-rebuilt config lacks it, and the site then stops " +
1401
+ 'managing those values. Intended for a NEW extension: it is a complete starter set whose ' +
1402
+ 'files would replace same-named files in an existing repository. It is the same file set ' +
1403
+ "the site commits. One browser per call (default: the project's first target). " +
1404
+ 'FREE — spends no credit.',
1161
1405
  parameters: z.object({
1162
1406
  projectId: z.string().describe('Project id from list_my_projects.'),
1163
1407
  browser: z
1164
1408
  .enum(['chrome', 'firefox', 'edge'])
1165
1409
  .optional()
1166
- .describe("Target browser. Omit for the project's first declared target."),
1410
+ .describe("Target browser; defaults to the project's first declared target."),
1167
1411
  }),
1168
1412
  execute: async (args, context) => {
1169
1413
  try {
@@ -1198,8 +1442,8 @@ export function registerTools(server, deps) {
1198
1442
  });
1199
1443
  add({
1200
1444
  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.',
1445
+ description: 'Read the markdown (and HTML) of one hosted privacy-policy version; version numbers come from ' +
1446
+ 'list_privacy_policy_versions. Pro only. Does not spend a credit.',
1203
1447
  parameters: z.object({
1204
1448
  projectId: z.string().describe('Project id from list_my_projects.'),
1205
1449
  versionNumber: z
@@ -1219,17 +1463,17 @@ export function registerTools(server, deps) {
1219
1463
  });
1220
1464
  add({
1221
1465
  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.',
1466
+ description: 'Publish a hosted privacy policy for a Pro project. Changes the live public policy page. ' +
1467
+ 'Without bodyMarkdown it generates the policy from the saved form and manifest (the first ' +
1468
+ 'version is produced this way); with bodyMarkdown it publishes that edited (or AI-updated) ' +
1469
+ 'draft at the same public URL. Once published, PRIVACY_POLICY_URL in the integration.file ' +
1470
+ 'from get_project_state is the hosted URL. Pro only.',
1227
1471
  parameters: z.object({
1228
1472
  projectId: z.string().describe('Project id from list_my_projects.'),
1229
1473
  bodyMarkdown: z
1230
1474
  .string()
1231
1475
  .optional()
1232
- .describe('Full policy markdown. Omit to generate from current form + manifest.'),
1476
+ .describe('Full policy markdown. When omitted, the policy is generated from the saved form and manifest.'),
1233
1477
  kind: z
1234
1478
  .enum(['generated', 'edited', 'ai_updated', 'reverted'])
1235
1479
  .optional()
@@ -1247,15 +1491,18 @@ export function registerTools(server, deps) {
1247
1491
  add({
1248
1492
  name: 'update_privacy_policy_with_ai',
1249
1493
  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 ' +
1252
- 'policy that is already published. Pro only.',
1494
+ 'required by new permissions or data practices. Returns proposedMarkdown only; nothing goes ' +
1495
+ 'live. aiStep states whether the AI step ran (ran | did_not_run | discarded | not_needed) and ' +
1496
+ 'notice says why; unless aiStep is ran, proposedMarkdown is a deterministic section merge, not an ' +
1497
+ 'AI draft. conflictHeadings and missingCategories list what still needs a manual edit. Publishing ' +
1498
+ 'is a separate step (publish_privacy_policy with the returned kind). Requires a policy that is ' +
1499
+ 'already published. Pro only.',
1253
1500
  parameters: z.object({
1254
1501
  projectId: z.string().describe('Project id from list_my_projects.'),
1255
1502
  }),
1256
1503
  execute: async (args, context) => {
1257
1504
  try {
1258
- return JSON.stringify(await bff(context).updatePrivacyPolicyWithAi(args), null, 2);
1505
+ return JSON.stringify(describePrivacyAiUpdate(await bff(context).updatePrivacyPolicyWithAi(args)), null, 2);
1259
1506
  }
1260
1507
  catch (err) {
1261
1508
  return readError(err, missingKeyMessage);
@@ -1267,40 +1514,55 @@ export function registerTools(server, deps) {
1267
1514
  if (caps.has('docs')) {
1268
1515
  add({
1269
1516
  name: 'get_development_guide',
1270
- description: 'Start here when developing an extension: get the complete tool inventory for THIS connection, ' +
1517
+ description: 'For extension-development tasks: returns the complete tool inventory for THIS connection, ' +
1271
1518
  'the Extenshi service directory with access requirements and docs links, GitHub/code placement ' +
1272
1519
  'guidance, and the ordered workflow from idea and repository through implementation, assets, ' +
1273
1520
  'privacy, tests/CI, store submission and maintenance. Distinguishes MCP tools from cabinet, ' +
1274
1521
  '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),
1522
+ parameters: z.object({
1523
+ sections: z
1524
+ .array(z.enum(['all', ...GUIDE_SECTIONS]))
1525
+ .optional()
1526
+ .describe('Full guide sections to include: tools, localActions, services, repository, workflow, handoff, or all. ' +
1527
+ 'Omitted: a compact overview with tool names, workflow stages and a table of contents.'),
1528
+ }),
1529
+ execute: async (args) => JSON.stringify(buildDevelopmentGuide(registeredTools, args?.sections), null, 2),
1277
1530
  });
1278
1531
  add({
1279
1532
  name: 'search_docs',
1280
1533
  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.',
1534
+ 'full @extenshi/cli command reference (scan, review-risk, publish, login). Applies to ' +
1535
+ '"how do I…" questions and to exact CLI commands and flags. Free: reads public docs, no ' +
1536
+ 'API key or quota required. Without a query it lists every available documentation page.',
1285
1537
  parameters: z.object({
1286
1538
  query: z
1287
1539
  .string()
1288
1540
  .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.'),
1541
+ .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
1542
  limit: z
1291
1543
  .number()
1292
1544
  .int()
1293
1545
  .min(1)
1294
1546
  .max(10)
1295
1547
  .default(4)
1296
- .describe('Max documentation sections to return (1–10).'),
1548
+ .describe('Max documentation passages (page subsections) to return (1–10).'),
1549
+ max_chars: z
1550
+ .number()
1551
+ .int()
1552
+ .min(MIN_EXCERPT_CHARS)
1553
+ .max(MAX_EXCERPT_CHARS)
1554
+ .optional()
1555
+ .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
1556
  }),
1298
1557
  execute: async (args) => {
1299
1558
  try {
1300
1559
  const query = args.query?.trim();
1301
1560
  if (!query)
1302
1561
  return await getDocsIndex(deps.cfg.docsUrl);
1303
- return await searchDocs(deps.cfg.docsUrl, query, args.limit ?? 4);
1562
+ return await searchDocs(deps.cfg.docsUrl, query, {
1563
+ limit: args.limit ?? 4,
1564
+ maxChars: args.max_chars,
1565
+ });
1304
1566
  }
1305
1567
  catch (err) {
1306
1568
  // Both branches keep the origin as `cause` — a docs outage is a
@@ -1341,11 +1603,11 @@ export function registerTools(server, deps) {
1341
1603
  add({
1342
1604
  name: 'generate_welcome_page_workflow',
1343
1605
  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, ' +
1606
+ 'right after installing. Returns the one action the page is built to drive (pin the extension, ' +
1345
1607
  'keep the new-tab change, use it on a site), exactly which illustrations to produce for ' +
1346
1608
  '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 ' +
1609
+ 'where to click, the limits enforced on save, and the block JSON format. Store screenshots ' +
1610
+ 'Extenshi already hosts (from get_extension) can serve as illustrations. Static content: no ' +
1349
1611
  'API key, no network, no credits.',
1350
1612
  parameters: z.object({
1351
1613
  extension_name: z.string().max(120).optional().describe('Extension display name.'),
@@ -1392,21 +1654,36 @@ export function registerTools(server, deps) {
1392
1654
  accentColor: args.accent_color,
1393
1655
  }),
1394
1656
  });
1657
+ add({
1658
+ name: 'generate_landing_page',
1659
+ description: 'Generate a static landing page (homepage) for a browser extension as one self-contained HTML ' +
1660
+ 'file: no JavaScript, escaped text, http(s)-only links (the support link may also be a mailto: ' +
1661
+ 'address), and store buttons only for the store ' +
1662
+ 'URLs given. Same generator as the cabinet Page generator, so a saved `page-generator` form ' +
1663
+ 'from get_project_state maps field for field. Optional logo (inline SVG, validated and embedded ' +
1664
+ 'as an image, or an image URL), screenshots and the canonical homepage URL for Open Graph and ' +
1665
+ 'JSON-LD. Returns JSON {html, bytes, warnings, nextSteps}: warnings list input that was dropped ' +
1666
+ 'or clamped; nextSteps cover hosting the file on HTTPS and registering the URL with ' +
1667
+ 'upsert_hosted_page, which feeds HOMEPAGE_URL. Extenshi does not host the page. Static content: ' +
1668
+ 'no API key, no network, no credits.',
1669
+ parameters: z.object(LANDING_FORM_SHAPE),
1670
+ execute: async (args) => JSON.stringify(renderGenerateLandingPage(args)),
1671
+ });
1395
1672
  add({
1396
1673
  name: 'list_extension_templates',
1397
1674
  description: 'The kinds of browser extension you can build, with the permissions each one REQUIRES: ' +
1398
1675
  'Popup (toolbar window), Side panel (docked beside the page), Page enhancer (runs on sites ' +
1399
1676
  '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.",
1677
+ 'permissions are the union. Applies when choosing manifest permissions: it gives the minimum ' +
1678
+ 'each shape needs (store review flags padded permissions). Also states the cross-browser ' +
1679
+ 'rules (Chromium side_panel vs Firefox sidebar_action) and the rule that a manifest may not ' +
1680
+ 'name a file the package lacks. ' +
1681
+ "FREE: no API key, no quota. A specific project's chosen types are in get_project_state.",
1405
1682
  parameters: z.object({
1406
1683
  types: z
1407
1684
  .array(z.string())
1408
1685
  .optional()
1409
- .describe('Limit to these type ids (popup, sidepanel, content, overlay). Omit for all four.'),
1686
+ .describe('Type ids to include (popup, sidepanel, content, overlay); all four when omitted.'),
1410
1687
  }),
1411
1688
  execute: async (args) => renderExtensionTemplates(args.types),
1412
1689
  });
@@ -1454,7 +1731,12 @@ export function registerTools(server, deps) {
1454
1731
  }
1455
1732
  },
1456
1733
  });
1457
- return renderCatalogPayload(shapeExtension(report));
1734
+ // The scan report goes out as-is (defanged + labelled as data: finding
1735
+ // text quotes the artifact's own code). It used to be passed through
1736
+ // shapeExtension — the catalog-DETAIL shaper — whose field list has
1737
+ // none of the report's keys, so every scan came back as
1738
+ // {"snapshots": []} after the credit was spent.
1739
+ return renderCatalogPayload(report);
1458
1740
  }
1459
1741
  catch (err) {
1460
1742
  // Keep the ScanError as `cause`: scanErrorMessage() renders 402/429
@@ -1470,14 +1752,15 @@ export function registerTools(server, deps) {
1470
1752
  if (caps.has('publish')) {
1471
1753
  add({
1472
1754
  name: 'publish_extension',
1473
- description: 'Publish an extension artifact (.zip/.crx/.xpi) to Chrome Web Store, Firefox AMO, and/or Edge Add-ons. ' +
1755
+ description: 'Publish an extension artifact (.zip/.crx/.xpi) to Chrome Web Store, Firefox AMO, and/or Edge Add-ons: ' +
1756
+ 'uploads and submits a new version to each selected store. ' +
1474
1757
  'FREE and fully local: the upload goes from this machine straight to the store APIs using store ' +
1475
1758
  'credentials from the MCP server environment (CHROME_APP_ID/CHROME_CLIENT_ID/CHROME_CLIENT_SECRET/' +
1476
1759
  'CHROME_REFRESH_TOKEN, FIREFOX_ADDON_GUID/FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET, ' +
1477
1760
  '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.',
1761
+ 'publishing is in an active testing phase: a quick Extenshi access check runs first (it identifies your ' +
1762
+ 'account by EXTENSHI_API_KEY). Edge submissions are polled to a terminal status. The artifact is ' +
1763
+ 'not security-scanned by this tool (scanning is scan_extension).',
1481
1764
  parameters: z.object({
1482
1765
  artifact_path: z
1483
1766
  .string()