@extenshi/mcp 0.1.4 → 0.2.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 ADDED
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Transport-agnostic tool registry for the Extenshi MCP server.
3
+ *
4
+ * The SAME 8 tools must run over two transports:
5
+ * - stdio (`index.ts`) — local, single `ek_…` key from the environment.
6
+ * - remote (`http.ts`, in the sibling `@extenshi/mcp-server`) — Streamable
7
+ * HTTP behind OAuth; identity is per-request (an access token → userId).
8
+ *
9
+ * To keep ONE source of truth for what the tools do, every tool body here is
10
+ * identity-agnostic: it resolves its catalog client / scan auth through the
11
+ * injected `ToolDeps` instead of reaching for a module-global key. stdio passes
12
+ * deps that ignore the call context and use the env key; the remote server
13
+ * passes deps that read `context.session` (the validated OAuth identity).
14
+ *
15
+ * Capability gating: the remote connector deliberately exposes a SUBSET — the
16
+ * read/research/docs tools only. `scan_extension` and `publish_extension` need
17
+ * the caller's LOCAL filesystem and LOCAL store credentials, which a hosted
18
+ * server has no access to (and must never store) — so they are registered only
19
+ * when the corresponding capability is present (stdio enables all four).
20
+ * See internal-docs/plans/2026-06-25-claude-connector-directory.md §13 #1.
21
+ *
22
+ * capability → tools
23
+ * ─────────────────────────────────────────────────────────────
24
+ * 'read' → search_extensions, get_extension, get_reviews,
25
+ * get_security, market_overview
26
+ * 'docs' → search_docs, generate_icon_workflow (free; no key)
27
+ * 'scan' → scan_extension (local artifact; stdio only)
28
+ * 'publish' → publish_extension (local creds; stdio only)
29
+ *
30
+ * stdout is the MCP protocol channel for stdio — nothing here may write to it.
31
+ */
32
+ import { UserError } from 'fastmcp';
33
+ import { z } from 'zod';
34
+ import { DocsError, getDocsIndex, searchDocs } from './docs.js';
35
+ import { renderIconWorkflow } from './icon-workflow.js';
36
+ import { PublishSetupError, publishArtifact, readStoreCredentials, validateStoreCredentials, } from './publish.js';
37
+ import { checkPublishAccess } from './publish-access.js';
38
+ import { ScanError, scanArtifact } from './scan.js';
39
+ import { describeStoreConstraints, validateSearchFilters } from './search-filters.js';
40
+ import { shapeExtension, shapeReviews, shapeSearch, shapeSecurity } from './shape.js';
41
+ import { captureError, captureEvent, classifyError } from './telemetry.js';
42
+ // ── Public links (shared by tool bodies + server instructions) ──────────────
43
+ export const KEY_PAGE = 'https://dojo.extenshi.io/api-keys';
44
+ export const SIGNUP_PAGE = 'https://auth.extenshi.io/signup';
45
+ export const BILLING_PAGE = 'https://dojo.extenshi.io/billing';
46
+ export const MISSING_KEY_MESSAGE = "This tool needs an Extenshi API key, and you don't have one set up yet — " +
47
+ "here's how to get going.\n\n" +
48
+ 'Getting started is free: no credit card required. Every account includes a ' +
49
+ 'free monthly allowance (25 catalog reads + 5 scans), so you can explore the ' +
50
+ 'catalog and run scans before paying for anything.\n\n' +
51
+ `1. Create a free account at ${SIGNUP_PAGE}\n` +
52
+ `2. Grab a key at ${KEY_PAGE}\n` +
53
+ '3. Set it as the EXTENSHI_API_KEY environment variable in your MCP client ' +
54
+ 'config (or run `extenshi login`).\n\n' +
55
+ 'Tip: the `search_docs` tool is free and needs no key — use it any time to ' +
56
+ 'look up product docs and CLI commands.';
57
+ export const SERVER_NAME = 'extenshi';
58
+ export const SERVER_INSTRUCTIONS = 'Extenshi catalog tools for extension developers: search the cross-store catalog, ' +
59
+ 'inspect an extension and its security findings, find competitors, read market stats, ' +
60
+ 'run a pre-publish security scan, and publish to the stores. Use search_docs (free, no ' +
61
+ 'key) to consult the live product documentation and the @extenshi/cli command reference — ' +
62
+ 'prefer quoting exact CLI commands and flags from the docs over guessing. Use ' +
63
+ 'generate_icon_workflow (free, no key) when the developer needs an extension icon — it ' +
64
+ 'returns the local agent-draws-SVG → CLI browser-panel preview → export workflow. The catalog and ' +
65
+ `scan tools require an Extenshi API key (${KEY_PAGE}). Every account gets a free monthly ` +
66
+ 'allowance — 25 reads and 5 scans per month; beyond it, buy prepaid credit packs (scans ' +
67
+ `and reads, never expire) at ${BILLING_PAGE}.`;
68
+ // ── Internal helpers (ported from index.ts, now deps-parametrized) ───────────
69
+ /**
70
+ * A `UserError` that keeps the error it renders as `cause`.
71
+ *
72
+ * ANY catch-all that turns a caught error into a `UserError` MUST build it here
73
+ * rather than with `new UserError(err.message)`. isExpectedError() reads a
74
+ * missing `cause` as "a message we authored" — i.e. expected — so a hand-rolled
75
+ * wrapper silently drops whatever it caught (a BFF 5xx, a TypeError) from error
76
+ * tracking. That's the bug this helper exists to prevent; the convention is not
77
+ * enforced by the type system, so it lives here and in isExpectedError().
78
+ *
79
+ * Two fastmcp details force this to be a helper rather than
80
+ * `new UserError(msg, { cause })`:
81
+ * 1. `UserError`'s constructor is `(message, extras?)` and passes ONLY the
82
+ * message to `super()` — an `ErrorOptions` second argument is silently
83
+ * dropped, so `.cause` would stay undefined.
84
+ * 2. `extras` is not a place to stash the origin either: fastmcp spreads it
85
+ * into the tool result as `structuredContent`, which would ship the raw
86
+ * error (stack, internal URLs) to the caller.
87
+ * So set `cause` on the instance. fastmcp never reads it, so nothing leaks — but
88
+ * classifyError()/isExpectedError() can see the origin behind the rendered
89
+ * message instead of guessing from its wording.
90
+ */
91
+ function userErrorFrom(message, cause) {
92
+ const err = new UserError(message);
93
+ err.cause = cause;
94
+ return err;
95
+ }
96
+ /**
97
+ * Normalize a thrown read error into a user-facing message.
98
+ *
99
+ * NB: this wraps EVERY failure — a BFF 5xx and a plain bug included — so the
100
+ * resulting `UserError` says nothing about whether the failure was expected.
101
+ * That's why the origin is preserved as `cause`: it's the only thing left that
102
+ * can tell a quota gate from a genuine fault. See isExpectedError().
103
+ */
104
+ function readError(err, missingKeyMessage) {
105
+ if (err instanceof UserError)
106
+ throw err;
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ // A 401 means the BFF rejected the key (enforcement landed / key invalid).
109
+ if (/unauthorized|401|api key/i.test(message)) {
110
+ throw userErrorFrom(`Request rejected — ${message}\n\n${missingKeyMessage}`, err);
111
+ }
112
+ throw userErrorFrom(message, err);
113
+ }
114
+ /** Map a scan backend failure to an actionable next step. */
115
+ function scanErrorMessage(err, missingKeyMessage) {
116
+ switch (err.status) {
117
+ case 401:
118
+ return `Authentication failed: ${err.message}\n\n${missingKeyMessage}`;
119
+ case 402:
120
+ return `Out of scan credits. Buy a scan pack at ${BILLING_PAGE} — the free allowance (5/mo) resets on the 1st (UTC).`;
121
+ case 403:
122
+ // FREE_REQUIRES_* came from the pre-credit-pack backend (free scans
123
+ // were gated on verified ownership). Kept for skew with old backends.
124
+ if (err.errorCode === 'FREE_REQUIRES_CLAIM' || err.errorCode === 'FREE_REQUIRES_VERIFIED_OWNERSHIP') {
125
+ return `${err.message}\n\nBuy a scan pack at ${BILLING_PAGE} to scan arbitrary artifacts.`;
126
+ }
127
+ return `Access denied: ${err.message}`;
128
+ case 429:
129
+ return `Rate limited — wait ${err.retryAfterSec ?? 60}s before trying again.`;
130
+ default:
131
+ return err.message;
132
+ }
133
+ }
134
+ /**
135
+ * Error classes that are EXPECTED, user-facing conditions rather than faults:
136
+ * the caller ran out of their free allowance (`quota`), got rate limited
137
+ * (`rate_limit`), or supplied a bad/absent key (`auth`). These surface to the
138
+ * user as an actionable message — they are not code exceptions, so they must
139
+ * NOT be shipped to error tracking (otherwise a routine billing gate mints a
140
+ * bogus, self-reopening issue). See classifyError() in ./telemetry.ts.
141
+ */
142
+ const EXPECTED_ERROR_KINDS = new Set(['quota', 'rate_limit', 'auth']);
143
+ /**
144
+ * True when a thrown error is an expected, user-facing condition rather than a
145
+ * bug worth an exception report.
146
+ *
147
+ * `UserError` alone can't answer this. It has two jobs in this file: messages we
148
+ * AUTHORED for the caller ("No extension found …", the missing-key help), and a
149
+ * last-resort wrapper the catch-alls put around anything that escaped (readError,
150
+ * the docs/scan handlers). Treating every `UserError` as expected would let that
151
+ * second group launder a BFF 500 or a plain TypeError into "expected" and drop it
152
+ * from error tracking — so the two are told apart by `cause`:
153
+ *
154
+ * - authored (no `cause`) → expected by construction: we wrote the message.
155
+ * - wrapping (has `cause`) → only as expected as what it wraps; classifyError
156
+ * follows the chain, so a 429 gate stays expected and a 500 does not.
157
+ *
158
+ * Corollary for anything that wraps a caught error: build it with
159
+ * userErrorFrom(), or it reads as authored and its origin never gets captured.
160
+ *
161
+ * `kind` defaults to classifying `err`; pass one to reuse a classification the
162
+ * caller already made, so the reported `error_kind` and this decision can't
163
+ * disagree.
164
+ */
165
+ export function isExpectedError(err, kind = classifyError(err)) {
166
+ if (EXPECTED_ERROR_KINDS.has(kind))
167
+ return true;
168
+ return err instanceof UserError && err.cause === undefined;
169
+ }
170
+ /**
171
+ * Wrap a tool definition so every call emits anonymous telemetry: which tool
172
+ * ran, how long it took, and how it failed (coarse error_kind + sanitized
173
+ * exception). The generic preserves the Zod-inferred `args` type — `parameters`
174
+ * fixes Params, so the inner execute stays as strongly typed as before.
175
+ * Fail-soft: telemetry never alters the tool's result or its thrown error.
176
+ */
177
+ function instrument(tool) {
178
+ const { name } = tool;
179
+ const original = tool.execute;
180
+ return {
181
+ ...tool,
182
+ execute: async (args, context) => {
183
+ const startedAt = Date.now();
184
+ captureEvent('mcp_tool_called', { tool: name });
185
+ try {
186
+ const result = await original(args, context);
187
+ captureEvent('mcp_tool_succeeded', { tool: name, duration_ms: Date.now() - startedAt });
188
+ return result;
189
+ }
190
+ catch (err) {
191
+ // Classified once and threaded into both uses below: the reported
192
+ // error_kind and the capture decision must never disagree about what
193
+ // this failure was.
194
+ const kind = classifyError(err);
195
+ // The failure count is always tracked, so we keep visibility into
196
+ // how often callers hit each condition (incl. the billing gate).
197
+ captureEvent('mcp_tool_failed', { tool: name, error_kind: kind, duration_ms: Date.now() - startedAt });
198
+ // …but only genuine faults are shipped as exceptions. Expected
199
+ // user-facing conditions (a quota gate, a bad key, a message we
200
+ // authored) are not bugs and must not open error-tracking issues.
201
+ if (!isExpectedError(err, kind))
202
+ captureError(err, { tool: name });
203
+ throw err;
204
+ }
205
+ },
206
+ };
207
+ }
208
+ // ── Registration ─────────────────────────────────────────────────────────────
209
+ /**
210
+ * MCP tool annotations (spec: title + behaviour hints). REQUIRED for the
211
+ * Anthropic Connectors Directory — its submission portal auto-syncs tools and
212
+ * refuses to submit any tool missing a `title` or a read/write hint. Kept as a
213
+ * name→annotation map (not inline per tool) so the read/write split is auditable
214
+ * in one place. Every remote-exposed tool is read-only; scan uploads an artifact
215
+ * (not read-only, not destructive); publish writes to public stores (destructive).
216
+ */
217
+ const TOOL_ANNOTATIONS = {
218
+ search_extensions: {
219
+ title: 'Search extension catalog',
220
+ readOnlyHint: true,
221
+ idempotentHint: true,
222
+ openWorldHint: true,
223
+ },
224
+ get_extension: {
225
+ title: 'Get extension details',
226
+ readOnlyHint: true,
227
+ idempotentHint: true,
228
+ openWorldHint: true,
229
+ },
230
+ get_reviews: {
231
+ title: 'Get extension reviews',
232
+ readOnlyHint: true,
233
+ idempotentHint: true,
234
+ openWorldHint: true,
235
+ },
236
+ get_security: {
237
+ title: 'Get extension security analysis',
238
+ readOnlyHint: true,
239
+ idempotentHint: true,
240
+ openWorldHint: true,
241
+ },
242
+ market_overview: {
243
+ title: 'Catalog market overview',
244
+ readOnlyHint: true,
245
+ idempotentHint: true,
246
+ openWorldHint: true,
247
+ },
248
+ search_docs: {
249
+ title: 'Search Extenshi documentation',
250
+ readOnlyHint: true,
251
+ idempotentHint: true,
252
+ openWorldHint: true,
253
+ },
254
+ generate_icon_workflow: {
255
+ title: 'Icon design workflow guide',
256
+ readOnlyHint: true,
257
+ idempotentHint: true,
258
+ openWorldHint: false,
259
+ },
260
+ scan_extension: {
261
+ title: 'Scan an extension package',
262
+ readOnlyHint: false,
263
+ destructiveHint: false,
264
+ openWorldHint: true,
265
+ },
266
+ publish_extension: {
267
+ title: 'Publish extension to stores',
268
+ readOnlyHint: false,
269
+ destructiveHint: true,
270
+ openWorldHint: true,
271
+ },
272
+ };
273
+ /**
274
+ * Register the Extenshi tools on a FastMCP server. Idempotent per server.
275
+ * Only the tools whose capability is present in `deps.capabilities` are added.
276
+ */
277
+ export function registerTools(server, deps) {
278
+ const caps = deps.capabilities;
279
+ const missingKeyMessage = deps.missingKeyMessage ?? MISSING_KEY_MESSAGE;
280
+ // Generic so each tool's Zod `parameters` infers its own `args` type (a
281
+ // non-generic wrapper would collapse Params to `never`).
282
+ function add(tool) {
283
+ // Attach the directory-required annotations (title + read/write hint) from
284
+ // the central map; an explicit `tool.annotations` (none today) still wins.
285
+ const annotations = { ...TOOL_ANNOTATIONS[tool.name], ...tool.annotations };
286
+ server.addTool(instrument({ ...tool, annotations }));
287
+ }
288
+ // Per-call helpers bound to the injected deps.
289
+ const bff = (ctx) => deps.getBff(ctx);
290
+ const requireApiKey = (ctx) => {
291
+ if (!deps.requireApiKey)
292
+ throw new UserError(missingKeyMessage);
293
+ return deps.requireApiKey(ctx);
294
+ };
295
+ // ── Read tools (free; key/identity required) ───────────────────────────────
296
+ if (caps.has('read')) {
297
+ add({
298
+ name: 'search_extensions',
299
+ description: 'Search the cross-store extension catalog (Chrome, Firefox, Edge) with hybrid relevance. ' +
300
+ 'ALL filters are applied server-side by the catalog database — always narrow with the ' +
301
+ 'parameters below (rating/users/reviews thresholds, store, category, pricing, risk, ' +
302
+ 'permissions, freshness, manifest version, trader status) rather than fetching a broad ' +
303
+ 'list and filtering the results yourself. Use `skip` to page through large result sets. ' +
304
+ `${describeStoreConstraints()} ` +
305
+ 'Returns a compact list for market research and competitive analysis.',
306
+ parameters: z.object({
307
+ query: z.string().optional().describe('Free-text search query (name, description, keywords).'),
308
+ stores: z
309
+ .array(z.enum(['CHROME', 'FIREFOX', 'EDGE']))
310
+ .optional()
311
+ .describe('Limit to stores: CHROME, FIREFOX, and/or EDGE.'),
312
+ categories: z.array(z.string()).optional().describe('Catalog category slugs to include.'),
313
+ pricing: z
314
+ .array(z.enum(['FREE', 'FREEMIUM', 'IN_APP_PURCHASES', 'SUBSCRIPTION']))
315
+ .optional()
316
+ .describe('Pricing models to include.'),
317
+ risk: z
318
+ .array(z.enum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']))
319
+ .optional()
320
+ .describe('Risk categories to include.'),
321
+ permissions: z.array(z.string()).optional().describe('Only extensions requesting these permissions.'),
322
+ minRating: z.number().min(0).max(5).optional().describe('Only ratings ≥ this (0–5).'),
323
+ maxRating: z.number().min(0).max(5).optional().describe('Only ratings ≤ this (0–5).'),
324
+ minWeeklyDownloads: z
325
+ .number()
326
+ .min(0)
327
+ .optional()
328
+ .describe('Minimum weekly downloads — FIREFOX-ONLY metric (Chrome/Edge do not report it). ' +
329
+ 'For Chrome/Edge popularity use sortBy:"popular" instead. Conflicts with a ' +
330
+ 'Chrome/Edge-only `stores` filter and will be rejected.'),
331
+ minReviews: z.number().min(0).optional().describe('Minimum number of store reviews/ratings.'),
332
+ updatedWithin: z
333
+ .enum(['30d', '90d', '1y', 'stale'])
334
+ .optional()
335
+ .describe('Freshness: updated within 30d / 90d / 1y, or "stale" (older than 1y).'),
336
+ manifestVersions: z
337
+ .array(z.union([z.literal(2), z.literal(3)]))
338
+ .optional()
339
+ .describe('Manifest version(s): 2 and/or 3.'),
340
+ traderStatuses: z
341
+ .array(z.enum(['TRADER', 'NON_TRADER']))
342
+ .optional()
343
+ .describe('EU DSA trader status.'),
344
+ monetizationModels: z
345
+ .array(z.enum(['FREE', 'ONE_TIME', 'SUBSCRIPTION', 'FREEMIUM', 'DONATIONS', 'ADS']))
346
+ .optional()
347
+ .describe('Author-declared monetization model (questionnaire).'),
348
+ hasPaywall: z.boolean().optional().describe('Author-declared paywall present.'),
349
+ isOpenSource: z.boolean().optional().describe('Author-declared open source.'),
350
+ noTelemetry: z.boolean().optional().describe('Author-declared no telemetry.'),
351
+ collectsHealthData: z.boolean().optional().describe('Author-declared collects health data.'),
352
+ includeDelisted: z
353
+ .boolean()
354
+ .optional()
355
+ .describe('Include extensions whose store listing was removed (default: hidden).'),
356
+ sortBy: z
357
+ .enum(['relevance', 'popular', 'rating', 'recent', 'name', 'safety', 'trader', 'size'])
358
+ .optional()
359
+ .describe('Sort field (default: popular, or relevance when a query is given).'),
360
+ sortOrder: z.enum(['asc', 'desc']).optional().describe('Sort direction (default: desc).'),
361
+ skip: z.number().int().min(0).optional().describe('Offset for pagination (default: 0).'),
362
+ limit: z.number().int().min(1).max(25).default(20).describe('Max results to return (1–25).'),
363
+ }),
364
+ execute: async (args, context) => {
365
+ try {
366
+ const conflict = validateSearchFilters(args);
367
+ if (conflict)
368
+ throw new UserError(conflict);
369
+ const limit = args.limit ?? 20;
370
+ const result = await bff(context).searchExtensions({
371
+ query: args.query,
372
+ stores: args.stores,
373
+ categories: args.categories,
374
+ pricingModels: args.pricing,
375
+ riskCategories: args.risk,
376
+ permissions: args.permissions,
377
+ minRating: args.minRating,
378
+ maxRating: args.maxRating,
379
+ minWeeklyDownloads: args.minWeeklyDownloads,
380
+ minReviews: args.minReviews,
381
+ updatedWithin: args.updatedWithin,
382
+ manifestVersions: args.manifestVersions,
383
+ traderStatuses: args.traderStatuses,
384
+ monetizationModels: args.monetizationModels,
385
+ hasPaywall: args.hasPaywall,
386
+ isOpenSource: args.isOpenSource,
387
+ noTelemetry: args.noTelemetry,
388
+ collectsHealthData: args.collectsHealthData,
389
+ includeDelisted: args.includeDelisted,
390
+ sortBy: args.sortBy ?? (args.query ? 'relevance' : 'popular'),
391
+ sortOrder: args.sortOrder,
392
+ skip: args.skip,
393
+ take: limit,
394
+ });
395
+ return JSON.stringify(shapeSearch(result, limit), null, 2);
396
+ }
397
+ catch (err) {
398
+ return readError(err, missingKeyMessage);
399
+ }
400
+ },
401
+ });
402
+ add({
403
+ name: 'get_extension',
404
+ description: 'Get full catalog detail for one extension by its numeric catalog ID: metadata, ' +
405
+ 'per-store ratings, install counts, categories, and a security badge.',
406
+ parameters: z.object({
407
+ extension_id: z.number().int().describe('Numeric catalog ID (from search_extensions results).'),
408
+ }),
409
+ execute: async (args, context) => {
410
+ try {
411
+ const result = await bff(context).getExtensionById(args.extension_id);
412
+ if (!result)
413
+ throw new UserError(`No extension found with catalog ID ${args.extension_id}.`);
414
+ return JSON.stringify(shapeExtension(result), null, 2);
415
+ }
416
+ catch (err) {
417
+ return readError(err, missingKeyMessage);
418
+ }
419
+ },
420
+ });
421
+ add({
422
+ name: 'get_reviews',
423
+ description: 'Get store user reviews for one extension from Firefox Add-ons and Edge Add-ons: star rating, ' +
424
+ 'a short review excerpt, date and language, plus a store-level `aggregate` (rating, count, ' +
425
+ 'reviews link). Chrome Web Store review rows are NOT returned (their text cannot be redistributed) — ' +
426
+ 'for a Chrome extension the `aggregate` (with a link to the store reviews tab) is the only public ' +
427
+ 'review content. Reviewer identity is intentionally omitted. ' +
428
+ 'Paginated newest-first by default — pass `cursor` (the `nextCursor` from a previous ' +
429
+ 'call) to fetch the next page, or sort by highest rating. Cursors are sort-specific: ' +
430
+ 'keep the same `sort` while paging, and start over if you change it. Reads existing ' +
431
+ 'scraped reviews; use `min_rating` to see only positive or only critical feedback.',
432
+ parameters: z.object({
433
+ extension_id: z.number().int().describe('Numeric catalog ID (from search_extensions results).'),
434
+ limit: z.number().int().min(1).max(50).default(20).describe('Max reviews to return (1–50).'),
435
+ cursor: z
436
+ .number()
437
+ .int()
438
+ .optional()
439
+ .describe('Pagination cursor — pass the `nextCursor` returned by a previous call.'),
440
+ language_id: z.number().int().optional().describe('Only reviews in this catalog language id.'),
441
+ min_rating: z
442
+ .number()
443
+ .int()
444
+ .min(1)
445
+ .max(5)
446
+ .optional()
447
+ .describe('Only reviews with a star rating ≥ this (1–5).'),
448
+ sort: z
449
+ .enum(['recent', 'rating'])
450
+ .optional()
451
+ .describe('Order by newest first (default) or highest rating.'),
452
+ }),
453
+ execute: async (args, context) => {
454
+ try {
455
+ const limit = args.limit;
456
+ const result = await bff(context).getReviews({
457
+ extensionId: args.extension_id,
458
+ limit,
459
+ cursor: args.cursor,
460
+ languageId: args.language_id,
461
+ minRating: args.min_rating,
462
+ sort: args.sort ?? 'recent',
463
+ });
464
+ return JSON.stringify(shapeReviews(result, limit), null, 2);
465
+ }
466
+ catch (err) {
467
+ return readError(err, missingKeyMessage);
468
+ }
469
+ },
470
+ });
471
+ add({
472
+ name: 'get_security',
473
+ description: 'Get the security analysis for an extension: safety score (0–100, higher = safer — the ' +
474
+ 'same coefficient the website shows), risk category, finding counts by ' +
475
+ 'severity, and the top grouped findings (scanner, rule, severity, count). Also returns ' +
476
+ 'the install-dialog preview — exactly what Chrome/Firefox show users in the permission ' +
477
+ 'prompt at install (consolidated + deduped from the manifest), available even for ' +
478
+ 'unscanned extensions. Reads existing scan results — does not trigger a new scan.',
479
+ parameters: z.object({
480
+ extension_id: z.number().int().describe('Numeric catalog ID.'),
481
+ }),
482
+ execute: async (args, context) => {
483
+ try {
484
+ const client = bff(context);
485
+ // getExtensionById carries `installDialogPreview` (a manifest transform,
486
+ // independent of scanning) — fetch it alongside the scan data so the
487
+ // security view always includes the install prompt the user would see.
488
+ const [security, riskSummary, extension] = await Promise.all([
489
+ client.getSecurityData(args.extension_id).catch(() => null),
490
+ client.getRiskSummary(args.extension_id).catch(() => null),
491
+ client.getExtensionById(args.extension_id).catch(() => null),
492
+ ]);
493
+ const installDialogPreview = extension && typeof extension === 'object'
494
+ ? extension.installDialogPreview
495
+ : null;
496
+ return JSON.stringify(shapeSecurity(security, riskSummary, installDialogPreview), null, 2);
497
+ }
498
+ catch (err) {
499
+ return readError(err, missingKeyMessage);
500
+ }
501
+ },
502
+ });
503
+ add({
504
+ name: 'market_overview',
505
+ description: 'Aggregate catalog market intelligence. Called with NO arguments it returns a full ' +
506
+ 'CATALOG-WIDE overview: totals, store split, the category tree, and the extended facet ' +
507
+ 'breakdown — Manifest V2/V3 adoption, sensitive-permission histogram, security risk-tier ' +
508
+ 'distribution, trader status, update-recency, and review-count buckets. Pass a `query` ' +
509
+ '(and/or store/category filters) to scope those facets to a search result set instead.',
510
+ parameters: z.object({
511
+ query: z.string().optional().describe('Optional query to scope facets to a search.'),
512
+ stores: z.array(z.enum(['CHROME', 'FIREFOX', 'EDGE'])).optional(),
513
+ categories: z.array(z.string()).optional(),
514
+ }),
515
+ execute: async (args, context) => {
516
+ try {
517
+ const client = bff(context);
518
+ const scoped = Boolean(args.query?.trim() || args.stores?.length || args.categories?.length);
519
+ // Search-scoped: progressive-narrowing facets over the match set.
520
+ if (scoped) {
521
+ const [stats, facets] = await Promise.all([
522
+ client.getStats().catch(() => null),
523
+ client
524
+ .getSearchFacets({ query: args.query, stores: args.stores, categories: args.categories })
525
+ .catch(() => null),
526
+ ]);
527
+ return JSON.stringify({ scope: 'search', stats: stats ?? undefined, facets: facets ?? undefined }, null, 2);
528
+ }
529
+ // Catalog-wide: the unscoped search facets are all-zero by design, so build the
530
+ // overview from the dedicated catalog-wide procedures (cached server-side).
531
+ const STORE_LABELS = {
532
+ CHROME: 'Chrome Web Store',
533
+ FIREFOX: 'Firefox Add-ons',
534
+ EDGE: 'Edge Add-ons',
535
+ };
536
+ const [stats, extended, categoryTree] = await Promise.all([
537
+ client.getStats().catch(() => null),
538
+ client.getExtendedFilterFacets().catch(() => null),
539
+ client.getCategoryTree().catch(() => null),
540
+ ]);
541
+ const storeDistribution = stats
542
+ ?.storeDistribution ?? [];
543
+ const stores = storeDistribution.map((r) => ({
544
+ name: r.store,
545
+ count: r.count,
546
+ label: STORE_LABELS[r.store] ?? r.store,
547
+ }));
548
+ return JSON.stringify({
549
+ scope: 'catalog-wide',
550
+ stats: stats ?? undefined,
551
+ facets: {
552
+ stores,
553
+ categoryTree: categoryTree ?? undefined,
554
+ extended: extended ?? undefined,
555
+ },
556
+ note: 'Monetization and download-volume facets are only computed when scoped to a query — pass `query` to get those.',
557
+ }, null, 2);
558
+ }
559
+ catch (err) {
560
+ return readError(err, missingKeyMessage);
561
+ }
562
+ },
563
+ });
564
+ }
565
+ // ── Documentation (free; no API key required) ──────────────────────────────
566
+ if (caps.has('docs')) {
567
+ add({
568
+ name: 'search_docs',
569
+ description: 'Search the official Extenshi documentation (docs.extenshi.io) — product guides plus the ' +
570
+ 'full @extenshi/cli command reference (scan, review-risk, publish, login). Use it to answer ' +
571
+ '"how do I…" questions and to give developers exact CLI commands and flags instead of ' +
572
+ 'guessing. Free: reads public docs, no API key or quota required. Omit the query to list ' +
573
+ 'every available documentation page.',
574
+ parameters: z.object({
575
+ query: z
576
+ .string()
577
+ .optional()
578
+ .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.'),
579
+ limit: z
580
+ .number()
581
+ .int()
582
+ .min(1)
583
+ .max(10)
584
+ .default(4)
585
+ .describe('Max documentation sections to return (1–10).'),
586
+ }),
587
+ execute: async (args) => {
588
+ try {
589
+ const query = args.query?.trim();
590
+ if (!query)
591
+ return await getDocsIndex(deps.cfg.docsUrl);
592
+ return await searchDocs(deps.cfg.docsUrl, query, args.limit ?? 4);
593
+ }
594
+ catch (err) {
595
+ // Both branches keep the origin as `cause` — a docs outage is a
596
+ // fault worth capturing, not an expected condition.
597
+ if (err instanceof DocsError)
598
+ throw userErrorFrom(err.message, err);
599
+ throw userErrorFrom(err instanceof Error ? err.message : String(err), err);
600
+ }
601
+ },
602
+ });
603
+ add({
604
+ name: 'generate_icon_workflow',
605
+ description: 'Get the recommended FREE local workflow for creating a browser-extension icon: the ' +
606
+ 'agent draws the SVG itself, then `@extenshi/cli icon preview` renders an offline ' +
607
+ 'verification page (Chrome/Firefox/Edge toolbar mockups, palette switcher with contrast ' +
608
+ 'warnings, store-size matrix, PNG/ZIP export). Returns the icon design requirements ' +
609
+ '(sizes, 16px legibility rules, light/dark survival) and exact commands. Static content: ' +
610
+ 'no API key, no network, no credits.',
611
+ parameters: z.object({
612
+ extension_name: z
613
+ .string()
614
+ .max(120)
615
+ .optional()
616
+ .describe('Extension display name to inline into the preview command (optional).'),
617
+ }),
618
+ execute: async (args) => renderIconWorkflow({ extensionName: args.extension_name }),
619
+ });
620
+ }
621
+ // ── Action tool (paid; key + LOCAL filesystem required; stdio only) ─────────
622
+ if (caps.has('scan')) {
623
+ add({
624
+ name: 'scan_extension',
625
+ description: 'Run a pre-publish security scan on a local extension artifact (.zip/.crx/.xpi) and ' +
626
+ 'return the report. Uses one scan from your free monthly allowance (5 scans/month) or a ' +
627
+ 'purchased scan credit once that runs out. Streams live per-scanner progress.',
628
+ parameters: z.object({
629
+ artifact_path: z
630
+ .string()
631
+ .describe('Absolute or relative path to the built extension artifact (≤50 MB).'),
632
+ extension_id: z
633
+ .number()
634
+ .int()
635
+ .optional()
636
+ .describe('Optional numeric catalog ID to associate the scan with a catalog listing.'),
637
+ }),
638
+ execute: async (args, context) => {
639
+ const apiKey = requireApiKey(context);
640
+ try {
641
+ const report = await scanArtifact({
642
+ artifactPath: args.artifact_path,
643
+ apiKey,
644
+ scanUrl: deps.cfg.scanUrl,
645
+ extensionId: args.extension_id?.toString(),
646
+ onProgress: (p) => {
647
+ if (typeof p.total === 'number' && p.total > 0) {
648
+ void context.reportProgress({ progress: p.completed ?? 0, total: p.total });
649
+ }
650
+ },
651
+ });
652
+ return JSON.stringify(shapeExtension(report), null, 2);
653
+ }
654
+ catch (err) {
655
+ // Keep the ScanError as `cause`: scanErrorMessage() renders 402/429
656
+ // and a 500 alike, so only the origin's status still says which of
657
+ // those is a fault worth capturing.
658
+ if (err instanceof ScanError)
659
+ throw userErrorFrom(scanErrorMessage(err, missingKeyMessage), err);
660
+ throw err;
661
+ }
662
+ },
663
+ });
664
+ }
665
+ if (caps.has('publish')) {
666
+ add({
667
+ name: 'publish_extension',
668
+ description: 'Publish an extension artifact (.zip/.crx/.xpi) to Chrome Web Store, Firefox AMO, and/or Edge Add-ons. ' +
669
+ 'FREE and fully local: the upload goes from this machine straight to the store APIs using store ' +
670
+ 'credentials from the MCP server environment (CHROME_APP_ID/CHROME_CLIENT_ID/CHROME_CLIENT_SECRET/' +
671
+ 'CHROME_REFRESH_TOKEN, FIREFOX_ADDON_GUID/FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET, ' +
672
+ 'EDGE_PRODUCT_ID/EDGE_CLIENT_ID/EDGE_CLIENT_SECRET/EDGE_TENANT_ID). The upload itself is local, but ' +
673
+ 'publishing is in an active testing phase: a quick Extenshi access check runs first (set EXTENSHI_API_KEY ' +
674
+ 'so it can recognize your account). Edge submissions are polled to a terminal status. ' +
675
+ 'Recommended flow: scan_extension first, then publish.',
676
+ parameters: z.object({
677
+ artifact_path: z
678
+ .string()
679
+ .describe('Path to the packaged extension (.zip for Chrome/Edge, .xpi/.zip for Firefox).'),
680
+ stores: z
681
+ .array(z.enum(['chrome', 'firefox', 'edge']))
682
+ .optional()
683
+ .describe('Target stores; defaults to every store that has complete credentials in the environment.'),
684
+ firefox_artifact_path: z
685
+ .string()
686
+ .optional()
687
+ .describe('Separate Firefox artifact (.xpi); defaults to artifact_path.'),
688
+ release_notes: z.string().optional().describe('Release notes for stores that accept them.'),
689
+ extension_id: z
690
+ .number()
691
+ .optional()
692
+ .describe('Numeric catalog ID — checks publish-beta access against this specific extension.'),
693
+ validate_only: z
694
+ .boolean()
695
+ .optional()
696
+ .describe('Only check which store credentials are configured and valid; publish nothing.'),
697
+ }),
698
+ execute: async (args, context) => {
699
+ try {
700
+ if (args.validate_only) {
701
+ const checks = await validateStoreCredentials(args.stores);
702
+ return JSON.stringify({ checks }, null, 2);
703
+ }
704
+ // Publish-access gate: `publish` is in an active testing phase. One BFF
705
+ // preflight evaluates the `publish-access` PostHog flag for this developer
706
+ // / extension. Fails open on any transport error — only a definitive
707
+ // server "no" blocks here (see checkPublishAccess).
708
+ const creds = readStoreCredentials();
709
+ const storeIds = {};
710
+ if (creds.chrome)
711
+ storeIds.chrome = creds.chrome.appId;
712
+ if (creds.firefox)
713
+ storeIds.firefox = creds.firefox.addonGuid;
714
+ if (creds.edge)
715
+ storeIds.edge = creds.edge.productId;
716
+ const access = await checkPublishAccess({
717
+ bffUrl: deps.cfg.bffUrl,
718
+ apiKey: deps.getApiKey?.(context) ?? null,
719
+ storeIds,
720
+ extensionId: args.extension_id,
721
+ });
722
+ if (!access.allowed) {
723
+ throw new UserError(access.message ??
724
+ "Publishing is in an active testing phase and isn't available for your account yet.");
725
+ }
726
+ void context.reportProgress({ progress: 0, total: 1 });
727
+ const result = await publishArtifact({
728
+ artifactPath: args.artifact_path,
729
+ stores: args.stores,
730
+ firefoxArtifactPath: args.firefox_artifact_path,
731
+ releaseNotes: args.release_notes,
732
+ });
733
+ void context.reportProgress({ progress: 1, total: 1 });
734
+ return JSON.stringify(result, null, 2);
735
+ }
736
+ catch (err) {
737
+ if (err instanceof PublishSetupError)
738
+ throw new UserError(err.message);
739
+ throw err;
740
+ }
741
+ },
742
+ });
743
+ }
744
+ }
745
+ //# sourceMappingURL=tools.js.map