@extenshi/mcp 0.6.0 → 0.8.0

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