@extenshi/mcp 0.9.0 → 0.11.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 (40) hide show
  1. package/README.md +28 -5
  2. package/dist/bff.d.ts +108 -0
  3. package/dist/bff.d.ts.map +1 -1
  4. package/dist/bff.js +21 -0
  5. package/dist/bff.js.map +1 -1
  6. package/dist/development-guide.d.ts +128 -43
  7. package/dist/development-guide.d.ts.map +1 -1
  8. package/dist/development-guide.js +115 -13
  9. package/dist/development-guide.js.map +1 -1
  10. package/dist/icon-workflow.d.ts.map +1 -1
  11. package/dist/icon-workflow.js +15 -2
  12. package/dist/icon-workflow.js.map +1 -1
  13. package/dist/landing-page.d.ts +22 -4
  14. package/dist/landing-page.d.ts.map +1 -1
  15. package/dist/landing-page.js +14 -11
  16. package/dist/landing-page.js.map +1 -1
  17. package/dist/landing-sections.d.ts +332 -0
  18. package/dist/landing-sections.d.ts.map +1 -0
  19. package/dist/landing-sections.js +1071 -0
  20. package/dist/landing-sections.js.map +1 -0
  21. package/dist/landing-workflow.d.ts +18 -0
  22. package/dist/landing-workflow.d.ts.map +1 -1
  23. package/dist/landing-workflow.js +56 -5
  24. package/dist/landing-workflow.js.map +1 -1
  25. package/dist/pay.d.ts +32 -1
  26. package/dist/pay.d.ts.map +1 -1
  27. package/dist/pay.js +31 -0
  28. package/dist/pay.js.map +1 -1
  29. package/dist/project-media.d.ts +29 -0
  30. package/dist/project-media.d.ts.map +1 -0
  31. package/dist/project-media.js +72 -0
  32. package/dist/project-media.js.map +1 -0
  33. package/dist/publish.d.ts +101 -0
  34. package/dist/publish.d.ts.map +1 -1
  35. package/dist/publish.js +81 -2
  36. package/dist/publish.js.map +1 -1
  37. package/dist/tools.d.ts.map +1 -1
  38. package/dist/tools.js +796 -16
  39. package/dist/tools.js.map +1 -1
  40. package/package.json +12 -12
package/dist/tools.js CHANGED
@@ -32,7 +32,7 @@
32
32
  *
33
33
  * stdout is the MCP protocol channel for stdio — nothing here may write to it.
34
34
  */
35
- import { agentEvidenceSchema, importManifestSchema, workspacePatchSchema } from '@extenshi/contracts';
35
+ import { agentCreateProjectSchema, agentEvidenceSchema, importManifestSchema, proposeDecisionSchema, workspacePatchSchema, } from '@extenshi/contracts';
36
36
  import { UserError } from 'fastmcp';
37
37
  import { z } from 'zod';
38
38
  import { zodToJsonSchema } from 'zod-to-json-schema';
@@ -40,11 +40,13 @@ import { buildDevelopmentGuide, GUIDE_SECTIONS } from './development-guide.js';
40
40
  import { DEFAULT_EXCERPT_CHARS, DocsError, getDocsIndex, MAX_EXCERPT_CHARS, MIN_EXCERPT_CHARS, searchDocs, } from './docs.js';
41
41
  import { renderIconWorkflow } from './icon-workflow.js';
42
42
  import { LANDING_LIMITS } from './landing-page.js';
43
+ import { LANDING_FONTS, LANDING_SECTION_TYPES, LANDING_UI_DEFAULTS, LANDING_V2_LIMITS, } from './landing-sections.js';
43
44
  import { hostedLandingNextSteps, landingFormFromArgs, renderGenerateLandingPage } from './landing-workflow.js';
44
45
  import { renderLocalizeWorkflow } from './localize-workflow.js';
45
46
  import { PAY_OPERATIONS, paySchemas } from './pay.js';
46
47
  import { describePrivacyAiUpdate } from './privacy-ai-outcome.js';
47
- import { PublishSetupError, publishArtifact, readStoreCredentials, validateStoreCredentials, } from './publish.js';
48
+ import { checkInlineImage, PROJECT_MEDIA_MAX_BYTES, PROJECT_MEDIA_MIME_TYPES, ProjectMediaInputError, readWorkspaceImage, } from './project-media.js';
49
+ import { amoCreateAllowed, PublishSetupError, publishArtifact, storeCredentialsFor, validateStoreCredentials, } from './publish.js';
48
50
  import { checkPublishAccess } from './publish-access.js';
49
51
  import { ScanError, scanArtifact } from './scan.js';
50
52
  import { describeStoreConstraints, validateSearchFilters } from './search-filters.js';
@@ -90,8 +92,11 @@ export const SERVER_INSTRUCTIONS = 'Extenshi helps develop and operate browser e
90
92
  'plan from scope and research through code, assets, privacy, CI, store release and maintenance. ' +
91
93
  'Keep a checklist covering the whole requested lifecycle. For standalone Pay, start with list_pay_apps; create_pay_app needs no development project or repository. Request explicit pay.read/pay.write scopes, use get_pay_readiness, and leave agreement acceptance and KYC to the author in the browser. Only the publishable SDK key and public verification key belong in extension code. Use search_docs (free) for current ' +
92
94
  'product details and exact CLI flags. With identity, call list_my_projects then get_project_state ' +
93
- 'to reuse the existing project and repository. Use list_extension_templates before the manifest, ' +
94
- 'and get_project_scaffold for a new project. Preserve existing source. For an existing extension, read get_project_workspace then preview and apply import_manifest to fill Dojo from its real manifest and default-locale messages before configuring services. Review the integration diff and preserve local edits; write integration.file ' +
95
+ 'to reuse the existing project and repository; for a new extension, create_project makes one (idempotent). ' +
96
+ 'Read get_decisions early: the AMO add-on id, license, seller identity and pricing are owner decisions — ' +
97
+ 'propose defaults with propose_decision; the owner decides them in Dojo, and release readiness blocks on them. ' +
98
+ 'Use list_extension_templates before the manifest, ' +
99
+ 'and get_project_scaffold for a new project. Preserve existing source. For an existing extension, read get_project_workspace then preview and apply import_manifest to fill Dojo from its real manifest and its _locales messages (messagesByLocale) before configuring services. Review the integration diff and preserve local edits; write integration.file ' +
95
100
  'verbatim to integration.path, check integration.unwired and re-read state after cabinet edits. ' +
96
101
  'Check get_credit_balance before metered work. get_risk_by_store_ids covers up to 40 store IDs ' +
97
102
  'for one read; get_security costs three reads for detailed findings. Hosted policy tools require ' +
@@ -444,6 +449,26 @@ const TOOL_ANNOTATIONS = {
444
449
  idempotentHint: true,
445
450
  openWorldHint: false,
446
451
  },
452
+ create_project: {
453
+ title: 'Create an extension project',
454
+ readOnlyHint: false,
455
+ idempotentHint: true,
456
+ destructiveHint: false,
457
+ openWorldHint: true,
458
+ },
459
+ get_decisions: {
460
+ title: 'Read owner decisions',
461
+ readOnlyHint: true,
462
+ idempotentHint: true,
463
+ openWorldHint: true,
464
+ },
465
+ propose_decision: {
466
+ title: 'Propose an owner decision',
467
+ readOnlyHint: false,
468
+ idempotentHint: true,
469
+ destructiveHint: false,
470
+ openWorldHint: true,
471
+ },
447
472
  list_my_projects: {
448
473
  title: 'List my extension projects',
449
474
  readOnlyHint: true,
@@ -560,6 +585,58 @@ const TOOL_ANNOTATIONS = {
560
585
  idempotentHint: true,
561
586
  openWorldHint: true,
562
587
  },
588
+ draft_landing_page: {
589
+ title: 'Draft a homepage from project state',
590
+ readOnlyHint: true,
591
+ idempotentHint: true,
592
+ openWorldHint: true,
593
+ },
594
+ preview_landing_page: {
595
+ title: 'Preview and validate a homepage',
596
+ readOnlyHint: true,
597
+ idempotentHint: true,
598
+ openWorldHint: true,
599
+ },
600
+ list_landing_page_versions: {
601
+ title: 'List homepage versions',
602
+ readOnlyHint: true,
603
+ idempotentHint: true,
604
+ openWorldHint: true,
605
+ },
606
+ rollback_landing_page: {
607
+ title: 'Restore an earlier homepage version',
608
+ readOnlyHint: false,
609
+ idempotentHint: false,
610
+ destructiveHint: false,
611
+ openWorldHint: true,
612
+ },
613
+ get_custom_domain: {
614
+ title: 'Read the custom domain setup',
615
+ readOnlyHint: true,
616
+ idempotentHint: true,
617
+ openWorldHint: true,
618
+ },
619
+ set_custom_domain: {
620
+ title: 'Add a custom domain',
621
+ readOnlyHint: false,
622
+ idempotentHint: true,
623
+ destructiveHint: false,
624
+ openWorldHint: true,
625
+ },
626
+ verify_custom_domain: {
627
+ title: 'Verify a custom domain',
628
+ readOnlyHint: false,
629
+ idempotentHint: true,
630
+ destructiveHint: false,
631
+ openWorldHint: true,
632
+ },
633
+ remove_custom_domain: {
634
+ title: 'Remove the custom domain',
635
+ readOnlyHint: false,
636
+ idempotentHint: true,
637
+ destructiveHint: true,
638
+ openWorldHint: true,
639
+ },
563
640
  unpublish_landing_page: {
564
641
  title: 'Unpublish the hosted landing page',
565
642
  readOnlyHint: false,
@@ -567,6 +644,60 @@ const TOOL_ANNOTATIONS = {
567
644
  destructiveHint: true,
568
645
  openWorldHint: true,
569
646
  },
647
+ get_install_instructions: {
648
+ title: 'Read the hosted install instructions',
649
+ readOnlyHint: true,
650
+ idempotentHint: true,
651
+ openWorldHint: true,
652
+ },
653
+ publish_install_instructions: {
654
+ title: 'Publish install instructions on extenshi.io',
655
+ readOnlyHint: false,
656
+ idempotentHint: true,
657
+ destructiveHint: false,
658
+ openWorldHint: true,
659
+ },
660
+ unpublish_install_instructions: {
661
+ title: 'Unpublish the hosted install instructions',
662
+ readOnlyHint: false,
663
+ idempotentHint: true,
664
+ destructiveHint: true,
665
+ openWorldHint: true,
666
+ },
667
+ get_page_translations: {
668
+ title: 'Read uninstall / welcome page translations',
669
+ readOnlyHint: true,
670
+ idempotentHint: true,
671
+ openWorldHint: true,
672
+ },
673
+ set_page_translations: {
674
+ title: 'Save translations of an uninstall / welcome page',
675
+ readOnlyHint: false,
676
+ idempotentHint: true,
677
+ destructiveHint: false,
678
+ openWorldHint: true,
679
+ },
680
+ get_legal_translations: {
681
+ title: 'Read privacy policy / license terms translations',
682
+ readOnlyHint: true,
683
+ idempotentHint: true,
684
+ openWorldHint: true,
685
+ },
686
+ set_legal_translations: {
687
+ title: 'Save translations of the privacy policy / license terms',
688
+ readOnlyHint: false,
689
+ idempotentHint: true,
690
+ destructiveHint: false,
691
+ openWorldHint: true,
692
+ },
693
+ upload_project_media: {
694
+ title: 'Upload a public image for hosted pages',
695
+ readOnlyHint: false,
696
+ // Content-addressed: the same bytes always return the same URL.
697
+ idempotentHint: true,
698
+ destructiveHint: false,
699
+ openWorldHint: true,
700
+ },
570
701
  remove_hosted_page: {
571
702
  title: 'Remove a hosted page registration',
572
703
  readOnlyHint: false,
@@ -574,6 +705,120 @@ const TOOL_ANNOTATIONS = {
574
705
  openWorldHint: true,
575
706
  },
576
707
  };
708
+ const hex = z.string().max(9);
709
+ const palette = z
710
+ .object({
711
+ accent: hex.optional(),
712
+ accentText: hex.optional(),
713
+ background: hex.optional(),
714
+ surface: hex.optional(),
715
+ text: hex.optional(),
716
+ muted: hex.optional(),
717
+ border: hex.optional(),
718
+ })
719
+ .optional();
720
+ const FONT_IDS = Object.keys(LANDING_FONTS);
721
+ /**
722
+ * Schema v2 of the landing form (shared-types/landing-sections.ts): a full,
723
+ * declarative homepage. Only read when schemaVersion is 2; a form without it
724
+ * renders exactly as before. Section contents are checked by the shared
725
+ * landingFormProblems, so the errors here, in the CLI and on the server match.
726
+ */
727
+ const LANDING_V2_SHAPE = {
728
+ schemaVersion: z
729
+ .literal(2)
730
+ .optional()
731
+ .describe('2 builds a full homepage from sections, theme tokens, SEO and locales. Absent: the classic ' +
732
+ 'one-screen page (unchanged output). draft_landing_page returns a complete v2 form.'),
733
+ sections: z
734
+ .array(z
735
+ .object({
736
+ id: z
737
+ .string()
738
+ .max(LANDING_V2_LIMITS.sectionId)
739
+ .describe('Lowercase slug, unique; also the locale override key.'),
740
+ type: z.enum(LANDING_SECTION_TYPES),
741
+ hidden: z.boolean().optional().describe('Keep in the form, leave out of the page.'),
742
+ title: z.string().max(LANDING_V2_LIMITS.title).optional(),
743
+ intro: z.string().max(LANDING_V2_LIMITS.intro).optional(),
744
+ })
745
+ .passthrough())
746
+ .max(LANDING_V2_LIMITS.sections)
747
+ .optional()
748
+ .describe('Ordered page sections (v2). Fields per type: ' +
749
+ 'hero {headline?, subheadline?, showBadges?}; about {body — plain text, blank line = new paragraph}; ' +
750
+ 'features {items: [{title, description?}]}; screenshots {items: [{url, alt, caption?}]} (hosted: ' +
751
+ 'upload_project_media URLs or store screenshots); how-it-works {steps: [{title, description?}]}; ' +
752
+ 'pricing {plans: [{name?, price?, period? one-time|month|year|lifetime, description?, features?, ' +
753
+ "highlighted?, offerSku?, ctaLabel?, ctaUrl?}], note?} — offerSku binds the plan to the project's " +
754
+ 'Extenshi Pay offer so price and period come from the live offer; faq {items: [{question, answer}]} ' +
755
+ '(also emitted as FAQPage JSON-LD); testimonials {items: [{quote, author, role?, sourceUrl, ' +
756
+ 'sourceLabel?}]} — real, attributed quotes only: author and a public https sourceUrl are required; ' +
757
+ 'store-badges {browsers?: [chrome|firefox|edge]}; changelog {entries: [{version, ' +
758
+ 'date? YYYY-MM-DD, notes: [string]}]}; links {items?: [{label, url}], includeProjectLinks?}. ' +
759
+ 'Plain text only everywhere — no HTML, no scripts.'),
760
+ tokens: z
761
+ .object({
762
+ mode: z
763
+ .enum(['light', 'dark', 'auto'])
764
+ .optional()
765
+ .describe('auto ships both palettes (prefers-color-scheme).'),
766
+ light: palette,
767
+ dark: palette,
768
+ headingFont: z.enum(FONT_IDS).optional(),
769
+ bodyFont: z.enum(FONT_IDS).optional(),
770
+ radius: z.enum(['none', 'small', 'medium', 'large']).optional(),
771
+ width: z.enum(['narrow', 'normal', 'wide']).optional(),
772
+ })
773
+ .optional()
774
+ .describe('Theme tokens (v2): hex colours per mode and fonts from a system-font allowlist (no web fonts — the ' +
775
+ 'hosted CSP loads nothing external). Poor contrast is reported as a warning.'),
776
+ seo: z
777
+ .object({
778
+ title: z.string().max(LANDING_V2_LIMITS.seoTitle).optional(),
779
+ description: z.string().max(LANDING_V2_LIMITS.seoDescription).optional(),
780
+ ogImageUrl: z.string().max(LANDING_LIMITS.url).optional(),
781
+ twitterSite: z.string().max(16).optional(),
782
+ authorName: z.string().max(120).optional(),
783
+ noindex: z.boolean().optional(),
784
+ })
785
+ .optional()
786
+ .describe('SEO / Open Graph (v2): <title>, meta description, og:image, twitter:site, JSON-LD author.'),
787
+ defaultLocale: z.string().max(35).optional().describe('Locale of the base content (default en).'),
788
+ locales: z
789
+ .record(z.string().max(35), z
790
+ .object({
791
+ extensionName: z.string().max(LANDING_LIMITS.extensionName).optional(),
792
+ tagline: z.string().max(LANDING_LIMITS.tagline).optional(),
793
+ description: z.string().max(LANDING_LIMITS.description).optional(),
794
+ seo: z
795
+ .object({ title: z.string().optional(), description: z.string().optional() })
796
+ .passthrough()
797
+ .optional(),
798
+ ui: z.record(z.string(), z.string().max(120)).optional(),
799
+ sections: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
800
+ })
801
+ .passthrough())
802
+ .optional()
803
+ .describe('Translations (v2), keyed by locale (de, pt-BR or the _locales spelling pt_BR; hosted pages accept ' +
804
+ 'store locales). Each is served at {url}/{locale} with hreflang alternates. sections.{id} mirrors ' +
805
+ "that section's shape; only strings are taken (arrays merge by index), so a translation cannot " +
806
+ 'change structure. ui overrides built-in headings and button labels.'),
807
+ ui: z
808
+ .record(z.string(), z.string().max(LANDING_V2_LIMITS.shortText))
809
+ .optional()
810
+ .describe(`Built-in strings to override (v2): ${Object.keys(LANDING_UI_DEFAULTS).join(', ')}.`),
811
+ uninstallUrl: z
812
+ .string()
813
+ .max(LANDING_LIMITS.url)
814
+ .optional()
815
+ .describe('Hosted uninstall feedback form URL (footer, v2).'),
816
+ termsUrl: z
817
+ .string()
818
+ .max(LANDING_LIMITS.url)
819
+ .optional()
820
+ .describe('License terms & refunds URL (footer, v2).'),
821
+ };
577
822
  /**
578
823
  * LandingForm fields shared by generate_landing_page (offline HTML) and
579
824
  * publish_landing_page (hosted on page.extenshi.io). Limits match the builder
@@ -653,6 +898,7 @@ const LANDING_FORM_SHAPE = {
653
898
  .max(LANDING_LIMITS.logoUrl)
654
899
  .optional()
655
900
  .describe('Logo image as an https URL, a relative path, or a data:image/(png|jpeg|gif|webp);base64 URL.'),
901
+ ...LANDING_V2_SHAPE,
656
902
  };
657
903
  /**
658
904
  * Register the Extenshi tools once on a FastMCP server.
@@ -729,7 +975,7 @@ export function registerTools(server, deps) {
729
975
  }
730
976
  add({
731
977
  name: 'import_manifest',
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.' +
978
+ description: 'Import an existing manifest.json into the Dojo manifest editor and project labels. Input: parsed JSON, optional default-locale messages, and optional messagesByLocale — every _locales/<lang>/messages.json keyed by locale code (only the keys named by the __MSG_*__ fields of the manifest are needed) — whose non-default locales fill the Dojo translations of name and description. Repository translations replace editor text per field; translations the repository lacks are kept, and warnings name each replaced one. 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
979
  PROVENANCE_HINT,
734
980
  parameters: importManifestSchema,
735
981
  annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
@@ -742,6 +988,63 @@ export function registerTools(server, deps) {
742
988
  }
743
989
  },
744
990
  });
991
+ add({
992
+ name: 'create_project',
993
+ description: 'Create a new extension project (a Dojo workspace) owned by this identity, through the same service ' +
994
+ 'the Dojo project wizard uses, with the same ownership and limits. Returns JSON {projectId, dojoUrl, ' +
995
+ 'project, pendingDecisions, nextSteps}: the next steps cover binding the repository and the starter ' +
996
+ 'files. Idempotent: a repeated request with the same idempotencyKey returns the project it already ' +
997
+ 'created (replayed: true); the same key with different input is rejected. Requires project.write.',
998
+ parameters: agentCreateProjectSchema,
999
+ execute: async (args, context) => {
1000
+ try {
1001
+ return JSON.stringify(await bff(context).createProject(args), null, 2);
1002
+ }
1003
+ catch (err) {
1004
+ return readError(err, missingKeyMessage);
1005
+ }
1006
+ },
1007
+ });
1008
+ add({
1009
+ name: 'get_decisions',
1010
+ description: 'Owner decisions for one project: the AMO add-on id, license, seller public name, support contact ' +
1011
+ 'and terms, pricing, target browsers and target locales. Each entry reports its status (undecided, ' +
1012
+ 'proposed or decided), the value, a suggested default where one is derivable (a stable {GUID} add-on ' +
1013
+ 'id or the id already in the Firefox manifest, the project browsers), who proposed or decided it and ' +
1014
+ 'when, and which releases it blocks (blocksBrowsers, blocksPayment). `pending` lists the keys the ' +
1015
+ 'owner has not decided. FREE.',
1016
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1017
+ parameters: z.object({ projectId: z.string().uuid().describe('Project id from list_my_projects.') }),
1018
+ execute: async (args, context) => {
1019
+ try {
1020
+ return JSON.stringify(await bff(context).getDecisions(args), null, 2);
1021
+ }
1022
+ catch (err) {
1023
+ return readError(err, missingKeyMessage);
1024
+ }
1025
+ },
1026
+ });
1027
+ add({
1028
+ name: 'propose_decision',
1029
+ description: 'Propose a value for one owner decision. The proposal is stored with its rationale, proposer and ' +
1030
+ 'time; the owner accepts or edits it on the Dojo project page, which is the only place a decision ' +
1031
+ 'becomes decided. A key the owner already decided is refused (DECISION_ALREADY_DECIDED). Value ' +
1032
+ 'formats: amo.addonId {GUID} or name@domain; license an SPDX id such as MIT, or all-rights-reserved; ' +
1033
+ 'seller.publicName text; seller.supportContact an email or HTTPS URL; seller.terms an HTTPS URL; ' +
1034
+ 'pricing {model: free|paid|donations, trialDays?, plans?: subscription|lifetime|one_time}; ' +
1035
+ 'targetBrowsers an array of chrome/firefox/edge; targetLocales an array of locale codes. Idempotent ' +
1036
+ 'per idempotencyKey. Requires project.write.',
1037
+ annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
1038
+ parameters: proposeDecisionSchema,
1039
+ execute: async (args, context) => {
1040
+ try {
1041
+ return JSON.stringify(await bff(context).proposeDecision(args), null, 2);
1042
+ }
1043
+ catch (err) {
1044
+ return readError(err, missingKeyMessage);
1045
+ }
1046
+ },
1047
+ });
745
1048
  const reads = [
746
1049
  [
747
1050
  'get_project_workspace',
@@ -753,7 +1056,11 @@ export function registerTools(server, deps) {
753
1056
  '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
1057
  'Readiness is measured against the release recorded in the workspace: until apply_project_patch sets patch.release ' +
755
1058
  '(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.',
1059
+ 'RELEASE_NOT_RECORDED; after that, evidence counts only when it matches that release artifactDigest. ' +
1060
+ "When the project has a linked Pay application, the payment check also carries `platform`: what Extenshi's own " +
1061
+ 'payment ledger proves (checkout, license, installation activation, recovery, refund). A live-mode purchase through ' +
1062
+ "those stages passes the check with source 'platform'; test-mode purchases are listed without passing it. Clients cannot " +
1063
+ "record source 'platform'.",
757
1064
  'getReleaseReadiness',
758
1065
  ],
759
1066
  ];
@@ -897,11 +1204,15 @@ export function registerTools(server, deps) {
897
1204
  name: 'publish_landing_page',
898
1205
  description: "Publish the project's landing page (homepage) on Extenshi hosting — the same page " +
899
1206
  '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. ' +
1207
+ "served at page.extenshi.io/{code}, or at the root of the project's custom domain once it is ACTIVE " +
1208
+ '(set_custom_domain). A schema-v2 form (schemaVersion 2; draft_landing_page returns one, ' +
1209
+ 'preview_landing_page validates it) adds sections, theme tokens, SEO, per-locale pages at {url}/{locale} with ' +
1210
+ 'hreflang, and llms.txt; pricing plans with offerSku take price and period from the live Pay offer. ' +
1211
+ 'The code is permanent: republishing creates a new version at the same URL. ' +
902
1212
  'By default the URL is also registered as the project homepage, which feeds HOMEPAGE_URL. Hosted ' +
903
1213
  '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, ' +
1214
+ 'Page generator or with upload_project_media (use its url for local screenshots or a logo); the logo ' +
1215
+ 'may also be an inline SVG or a raster data: URL. Returns JSON {url, publicCode, ' +
905
1216
  'versionNumber, contentHash, bytes, warnings, homepage, nextSteps}. Requires hosted.write. ' +
906
1217
  `API reference: ${HOSTED_PAGES_DOCS}`,
907
1218
  parameters: z.object({
@@ -956,6 +1267,407 @@ export function registerTools(server, deps) {
956
1267
  }
957
1268
  },
958
1269
  });
1270
+ add({
1271
+ name: 'draft_landing_page',
1272
+ description: 'Draft a full homepage (schema v2) from what the project already knows: the manifest draft and its ' +
1273
+ 'store translations, the published page or saved Page generator form, public project media, ' +
1274
+ 'Extenshi Pay offers (pricing plans bound by SKU), hosted artifacts (privacy policy, uninstall ' +
1275
+ 'feedback form, license terms, support page) and the workspace release/locales. Writes nothing. ' +
1276
+ 'Returns JSON {form, sources (field → where it came from), todos (what only you can supply — ' +
1277
+ 'features, how-it-works, real testimonials, translations), problems, previewUrl}. The form is ' +
1278
+ 'accepted as-is by preview_landing_page and publish_landing_page. storeUrls fills the install ' +
1279
+ 'buttons when the project does not know its listings yet. Requires project.read.',
1280
+ parameters: z.object({
1281
+ projectId: z.string().uuid(),
1282
+ storeUrls: z
1283
+ .object({
1284
+ chrome: z.string().url().max(2000).optional(),
1285
+ firefox: z.string().url().max(2000).optional(),
1286
+ edge: z.string().url().max(2000).optional(),
1287
+ })
1288
+ .optional()
1289
+ .describe('Store listing URLs, when the project does not have them yet.'),
1290
+ }),
1291
+ execute: async (args, context) => {
1292
+ try {
1293
+ return JSON.stringify(await bff(context).draftLandingPage(args));
1294
+ }
1295
+ catch (err) {
1296
+ return readError(err, missingKeyMessage);
1297
+ }
1298
+ },
1299
+ });
1300
+ add({
1301
+ name: 'preview_landing_page',
1302
+ description: 'Validate and render a landing form exactly as publish_landing_page would — same image rules, ' +
1303
+ 'store-locale check, Pay offer prices and canonical URL — without publishing. Returns JSON ' +
1304
+ '{ok, problems: [{path, severity, message, fix}], html (the page for `locale`), url, locales, ' +
1305
+ 'bytes, contentHash, llmsTxt, warnings}. `ok: false` means publish would refuse the form; every ' +
1306
+ 'problem says what to change. `html` is byte-for-byte the page publish would serve. ' +
1307
+ 'Requires project.read.',
1308
+ parameters: z.object({
1309
+ projectId: z.string().uuid(),
1310
+ locale: z
1311
+ .string()
1312
+ .max(35)
1313
+ .optional()
1314
+ .describe('Which locale page to return (default the default locale).'),
1315
+ ...LANDING_FORM_SHAPE,
1316
+ }),
1317
+ execute: async (args, context) => {
1318
+ const { projectId, locale, ...fields } = args;
1319
+ try {
1320
+ return JSON.stringify(await bff(context).previewLandingPage({
1321
+ projectId,
1322
+ locale,
1323
+ form: landingFormFromArgs(fields),
1324
+ }));
1325
+ }
1326
+ catch (err) {
1327
+ return readError(err, missingKeyMessage);
1328
+ }
1329
+ },
1330
+ });
1331
+ add({
1332
+ name: 'list_landing_page_versions',
1333
+ description: 'Version history of the hosted landing page, newest first: versionNumber, createdAt, contentHash, ' +
1334
+ 'reason (publish | rollback | domain — a canonical switch after the custom domain changed), ' +
1335
+ 'restoredFromVersion, schemaVersion, locales and which one is live. Requires project.read.',
1336
+ parameters: z.object({ projectId: z.string().uuid() }),
1337
+ execute: async (args, context) => {
1338
+ try {
1339
+ return JSON.stringify(await bff(context).listLandingVersions(args));
1340
+ }
1341
+ catch (err) {
1342
+ return readError(err, missingKeyMessage);
1343
+ }
1344
+ },
1345
+ });
1346
+ add({
1347
+ name: 'rollback_landing_page',
1348
+ description: 'Restore an earlier version of the hosted landing page. History stays append-only: the old form ' +
1349
+ "is published as a NEW version, re-rendered with today's URL and Pay offer prices. Returns the " +
1350
+ 'same JSON as publish_landing_page. Requires hosted.write.',
1351
+ parameters: z.object({
1352
+ projectId: z.string().uuid(),
1353
+ versionNumber: z.number().int().min(1),
1354
+ registerAsHomepage: z.boolean().optional(),
1355
+ }),
1356
+ execute: async (args, context) => {
1357
+ try {
1358
+ const result = (await bff(context).rollbackLandingPage(args));
1359
+ return JSON.stringify({ ...result, nextSteps: hostedLandingNextSteps(result) });
1360
+ }
1361
+ catch (err) {
1362
+ return readError(err, missingKeyMessage);
1363
+ }
1364
+ },
1365
+ });
1366
+ add({
1367
+ name: 'get_custom_domain',
1368
+ description: "The project's custom domain: hostname, status (PENDING → VERIFIED → ACTIVE; FAILED; SUSPENDED when " +
1369
+ 'the TXT record disappeared), the exact DNS records to set, whether HTTPS for custom domains is ' +
1370
+ 'enabled on the platform (edgeConfigured) and the next step. Only ACTIVE is served. Requires project.read.',
1371
+ parameters: z.object({ projectId: z.string().uuid() }),
1372
+ execute: async (args, context) => {
1373
+ try {
1374
+ return JSON.stringify(await bff(context).getCustomDomain(args));
1375
+ }
1376
+ catch (err) {
1377
+ return readError(err, missingKeyMessage);
1378
+ }
1379
+ },
1380
+ });
1381
+ add({
1382
+ name: 'set_custom_domain',
1383
+ description: 'Add a custom domain (www.example.com, or the apex) for the homepage and every hosted page of the ' +
1384
+ 'project (Pro; one hostname per project). Returns the exact DNS records: a CNAME to ' +
1385
+ 'hosted.extenshi.io (at an apex: CNAME flattening / ALIAS / ANAME, or use www) and a TXT ownership ' +
1386
+ 'token that stays in place (it is re-checked). Nothing is served until verify_custom_domain confirms the token and the ' +
1387
+ 'HTTPS certificate is issued; then the homepage is served at the domain root, its canonical and ' +
1388
+ 'HOMEPAGE_URL switch to it. Replacing the hostname starts over. Requires hosted.write.',
1389
+ parameters: z.object({ projectId: z.string().uuid(), hostname: z.string().max(253) }),
1390
+ execute: async (args, context) => {
1391
+ try {
1392
+ return JSON.stringify(await bff(context).setCustomDomain(args));
1393
+ }
1394
+ catch (err) {
1395
+ return readError(err, missingKeyMessage);
1396
+ }
1397
+ },
1398
+ });
1399
+ add({
1400
+ name: 'verify_custom_domain',
1401
+ description: 'Check the custom domain now: looks up the TXT token, requests or refreshes the HTTPS certificate, ' +
1402
+ 'and moves the status (PENDING/FAILED → VERIFIED → ACTIVE). When it becomes ACTIVE the landing page ' +
1403
+ 'is republished with the new canonical and HOMEPAGE_URL moves to https://{hostname}/ (the ' +
1404
+ 'integration file in get_project_state follows). Safe to call repeatedly; DNS changes can take ' +
1405
+ 'minutes. The platform also re-checks every 6 hours. Requires hosted.write.',
1406
+ parameters: z.object({ projectId: z.string().uuid() }),
1407
+ execute: async (args, context) => {
1408
+ try {
1409
+ return JSON.stringify(await bff(context).verifyCustomDomain(args));
1410
+ }
1411
+ catch (err) {
1412
+ return readError(err, missingKeyMessage);
1413
+ }
1414
+ },
1415
+ });
1416
+ add({
1417
+ name: 'remove_custom_domain',
1418
+ description: 'Remove the custom domain: it stops being served, the certificate is released, and the landing ' +
1419
+ 'canonical and HOMEPAGE_URL move back to page.extenshi.io/{code}. Remove the DNS records afterwards. ' +
1420
+ 'Requires hosted.write.',
1421
+ parameters: z.object({ projectId: z.string().uuid() }),
1422
+ execute: async (args, context) => {
1423
+ try {
1424
+ return JSON.stringify(await bff(context).removeCustomDomain(args));
1425
+ }
1426
+ catch (err) {
1427
+ return readError(err, missingKeyMessage);
1428
+ }
1429
+ },
1430
+ });
1431
+ add({
1432
+ name: 'get_install_instructions',
1433
+ description: 'The hosted install instructions page of a project: {page: {publicCode, enabled, publishedAt, ' +
1434
+ 'input, url} or null, draft, draftProblems} — `draft` is the form saved by the install-instructions ' +
1435
+ 'tool in the cabinet; `draftProblems` lists why it cannot be published as it is. The page shows store and optional sideload steps for Chrome, Firefox and Edge in the ' +
1436
+ 'visitor’s browser language (en, es, pt-BR, fr, de, it, ru, ja, ko, zh-CN, ar), the visitor’s own ' +
1437
+ 'browser first. Free. Requires project.read.',
1438
+ parameters: z.object({ projectId: z.string().uuid() }),
1439
+ execute: async (args, context) => {
1440
+ try {
1441
+ return JSON.stringify(await bff(context).getInstructionsPage(args));
1442
+ }
1443
+ catch (err) {
1444
+ return readError(err, missingKeyMessage);
1445
+ }
1446
+ },
1447
+ });
1448
+ add({
1449
+ name: 'publish_install_instructions',
1450
+ description: 'Publish (or update) the project’s install instructions at a stable public URL ' +
1451
+ '(dojo.extenshi.io/instructions/<code>, or https://<custom domain>/instructions). Without `input` ' +
1452
+ 'the form saved in the cabinet is published (a saved form that does not validate is refused with ' +
1453
+ 'INSTRUCTIONS_INVALID and its field errors). Store URLs are the https listing links on ' +
1454
+ 'chromewebstore.google.com, addons.mozilla.org and microsoftedge.microsoft.com; browsers without one ' +
1455
+ 'are hidden unless none is set. The install steps are translated by Extenshi; `features` are ' +
1456
+ 'translated with set_page_translations (surface instructions). Returns {publicCode, url}. Free. ' +
1457
+ 'Requires hosted.write.',
1458
+ parameters: z.object({
1459
+ projectId: z.string().uuid(),
1460
+ input: z
1461
+ .object({
1462
+ extensionName: z.string().max(120),
1463
+ storeUrls: z
1464
+ .object({
1465
+ chrome: z.string().max(2048).optional(),
1466
+ firefox: z.string().max(2048).optional(),
1467
+ edge: z.string().max(2048).optional(),
1468
+ })
1469
+ .optional(),
1470
+ features: z.array(z.string().max(200)).max(20).optional(),
1471
+ includeSideload: z.boolean().optional(),
1472
+ supportUrl: z.string().max(2048).optional().describe('https:// URL or an email address.'),
1473
+ })
1474
+ .optional(),
1475
+ }),
1476
+ execute: async (args, context) => {
1477
+ try {
1478
+ return JSON.stringify(await bff(context).publishInstructions(args));
1479
+ }
1480
+ catch (err) {
1481
+ return readError(err, missingKeyMessage);
1482
+ }
1483
+ },
1484
+ });
1485
+ add({
1486
+ name: 'unpublish_install_instructions',
1487
+ description: 'Take the hosted install instructions offline. The code and URL are kept, so publishing again ' +
1488
+ 'restores the same URL. Requires hosted.write.',
1489
+ parameters: z.object({ projectId: z.string().uuid() }),
1490
+ execute: async (args, context) => {
1491
+ try {
1492
+ return JSON.stringify(await bff(context).setInstructionsEnabled({ projectId: args.projectId, enabled: false }));
1493
+ }
1494
+ catch (err) {
1495
+ return readError(err, missingKeyMessage);
1496
+ }
1497
+ },
1498
+ });
1499
+ add({
1500
+ name: 'get_page_translations',
1501
+ description: "The developer-written text on the project's hosted uninstall forms, welcome pages and install " +
1502
+ 'instructions (the features list), for ' +
1503
+ 'translation: per page {surface, publicCode, name, defaultLocale, locales, fields [{path, source, ' +
1504
+ 'label, max, multiline}], status per language [{locale, translated, missing, outdated}], ' +
1505
+ 'translations}. Languages are the project’s (manifest translations + the targetLocales decision); ' +
1506
+ 'the install instructions page offers every language its steps come in. ' +
1507
+ 'Built-in text (buttons, reasons, footers) is translated by Extenshi into en, es, pt-BR, fr, de, ' +
1508
+ 'it, ru, ja, ko, zh-CN and ar and is not listed. A page is shown in the user’s browser language ' +
1509
+ 'when the project offers it, otherwise in defaultLocale; an untranslated or outdated field shows ' +
1510
+ 'its original. Requires project.read.',
1511
+ parameters: z.object({ projectId: z.string().uuid() }),
1512
+ execute: async (args, context) => {
1513
+ try {
1514
+ return JSON.stringify(await bff(context).getPageTranslations(args));
1515
+ }
1516
+ catch (err) {
1517
+ return readError(err, missingKeyMessage);
1518
+ }
1519
+ },
1520
+ });
1521
+ add({
1522
+ name: 'set_page_translations',
1523
+ description: 'Save one language of one uninstall form, welcome page or install instructions page: `translations` ' +
1524
+ 'maps field paths (from ' +
1525
+ 'get_page_translations) to translated text; the server records which original each translates, so ' +
1526
+ 'a later edit of the original marks it outdated. An empty string removes a translation. Unknown ' +
1527
+ 'paths, over-long text and the default language are refused (TRANSLATIONS_INVALID). Placeholders ' +
1528
+ 'and product names are kept as written. Returns the page’s updated translation status. Requires ' +
1529
+ 'hosted.write.',
1530
+ parameters: z.object({
1531
+ projectId: z.string().uuid(),
1532
+ surface: z.enum(['uninstall', 'welcome', 'instructions']),
1533
+ publicCode: z.string().max(40).describe('The page code, from get_page_translations.'),
1534
+ locale: z.string().max(35).describe('BCP 47 tag or the _locales spelling (de, pt-BR, pt_BR).'),
1535
+ translations: z.record(z.string().max(80), z.string().max(2000)),
1536
+ }),
1537
+ execute: async (args, context) => {
1538
+ try {
1539
+ return JSON.stringify(await bff(context).setPageTranslations(args));
1540
+ }
1541
+ catch (err) {
1542
+ return readError(err, missingKeyMessage);
1543
+ }
1544
+ },
1545
+ });
1546
+ const legalDocument = z
1547
+ .enum(['privacy_policy', 'license_terms'])
1548
+ .describe('privacy_policy (hosted policy, Pro) or license_terms (Extenshi Pay license terms & refunds).');
1549
+ add({
1550
+ name: 'get_legal_translations',
1551
+ description: "Translated copies of the project's published privacy policy or license terms. The English " +
1552
+ 'version is the binding text and stays at the base URL; each translation lives at ' +
1553
+ '<url>/<locale>, says it is a translation for convenience and links to the English original. ' +
1554
+ 'Extenshi translates its own template text (en, es, pt-BR, fr, de, it, ru, ja, ko, zh-CN, ar); ' +
1555
+ 'what you translate is listed in fields: `section:<id>` for a section the author edited or added ' +
1556
+ '(translate the whole section, heading line included) and `answer:<field>` for free-text answers ' +
1557
+ 'inside template sentences (e.g. a retention period). A section without a current translation is ' +
1558
+ 'shown in English. Returns {defaultLocale: "en", locales, fields, status per language, ' +
1559
+ 'translations, urls}. Languages are the project’s (manifest translations + targetLocales). ' +
1560
+ 'Requires a published document; privacy_policy needs project.read, license_terms pay.read.',
1561
+ parameters: z.object({ projectId: z.string().uuid(), document: legalDocument }),
1562
+ execute: async ({ projectId, document }, context) => {
1563
+ try {
1564
+ const client = bff(context);
1565
+ return JSON.stringify(document === 'privacy_policy'
1566
+ ? await client.getPrivacyPolicyTranslations({ projectId })
1567
+ : await client.getLicenseTermsTranslations({ projectId }));
1568
+ }
1569
+ catch (err) {
1570
+ return readError(err, missingKeyMessage);
1571
+ }
1572
+ },
1573
+ });
1574
+ add({
1575
+ name: 'set_legal_translations',
1576
+ description: 'Save one language of the privacy policy or license terms translation: `translations` maps field ' +
1577
+ 'paths from get_legal_translations to text. A `section:<id>` text is accepted as exactly one ' +
1578
+ 'section: its "## " heading line first ("# " for `section:lead`) and no other heading. ' +
1579
+ 'The server records which English text each translates, so a later edit marks it outdated; an ' +
1580
+ 'empty string removes one. Only the project’s languages are accepted (TRANSLATIONS_INVALID ' +
1581
+ 'otherwise). Nothing changes in the English original. privacy_policy needs hosted.write and a ' +
1582
+ 'Pro project; license_terms needs pay.write.',
1583
+ parameters: z.object({
1584
+ projectId: z.string().uuid(),
1585
+ document: legalDocument,
1586
+ locale: z.string().max(35).describe('BCP 47 tag or the _locales spelling (de, pt-BR, pt_BR).'),
1587
+ translations: z.record(z.string().max(300), z.string().max(16000)),
1588
+ sources: z
1589
+ .record(z.string().max(300), z.string().max(64000))
1590
+ .optional()
1591
+ .describe('The `source` of each field as get_legal_translations returned it; a save whose English has changed since is refused (TRANSLATIONS_STALE).'),
1592
+ }),
1593
+ execute: async ({ document, ...args }, context) => {
1594
+ try {
1595
+ const client = bff(context);
1596
+ return JSON.stringify(document === 'privacy_policy'
1597
+ ? await client.setPrivacyPolicyTranslations(args)
1598
+ : await client.setLicenseTermsTranslations(args));
1599
+ }
1600
+ catch (err) {
1601
+ return readError(err, missingKeyMessage);
1602
+ }
1603
+ },
1604
+ });
1605
+ // A local filesystem exists only where local artifacts do (stdio).
1606
+ const localFiles = caps.has('scan');
1607
+ add({
1608
+ name: 'upload_project_media',
1609
+ description: "Upload a logo or screenshot (PNG, JPEG or WebP, up to 2 MB) to the project's PUBLIC media store — " +
1610
+ 'the same store the Dojo Page generator uploads into — and get a stable https URL that ' +
1611
+ 'publish_landing_page accepts as screenshots[].url or logoUrl. The server checks the file by content ' +
1612
+ '(magic bytes), strips EXIF/XMP/text metadata and anything after the image end marker, bounds the ' +
1613
+ 'dimensions (16–4096 px) and enforces a per-project quota (100 files / 50 MB, Dojo uploads included); ' +
1614
+ 'identical bytes return the same URL without using quota. SVG is refused: rasterize it first ' +
1615
+ '(CLI: extenshi icon store-assets) or pass an inline logo as logoSvg. ' +
1616
+ (localFiles
1617
+ ? 'Pass filePath (a file inside the workspace) or dataBase64 + mime. '
1618
+ : 'Pass dataBase64 + mime (this connection has no access to local files). ') +
1619
+ 'Store screenshots come from the CLI: extenshi screenshots. Returns JSON {url, sha256, mime, bytes, ' +
1620
+ 'width, height, strippedBytes, deduplicated, quota}. Requires hosted.write. ' +
1621
+ `CLI: extenshi media upload <file> --project <id>. API reference: ${HOSTED_PAGES_DOCS}`,
1622
+ parameters: z.object({
1623
+ projectId: z.string().uuid(),
1624
+ filePath: z
1625
+ .string()
1626
+ .min(1)
1627
+ .max(4096)
1628
+ .optional()
1629
+ .describe('Image file inside the workspace (local stdio server only).'),
1630
+ dataBase64: z
1631
+ .string()
1632
+ .min(1)
1633
+ .max(Math.ceil(PROJECT_MEDIA_MAX_BYTES / 3) * 4 + 16)
1634
+ .optional()
1635
+ .describe('The raw image, base64-encoded (no data: prefix). Use with mime.'),
1636
+ mime: z.enum(PROJECT_MEDIA_MIME_TYPES).optional().describe('Required with dataBase64.'),
1637
+ }),
1638
+ execute: async (args, context) => {
1639
+ let payload;
1640
+ try {
1641
+ if (args.filePath && args.dataBase64)
1642
+ throw new ProjectMediaInputError('Pass filePath OR dataBase64, not both.');
1643
+ if (args.filePath) {
1644
+ if (!localFiles)
1645
+ throw new ProjectMediaInputError('filePath works only with the local stdio server; send dataBase64 + mime instead.');
1646
+ const read = await readWorkspaceImage(args.filePath);
1647
+ payload = { mime: read.mime, dataBase64: read.dataBase64 };
1648
+ }
1649
+ else if (args.dataBase64) {
1650
+ if (!args.mime)
1651
+ throw new ProjectMediaInputError('mime is required with dataBase64.');
1652
+ checkInlineImage(args.dataBase64, args.mime);
1653
+ payload = { mime: args.mime, dataBase64: args.dataBase64 };
1654
+ }
1655
+ else
1656
+ throw new ProjectMediaInputError('Pass filePath or dataBase64 + mime.');
1657
+ }
1658
+ catch (err) {
1659
+ if (err instanceof ProjectMediaInputError)
1660
+ throw new UserError(err.message);
1661
+ throw err;
1662
+ }
1663
+ try {
1664
+ return JSON.stringify(await bff(context).uploadProjectMedia({ projectId: args.projectId, ...payload }));
1665
+ }
1666
+ catch (err) {
1667
+ return readError(err, missingKeyMessage);
1668
+ }
1669
+ },
1670
+ });
959
1671
  add({
960
1672
  name: 'list_hosted_pages',
961
1673
  description: 'List the project registered hosted pages (homepage/support) with their verification status, content hash and last check time.',
@@ -1517,13 +2229,14 @@ export function registerTools(server, deps) {
1517
2229
  description: 'For extension-development tasks: returns the complete tool inventory for THIS connection, ' +
1518
2230
  'the Extenshi service directory with access requirements and docs links, GitHub/code placement ' +
1519
2231
  'guidance, and the ordered workflow from idea and repository through implementation, assets, ' +
1520
- 'privacy, tests/CI, store submission and maintenance. Distinguishes MCP tools from cabinet, ' +
1521
- 'CLI and external steps. Free; no key, account lookup or network request.',
2232
+ 'privacy, tests/CI, store submission and maintenance, plus store media: the scenes.json format for ' +
2233
+ 'CLI screenshots (per-locale store screenshots) and listing artwork sizes per store. Distinguishes ' +
2234
+ 'MCP tools from cabinet, CLI and external steps. Free; no key, account lookup or network request.',
1522
2235
  parameters: z.object({
1523
2236
  sections: z
1524
2237
  .array(z.enum(['all', ...GUIDE_SECTIONS]))
1525
2238
  .optional()
1526
- .describe('Full guide sections to include: tools, localActions, services, repository, workflow, handoff, or all. ' +
2239
+ .describe('Full guide sections to include: tools, localActions, services, repository, workflow, handoff, storeMedia, or all. ' +
1527
2240
  'Omitted: a compact overview with tool names, workflow stages and a table of contents.'),
1528
2241
  }),
1529
2242
  execute: async (args) => JSON.stringify(buildDevelopmentGuide(registeredTools, args?.sections), null, 2),
@@ -1664,7 +2377,9 @@ export function registerTools(server, deps) {
1664
2377
  'as an image, or an image URL), screenshots and the canonical homepage URL for Open Graph and ' +
1665
2378
  'JSON-LD. Returns JSON {html, bytes, warnings, nextSteps}: warnings list input that was dropped ' +
1666
2379
  '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: ' +
2380
+ 'upsert_hosted_page, which feeds HOMEPAGE_URL. Extenshi does not host the page. With schemaVersion 2 ' +
2381
+ 'the page is built from sections, theme tokens, SEO and locales (see the field descriptions); the ' +
2382
+ 'result adds problems [{path, severity, message, fix}] and localePages. Static content: ' +
1668
2383
  'no API key, no network, no credits.',
1669
2384
  parameters: z.object(LANDING_FORM_SHAPE),
1670
2385
  execute: async (args) => JSON.stringify(renderGenerateLandingPage(args)),
@@ -1759,8 +2474,12 @@ export function registerTools(server, deps) {
1759
2474
  'CHROME_REFRESH_TOKEN, FIREFOX_ADDON_GUID/FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET, ' +
1760
2475
  'EDGE_PRODUCT_ID/EDGE_CLIENT_ID/EDGE_CLIENT_SECRET/EDGE_TENANT_ID). The upload itself is local, but ' +
1761
2476
  '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).',
2477
+ 'account by EXTENSHI_API_KEY). Edge submissions are polled to a terminal status. For Firefox, ' +
2478
+ 'listing_path turns the upload into a listed AMO submission with metadata: first-time add-on creation ' +
2479
+ '(gated by allow_new_addon or the project decision), per-locale name/summary/description, categories, ' +
2480
+ 'license, homepage, support email and URL, privacy policy, release and reviewer notes, reviewer source ' +
2481
+ '(source_path) and previews (screenshots_dir); the result lists each step. The artifact is not ' +
2482
+ 'security-scanned by this tool (scanning is scan_extension).',
1764
2483
  parameters: z.object({
1765
2484
  artifact_path: z
1766
2485
  .string()
@@ -1774,6 +2493,42 @@ export function registerTools(server, deps) {
1774
2493
  .optional()
1775
2494
  .describe('Separate Firefox artifact (.xpi); defaults to artifact_path.'),
1776
2495
  release_notes: z.string().optional().describe('Release notes for stores that accept them.'),
2496
+ listing_path: z
2497
+ .string()
2498
+ .optional()
2499
+ .describe('Firefox: AMO listing file (JSON with per-locale fields, or the AMO.md that generate-listing --store ' +
2500
+ 'firefox writes, placeholders filled). Makes the Firefox submission a listed one with metadata: ' +
2501
+ 'the add-on is created on first submission (see allow_new_addon) or its listing is edited, then ' +
2502
+ 'privacy policy, release notes, reviewer notes, source and previews are applied.'),
2503
+ source_path: z
2504
+ .string()
2505
+ .optional()
2506
+ .describe('Firefox: reviewer source archive (.zip/.tar.gz/.tgz/.tar.bz2) for the new version.'),
2507
+ screenshots_dir: z
2508
+ .string()
2509
+ .optional()
2510
+ .describe('Firefox: AMO previews in file-name order: a directory of .png/.jpg/.gif files, or the extenshi ' +
2511
+ 'screenshots output directory (its firefox/<locale>/ set, default locale first).'),
2512
+ allow_new_addon: z
2513
+ .boolean()
2514
+ .optional()
2515
+ .describe('Firefox: permits creating a NEW AMO add-on, whose id becomes permanent. With project_id the ' +
2516
+ "project's decided amo.addonId governs creation instead."),
2517
+ addon_id: z
2518
+ .string()
2519
+ .max(255)
2520
+ .optional()
2521
+ .describe('Firefox: the AMO add-on id ({GUID} or name@domain), stated explicitly. Needed with allow_new_addon ' +
2522
+ 'when there is no project_id; fills FIREFOX_ADDON_GUID when the environment has none.'),
2523
+ replace_screenshots: z
2524
+ .boolean()
2525
+ .optional()
2526
+ .describe('Firefox: replaces the previews of an existing AMO add-on with screenshots_dir.'),
2527
+ project_id: z
2528
+ .string()
2529
+ .uuid()
2530
+ .optional()
2531
+ .describe('Dojo project id. Its owner-decided amo.addonId gates first-time AMO creation and has to equal FIREFOX_ADDON_GUID.'),
1777
2532
  extension_id: z
1778
2533
  .number()
1779
2534
  .optional()
@@ -1793,7 +2548,7 @@ export function registerTools(server, deps) {
1793
2548
  // preflight evaluates the `publish-access` PostHog flag for this developer
1794
2549
  // / extension. Fails open on any transport error — only a definitive
1795
2550
  // server "no" blocks here (see checkPublishAccess).
1796
- const creds = readStoreCredentials();
2551
+ const creds = storeCredentialsFor(args.addon_id);
1797
2552
  const storeIds = {};
1798
2553
  if (creds.chrome)
1799
2554
  storeIds.chrome = creds.chrome.appId;
@@ -1811,12 +2566,37 @@ export function registerTools(server, deps) {
1811
2566
  throw new UserError(access.message ??
1812
2567
  "Publishing is in an active testing phase and isn't available for your account yet.");
1813
2568
  }
2569
+ let firefoxListing;
2570
+ if (args.listing_path || args.source_path || args.screenshots_dir) {
2571
+ if (!args.listing_path)
2572
+ throw new UserError('source_path and screenshots_dir need listing_path, the AMO listing they belong to.');
2573
+ let decision = null;
2574
+ if (args.project_id) {
2575
+ const view = (await bff(context).getDecisions({ projectId: args.project_id }));
2576
+ decision = view.decisions?.find((d) => d.key === 'amo.addonId') ?? null;
2577
+ }
2578
+ firefoxListing = {
2579
+ listingPath: args.listing_path,
2580
+ sourcePath: args.source_path,
2581
+ screenshotsDir: args.screenshots_dir,
2582
+ replaceScreenshots: args.replace_screenshots,
2583
+ allowCreate: amoCreateAllowed({
2584
+ addonGuid: creds.firefox?.addonGuid ?? '',
2585
+ allowNewAddon: args.allow_new_addon,
2586
+ addonId: args.addon_id,
2587
+ projectId: args.project_id,
2588
+ decision,
2589
+ }),
2590
+ };
2591
+ }
1814
2592
  void context.reportProgress({ progress: 0, total: 1 });
1815
2593
  const result = await publishArtifact({
1816
2594
  artifactPath: args.artifact_path,
1817
2595
  stores: args.stores,
1818
2596
  firefoxArtifactPath: args.firefox_artifact_path,
1819
2597
  releaseNotes: args.release_notes,
2598
+ addonId: args.addon_id,
2599
+ firefoxListing,
1820
2600
  });
1821
2601
  void context.reportProgress({ progress: 1, total: 1 });
1822
2602
  return JSON.stringify(result, null, 2);