@extenshi/mcp 0.8.0 → 0.10.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 (52) hide show
  1. package/README.md +11 -2
  2. package/dist/bff.d.ts +33 -0
  3. package/dist/bff.d.ts.map +1 -1
  4. package/dist/bff.js +7 -0
  5. package/dist/bff.js.map +1 -1
  6. package/dist/development-guide.d.ts +121 -37
  7. package/dist/development-guide.d.ts.map +1 -1
  8. package/dist/development-guide.js +97 -12
  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/json-ld.d.ts +89 -0
  14. package/dist/json-ld.d.ts.map +1 -0
  15. package/dist/json-ld.js +118 -0
  16. package/dist/json-ld.js.map +1 -0
  17. package/dist/landing-page.d.ts +123 -0
  18. package/dist/landing-page.d.ts.map +1 -0
  19. package/dist/landing-page.js +405 -0
  20. package/dist/landing-page.js.map +1 -0
  21. package/dist/landing-workflow.d.ts +49 -0
  22. package/dist/landing-workflow.d.ts.map +1 -0
  23. package/dist/landing-workflow.js +80 -0
  24. package/dist/landing-workflow.js.map +1 -0
  25. package/dist/pay.d.ts +8 -1
  26. package/dist/pay.d.ts.map +1 -1
  27. package/dist/pay.js +9 -0
  28. package/dist/pay.js.map +1 -1
  29. package/dist/privacy-ai-outcome.d.ts +21 -0
  30. package/dist/privacy-ai-outcome.d.ts.map +1 -0
  31. package/dist/privacy-ai-outcome.js +69 -0
  32. package/dist/privacy-ai-outcome.js.map +1 -0
  33. package/dist/project-media.d.ts +29 -0
  34. package/dist/project-media.d.ts.map +1 -0
  35. package/dist/project-media.js +72 -0
  36. package/dist/project-media.js.map +1 -0
  37. package/dist/publish.d.ts +101 -0
  38. package/dist/publish.d.ts.map +1 -1
  39. package/dist/publish.js +81 -2
  40. package/dist/publish.js.map +1 -1
  41. package/dist/scan.d.ts +7 -0
  42. package/dist/scan.d.ts.map +1 -1
  43. package/dist/scan.js +26 -2
  44. package/dist/scan.js.map +1 -1
  45. package/dist/telemetry.d.ts.map +1 -1
  46. package/dist/telemetry.js +3 -0
  47. package/dist/telemetry.js.map +1 -1
  48. package/dist/tools.d.ts +1 -1
  49. package/dist/tools.d.ts.map +1 -1
  50. package/dist/tools.js +436 -15
  51. package/dist/tools.js.map +1 -1
  52. package/package.json +3 -3
package/dist/tools.js CHANGED
@@ -26,22 +26,26 @@
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
  *
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';
39
39
  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
+ import { LANDING_LIMITS } from './landing-page.js';
43
+ import { hostedLandingNextSteps, landingFormFromArgs, renderGenerateLandingPage } from './landing-workflow.js';
42
44
  import { renderLocalizeWorkflow } from './localize-workflow.js';
43
45
  import { PAY_OPERATIONS, paySchemas } from './pay.js';
44
- import { PublishSetupError, publishArtifact, readStoreCredentials, validateStoreCredentials, } from './publish.js';
46
+ import { describePrivacyAiUpdate } from './privacy-ai-outcome.js';
47
+ import { checkInlineImage, PROJECT_MEDIA_MAX_BYTES, PROJECT_MEDIA_MIME_TYPES, ProjectMediaInputError, readWorkspaceImage, } from './project-media.js';
48
+ import { amoCreateAllowed, PublishSetupError, publishArtifact, storeCredentialsFor, validateStoreCredentials, } from './publish.js';
45
49
  import { checkPublishAccess } from './publish-access.js';
46
50
  import { ScanError, scanArtifact } from './scan.js';
47
51
  import { describeStoreConstraints, validateSearchFilters } from './search-filters.js';
@@ -87,7 +91,10 @@ export const SERVER_INSTRUCTIONS = 'Extenshi helps develop and operate browser e
87
91
  'plan from scope and research through code, assets, privacy, CI, store release and maintenance. ' +
88
92
  '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 ' +
89
93
  'product details and exact CLI flags. With identity, call list_my_projects then get_project_state ' +
90
- 'to reuse the existing project and repository. Use list_extension_templates before the manifest, ' +
94
+ 'to reuse the existing project and repository; for a new extension, create_project makes one (idempotent). ' +
95
+ 'Read get_decisions early: the AMO add-on id, license, seller identity and pricing are owner decisions — ' +
96
+ 'propose defaults with propose_decision; the owner decides them in Dojo, and release readiness blocks on them. ' +
97
+ 'Use list_extension_templates before the manifest, ' +
91
98
  '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 ' +
92
99
  'verbatim to integration.path, check integration.unwired and re-read state after cabinet edits. ' +
93
100
  'Check get_credit_balance before metered work. get_risk_by_store_ids covers up to 40 store IDs ' +
@@ -441,6 +448,26 @@ const TOOL_ANNOTATIONS = {
441
448
  idempotentHint: true,
442
449
  openWorldHint: false,
443
450
  },
451
+ create_project: {
452
+ title: 'Create an extension project',
453
+ readOnlyHint: false,
454
+ idempotentHint: true,
455
+ destructiveHint: false,
456
+ openWorldHint: true,
457
+ },
458
+ get_decisions: {
459
+ title: 'Read owner decisions',
460
+ readOnlyHint: true,
461
+ idempotentHint: true,
462
+ openWorldHint: true,
463
+ },
464
+ propose_decision: {
465
+ title: 'Propose an owner decision',
466
+ readOnlyHint: false,
467
+ idempotentHint: true,
468
+ destructiveHint: false,
469
+ openWorldHint: true,
470
+ },
444
471
  list_my_projects: {
445
472
  title: 'List my extension projects',
446
473
  readOnlyHint: true,
@@ -501,6 +528,12 @@ const TOOL_ANNOTATIONS = {
501
528
  idempotentHint: true,
502
529
  openWorldHint: false,
503
530
  },
531
+ generate_landing_page: {
532
+ title: 'Generate a static landing page',
533
+ readOnlyHint: true,
534
+ idempotentHint: true,
535
+ openWorldHint: false,
536
+ },
504
537
  scan_extension: {
505
538
  title: 'Scan an extension package',
506
539
  readOnlyHint: false,
@@ -538,6 +571,34 @@ const TOOL_ANNOTATIONS = {
538
571
  idempotentHint: true,
539
572
  openWorldHint: true,
540
573
  },
574
+ publish_landing_page: {
575
+ title: 'Publish a landing page on extenshi.io',
576
+ readOnlyHint: false,
577
+ idempotentHint: false,
578
+ destructiveHint: false,
579
+ openWorldHint: true,
580
+ },
581
+ get_landing_page: {
582
+ title: 'Read the hosted landing page',
583
+ readOnlyHint: true,
584
+ idempotentHint: true,
585
+ openWorldHint: true,
586
+ },
587
+ unpublish_landing_page: {
588
+ title: 'Unpublish the hosted landing page',
589
+ readOnlyHint: false,
590
+ idempotentHint: true,
591
+ destructiveHint: true,
592
+ openWorldHint: true,
593
+ },
594
+ upload_project_media: {
595
+ title: 'Upload a public image for hosted pages',
596
+ readOnlyHint: false,
597
+ // Content-addressed: the same bytes always return the same URL.
598
+ idempotentHint: true,
599
+ destructiveHint: false,
600
+ openWorldHint: true,
601
+ },
541
602
  remove_hosted_page: {
542
603
  title: 'Remove a hosted page registration',
543
604
  readOnlyHint: false,
@@ -545,6 +606,86 @@ const TOOL_ANNOTATIONS = {
545
606
  openWorldHint: true,
546
607
  },
547
608
  };
609
+ /**
610
+ * LandingForm fields shared by generate_landing_page (offline HTML) and
611
+ * publish_landing_page (hosted on page.extenshi.io). Limits match the builder
612
+ * and the BFF, so a form valid for one is valid for the other.
613
+ */
614
+ const LANDING_FORM_SHAPE = {
615
+ extensionName: z.string().min(1).max(LANDING_LIMITS.extensionName).describe('Extension display name.'),
616
+ tagline: z.string().max(LANDING_LIMITS.tagline).optional().describe('One-line promise under the name.'),
617
+ description: z
618
+ .string()
619
+ .max(LANDING_LIMITS.description)
620
+ .optional()
621
+ .describe('Short paragraph on what the extension does; also the meta description.'),
622
+ accentColor: z
623
+ .string()
624
+ .max(9)
625
+ .optional()
626
+ .describe('Hex accent colour for the primary button and monogram (default #5e5ce6).'),
627
+ theme: z.enum(['light', 'dark']).optional().describe('Page colour scheme (default dark).'),
628
+ chromeUrl: z
629
+ .string()
630
+ .max(LANDING_LIMITS.url)
631
+ .optional()
632
+ .describe('Chrome Web Store listing URL. Empty or omitted hides the Chrome button.'),
633
+ firefoxUrl: z
634
+ .string()
635
+ .max(LANDING_LIMITS.url)
636
+ .optional()
637
+ .describe('Firefox Add-ons listing URL. Empty or omitted hides the Firefox button.'),
638
+ edgeUrl: z
639
+ .string()
640
+ .max(LANDING_LIMITS.url)
641
+ .optional()
642
+ .describe('Edge Add-ons listing URL. Empty or omitted hides the Edge button.'),
643
+ privacyPolicyUrl: z
644
+ .string()
645
+ .max(LANDING_LIMITS.url)
646
+ .optional()
647
+ .describe('Privacy policy URL for the footer (for example the hosted privacy.extenshi.io page).'),
648
+ supportUrl: z
649
+ .string()
650
+ .max(LANDING_LIMITS.url)
651
+ .optional()
652
+ .describe('Support link for the footer: an http(s) URL, or a single mailto: address (optionally ' +
653
+ '?subject= with a percent-encoded value).'),
654
+ homepageUrl: z
655
+ .string()
656
+ .max(LANDING_LIMITS.url)
657
+ .optional()
658
+ .describe('Public https URL the page will be served from: canonical link, og:url, JSON-LD url, and the ' +
659
+ 'base for relative screenshot paths in og:image.'),
660
+ features: z
661
+ .array(z.object({
662
+ title: z.string().max(LANDING_LIMITS.featureTitle),
663
+ description: z.string().max(LANDING_LIMITS.featureDescription).optional(),
664
+ }))
665
+ .max(LANDING_LIMITS.features)
666
+ .optional()
667
+ .describe('Feature cards; entries with an empty title are skipped.'),
668
+ screenshots: z
669
+ .array(z.object({
670
+ url: z.string().max(LANDING_LIMITS.url),
671
+ alt: z.string().max(LANDING_LIMITS.screenshotAlt).optional(),
672
+ }))
673
+ .max(LANDING_LIMITS.screenshots)
674
+ .optional()
675
+ .describe('Screenshots as https URLs (for example store screenshots from get_extension) or paths ' +
676
+ 'relative to index.html, each with alt text.'),
677
+ logoSvg: z
678
+ .string()
679
+ .max(LANDING_LIMITS.logoSvgBytes)
680
+ .optional()
681
+ .describe('Inline SVG logo source. Refused (monogram shown, reason in warnings) if it contains ' +
682
+ 'scripts, event handlers, foreignObject, animation, entity references or non-fragment hrefs.'),
683
+ logoUrl: z
684
+ .string()
685
+ .max(LANDING_LIMITS.logoUrl)
686
+ .optional()
687
+ .describe('Logo image as an https URL, a relative path, or a data:image/(png|jpeg|gif|webp);base64 URL.'),
688
+ };
548
689
  /**
549
690
  * Register the Extenshi tools once on a FastMCP server.
550
691
  * Only the tools whose capability is present in `deps.capabilities` are added.
@@ -633,6 +774,63 @@ export function registerTools(server, deps) {
633
774
  }
634
775
  },
635
776
  });
777
+ add({
778
+ name: 'create_project',
779
+ description: 'Create a new extension project (a Dojo workspace) owned by this identity, through the same service ' +
780
+ 'the Dojo project wizard uses, with the same ownership and limits. Returns JSON {projectId, dojoUrl, ' +
781
+ 'project, pendingDecisions, nextSteps}: the next steps cover binding the repository and the starter ' +
782
+ 'files. Idempotent: a repeated request with the same idempotencyKey returns the project it already ' +
783
+ 'created (replayed: true); the same key with different input is rejected. Requires project.write.',
784
+ parameters: agentCreateProjectSchema,
785
+ execute: async (args, context) => {
786
+ try {
787
+ return JSON.stringify(await bff(context).createProject(args), null, 2);
788
+ }
789
+ catch (err) {
790
+ return readError(err, missingKeyMessage);
791
+ }
792
+ },
793
+ });
794
+ add({
795
+ name: 'get_decisions',
796
+ description: 'Owner decisions for one project: the AMO add-on id, license, seller public name, support contact ' +
797
+ 'and terms, pricing, target browsers and target locales. Each entry reports its status (undecided, ' +
798
+ 'proposed or decided), the value, a suggested default where one is derivable (a stable {GUID} add-on ' +
799
+ 'id or the id already in the Firefox manifest, the project browsers), who proposed or decided it and ' +
800
+ 'when, and which releases it blocks (blocksBrowsers, blocksPayment). `pending` lists the keys the ' +
801
+ 'owner has not decided. FREE.',
802
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
803
+ parameters: z.object({ projectId: z.string().uuid().describe('Project id from list_my_projects.') }),
804
+ execute: async (args, context) => {
805
+ try {
806
+ return JSON.stringify(await bff(context).getDecisions(args), null, 2);
807
+ }
808
+ catch (err) {
809
+ return readError(err, missingKeyMessage);
810
+ }
811
+ },
812
+ });
813
+ add({
814
+ name: 'propose_decision',
815
+ description: 'Propose a value for one owner decision. The proposal is stored with its rationale, proposer and ' +
816
+ 'time; the owner accepts or edits it on the Dojo project page, which is the only place a decision ' +
817
+ 'becomes decided. A key the owner already decided is refused (DECISION_ALREADY_DECIDED). Value ' +
818
+ 'formats: amo.addonId {GUID} or name@domain; license an SPDX id such as MIT, or all-rights-reserved; ' +
819
+ 'seller.publicName text; seller.supportContact an email or HTTPS URL; seller.terms an HTTPS URL; ' +
820
+ 'pricing {model: free|paid|donations, trialDays?, plans?: subscription|lifetime|one_time}; ' +
821
+ 'targetBrowsers an array of chrome/firefox/edge; targetLocales an array of locale codes. Idempotent ' +
822
+ 'per idempotencyKey. Requires project.write.',
823
+ annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
824
+ parameters: proposeDecisionSchema,
825
+ execute: async (args, context) => {
826
+ try {
827
+ return JSON.stringify(await bff(context).proposeDecision(args), null, 2);
828
+ }
829
+ catch (err) {
830
+ return readError(err, missingKeyMessage);
831
+ }
832
+ },
833
+ });
636
834
  const reads = [
637
835
  [
638
836
  'get_project_workspace',
@@ -644,7 +842,11 @@ export function registerTools(server, deps) {
644
842
  'Explain readiness for the recorded browser and artifact by locale. Local or agent reports are not treated as CI attestations. Stale evidence does not pass. Requires a Pro project. ' +
645
843
  'Readiness is measured against the release recorded in the workspace: until apply_project_patch sets patch.release ' +
646
844
  '(browser, version, artifactDigest, manifestDigest, locales, paymentRequired, and commit or dirtyTreeDigest) it reports ' +
647
- 'RELEASE_NOT_RECORDED; after that, evidence counts only when it matches that release artifactDigest.',
845
+ 'RELEASE_NOT_RECORDED; after that, evidence counts only when it matches that release artifactDigest. ' +
846
+ "When the project has a linked Pay application, the payment check also carries `platform`: what Extenshi's own " +
847
+ 'payment ledger proves (checkout, license, installation activation, recovery, refund). A live-mode purchase through ' +
848
+ "those stages passes the check with source 'platform'; test-mode purchases are listed without passing it. Clients cannot " +
849
+ "record source 'platform'.",
648
850
  'getReleaseReadiness',
649
851
  ],
650
852
  ];
@@ -784,6 +986,136 @@ export function registerTools(server, deps) {
784
986
  }
785
987
  },
786
988
  });
989
+ add({
990
+ name: 'publish_landing_page',
991
+ description: "Publish the project's landing page (homepage) on Extenshi hosting — the same page " +
992
+ 'generate_landing_page returns, rendered server-side from the form (no caller HTML is stored) and ' +
993
+ 'served at page.extenshi.io/{code}, or https://{custom-domain}/landing when the project has an ' +
994
+ 'active custom domain. The code is permanent: republishing creates a new version at the same URL. ' +
995
+ 'By default the URL is also registered as the project homepage, which feeds HOMEPAGE_URL. Hosted ' +
996
+ 'images are limited to store screenshot URLs (from get_extension) and images uploaded in the Dojo ' +
997
+ 'Page generator or with upload_project_media (use its url for local screenshots or a logo); the logo ' +
998
+ 'may also be an inline SVG or a raster data: URL. Returns JSON {url, publicCode, ' +
999
+ 'versionNumber, contentHash, bytes, warnings, homepage, nextSteps}. Requires hosted.write. ' +
1000
+ `API reference: ${HOSTED_PAGES_DOCS}`,
1001
+ parameters: z.object({
1002
+ projectId: z.string().uuid(),
1003
+ registerAsHomepage: z
1004
+ .boolean()
1005
+ .optional()
1006
+ .describe('Register the URL as the project homepage (default true). False keeps an existing homepage.'),
1007
+ ...LANDING_FORM_SHAPE,
1008
+ }),
1009
+ execute: async (args, context) => {
1010
+ const { projectId, registerAsHomepage, ...fields } = args;
1011
+ try {
1012
+ const result = (await bff(context).publishLandingPage({
1013
+ projectId,
1014
+ registerAsHomepage,
1015
+ form: landingFormFromArgs(fields),
1016
+ }));
1017
+ return JSON.stringify({ ...result, nextSteps: hostedLandingNextSteps(result) });
1018
+ }
1019
+ catch (err) {
1020
+ return readError(err, missingKeyMessage);
1021
+ }
1022
+ },
1023
+ });
1024
+ add({
1025
+ name: 'get_landing_page',
1026
+ description: "The project's hosted landing page: public code, live version, publish state, URL (null when " +
1027
+ 'unpublished) and the form the live version was rendered from.',
1028
+ parameters: z.object({ projectId: z.string().uuid() }),
1029
+ execute: async (args, context) => {
1030
+ try {
1031
+ return JSON.stringify(await bff(context).getLandingPage(args));
1032
+ }
1033
+ catch (err) {
1034
+ return readError(err, missingKeyMessage);
1035
+ }
1036
+ },
1037
+ });
1038
+ add({
1039
+ name: 'unpublish_landing_page',
1040
+ description: 'Take the hosted landing page offline (it answers 404). The public code and version history are ' +
1041
+ 'kept, so publishing again restores the same URL. A homepage registration that points at this ' +
1042
+ `page is removed with it. Requires hosted.write. API reference: ${HOSTED_PAGES_DOCS}`,
1043
+ parameters: z.object({ projectId: z.string().uuid() }),
1044
+ execute: async (args, context) => {
1045
+ try {
1046
+ return JSON.stringify(await bff(context).unpublishLandingPage(args));
1047
+ }
1048
+ catch (err) {
1049
+ return readError(err, missingKeyMessage);
1050
+ }
1051
+ },
1052
+ });
1053
+ // A local filesystem exists only where local artifacts do (stdio).
1054
+ const localFiles = caps.has('scan');
1055
+ add({
1056
+ name: 'upload_project_media',
1057
+ description: "Upload a logo or screenshot (PNG, JPEG or WebP, up to 2 MB) to the project's PUBLIC media store — " +
1058
+ 'the same store the Dojo Page generator uploads into — and get a stable https URL that ' +
1059
+ 'publish_landing_page accepts as screenshots[].url or logoUrl. The server checks the file by content ' +
1060
+ '(magic bytes), strips EXIF/XMP/text metadata and anything after the image end marker, bounds the ' +
1061
+ 'dimensions (16–4096 px) and enforces a per-project quota (100 files / 50 MB, Dojo uploads included); ' +
1062
+ 'identical bytes return the same URL without using quota. SVG is refused: rasterize it first ' +
1063
+ '(CLI: extenshi icon store-assets) or pass an inline logo as logoSvg. ' +
1064
+ (localFiles
1065
+ ? 'Pass filePath (a file inside the workspace) or dataBase64 + mime. '
1066
+ : 'Pass dataBase64 + mime (this connection has no access to local files). ') +
1067
+ 'Store screenshots come from the CLI: extenshi screenshots. Returns JSON {url, sha256, mime, bytes, ' +
1068
+ 'width, height, strippedBytes, deduplicated, quota}. Requires hosted.write. ' +
1069
+ `CLI: extenshi media upload <file> --project <id>. API reference: ${HOSTED_PAGES_DOCS}`,
1070
+ parameters: z.object({
1071
+ projectId: z.string().uuid(),
1072
+ filePath: z
1073
+ .string()
1074
+ .min(1)
1075
+ .max(4096)
1076
+ .optional()
1077
+ .describe('Image file inside the workspace (local stdio server only).'),
1078
+ dataBase64: z
1079
+ .string()
1080
+ .min(1)
1081
+ .max(Math.ceil(PROJECT_MEDIA_MAX_BYTES / 3) * 4 + 16)
1082
+ .optional()
1083
+ .describe('The raw image, base64-encoded (no data: prefix). Use with mime.'),
1084
+ mime: z.enum(PROJECT_MEDIA_MIME_TYPES).optional().describe('Required with dataBase64.'),
1085
+ }),
1086
+ execute: async (args, context) => {
1087
+ let payload;
1088
+ try {
1089
+ if (args.filePath && args.dataBase64)
1090
+ throw new ProjectMediaInputError('Pass filePath OR dataBase64, not both.');
1091
+ if (args.filePath) {
1092
+ if (!localFiles)
1093
+ throw new ProjectMediaInputError('filePath works only with the local stdio server; send dataBase64 + mime instead.');
1094
+ const read = await readWorkspaceImage(args.filePath);
1095
+ payload = { mime: read.mime, dataBase64: read.dataBase64 };
1096
+ }
1097
+ else if (args.dataBase64) {
1098
+ if (!args.mime)
1099
+ throw new ProjectMediaInputError('mime is required with dataBase64.');
1100
+ checkInlineImage(args.dataBase64, args.mime);
1101
+ payload = { mime: args.mime, dataBase64: args.dataBase64 };
1102
+ }
1103
+ else
1104
+ throw new ProjectMediaInputError('Pass filePath or dataBase64 + mime.');
1105
+ }
1106
+ catch (err) {
1107
+ if (err instanceof ProjectMediaInputError)
1108
+ throw new UserError(err.message);
1109
+ throw err;
1110
+ }
1111
+ try {
1112
+ return JSON.stringify(await bff(context).uploadProjectMedia({ projectId: args.projectId, ...payload }));
1113
+ }
1114
+ catch (err) {
1115
+ return readError(err, missingKeyMessage);
1116
+ }
1117
+ },
1118
+ });
787
1119
  add({
788
1120
  name: 'list_hosted_pages',
789
1121
  description: 'List the project registered hosted pages (homepage/support) with their verification status, content hash and last check time.',
@@ -1320,14 +1652,17 @@ export function registerTools(server, deps) {
1320
1652
  name: 'update_privacy_policy_with_ai',
1321
1653
  description: "Propose an updated privacy policy that keeps the author's custom wording and adds sections " +
1322
1654
  'required by new permissions or data practices. Returns proposedMarkdown only; nothing goes ' +
1323
- 'live. Publishing it is a separate step (publish_privacy_policy, kind ai_updated). Requires a ' +
1324
- 'policy that is already published. Pro only.',
1655
+ 'live. aiStep states whether the AI step ran (ran | did_not_run | discarded | not_needed) and ' +
1656
+ 'notice says why; unless aiStep is ran, proposedMarkdown is a deterministic section merge, not an ' +
1657
+ 'AI draft. conflictHeadings and missingCategories list what still needs a manual edit. Publishing ' +
1658
+ 'is a separate step (publish_privacy_policy with the returned kind). Requires a policy that is ' +
1659
+ 'already published. Pro only.',
1325
1660
  parameters: z.object({
1326
1661
  projectId: z.string().describe('Project id from list_my_projects.'),
1327
1662
  }),
1328
1663
  execute: async (args, context) => {
1329
1664
  try {
1330
- return JSON.stringify(await bff(context).updatePrivacyPolicyWithAi(args), null, 2);
1665
+ return JSON.stringify(describePrivacyAiUpdate(await bff(context).updatePrivacyPolicyWithAi(args)), null, 2);
1331
1666
  }
1332
1667
  catch (err) {
1333
1668
  return readError(err, missingKeyMessage);
@@ -1342,13 +1677,14 @@ export function registerTools(server, deps) {
1342
1677
  description: 'For extension-development tasks: returns the complete tool inventory for THIS connection, ' +
1343
1678
  'the Extenshi service directory with access requirements and docs links, GitHub/code placement ' +
1344
1679
  'guidance, and the ordered workflow from idea and repository through implementation, assets, ' +
1345
- 'privacy, tests/CI, store submission and maintenance. Distinguishes MCP tools from cabinet, ' +
1346
- 'CLI and external steps. Free; no key, account lookup or network request.',
1680
+ 'privacy, tests/CI, store submission and maintenance, plus store media: the scenes.json format for ' +
1681
+ 'CLI screenshots (per-locale store screenshots) and listing artwork sizes per store. Distinguishes ' +
1682
+ 'MCP tools from cabinet, CLI and external steps. Free; no key, account lookup or network request.',
1347
1683
  parameters: z.object({
1348
1684
  sections: z
1349
1685
  .array(z.enum(['all', ...GUIDE_SECTIONS]))
1350
1686
  .optional()
1351
- .describe('Full guide sections to include: tools, localActions, services, repository, workflow, handoff, or all. ' +
1687
+ .describe('Full guide sections to include: tools, localActions, services, repository, workflow, handoff, storeMedia, or all. ' +
1352
1688
  'Omitted: a compact overview with tool names, workflow stages and a table of contents.'),
1353
1689
  }),
1354
1690
  execute: async (args) => JSON.stringify(buildDevelopmentGuide(registeredTools, args?.sections), null, 2),
@@ -1479,6 +1815,21 @@ export function registerTools(server, deps) {
1479
1815
  accentColor: args.accent_color,
1480
1816
  }),
1481
1817
  });
1818
+ add({
1819
+ name: 'generate_landing_page',
1820
+ description: 'Generate a static landing page (homepage) for a browser extension as one self-contained HTML ' +
1821
+ 'file: no JavaScript, escaped text, http(s)-only links (the support link may also be a mailto: ' +
1822
+ 'address), and store buttons only for the store ' +
1823
+ 'URLs given. Same generator as the cabinet Page generator, so a saved `page-generator` form ' +
1824
+ 'from get_project_state maps field for field. Optional logo (inline SVG, validated and embedded ' +
1825
+ 'as an image, or an image URL), screenshots and the canonical homepage URL for Open Graph and ' +
1826
+ 'JSON-LD. Returns JSON {html, bytes, warnings, nextSteps}: warnings list input that was dropped ' +
1827
+ 'or clamped; nextSteps cover hosting the file on HTTPS and registering the URL with ' +
1828
+ 'upsert_hosted_page, which feeds HOMEPAGE_URL. Extenshi does not host the page. Static content: ' +
1829
+ 'no API key, no network, no credits.',
1830
+ parameters: z.object(LANDING_FORM_SHAPE),
1831
+ execute: async (args) => JSON.stringify(renderGenerateLandingPage(args)),
1832
+ });
1482
1833
  add({
1483
1834
  name: 'list_extension_templates',
1484
1835
  description: 'The kinds of browser extension you can build, with the permissions each one REQUIRES: ' +
@@ -1541,7 +1892,12 @@ export function registerTools(server, deps) {
1541
1892
  }
1542
1893
  },
1543
1894
  });
1544
- return renderCatalogPayload(shapeExtension(report));
1895
+ // The scan report goes out as-is (defanged + labelled as data: finding
1896
+ // text quotes the artifact's own code). It used to be passed through
1897
+ // shapeExtension — the catalog-DETAIL shaper — whose field list has
1898
+ // none of the report's keys, so every scan came back as
1899
+ // {"snapshots": []} after the credit was spent.
1900
+ return renderCatalogPayload(report);
1545
1901
  }
1546
1902
  catch (err) {
1547
1903
  // Keep the ScanError as `cause`: scanErrorMessage() renders 402/429
@@ -1564,8 +1920,12 @@ export function registerTools(server, deps) {
1564
1920
  'CHROME_REFRESH_TOKEN, FIREFOX_ADDON_GUID/FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET, ' +
1565
1921
  'EDGE_PRODUCT_ID/EDGE_CLIENT_ID/EDGE_CLIENT_SECRET/EDGE_TENANT_ID). The upload itself is local, but ' +
1566
1922
  'publishing is in an active testing phase: a quick Extenshi access check runs first (it identifies your ' +
1567
- 'account by EXTENSHI_API_KEY). Edge submissions are polled to a terminal status. The artifact is ' +
1568
- 'not security-scanned by this tool (scanning is scan_extension).',
1923
+ 'account by EXTENSHI_API_KEY). Edge submissions are polled to a terminal status. For Firefox, ' +
1924
+ 'listing_path turns the upload into a listed AMO submission with metadata: first-time add-on creation ' +
1925
+ '(gated by allow_new_addon or the project decision), per-locale name/summary/description, categories, ' +
1926
+ 'license, homepage, support email and URL, privacy policy, release and reviewer notes, reviewer source ' +
1927
+ '(source_path) and previews (screenshots_dir); the result lists each step. The artifact is not ' +
1928
+ 'security-scanned by this tool (scanning is scan_extension).',
1569
1929
  parameters: z.object({
1570
1930
  artifact_path: z
1571
1931
  .string()
@@ -1579,6 +1939,42 @@ export function registerTools(server, deps) {
1579
1939
  .optional()
1580
1940
  .describe('Separate Firefox artifact (.xpi); defaults to artifact_path.'),
1581
1941
  release_notes: z.string().optional().describe('Release notes for stores that accept them.'),
1942
+ listing_path: z
1943
+ .string()
1944
+ .optional()
1945
+ .describe('Firefox: AMO listing file (JSON with per-locale fields, or the AMO.md that generate-listing --store ' +
1946
+ 'firefox writes, placeholders filled). Makes the Firefox submission a listed one with metadata: ' +
1947
+ 'the add-on is created on first submission (see allow_new_addon) or its listing is edited, then ' +
1948
+ 'privacy policy, release notes, reviewer notes, source and previews are applied.'),
1949
+ source_path: z
1950
+ .string()
1951
+ .optional()
1952
+ .describe('Firefox: reviewer source archive (.zip/.tar.gz/.tgz/.tar.bz2) for the new version.'),
1953
+ screenshots_dir: z
1954
+ .string()
1955
+ .optional()
1956
+ .describe('Firefox: AMO previews in file-name order: a directory of .png/.jpg/.gif files, or the extenshi ' +
1957
+ 'screenshots output directory (its firefox/<locale>/ set, default locale first).'),
1958
+ allow_new_addon: z
1959
+ .boolean()
1960
+ .optional()
1961
+ .describe('Firefox: permits creating a NEW AMO add-on, whose id becomes permanent. With project_id the ' +
1962
+ "project's decided amo.addonId governs creation instead."),
1963
+ addon_id: z
1964
+ .string()
1965
+ .max(255)
1966
+ .optional()
1967
+ .describe('Firefox: the AMO add-on id ({GUID} or name@domain), stated explicitly. Needed with allow_new_addon ' +
1968
+ 'when there is no project_id; fills FIREFOX_ADDON_GUID when the environment has none.'),
1969
+ replace_screenshots: z
1970
+ .boolean()
1971
+ .optional()
1972
+ .describe('Firefox: replaces the previews of an existing AMO add-on with screenshots_dir.'),
1973
+ project_id: z
1974
+ .string()
1975
+ .uuid()
1976
+ .optional()
1977
+ .describe('Dojo project id. Its owner-decided amo.addonId gates first-time AMO creation and has to equal FIREFOX_ADDON_GUID.'),
1582
1978
  extension_id: z
1583
1979
  .number()
1584
1980
  .optional()
@@ -1598,7 +1994,7 @@ export function registerTools(server, deps) {
1598
1994
  // preflight evaluates the `publish-access` PostHog flag for this developer
1599
1995
  // / extension. Fails open on any transport error — only a definitive
1600
1996
  // server "no" blocks here (see checkPublishAccess).
1601
- const creds = readStoreCredentials();
1997
+ const creds = storeCredentialsFor(args.addon_id);
1602
1998
  const storeIds = {};
1603
1999
  if (creds.chrome)
1604
2000
  storeIds.chrome = creds.chrome.appId;
@@ -1616,12 +2012,37 @@ export function registerTools(server, deps) {
1616
2012
  throw new UserError(access.message ??
1617
2013
  "Publishing is in an active testing phase and isn't available for your account yet.");
1618
2014
  }
2015
+ let firefoxListing;
2016
+ if (args.listing_path || args.source_path || args.screenshots_dir) {
2017
+ if (!args.listing_path)
2018
+ throw new UserError('source_path and screenshots_dir need listing_path, the AMO listing they belong to.');
2019
+ let decision = null;
2020
+ if (args.project_id) {
2021
+ const view = (await bff(context).getDecisions({ projectId: args.project_id }));
2022
+ decision = view.decisions?.find((d) => d.key === 'amo.addonId') ?? null;
2023
+ }
2024
+ firefoxListing = {
2025
+ listingPath: args.listing_path,
2026
+ sourcePath: args.source_path,
2027
+ screenshotsDir: args.screenshots_dir,
2028
+ replaceScreenshots: args.replace_screenshots,
2029
+ allowCreate: amoCreateAllowed({
2030
+ addonGuid: creds.firefox?.addonGuid ?? '',
2031
+ allowNewAddon: args.allow_new_addon,
2032
+ addonId: args.addon_id,
2033
+ projectId: args.project_id,
2034
+ decision,
2035
+ }),
2036
+ };
2037
+ }
1619
2038
  void context.reportProgress({ progress: 0, total: 1 });
1620
2039
  const result = await publishArtifact({
1621
2040
  artifactPath: args.artifact_path,
1622
2041
  stores: args.stores,
1623
2042
  firefoxArtifactPath: args.firefox_artifact_path,
1624
2043
  releaseNotes: args.release_notes,
2044
+ addonId: args.addon_id,
2045
+ firefoxListing,
1625
2046
  });
1626
2047
  void context.reportProgress({ progress: 1, total: 1 });
1627
2048
  return JSON.stringify(result, null, 2);