@extenshi/mcp 0.5.1 → 0.6.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.
package/dist/tools.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * passes deps that read `context.session` (the validated OAuth identity).
14
14
  *
15
15
  * Capability gating: the remote connector deliberately exposes a SUBSET — the
16
- * read/research/docs tools only. `scan_extension` and `publish_extension` need
16
+ * catalog/project/policy/guidance tools. `scan_extension` and `publish_extension` need
17
17
  * the caller's LOCAL filesystem and LOCAL store credentials, which a hosted
18
18
  * server has no access to (and must never store) — so they are registered only
19
19
  * when the corresponding capability is present (stdio enables all four).
@@ -22,8 +22,10 @@
22
22
  * capability → tools
23
23
  * ─────────────────────────────────────────────────────────────
24
24
  * 'read' → search_extensions, get_extension, get_reviews,
25
- * get_security, get_risk_by_store_ids, market_overview
26
- * 'docs' → search_docs, list_extension_templates, generate_icon_workflow,
25
+ * get_security, get_risk_by_store_ids, market_overview,
26
+ * list_my_projects, get_project_state, get_project_scaffold,
27
+ * list/get/publish/update privacy policy (hosted; Pro)
28
+ * 'docs' → get_development_guide, search_docs, list_extension_templates, generate_icon_workflow,
27
29
  * generate_welcome_page_workflow (free; no key)
28
30
  * 'scan' → scan_extension (local artifact; stdio only)
29
31
  * 'publish' → publish_extension (local creds; stdio only)
@@ -32,8 +34,10 @@
32
34
  */
33
35
  import { UserError } from 'fastmcp';
34
36
  import { z } from 'zod';
37
+ import { buildDevelopmentGuide } from './development-guide.js';
35
38
  import { DocsError, getDocsIndex, searchDocs } from './docs.js';
36
39
  import { renderIconWorkflow } from './icon-workflow.js';
40
+ import { evidenceSchema, importManifestSchema, workspacePatchSchema } from './project-workspace.js';
37
41
  import { PublishSetupError, publishArtifact, readStoreCredentials, validateStoreCredentials, } from './publish.js';
38
42
  import { checkPublishAccess } from './publish-access.js';
39
43
  import { ScanError, scanArtifact } from './scan.js';
@@ -70,33 +74,33 @@ export const MISSING_KEY_MESSAGE = "This tool needs an Extenshi API key, and you
70
74
  */
71
75
  export const MAX_BATCH_EXTENSIONS = 40;
72
76
  export const SERVER_NAME = 'extenshi';
73
- export const SERVER_INSTRUCTIONS = 'Extenshi catalog tools for extension developers: search the cross-store catalog, ' +
74
- 'inspect an extension and its security findings, find competitors, read market stats, ' +
75
- 'run a pre-publish security scan, and publish to the stores. Use search_docs (free, no ' +
76
- 'key) to consult the live product documentation and the @extenshi/cli command reference — ' +
77
- 'prefer quoting exact CLI commands and flags from the docs over guessing. Use ' +
78
- 'generate_icon_workflow (free, no key) when the developer needs an extension icon — it ' +
79
- 'returns the local agent-draws-SVG → CLI browser-panel preview → export workflow. Use ' +
80
- 'generate_welcome_page_workflow (free, no key) when the developer needs the page shown right ' +
81
- 'after install — it returns which illustrations to produce for the chosen goal, how to mark ' +
82
- 'up where to click, and the block JSON to hand back. Call ' +
83
- 'get_credit_balance (free — it never spends a credit) to check remaining read/scan credits ' +
84
- 'before a large batch instead of guessing whether it is safe. When the developer is BUILDING ' +
85
- 'an extension: call list_extension_templates (free, no key) for the four shapes and the ' +
86
- 'permissions each one requires before writing a manifest, and — when they mention "my ' +
87
- 'extension" or "my project" — list_my_projects then get_project_state (both free) to read the ' +
88
- 'types, manifest and hosted URLs they already configured on extenshi.io instead of asking them ' +
89
- 'to repeat it. Re-read get_project_state after they change something on the site, and write its ' +
90
- '`integration.file` verbatim rather than hand-assembling the same values — that is what keeps the ' +
91
- 'site and the extension in sync. ' +
92
- 'In get_extension / get_reviews / ' +
93
- 'get_security you can identify an extension either by its numeric catalog id or by its store id ' +
94
- '(the id in the store URL; add the store for a Chrome/Edge id). To check a LIST of installed ' +
95
- `extensions by store id, use get_risk_by_store_ids — up to ${MAX_BATCH_EXTENSIONS} per call for one ` +
96
- 'read credit total, instead of 3 credits per extension via get_security. The catalog and ' +
97
- `scan tools require an Extenshi API key (${KEY_PAGE}). Every account gets a one-time free ` +
98
- 'allowance — 10 reads and 3 scans; beyond it, buy prepaid credit packs (scans ' +
99
- `and reads, never expire) at ${BILLING_PAGE}.`;
77
+ export const SERVER_INSTRUCTIONS = 'Extenshi helps develop and operate browser extensions. Start extension-development tasks with ' +
78
+ 'get_development_guide (free): it lists every tool on this connection, the service directory, ' +
79
+ 'account/local prerequisites, documentation links, GitHub repository guidance and the ordered ' +
80
+ '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 ' +
82
+ 'product details and exact CLI flags. With identity, call list_my_projects then get_project_state ' +
83
+ 'to reuse the existing project and repository. Use list_extension_templates before the manifest, ' +
84
+ '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 ' +
85
+ 'verbatim to integration.path, check integration.unwired and re-read state after cabinet edits. ' +
86
+ 'Check get_credit_balance before metered work. get_risk_by_store_ids covers up to 40 store IDs ' +
87
+ 'for one read; get_security costs three reads for detailed findings. Hosted policy tools require ' +
88
+ 'Pro; publish_privacy_policy changes the live policy, while update_privacy_policy_with_ai returns ' +
89
+ "a proposal to review. Respect the author's authorization for publication and repository writes. " +
90
+ 'Tool registration is not proof of account entitlements or a completed release. Verify hosted ' +
91
+ 'URLs, built artifacts, store status and optional purchases before reporting completion. ' +
92
+ `API keys: ${KEY_PAGE}. Credits: ${BILLING_PAGE}.`;
93
+ /** Match initialize instructions to the same capabilities used for registration. */
94
+ export function getServerInstructions(capabilities) {
95
+ const local = [
96
+ capabilities.has('scan') ? 'scan_extension' : null,
97
+ capabilities.has('publish') ? 'publish_extension' : null,
98
+ ].filter(Boolean);
99
+ return (SERVER_INSTRUCTIONS +
100
+ (local.length
101
+ ? ` This connection exposes ${local.join(' and ')} for local artifacts; check credentials and access before use.`
102
+ : " This connection has no local artifact scan or store-publishing tool. Use the local stdio server or CLI on the developer's machine for those stages; hosted privacy-policy publishing is a separate capability."));
103
+ }
100
104
  // ── Internal helpers (ported from index.ts, now deps-parametrized) ───────────
101
105
  /**
102
106
  * A `UserError` that keeps the error it renders as `cause`.
@@ -200,10 +204,36 @@ export function isExpectedError(err, kind = classifyError(err)) {
200
204
  return err instanceof UserError && err.cause === undefined;
201
205
  }
202
206
  /**
203
- * Wrap a tool definition so every call emits anonymous telemetry: which tool
204
- * ran, how long it took, and how it failed (coarse error_kind + sanitized
205
- * exception). The generic preserves the Zod-inferred `args` type — `parameters`
206
- * fixes Params, so the inner execute stays as strongly typed as before.
207
+ * Attribution for a tool call, lifted from the session the surface supplies.
208
+ *
209
+ * Local stdio has no session at all, so both fields come back undefined and
210
+ * captureEvent falls back to the per-install anonymous id — unchanged behaviour.
211
+ * The hosted connector authenticates every request, so it supplies both: the
212
+ * account id becomes the PostHog distinct_id, and the connection id threads one
213
+ * client's calls together (see tools/extenshi-mcp-server/src/identity.ts, which
214
+ * mints it per `authenticate()` — i.e. per connection, NOT per cached token).
215
+ *
216
+ * Read defensively: `session` is FastMCP's opaque auth record, and a surface
217
+ * that carries neither field must degrade to the local behaviour rather than
218
+ * throw inside instrumentation.
219
+ */
220
+ function attribution(session) {
221
+ if (!session || typeof session !== 'object')
222
+ return {};
223
+ const s = session;
224
+ return {
225
+ userId: typeof s.userId === 'string' && s.userId ? s.userId : undefined,
226
+ sessionId: typeof s.sessionId === 'string' && s.sessionId ? s.sessionId : undefined,
227
+ };
228
+ }
229
+ /**
230
+ * Wrap a tool definition so every call is reported: which tool ran, how long it
231
+ * took, and how it failed (coarse error_kind + sanitized exception). On the
232
+ * hosted connector the report also carries WHO — the authenticated account id —
233
+ * and which connection it belonged to; on local stdio it stays anonymous. Never
234
+ * the arguments, never the result. The generic preserves the Zod-inferred
235
+ * `args` type — `parameters` fixes Params, so the inner execute stays as
236
+ * strongly typed as before.
207
237
  * Fail-soft: telemetry never alters the tool's result or its thrown error.
208
238
  */
209
239
  function instrument(tool) {
@@ -213,10 +243,12 @@ function instrument(tool) {
213
243
  ...tool,
214
244
  execute: async (args, context) => {
215
245
  const startedAt = Date.now();
216
- captureEvent('mcp_tool_called', { tool: name });
246
+ const { userId, sessionId } = attribution(context.session);
247
+ const props = { tool: name, ...(sessionId ? { mcp_session_id: sessionId } : {}) };
248
+ captureEvent('mcp_tool_called', props, userId);
217
249
  try {
218
250
  const result = await original(args, context);
219
- captureEvent('mcp_tool_succeeded', { tool: name, duration_ms: Date.now() - startedAt });
251
+ captureEvent('mcp_tool_succeeded', { ...props, duration_ms: Date.now() - startedAt }, userId);
220
252
  return result;
221
253
  }
222
254
  catch (err) {
@@ -226,7 +258,7 @@ function instrument(tool) {
226
258
  const kind = classifyError(err);
227
259
  // The failure count is always tracked, so we keep visibility into
228
260
  // how often callers hit each condition (incl. the billing gate).
229
- captureEvent('mcp_tool_failed', { tool: name, error_kind: kind, duration_ms: Date.now() - startedAt });
261
+ captureEvent('mcp_tool_failed', { ...props, error_kind: kind, duration_ms: Date.now() - startedAt }, userId);
230
262
  // …but only genuine faults are shipped as exceptions. Expected
231
263
  // user-facing conditions (a quota gate, a bad key, a message we
232
264
  // authored) are not bugs and must not open error-tracking issues.
@@ -316,7 +348,7 @@ async function resolveOptionalExtensionId(client, args) {
316
348
  * Anthropic Connectors Directory — its submission portal auto-syncs tools and
317
349
  * refuses to submit any tool missing a `title` or a read/write hint. Kept as a
318
350
  * name→annotation map (not inline per tool) so the read/write split is auditable
319
- * in one place. Every remote-exposed tool is read-only; scan uploads an artifact
351
+ * in one place. Hosted policy publication writes a public page; scan uploads an artifact
320
352
  * (not read-only, not destructive); publish writes to public stores (destructive).
321
353
  */
322
354
  const TOOL_ANNOTATIONS = {
@@ -362,6 +394,12 @@ const TOOL_ANNOTATIONS = {
362
394
  idempotentHint: true,
363
395
  openWorldHint: true,
364
396
  },
397
+ get_development_guide: {
398
+ title: 'Extension development services and workflow',
399
+ readOnlyHint: true,
400
+ idempotentHint: true,
401
+ openWorldHint: false,
402
+ },
365
403
  search_docs: {
366
404
  title: 'Search Extenshi documentation',
367
405
  readOnlyHint: true,
@@ -392,6 +430,30 @@ const TOOL_ANNOTATIONS = {
392
430
  idempotentHint: true,
393
431
  openWorldHint: true,
394
432
  },
433
+ list_privacy_policy_versions: {
434
+ title: 'List hosted privacy policy versions',
435
+ readOnlyHint: true,
436
+ idempotentHint: true,
437
+ openWorldHint: true,
438
+ },
439
+ get_privacy_policy_version: {
440
+ title: 'Read a privacy policy version',
441
+ readOnlyHint: true,
442
+ idempotentHint: true,
443
+ openWorldHint: true,
444
+ },
445
+ publish_privacy_policy: {
446
+ title: 'Publish a hosted privacy policy',
447
+ readOnlyHint: false,
448
+ destructiveHint: false,
449
+ openWorldHint: true,
450
+ },
451
+ update_privacy_policy_with_ai: {
452
+ title: 'Update a privacy policy with AI',
453
+ readOnlyHint: false,
454
+ destructiveHint: false,
455
+ openWorldHint: true,
456
+ },
395
457
  generate_icon_workflow: {
396
458
  title: 'Icon design workflow guide',
397
459
  readOnlyHint: true,
@@ -418,10 +480,11 @@ const TOOL_ANNOTATIONS = {
418
480
  },
419
481
  };
420
482
  /**
421
- * Register the Extenshi tools on a FastMCP server. Idempotent per server.
483
+ * Register the Extenshi tools once on a FastMCP server.
422
484
  * Only the tools whose capability is present in `deps.capabilities` are added.
423
485
  */
424
486
  export function registerTools(server, deps) {
487
+ const registeredTools = [];
425
488
  const caps = deps.capabilities;
426
489
  const missingKeyMessage = deps.missingKeyMessage ?? MISSING_KEY_MESSAGE;
427
490
  // Generic so each tool's Zod `parameters` infers its own `args` type (a
@@ -429,8 +492,13 @@ export function registerTools(server, deps) {
429
492
  function add(tool) {
430
493
  // Attach the directory-required annotations (title + read/write hint) from
431
494
  // the central map; an explicit `tool.annotations` (none today) still wins.
432
- const annotations = { ...TOOL_ANNOTATIONS[tool.name], ...tool.annotations };
495
+ const annotations = {
496
+ ...TOOL_ANNOTATIONS[tool.name],
497
+ ...tool.annotations,
498
+ title: tool.annotations?.title ?? TOOL_ANNOTATIONS[tool.name]?.title ?? tool.name.replaceAll('_', ' '),
499
+ };
433
500
  server.addTool(instrument({ ...tool, annotations }));
501
+ registeredTools.push({ name: tool.name, description: tool.description ?? '', annotations });
434
502
  }
435
503
  // Per-call helpers bound to the injected deps.
436
504
  const bff = (ctx) => deps.getBff(ctx);
@@ -441,6 +509,116 @@ export function registerTools(server, deps) {
441
509
  };
442
510
  // ── Read tools (free; key/identity required) ───────────────────────────────
443
511
  if (caps.has('read')) {
512
+ add({
513
+ 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.',
515
+ parameters: importManifestSchema,
516
+ annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
517
+ execute: async (args, context) => {
518
+ try {
519
+ return JSON.stringify(await bff(context).importManifest(args));
520
+ }
521
+ catch (err) {
522
+ return readError(err, missingKeyMessage);
523
+ }
524
+ },
525
+ });
526
+ const reads = [
527
+ [
528
+ 'get_project_workspace',
529
+ 'Read revisioned source/built manifests, repository metadata, scope and release snapshot. These are observations, separate from scaffold drafts.',
530
+ 'getProjectWorkspace',
531
+ ],
532
+ [
533
+ '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.',
535
+ 'getReleaseReadiness',
536
+ ],
537
+ ];
538
+ for (const [name, description, method] of reads)
539
+ add({
540
+ name,
541
+ description,
542
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
543
+ parameters: z.object({
544
+ projectId: z.string().uuid(),
545
+ browser: z
546
+ .enum(['chrome', 'firefox', 'edge'])
547
+ .optional()
548
+ .describe('Release status: select a browser from release history.'),
549
+ artifactDigest: z
550
+ .string()
551
+ .regex(/^[a-f0-9]{64}$/)
552
+ .optional()
553
+ .describe('Release status: select the exact artifact from history.'),
554
+ }),
555
+ execute: async (args, context) => {
556
+ try {
557
+ return JSON.stringify(await bff(context)[method](args));
558
+ }
559
+ catch (err) {
560
+ return readError(err, missingKeyMessage);
561
+ }
562
+ },
563
+ });
564
+ add({
565
+ 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.',
567
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
568
+ parameters: z.object({}),
569
+ execute: async (_args, context) => {
570
+ try {
571
+ const client = bff(context);
572
+ const [connection, balance] = await Promise.all([
573
+ client.connectionDiagnostics(),
574
+ client.getApiCallerBalance(),
575
+ ]);
576
+ return JSON.stringify({ connection, balance, registeredTools: registeredTools.map((t) => t.name) });
577
+ }
578
+ catch (err) {
579
+ return readError(err, missingKeyMessage);
580
+ }
581
+ },
582
+ });
583
+ for (const [name, method, dryRun] of [
584
+ ['diff_project_state', 'diffProjectWorkspace', true],
585
+ ['apply_project_patch', 'patchProjectWorkspace', false],
586
+ ])
587
+ add({
588
+ name,
589
+ 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.',
592
+ annotations: {
593
+ readOnlyHint: dryRun,
594
+ idempotentHint: true,
595
+ destructiveHint: !dryRun,
596
+ openWorldHint: true,
597
+ },
598
+ parameters: workspacePatchSchema,
599
+ execute: async (args, context) => {
600
+ try {
601
+ return JSON.stringify(await bff(context)[method](args));
602
+ }
603
+ catch (err) {
604
+ return readError(err, missingKeyMessage);
605
+ }
606
+ },
607
+ });
608
+ add({
609
+ 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.',
611
+ annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
612
+ parameters: evidenceSchema,
613
+ execute: async (args, context) => {
614
+ try {
615
+ return JSON.stringify(await bff(context).recordProjectEvidence(args));
616
+ }
617
+ catch (err) {
618
+ return readError(err, missingKeyMessage);
619
+ }
620
+ },
621
+ });
444
622
  add({
445
623
  name: 'search_extensions',
446
624
  description: 'Search the cross-store extension catalog (Chrome, Firefox, Edge) with hybrid relevance. ' +
@@ -812,7 +990,7 @@ export function registerTools(server, deps) {
812
990
  'browser, the files that type needs, live hosted URLs (uninstall survey), and an index of ' +
813
991
  'every saved tool state with its size. Use it before writing code so the manifest you ship ' +
814
992
  'matches what the developer configured on the site — and re-read it after they change ' +
815
- 'something rather than assuming. It also returns the integration contract: write ' +
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 ' +
816
994
  '`integration.file` to `integration.path` VERBATIM to wire the extension to this project — ' +
817
995
  "those bytes carry a fingerprint extenshi.io uses to tell the developer's edits from its " +
818
996
  'own, so an equivalent file you assemble yourself makes the site stop managing every value ' +
@@ -847,7 +1025,7 @@ export function registerTools(server, deps) {
847
1025
  "content-script files that project's types need, placeholder icons, and " +
848
1026
  'src/extenshi.config.js. Write the files VERBATIM — the config carries a fingerprint ' +
849
1027
  "that lets extenshi.io keep recognising the repository as this project's, and rebuilding " +
850
- 'it by hand makes the site stop managing those values. Prefer this over writing a manifest ' +
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 ' +
851
1029
  'yourself: it is the same set the site commits, so what you write and what the site ' +
852
1030
  "expects cannot disagree. One browser per call (default: the project's first target). " +
853
1031
  'FREE — never spends a credit.',
@@ -871,9 +1049,103 @@ export function registerTools(server, deps) {
871
1049
  }
872
1050
  },
873
1051
  });
1052
+ add({
1053
+ name: 'list_privacy_policy_versions',
1054
+ description: "List hosted privacy-policy versions for one of the developer's Pro projects: version number, " +
1055
+ 'kind (generated / edited / ai_updated / reverted), and which one is live. The public URL is ' +
1056
+ 'in get_project_state hostedArtifacts.privacyPolicy. Pro only — a free project returns ' +
1057
+ 'PROJECT_PREMIUM_REQUIRED. Does not spend a credit.',
1058
+ parameters: z.object({
1059
+ projectId: z.string().describe('Project id from list_my_projects.'),
1060
+ }),
1061
+ execute: async (args, context) => {
1062
+ try {
1063
+ return JSON.stringify(await bff(context).listPrivacyPolicyVersions(args), null, 2);
1064
+ }
1065
+ catch (err) {
1066
+ return readError(err, missingKeyMessage);
1067
+ }
1068
+ },
1069
+ });
1070
+ add({
1071
+ 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.',
1074
+ parameters: z.object({
1075
+ projectId: z.string().describe('Project id from list_my_projects.'),
1076
+ versionNumber: z
1077
+ .number()
1078
+ .int()
1079
+ .positive()
1080
+ .describe('Version number from list_privacy_policy_versions.'),
1081
+ }),
1082
+ execute: async (args, context) => {
1083
+ try {
1084
+ return JSON.stringify(await bff(context).getPrivacyPolicyVersion(args), null, 2);
1085
+ }
1086
+ catch (err) {
1087
+ return readError(err, missingKeyMessage);
1088
+ }
1089
+ },
1090
+ });
1091
+ add({
1092
+ 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.',
1098
+ parameters: z.object({
1099
+ projectId: z.string().describe('Project id from list_my_projects.'),
1100
+ bodyMarkdown: z
1101
+ .string()
1102
+ .optional()
1103
+ .describe('Full policy markdown. Omit to generate from current form + manifest.'),
1104
+ kind: z
1105
+ .enum(['generated', 'edited', 'ai_updated', 'reverted'])
1106
+ .optional()
1107
+ .describe('How this version was produced. Default: generated if no markdown, edited if markdown is set.'),
1108
+ }),
1109
+ execute: async (args, context) => {
1110
+ try {
1111
+ return JSON.stringify(await bff(context).publishPrivacyPolicy(args), null, 2);
1112
+ }
1113
+ catch (err) {
1114
+ return readError(err, missingKeyMessage);
1115
+ }
1116
+ },
1117
+ });
1118
+ add({
1119
+ name: 'update_privacy_policy_with_ai',
1120
+ 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 ' +
1123
+ 'policy that is already published. Pro only.',
1124
+ parameters: z.object({
1125
+ projectId: z.string().describe('Project id from list_my_projects.'),
1126
+ }),
1127
+ execute: async (args, context) => {
1128
+ try {
1129
+ return JSON.stringify(await bff(context).updatePrivacyPolicyWithAi(args), null, 2);
1130
+ }
1131
+ catch (err) {
1132
+ return readError(err, missingKeyMessage);
1133
+ }
1134
+ },
1135
+ });
874
1136
  }
875
1137
  // ── Documentation (free; no API key required) ──────────────────────────────
876
1138
  if (caps.has('docs')) {
1139
+ add({
1140
+ name: 'get_development_guide',
1141
+ description: 'Start here when developing an extension: get the complete tool inventory for THIS connection, ' +
1142
+ 'the Extenshi service directory with access requirements and docs links, GitHub/code placement ' +
1143
+ 'guidance, and the ordered workflow from idea and repository through implementation, assets, ' +
1144
+ 'privacy, tests/CI, store submission and maintenance. Distinguishes MCP tools from cabinet, ' +
1145
+ '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),
1148
+ });
877
1149
  add({
878
1150
  name: 'search_docs',
879
1151
  description: 'Search the official Extenshi documentation (docs.extenshi.io) — product guides plus the ' +