UNPKG

@extenshi/mcp

Version:

Extenshi MCP server — bring the extension catalog (search, security, market research) and pre-publish scanning into your AI tools

2,613 lines • 145 kB
/**
 * Transport-agnostic tool registry for the Extenshi MCP server.
 *
 * The SAME tool set must run over two transports:
 *   - stdio  (`index.ts`)  — local, single `ek_…` key from the environment.
 *   - remote (`http.ts`, in the sibling `@extenshi/mcp-server`) — Streamable
 *     HTTP behind OAuth; identity is per-request (an access token → userId).
 *
 * To keep ONE source of truth for what the tools do, every tool body here is
 * identity-agnostic: it resolves its catalog client / scan auth through the
 * injected `ToolDeps` instead of reaching for a module-global key. stdio passes
 * deps that ignore the call context and use the env key; the remote server
 * passes deps that read `context.session` (the validated OAuth identity).
 *
 * Capability gating: the remote connector deliberately exposes a SUBSET — the
 * catalog/project/policy/guidance tools. `scan_extension` and `publish_extension` need
 * the caller's LOCAL filesystem and LOCAL store credentials, which a hosted
 * server has no access to (and must never store) — so they are registered only
 * when the corresponding capability is present (stdio enables all four).
 * See internal-docs/plans/2026-06-25-claude-connector-directory.md §13 #1.
 *
 *   capability → tools
 *   ─────────────────────────────────────────────────────────────
 *   'read'    → search_extensions, get_extension, get_reviews,
 *               get_security, get_risk_by_store_ids, market_overview,
 *               list_my_projects, get_project_state, get_project_scaffold,
 *               list/get/publish/update privacy policy (hosted; Pro)
 *   'docs'    → get_development_guide, search_docs, list_extension_templates, generate_icon_workflow,
 *               generate_welcome_page_workflow, generate_landing_page, localize_workflow (free; no key)
 *   'scan'    → scan_extension             (local artifact; stdio only)
 *   'publish' → publish_extension          (local creds; stdio only)
 *
 * stdout is the MCP protocol channel for stdio — nothing here may write to it.
 */
import { agentCreateProjectSchema, agentEvidenceSchema, importManifestSchema, proposeDecisionSchema, workspacePatchSchema, } from '@extenshi/contracts';
import { UserError } from 'fastmcp';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { buildDevelopmentGuide, GUIDE_SECTIONS } from './development-guide.js';
import { DEFAULT_EXCERPT_CHARS, DocsError, getDocsIndex, MAX_EXCERPT_CHARS, MIN_EXCERPT_CHARS, searchDocs, } from './docs.js';
import { renderIconWorkflow } from './icon-workflow.js';
import { LANDING_LIMITS } from './landing-page.js';
import { LANDING_FONTS, LANDING_SECTION_TYPES, LANDING_UI_DEFAULTS, LANDING_V2_LIMITS, } from './landing-sections.js';
import { hostedLandingNextSteps, landingFormFromArgs, renderGenerateLandingPage } from './landing-workflow.js';
import { renderLocalizeWorkflow } from './localize-workflow.js';
import { PAY_OPERATIONS, paySchemas } from './pay.js';
import { describePrivacyAiUpdate } from './privacy-ai-outcome.js';
import { checkInlineImage, PROJECT_MEDIA_MAX_BYTES, PROJECT_MEDIA_MIME_TYPES, ProjectMediaInputError, readWorkspaceImage, } from './project-media.js';
import { amoCreateAllowed, PublishSetupError, publishArtifact, storeCredentialsFor, validateStoreCredentials, } from './publish.js';
import { checkPublishAccess } from './publish-access.js';
import { ScanError, scanArtifact } from './scan.js';
import { describeStoreConstraints, validateSearchFilters } from './search-filters.js';
import { DEFAULT_MARKET_TOP_N, MARKET_FACETS, MAX_MARKET_TOP_N, shapeExtension, shapeMarketOverview, shapeReviews, shapeSearch, shapeSecurity, shapeStoreRiskBatch, } from './shape.js';
import { captureError, captureEvent, classifyError } from './telemetry.js';
import { renderExtensionTemplates } from './templates.js';
import { renderCatalogPayload } from './untrusted.js';
import { renderWelcomeWorkflow } from './welcome-workflow.js';
// ── Public links (shared by tool bodies + server instructions) ──────────────
export const KEY_PAGE = 'https://dojo.extenshi.io/api-keys';
export const SIGNUP_PAGE = 'https://auth.extenshi.io/signup';
export const BILLING_PAGE = 'https://dojo.extenshi.io/billing';
export const HOSTED_PAGES_DOCS = 'https://docs.extenshi.io/developers/project-sync#hosted-pages-from-the-api';
// Every workspace write carries provenance; saying so up front saves the agent a
// failed first call on `provenance.toolVersion: Required`.
const PROVENANCE_HINT = ' `provenance` is required: {source: "agent", observedAt: ISO-8601 timestamp, toolVersion: your client name and version}.';
export const MISSING_KEY_MESSAGE = "This tool needs an Extenshi API key, and you don't have one set up yet — " +
    "here's how to get going.\n\n" +
    'Getting started is free: no credit card required. Every account includes a ' +
    'one-time free allowance (10 catalog reads + 3 scans), so you can explore the ' +
    'catalog and run scans before paying for anything.\n\n' +
    `1. Create a free account at ${SIGNUP_PAGE}\n` +
    `2. Grab a key at ${KEY_PAGE}\n` +
    '3. Set it as the EXTENSHI_API_KEY environment variable in your MCP client ' +
    'config (or run `extenshi login`).\n\n' +
    'Tip: the `search_docs` tool is free and needs no key — use it any time to ' +
    'look up product docs and CLI commands.';
/**
 * Max extensions `get_risk_by_store_ids` accepts per call.
 *
 * CROSS-PACKAGE SYNC POINT — nothing can import across it: this is a published
 * npm package, the enforcing code is `METERED_BATCH_MAX_EXTENSIONS` in
 * `catalog/catalog-bff/src/lib/metered-batch-cap.ts`. The BFF is the authority
 * (it rejects an over-cap metered batch); this constant only keeps the tool
 * schema and description honest so an agent is told the limit up front instead
 * of discovering it as a round-tripped error. Pinned by `tools.test.ts`.
 */
export const MAX_BATCH_EXTENSIONS = 40;
export const SERVER_NAME = 'extenshi';
export const SERVER_INSTRUCTIONS = 'Extenshi helps develop and operate browser extensions. Start extension-development tasks with ' +
    'get_development_guide (free): it lists every tool on this connection, the service directory, ' +
    'account/local prerequisites, documentation links, GitHub repository guidance and the ordered ' +
    'plan from scope and research through code, assets, privacy, CI, store release and maintenance. ' +
    '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 ' +
    'product details and exact CLI flags. With identity, call list_my_projects then get_project_state ' +
    'to reuse the existing project and repository; for a new extension, create_project makes one (idempotent). ' +
    'Read get_decisions early: the AMO add-on id, license, seller identity and pricing are owner decisions — ' +
    'propose defaults with propose_decision; the owner decides them in Dojo, and release readiness blocks on them. ' +
    'Use list_extension_templates before the manifest, ' +
    'and get_project_scaffold for a new project. Preserve existing source. For an existing extension, read get_project_workspace then preview and apply import_manifest to fill Dojo from its real manifest and its _locales messages (messagesByLocale) before configuring services. Review the integration diff and preserve local edits; write integration.file ' +
    'verbatim to integration.path, check integration.unwired and re-read state after cabinet edits. ' +
    'Check get_credit_balance before metered work. get_risk_by_store_ids covers up to 40 store IDs ' +
    'for one read; get_security costs three reads for detailed findings. Hosted policy tools require ' +
    'Pro; publish_privacy_policy changes the live policy, while update_privacy_policy_with_ai returns ' +
    "a proposal to review. Respect the author's authorization for publication and repository writes. " +
    'Tool registration is not proof of account entitlements or a completed release. Verify hosted ' +
    'URLs, built artifacts, store status and optional purchases before reporting completion. ' +
    `API keys: ${KEY_PAGE}. Credits: ${BILLING_PAGE}.`;
/** Match initialize instructions to the same capabilities used for registration. */
export function getServerInstructions(capabilities) {
    const local = [
        capabilities.has('scan') ? 'scan_extension' : null,
        capabilities.has('publish') ? 'publish_extension' : null,
    ].filter(Boolean);
    return (SERVER_INSTRUCTIONS +
        (local.length
            ? ` This connection exposes ${local.join(' and ')} for local artifacts; check credentials and access before use.`
            : " 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."));
}
// ── Internal helpers (ported from index.ts, now deps-parametrized) ───────────
/**
 * A `UserError` that keeps the error it renders as `cause`.
 *
 * ANY catch-all that turns a caught error into a `UserError` MUST build it here
 * rather than with `new UserError(err.message)`. isExpectedError() reads a
 * missing `cause` as "a message we authored" — i.e. expected — so a hand-rolled
 * wrapper silently drops whatever it caught (a BFF 5xx, a TypeError) from error
 * tracking. That's the bug this helper exists to prevent; the convention is not
 * enforced by the type system, so it lives here and in isExpectedError().
 *
 * Two fastmcp details force this to be a helper rather than
 * `new UserError(msg, { cause })`:
 *  1. `UserError`'s constructor is `(message, extras?)` and passes ONLY the
 *     message to `super()` — an `ErrorOptions` second argument is silently
 *     dropped, so `.cause` would stay undefined.
 *  2. `extras` is not a place to stash the origin either: fastmcp spreads it
 *     into the tool result as `structuredContent`, which would ship the raw
 *     error (stack, internal URLs) to the caller.
 * So set `cause` on the instance. fastmcp never reads it, so nothing leaks — but
 * classifyError()/isExpectedError() can see the origin behind the rendered
 * message instead of guessing from its wording.
 */
function userErrorFrom(message, cause) {
    const err = new UserError(message);
    err.cause = cause;
    return err;
}
/**
 * Normalize a thrown read error into a user-facing message.
 *
 * NB: this wraps EVERY failure — a BFF 5xx and a plain bug included — so the
 * resulting `UserError` says nothing about whether the failure was expected.
 * That's why the origin is preserved as `cause`: it's the only thing left that
 * can tell a quota gate from a genuine fault. See isExpectedError().
 */
function readError(err, missingKeyMessage) {
    if (err instanceof UserError)
        throw err;
    const message = err instanceof Error ? err.message : String(err);
    // A 401 means the BFF rejected the key (enforcement landed / key invalid).
    if (/unauthorized|401|api key/i.test(message)) {
        throw userErrorFrom(`Request rejected — ${message}\n\n${missingKeyMessage}`, err);
    }
    throw userErrorFrom(message, err);
}
/** Map a scan backend failure to an actionable next step. */
function scanErrorMessage(err, missingKeyMessage) {
    switch (err.status) {
        case 401:
            return `Authentication failed: ${err.message}\n\n${missingKeyMessage}`;
        case 402:
            return `Out of scan credits. Buy a scan pack at ${BILLING_PAGE} to continue — the one-time free allowance (3 scans) does not renew.`;
        case 403:
            // FREE_REQUIRES_* came from the pre-credit-pack backend (free scans
            // were gated on verified ownership). Kept for skew with old backends.
            if (err.errorCode === 'FREE_REQUIRES_CLAIM' || err.errorCode === 'FREE_REQUIRES_VERIFIED_OWNERSHIP') {
                return `${err.message}\n\nBuy a scan pack at ${BILLING_PAGE} to scan arbitrary artifacts.`;
            }
            return `Access denied: ${err.message}`;
        case 429:
            return `Rate limited — wait ${err.retryAfterSec ?? 60}s before trying again.`;
        default:
            return err.message;
    }
}
/**
 * Error classes that are EXPECTED, user-facing conditions rather than faults:
 * the caller ran out of their free allowance (`quota`), got rate limited
 * (`rate_limit`), or supplied a bad/absent key (`auth`). These surface to the
 * user as an actionable message — they are not code exceptions, so they must
 * NOT be shipped to error tracking (otherwise a routine billing gate mints a
 * bogus, self-reopening issue). See classifyError() in ./telemetry.ts.
 */
const EXPECTED_ERROR_KINDS = new Set(['quota', 'rate_limit', 'auth']);
/**
 * True when a thrown error is an expected, user-facing condition rather than a
 * bug worth an exception report.
 *
 * `UserError` alone can't answer this. It has two jobs in this file: messages we
 * AUTHORED for the caller ("No extension found …", the missing-key help), and a
 * last-resort wrapper the catch-alls put around anything that escaped (readError,
 * the docs/scan handlers). Treating every `UserError` as expected would let that
 * second group launder a BFF 500 or a plain TypeError into "expected" and drop it
 * from error tracking — so the two are told apart by `cause`:
 *
 *   - authored (no `cause`)  → expected by construction: we wrote the message.
 *   - wrapping (has `cause`) → only as expected as what it wraps; classifyError
 *     follows the chain, so a 429 gate stays expected and a 500 does not.
 *
 * Corollary for anything that wraps a caught error: build it with
 * userErrorFrom(), or it reads as authored and its origin never gets captured.
 *
 * `kind` defaults to classifying `err`; pass one to reuse a classification the
 * caller already made, so the reported `error_kind` and this decision can't
 * disagree.
 */
export function isExpectedError(err, kind = classifyError(err)) {
    if (EXPECTED_ERROR_KINDS.has(kind))
        return true;
    return err instanceof UserError && err.cause === undefined;
}
/**
 * Attribution for a tool call, lifted from the session the surface supplies.
 *
 * Local stdio has no session at all, so both fields come back undefined and
 * captureEvent falls back to the per-install anonymous id — unchanged behaviour.
 * The hosted connector authenticates every request, so it supplies both: the
 * account id becomes the PostHog distinct_id, and the connection id threads one
 * client's calls together (see tools/extenshi-mcp-server/src/identity.ts, which
 * mints it per `authenticate()` — i.e. per connection, NOT per cached token).
 *
 * Read defensively: `session` is FastMCP's opaque auth record, and a surface
 * that carries neither field must degrade to the local behaviour rather than
 * throw inside instrumentation.
 */
function attribution(session) {
    if (!session || typeof session !== 'object')
        return {};
    const s = session;
    return {
        userId: typeof s.userId === 'string' && s.userId ? s.userId : undefined,
        sessionId: typeof s.sessionId === 'string' && s.sessionId ? s.sessionId : undefined,
    };
}
/**
 * Wrap a tool definition so every call is reported: which tool ran, how long it
 * took, and how it failed (coarse error_kind + sanitized exception). On the
 * hosted connector the report also carries WHO — the authenticated account id —
 * and which connection it belonged to; on local stdio it stays anonymous. Never
 * the arguments, never the result. The generic preserves the Zod-inferred
 * `args` type — `parameters` fixes Params, so the inner execute stays as
 * strongly typed as before.
 * Fail-soft: telemetry never alters the tool's result or its thrown error.
 */
/**
 * Publish every tool's input schema with all `$ref`s inlined.
 *
 * fastmcp converts `parameters` through xsschema, which runs zod-to-json-schema
 * with its default `$refStrategy: 'root'`: every zod instance used twice in one
 * schema (the shared `digest`, `provenanceSchema` strings, …) becomes a bare
 * `{"$ref": "#/properties/…"}` with no `type`. The Claude connector directory
 * rejects those ("Add a type to this parameter: inputDigest"), and weaker MCP
 * clients cannot resolve them either. xsschema prefers a Standard JSON Schema
 * `jsonSchema` hook when one is present, so we supply an inlined conversion
 * there; validation still goes through the original zod `~standard.validate`.
 */
function withInlinedJsonSchema(parameters) {
    const input = () => zodToJsonSchema(parameters, { $refStrategy: 'none' });
    return Object.create(parameters, {
        '~standard': { value: { ...parameters['~standard'], jsonSchema: { input, output: input } } },
    });
}
function instrument(tool) {
    const { name } = tool;
    const original = tool.execute;
    return {
        ...tool,
        execute: async (args, context) => {
            // The hosted connector lets a user decline analytics on the consent
            // screen; honour it for every event, including error reports.
            if (context.session?.telemetry === false)
                return original(args, context);
            const startedAt = Date.now();
            const { userId, sessionId } = attribution(context.session);
            const props = { tool: name, ...(sessionId ? { mcp_session_id: sessionId } : {}) };
            captureEvent('mcp_tool_called', props, userId);
            try {
                const result = await original(args, context);
                captureEvent('mcp_tool_succeeded', { ...props, duration_ms: Date.now() - startedAt }, userId);
                return result;
            }
            catch (err) {
                // Classified once and threaded into both uses below: the reported
                // error_kind and the capture decision must never disagree about what
                // this failure was.
                const kind = classifyError(err);
                // The failure count is always tracked, so we keep visibility into
                // how often callers hit each condition (incl. the billing gate).
                captureEvent('mcp_tool_failed', { ...props, error_kind: kind, duration_ms: Date.now() - startedAt }, userId);
                // …but only genuine faults are shipped as exceptions. Expected
                // user-facing conditions (a quota gate, a bad key, a message we
                // authored) are not bugs and must not open error-tracking issues.
                if (!isExpectedError(err, kind))
                    captureError(err, { tool: name });
                throw err;
            }
        },
    };
}
// ── Extension reference (numeric catalog id OR store id) ─────────────────────
/**
 * The shared parameter shape for "which extension" across the read tools. A
 * caller may point at an extension EITHER by its numeric catalog `extension_id`
 * (from search_extensions, as before) OR by its public `store_id` — the id in
 * the store URL — which is often the only precise identifier an agent has.
 *
 * `extension_id` is now optional (it used to be required); this is
 * backward-compatible — existing callers that pass it are unaffected, and
 * resolveExtensionId() enforces that exactly one form is supplied.
 */
const EXTENSION_REF_SHAPE = {
    extension_id: z.number().int().optional().describe('Numeric catalog ID (from search_extensions results).'),
    store_id: z
        .string()
        .min(1)
        .max(100)
        .optional()
        .describe('Alternative to extension_id: the extension’s id in its store URL (e.g. Chrome ' +
        '"cjpalhdlnbpafiamejdnhcphjbkeiagm"). A Chrome/Edge id (32 letters a–p) also requires ' +
        '`store`: both stores share that format. Firefox ids (slug / GUID / email) are ' +
        'unambiguous. Resolving a store_id is free; only the read that follows costs a credit.'),
    store: z
        .enum(['CHROME', 'FIREFOX', 'EDGE'])
        .optional()
        .describe('Which store `store_id` belongs to. REQUIRED for Chrome/Edge ids (identical format).'),
};
/**
 * Resolve an extension reference to a numeric catalog id. Prefers an explicit
 * `extension_id`; otherwise resolves `store_id` (+ `store`) via the FREE
 * `resolveExtensionRef` BFF endpoint — so a store-id read costs exactly one
 * credit (the read itself), never two. Throws an actionable UserError when
 * neither form is supplied, when the store id is ambiguous/invalid (surfaced from
 * the backend as an authored message, so it reads as expected — not a fault), or
 * when no catalog listing exists for it yet.
 */
async function resolveExtensionId(client, args) {
    if (typeof args.extension_id === 'number')
        return args.extension_id;
    if (args.store_id) {
        let ref;
        try {
            ref = await client.resolveExtensionRef({ storeId: args.store_id, store: args.store });
        }
        catch (err) {
            // A BAD_REQUEST is a caller-input problem (ambiguous/invalid store id),
            // not a fault: re-throw the backend's message as an authored UserError so
            // it surfaces to the agent as guidance and doesn't open a tracking issue.
            const code = err?.data?.code;
            if (code === 'BAD_REQUEST')
                throw new UserError(err instanceof Error ? err.message : String(err));
            throw err;
        }
        if (!ref) {
            throw new UserError(`No catalog extension found for store id "${args.store_id}"${args.store ? ` in ${args.store}` : ''}. ` +
                'It may not be in the catalog yet — try search_extensions by name, or double-check the id.');
        }
        return ref.id;
    }
    throw new UserError('Specify the extension: pass extension_id (numeric catalog id from search_extensions) OR ' +
        'store_id (the id in the store URL; add `store` for a Chrome/Edge id).');
}
/**
 * Optional variant for tools where the extension link is not required (scan
 * association): returns the resolved id, or undefined when NEITHER form is given.
 * A supplied-but-unresolvable store_id still throws (via resolveExtensionId) so a
 * typo is caught before a scan credit is spent, rather than silently dropped.
 */
async function resolveOptionalExtensionId(client, args) {
    if (typeof args.extension_id !== 'number' && !args.store_id)
        return undefined;
    return resolveExtensionId(client, args);
}
// ── Registration ─────────────────────────────────────────────────────────────
/**
 * MCP tool annotations (spec: title + behaviour hints). REQUIRED for the
 * Anthropic Connectors Directory — its submission portal auto-syncs tools and
 * refuses to submit any tool missing a `title` or a read/write hint. Kept as a
 * name→annotation map (not inline per tool) so the read/write split is auditable
 * in one place. Hosted policy publication writes a public page; scan uploads an artifact
 * (not read-only, not destructive); publish writes to public stores (destructive).
 */
const TOOL_ANNOTATIONS = {
    search_extensions: {
        title: 'Search extension catalog',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_extension: {
        title: 'Get extension details',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_reviews: {
        title: 'Get extension reviews',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_security: {
        title: 'Get extension security analysis',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_risk_by_store_ids: {
        title: 'Bulk risk lookup by store id',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    market_overview: {
        title: 'Catalog market overview',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_credit_balance: {
        title: 'Check credit balance',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_development_guide: {
        title: 'Extension development services and workflow',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: false,
    },
    search_docs: {
        title: 'Search Extenshi documentation',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    list_extension_templates: {
        title: 'Extension types and their permissions',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: false,
    },
    create_project: {
        title: 'Create an extension project',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    get_decisions: {
        title: 'Read owner decisions',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    propose_decision: {
        title: 'Propose an owner decision',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    list_my_projects: {
        title: 'List my extension projects',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_project_state: {
        title: 'Read my project state',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_project_scaffold: {
        title: 'Get my starter extension files',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    list_privacy_policy_versions: {
        title: 'List hosted privacy policy versions',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    get_privacy_policy_version: {
        title: 'Read a privacy policy version',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    publish_privacy_policy: {
        title: 'Publish a hosted privacy policy',
        readOnlyHint: false,
        destructiveHint: false,
        openWorldHint: true,
    },
    update_privacy_policy_with_ai: {
        title: 'Update a privacy policy with AI',
        readOnlyHint: false,
        destructiveHint: false,
        openWorldHint: true,
    },
    localize_workflow: {
        title: 'Local extension localization workflow',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: false,
    },
    generate_icon_workflow: {
        title: 'Icon design workflow guide',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: false,
    },
    generate_welcome_page_workflow: {
        title: 'Welcome page design brief',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: false,
    },
    generate_landing_page: {
        title: 'Generate a static landing page',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: false,
    },
    scan_extension: {
        title: 'Scan an extension package',
        readOnlyHint: false,
        destructiveHint: false,
        openWorldHint: true,
    },
    publish_extension: {
        title: 'Publish extension to stores',
        readOnlyHint: false,
        destructiveHint: true,
        openWorldHint: true,
    },
    create_ci_ingest_secret: {
        title: 'Create or rotate the CI evidence ingest secret',
        readOnlyHint: false,
        destructiveHint: true,
        openWorldHint: true,
    },
    upsert_hosted_page: {
        title: 'Register a hosted page URL',
        readOnlyHint: false,
        destructiveHint: false,
        openWorldHint: true,
    },
    verify_hosted_artifact: {
        title: 'Verify a hosted page matches its record',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    list_hosted_pages: {
        title: 'List hosted page registrations',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    publish_landing_page: {
        title: 'Publish a landing page on extenshi.io',
        readOnlyHint: false,
        idempotentHint: false,
        destructiveHint: false,
        openWorldHint: true,
    },
    get_landing_page: {
        title: 'Read the hosted landing page',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    draft_landing_page: {
        title: 'Draft a homepage from project state',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    preview_landing_page: {
        title: 'Preview and validate a homepage',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    list_landing_page_versions: {
        title: 'List homepage versions',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    rollback_landing_page: {
        title: 'Restore an earlier homepage version',
        readOnlyHint: false,
        idempotentHint: false,
        destructiveHint: false,
        openWorldHint: true,
    },
    get_custom_domain: {
        title: 'Read the custom domain setup',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    set_custom_domain: {
        title: 'Add a custom domain',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    verify_custom_domain: {
        title: 'Verify a custom domain',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    remove_custom_domain: {
        title: 'Remove the custom domain',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: true,
        openWorldHint: true,
    },
    unpublish_landing_page: {
        title: 'Unpublish the hosted landing page',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: true,
        openWorldHint: true,
    },
    get_install_instructions: {
        title: 'Read the hosted install instructions',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    publish_install_instructions: {
        title: 'Publish install instructions on extenshi.io',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    unpublish_install_instructions: {
        title: 'Unpublish the hosted install instructions',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: true,
        openWorldHint: true,
    },
    get_page_translations: {
        title: 'Read uninstall / welcome page translations',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    set_page_translations: {
        title: 'Save translations of an uninstall / welcome page',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    get_legal_translations: {
        title: 'Read privacy policy / license terms translations',
        readOnlyHint: true,
        idempotentHint: true,
        openWorldHint: true,
    },
    set_legal_translations: {
        title: 'Save translations of the privacy policy / license terms',
        readOnlyHint: false,
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    upload_project_media: {
        title: 'Upload a public image for hosted pages',
        readOnlyHint: false,
        // Content-addressed: the same bytes always return the same URL.
        idempotentHint: true,
        destructiveHint: false,
        openWorldHint: true,
    },
    remove_hosted_page: {
        title: 'Remove a hosted page registration',
        readOnlyHint: false,
        destructiveHint: true,
        openWorldHint: true,
    },
};
const hex = z.string().max(9);
const palette = z
    .object({
    accent: hex.optional(),
    accentText: hex.optional(),
    background: hex.optional(),
    surface: hex.optional(),
    text: hex.optional(),
    muted: hex.optional(),
    border: hex.optional(),
})
    .optional();
const FONT_IDS = Object.keys(LANDING_FONTS);
/**
 * Schema v2 of the landing form (shared-types/landing-sections.ts): a full,
 * declarative homepage. Only read when schemaVersion is 2; a form without it
 * renders exactly as before. Section contents are checked by the shared
 * landingFormProblems, so the errors here, in the CLI and on the server match.
 */
const LANDING_V2_SHAPE = {
    schemaVersion: z
        .literal(2)
        .optional()
        .describe('2 builds a full homepage from sections, theme tokens, SEO and locales. Absent: the classic ' +
        'one-screen page (unchanged output). draft_landing_page returns a complete v2 form.'),
    sections: z
        .array(z
        .object({
        id: z
            .string()
            .max(LANDING_V2_LIMITS.sectionId)
            .describe('Lowercase slug, unique; also the locale override key.'),
        type: z.enum(LANDING_SECTION_TYPES),
        hidden: z.boolean().optional().describe('Keep in the form, leave out of the page.'),
        title: z.string().max(LANDING_V2_LIMITS.title).optional(),
        intro: z.string().max(LANDING_V2_LIMITS.intro).optional(),
    })
        .passthrough())
        .max(LANDING_V2_LIMITS.sections)
        .optional()
        .describe('Ordered page sections (v2). Fields per type: ' +
        'hero {headline?, subheadline?, showBadges?}; about {body — plain text, blank line = new paragraph}; ' +
        'features {items: [{title, description?}]}; screenshots {items: [{url, alt, caption?}]} (hosted: ' +
        'upload_project_media URLs or store screenshots); how-it-works {steps: [{title, description?}]}; ' +
        'pricing {plans: [{name?, price?, period? one-time|month|year|lifetime, description?, features?, ' +
        "highlighted?, offerSku?, ctaLabel?, ctaUrl?}], note?} — offerSku binds the plan to the project's " +
        'Extenshi Pay offer so price and period come from the live offer; faq {items: [{question, answer}]} ' +
        '(also emitted as FAQPage JSON-LD); testimonials {items: [{quote, author, role?, sourceUrl, ' +
        'sourceLabel?}]} — real, attributed quotes only: author and a public https sourceUrl are required; ' +
        'store-badges {browsers?: [chrome|firefox|edge]}; changelog {entries: [{version, ' +
        'date? YYYY-MM-DD, notes: [string]}]}; links {items?: [{label, url}], includeProjectLinks?}. ' +
        'Plain text only everywhere — no HTML, no scripts.'),
    tokens: z
        .object({
        mode: z
            .enum(['light', 'dark', 'auto'])
            .optional()
            .describe('auto ships both palettes (prefers-color-scheme).'),
        light: palette,
        dark: palette,
        headingFont: z.enum(FONT_IDS).optional(),
        bodyFont: z.enum(FONT_IDS).optional(),
        radius: z.enum(['none', 'small', 'medium', 'large']).optional(),
        width: z.enum(['narrow', 'normal', 'wide']).optional(),
    })
        .optional()
        .describe('Theme tokens (v2): hex colours per mode and fonts from a system-font allowlist (no web fonts — the ' +
        'hosted CSP loads nothing external). Poor contrast is reported as a warning.'),
    seo: z
        .object({
        title: z.string().max(LANDING_V2_LIMITS.seoTitle).optional(),
        description: z.string().max(LANDING_V2_LIMITS.seoDescription).optional(),
        ogImageUrl: z.string().max(LANDING_LIMITS.url).optional(),
        twitterSite: z.string().max(16).optional(),
        authorName: z.string().max(120).optional(),
        noindex: z.boolean().optional(),
    })
        .optional()
        .describe('SEO / Open Graph (v2): <title>, meta description, og:image, twitter:site, JSON-LD author.'),
    defaultLocale: z.string().max(35).optional().describe('Locale of the base content (default en).'),
    locales: z
        .record(z.string().max(35), z
        .object({
        extensionName: z.string().max(LANDING_LIMITS.extensionName).optional(),
        tagline: z.string().max(LANDING_LIMITS.tagline).optional(),
        description: z.string().max(LANDING_LIMITS.description).optional(),
        seo: z
            .object({ title: z.string().optional(), description: z.string().optional() })
            .passthrough()
            .optional(),
        ui: z.record(z.string(), z.string().max(120)).optional(),
        sections: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
    })
        .passthrough())
        .optional()
        .describe('Translations (v2), keyed by locale (de, pt-BR or the _locales spelling pt_BR; hosted pages accept ' +
        'store locales). Each is served at {url}/{locale} with hreflang alternates. sections.{id} mirrors ' +
        "that section's shape; only strings are taken (arrays merge by index), so a translation cannot " +
        'change structure. ui overrides built-in headings and button labels.'),
    ui: z
        .record(z.string(), z.string().max(LANDING_V2_LIMITS.shortText))
        .optional()
        .describe(`Built-in strings to override (v2): ${Object.keys(LANDING_UI_DEFAULTS).join(', ')}.`),
    uninstallUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('Hosted uninstall feedback form URL (footer, v2).'),
    termsUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('License terms & refunds URL (footer, v2).'),
};
/**
 * LandingForm fields shared by generate_landing_page (offline HTML) and
 * publish_landing_page (hosted on page.extenshi.io). Limits match the builder
 * and the BFF, so a form valid for one is valid for the other.
 */
const LANDING_FORM_SHAPE = {
    extensionName: z.string().min(1).max(LANDING_LIMITS.extensionName).describe('Extension display name.'),
    tagline: z.string().max(LANDING_LIMITS.tagline).optional().describe('One-line promise under the name.'),
    description: z
        .string()
        .max(LANDING_LIMITS.description)
        .optional()
        .describe('Short paragraph on what the extension does; also the meta description.'),
    accentColor: z
        .string()
        .max(9)
        .optional()
        .describe('Hex accent colour for the primary button and monogram (default #5e5ce6).'),
    theme: z.enum(['light', 'dark']).optional().describe('Page colour scheme (default dark).'),
    chromeUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('Chrome Web Store listing URL. Empty or omitted hides the Chrome button.'),
    firefoxUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('Firefox Add-ons listing URL. Empty or omitted hides the Firefox button.'),
    edgeUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('Edge Add-ons listing URL. Empty or omitted hides the Edge button.'),
    privacyPolicyUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('Privacy policy URL for the footer (for example the hosted privacy.extenshi.io page).'),
    supportUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('Support link for the footer: an http(s) URL, or a single mailto: address (optionally ' +
        '?subject= with a percent-encoded value).'),
    homepageUrl: z
        .string()
        .max(LANDING_LIMITS.url)
        .optional()
        .describe('Public https URL the page will be served from: canonical link, og:url, JSON-LD url, and the ' +
        'base for relative screenshot paths in og:image.'),
    features: z
        .array(z.object({
        title: z.string().max(LANDING_LIMITS.featureTitle),
        description: z.string().max(LANDING_LIMITS.featureDescription).optional(),
    }))
        .max(LANDING_LIMITS.features)
        .optional()
        .describe('Feature cards; entries with an empty title are skipped.'),
    screenshots: z
        .array(z.object({
        url: z.string().max(LANDING_LIMITS.url),
        alt: z.string().max(LANDING_LIMITS.screenshotAlt).optional(),
    }))
        .max(LANDING_LIMITS.screenshots)
        .optional()
        .describe('Screenshots as https URLs (for example store screenshots from get_extension) or paths ' +
        'relative to index.html, each with alt text.'),
    logoSvg: z
        .string()
        .max(LANDING_LIMITS.logoSvgBytes)
        .optional()
        .describe('Inline SVG logo source. Refused (monogram shown, reason in warnings) if it contains ' +
        'scripts, event handlers, foreignObject, animation, entity references or non-fragment hrefs.'),
    logoUrl: z
        .string()
        .max(LANDING_LIMITS.logoUrl)
        .optional()
        .describe('Logo image as an https URL, a relative path, or a data:image/(png|jpeg|gif|webp);base64 URL.'),
    ...LANDING_V2_SHAPE,
};
/**
 * Register the Extenshi tools once on a FastMCP server.
 * Only the tools whose capability is present in `deps.capabilities` are added.
 */
export function registerTools(server, deps) {
    const registeredTools = [];
    const caps = deps.capabilities;
    const missingKeyMessage = deps.missingKeyMessage ?? MISSING_KEY_MESSAGE;
    // Generic so each tool's Zod `parameters` infers its own `args` type (a
    // non-generic wrapper would collapse Params to `never`).
    function add(tool) {
        // Attach the directory-required annotations (title + read/write hint) from
        // the central map; an explicit `tool.annotations` (none today) still wins.
        const annotations = {
            ...TOOL_ANNOTATIONS[tool.name],
            ...tool.annotations,
            title: tool.annotations?.title ?? TOOL_ANNOTATIONS[tool.name]?.title ?? tool.name.replaceAll('_', ' '),
        };
        const parameters = tool.parameters && withInlinedJsonSchema(tool.parameters);
        server.addTool(instrument({ ...tool, annotations, ...(parameters ? { parameters } : {}) }));
        registeredTools.push({ name: tool.name, description: tool.description ?? '', annotations });
    }
    // Per-call helpers bound to the injected deps.
    const bff = (ctx) => deps.getBff(ctx);
    const requireApiKey = (ctx) => {
        if (!deps.requireApiKey)
            throw new UserError(missingKeyMessage);
        return deps.requireApiKey(ctx);
    };
    // ── Read tools (free; key/identity required) ───────────────────────────────
    if (caps.has('read')) {
        for (const [method, name, , , mutation, description] of PAY_OPERATIONS) {
            add({
                name,
                description: `${description} Requires ${mutation ? 'pay.write' : 'pay.read'} permission.`,
                parameters: paySchemas[method],
                annotations: {
                    readOnlyHint: !mutation,
                    destructiveHint: mutation,
                    idempotentHint: !['createPayApp', 'connectPaySeller', 'rotatePayKey'].includes(method),
                    openWorldHint: true,
                },
                execute: async (args, context) => {
                    try {
                        const invoke = bff(context)[method];
                        return JSON.stringify(await invoke(args));
                    }
                    catch (err) {
                        if (err instanceof UserError && /^MCP_SCOPE_REQUIRED:pay\.(read|write)\./.test(err.message))
                            throw err;
                        const status = err?.data?.httpStatus;
                        // Only OUR authored backend messages are matched, never echoed, so a
                        // provider string can't leak through; they pick the precise guidance.
                        const backendMessage = err instanceof Error ? err.message : '';
                        const message = status === 403
                            ? 'Pay access denied. Grant pay.read/pay.write explicitly and verify application ownership.'
                            : status === 412 && backendMessage.includes('Pay application is archived')
                                ? 'This Pay application is archived. Use an active application from list_pay_apps, or create a new one.'
                                : status === 412
                                    ? 'Pay prerequisites are incomplete. Read get_pay_readiness; the author completes legal acceptance and Stripe KYC in the browser.'
                                    : status === 404 && backendMessage.includes('No payment gateway connected')
                                        ? '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.'
                                        : status === 404
                                            ? 'Pay application or resource not found. Check the appId with list_pay_apps.'
                                            : status === 401
                                                ? 'Pay authentication failed. Reconnect or configure a developer API key with explicit Pay permissions.'
                                                : 'Pay request failed. Check authentication, application readiness and backend support before retrying a mutation.';
                        // Provider failures may contain customer data or credentials; never echo them.
                        throw userErrorFrom(message, err);
                    }
                },
            });
        }
        add({
            name: 'import_manifest',
            description: 'Import an existing manifest.json into the Dojo manifest editor and project labels. Input: parsed JSON, optional default-locale messages, and optional messagesByLocale — every _locales/<lang>/messages.json keyed by locale code (only the keys named by the __MSG_*__ fields of the manifest are needed) — whose non-default locales fill the Dojo translations of name and description. Repository translations replace editor text per field; translations the repository lacks are kept, and warnings name each replaced one. Source import fills supported fields and preserves all other JSON; built imports remain separate observations. Default dryRun=true returns changes and expectedStateHash without writing; dryRun=false with that hash applies them. Does not infer data collection, prices, or publication. Requires project.write.' +
                PROVENANCE_HINT,
            parameters: importManifestSchema,
            annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).importManifest(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'create_project',
            description: 'Create a new extension project (a Dojo workspace) owned by this identity, through the same service ' +
                'the Dojo project wizard uses, with the same ownership and limits. Returns JSON {projectId, dojoUrl, ' +
                'project, pendingDecisions, nextSteps}: the next steps cover binding the repository and the starter ' +
                'files. Idempotent: a repeated request with the same idempotencyKey returns the project it already ' +
                'created (replayed: true); the same key with different input is rejected. Requires project.write.',
            parameters: agentCreateProjectSchema,
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).createProject(args), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_decisions',
            description: 'Owner decisions for one project: the AMO add-on id, license, seller public name, support contact ' +
                'and terms, pricing, target browsers and target locales. Each entry reports its status (undecided, ' +
                'proposed or decided), the value, a suggested default where one is derivable (a stable {GUID} add-on ' +
                'id or the id already in the Firefox manifest, the project browsers), who proposed or decided it and ' +
                'when, and which releases it blocks (blocksBrowsers, blocksPayment). `pending` lists the keys the ' +
                'owner has not decided. FREE.',
            annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
            parameters: z.object({ projectId: z.string().uuid().describe('Project id from list_my_projects.') }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).getDecisions(args), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'propose_decision',
            description: 'Propose a value for one owner decision. The proposal is stored with its rationale, proposer and ' +
                'time; the owner accepts or edits it on the Dojo project page, which is the only place a decision ' +
                'becomes decided. A key the owner already decided is refused (DECISION_ALREADY_DECIDED). Value ' +
                'formats: amo.addonId {GUID} or name@domain; license an SPDX id such as MIT, or all-rights-reserved; ' +
                'seller.publicName text; seller.supportContact an email or HTTPS URL; seller.terms an HTTPS URL; ' +
                'pricing {model: free|paid|donations, trialDays?, plans?: subscription|lifetime|one_time}; ' +
                'targetBrowsers an array of chrome/firefox/edge; targetLocales an array of locale codes. Idempotent ' +
                'per idempotencyKey. Requires project.write.',
            annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
            parameters: proposeDecisionSchema,
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).proposeDecision(args), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        const reads = [
            [
                'get_project_workspace',
                'Read revisioned source/built manifests, repository metadata, scope and release snapshot. These are observations, separate from scaffold drafts.',
                'getProjectWorkspace',
            ],
            [
                'get_release_readiness',
                '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. ' +
                    'Readiness is measured against the release recorded in the workspace: until apply_project_patch sets patch.release ' +
                    '(browser, version, artifactDigest, manifestDigest, locales, paymentRequired, and commit or dirtyTreeDigest) it reports ' +
                    'RELEASE_NOT_RECORDED; after that, evidence counts only when it matches that release artifactDigest. ' +
                    "When the project has a linked Pay application, the payment check also carries `platform`: what Extenshi's own " +
                    'payment ledger proves (checkout, license, installation activation, recovery, refund). A live-mode purchase through ' +
                    "those stages passes the check with source 'platform'; test-mode purchases are listed without passing it. Clients cannot " +
                    "record source 'platform'.",
                'getReleaseReadiness',
            ],
        ];
        for (const [name, description, method] of reads)
            add({
                name,
                description,
                annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
                parameters: z.object({
                    projectId: z.string().uuid(),
                    browser: z
                        .enum(['chrome', 'firefox', 'edge'])
                        .optional()
                        .describe('Release status: select a browser from release history.'),
                    artifactDigest: z
                        .string()
                        .regex(/^[a-f0-9]{64}$/)
                        .optional()
                        .describe('Release status: select the exact artifact from history.'),
                }),
                execute: async (args, context) => {
                    try {
                        return JSON.stringify(await bff(context)[method](args));
                    }
                    catch (err) {
                        return readError(err, missingKeyMessage);
                    }
                },
            });
        add({
            name: 'connection_diagnostics',
            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.',
            annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
            parameters: z.object({}),
            execute: async (_args, context) => {
                try {
                    const client = bff(context);
                    const [connection, balance] = await Promise.all([
                        client.connectionDiagnostics(),
                        client.getApiCallerBalance(),
                    ]);
                    return JSON.stringify({ connection, balance, registeredTools: registeredTools.map((t) => t.name) });
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        for (const [name, method, dryRun] of [
            ['diff_project_state', 'diffProjectWorkspace', true],
            ['apply_project_patch', 'patchProjectWorkspace', false],
        ])
            add({
                name,
                description: dryRun
                    ? `Preview a three-way metadata diff against a server-held base revision. No writes.${PROVENANCE_HINT}`
                    : `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}`,
                annotations: {
                    readOnlyHint: dryRun,
                    idempotentHint: true,
                    destructiveHint: !dryRun,
                    openWorldHint: true,
                },
                parameters: workspacePatchSchema,
                execute: async (args, context) => {
                    try {
                        return JSON.stringify(await bff(context)[method](args));
                    }
                    catch (err) {
                        return readError(err, missingKeyMessage);
                    }
                },
            });
        add({
            name: 'create_ci_ingest_secret',
            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.',
            annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, openWorldHint: true },
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).createCiIngestSecret(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'upsert_hosted_page',
            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}`,
            annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
            parameters: z.object({
                projectId: z.string().uuid(),
                kind: z.enum(['homepage', 'support']),
                url: z.string().url().max(2000),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).upsertHostedPage(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'verify_hosted_artifact',
            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}`,
            annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
            parameters: z.object({
                projectId: z.string().uuid(),
                kind: z.enum(['homepage', 'support', 'privacy']),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).verifyHostedArtifact(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'remove_hosted_page',
            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}`,
            annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: true },
            parameters: z.object({
                projectId: z.string().uuid(),
                kind: z.enum(['homepage', 'support']),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).removeHostedPage(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'publish_landing_page',
            description: "Publish the project's landing page (homepage) on Extenshi hosting — the same page " +
                'generate_landing_page returns, rendered server-side from the form (no caller HTML is stored) and ' +
                "served at page.extenshi.io/{code}, or at the root of the project's custom domain once it is ACTIVE " +
                '(set_custom_domain). A schema-v2 form (schemaVersion 2; draft_landing_page returns one, ' +
                'preview_landing_page validates it) adds sections, theme tokens, SEO, per-locale pages at {url}/{locale} with ' +
                'hreflang, and llms.txt; pricing plans with offerSku take price and period from the live Pay offer. ' +
                'The code is permanent: republishing creates a new version at the same URL. ' +
                'By default the URL is also registered as the project homepage, which feeds HOMEPAGE_URL. Hosted ' +
                'images are limited to store screenshot URLs (from get_extension) and images uploaded in the Dojo ' +
                'Page generator or with upload_project_media (use its url for local screenshots or a logo); the logo ' +
                'may also be an inline SVG or a raster data: URL. Returns JSON {url, publicCode, ' +
                'versionNumber, contentHash, bytes, warnings, homepage, nextSteps}. Requires hosted.write. ' +
                `API reference: ${HOSTED_PAGES_DOCS}`,
            parameters: z.object({
                projectId: z.string().uuid(),
                registerAsHomepage: z
                    .boolean()
                    .optional()
                    .describe('Register the URL as the project homepage (default true). False keeps an existing homepage.'),
                ...LANDING_FORM_SHAPE,
            }),
            execute: async (args, context) => {
                const { projectId, registerAsHomepage, ...fields } = args;
                try {
                    const result = (await bff(context).publishLandingPage({
                        projectId,
                        registerAsHomepage,
                        form: landingFormFromArgs(fields),
                    }));
                    return JSON.stringify({ ...result, nextSteps: hostedLandingNextSteps(result) });
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_landing_page',
            description: "The project's hosted landing page: public code, live version, publish state, URL (null when " +
                'unpublished) and the form the live version was rendered from.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).getLandingPage(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'unpublish_landing_page',
            description: 'Take the hosted landing page offline (it answers 404). The public code and version history are ' +
                'kept, so publishing again restores the same URL. A homepage registration that points at this ' +
                `page is removed with it. Requires hosted.write. API reference: ${HOSTED_PAGES_DOCS}`,
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).unpublishLandingPage(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'draft_landing_page',
            description: 'Draft a full homepage (schema v2) from what the project already knows: the manifest draft and its ' +
                'store translations, the published page or saved Page generator form, public project media, ' +
                'Extenshi Pay offers (pricing plans bound by SKU), hosted artifacts (privacy policy, uninstall ' +
                'feedback form, license terms, support page) and the workspace release/locales. Writes nothing. ' +
                'Returns JSON {form, sources (field → where it came from), todos (what only you can supply — ' +
                'features, how-it-works, real testimonials, translations), problems, previewUrl}. The form is ' +
                'accepted as-is by preview_landing_page and publish_landing_page. storeUrls fills the install ' +
                'buttons when the project does not know its listings yet. Requires project.read.',
            parameters: z.object({
                projectId: z.string().uuid(),
                storeUrls: z
                    .object({
                    chrome: z.string().url().max(2000).optional(),
                    firefox: z.string().url().max(2000).optional(),
                    edge: z.string().url().max(2000).optional(),
                })
                    .optional()
                    .describe('Store listing URLs, when the project does not have them yet.'),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).draftLandingPage(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'preview_landing_page',
            description: 'Validate and render a landing form exactly as publish_landing_page would — same image rules, ' +
                'store-locale check, Pay offer prices and canonical URL — without publishing. Returns JSON ' +
                '{ok, problems: [{path, severity, message, fix}], html (the page for `locale`), url, locales, ' +
                'bytes, contentHash, llmsTxt, warnings}. `ok: false` means publish would refuse the form; every ' +
                'problem says what to change. `html` is byte-for-byte the page publish would serve. ' +
                'Requires project.read.',
            parameters: z.object({
                projectId: z.string().uuid(),
                locale: z
                    .string()
                    .max(35)
                    .optional()
                    .describe('Which locale page to return (default the default locale).'),
                ...LANDING_FORM_SHAPE,
            }),
            execute: async (args, context) => {
                const { projectId, locale, ...fields } = args;
                try {
                    return JSON.stringify(await bff(context).previewLandingPage({
                        projectId,
                        locale,
                        form: landingFormFromArgs(fields),
                    }));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'list_landing_page_versions',
            description: 'Version history of the hosted landing page, newest first: versionNumber, createdAt, contentHash, ' +
                'reason (publish | rollback | domain — a canonical switch after the custom domain changed), ' +
                'restoredFromVersion, schemaVersion, locales and which one is live. Requires project.read.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).listLandingVersions(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'rollback_landing_page',
            description: 'Restore an earlier version of the hosted landing page. History stays append-only: the old form ' +
                "is published as a NEW version, re-rendered with today's URL and Pay offer prices. Returns the " +
                'same JSON as publish_landing_page. Requires hosted.write.',
            parameters: z.object({
                projectId: z.string().uuid(),
                versionNumber: z.number().int().min(1),
                registerAsHomepage: z.boolean().optional(),
            }),
            execute: async (args, context) => {
                try {
                    const result = (await bff(context).rollbackLandingPage(args));
                    return JSON.stringify({ ...result, nextSteps: hostedLandingNextSteps(result) });
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_custom_domain',
            description: "The project's custom domain: hostname, status (PENDING → VERIFIED → ACTIVE; FAILED; SUSPENDED when " +
                'the TXT record disappeared), the exact DNS records to set, whether HTTPS for custom domains is ' +
                'enabled on the platform (edgeConfigured) and the next step. Only ACTIVE is served. Requires project.read.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).getCustomDomain(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'set_custom_domain',
            description: 'Add a custom domain (www.example.com, or the apex) for the homepage and every hosted page of the ' +
                'project (Pro; one hostname per project). Returns the exact DNS records: a CNAME to ' +
                'hosted.extenshi.io (at an apex: CNAME flattening / ALIAS / ANAME, or use www) and a TXT ownership ' +
                'token that stays in place (it is re-checked). Nothing is served until verify_custom_domain confirms the token and the ' +
                'HTTPS certificate is issued; then the homepage is served at the domain root, its canonical and ' +
                'HOMEPAGE_URL switch to it. Replacing the hostname starts over. Requires hosted.write.',
            parameters: z.object({ projectId: z.string().uuid(), hostname: z.string().max(253) }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).setCustomDomain(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'verify_custom_domain',
            description: 'Check the custom domain now: looks up the TXT token, requests or refreshes the HTTPS certificate, ' +
                'and moves the status (PENDING/FAILED → VERIFIED → ACTIVE). When it becomes ACTIVE the landing page ' +
                'is republished with the new canonical and HOMEPAGE_URL moves to https://{hostname}/ (the ' +
                'integration file in get_project_state follows). Safe to call repeatedly; DNS changes can take ' +
                'minutes. The platform also re-checks every 6 hours. Requires hosted.write.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).verifyCustomDomain(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'remove_custom_domain',
            description: 'Remove the custom domain: it stops being served, the certificate is released, and the landing ' +
                'canonical and HOMEPAGE_URL move back to page.extenshi.io/{code}. Remove the DNS records afterwards. ' +
                'Requires hosted.write.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).removeCustomDomain(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_install_instructions',
            description: 'The hosted install instructions page of a project: {page: {publicCode, enabled, publishedAt, ' +
                'input, url} or null, draft, draftProblems} — `draft` is the form saved by the install-instructions ' +
                'tool in the cabinet; `draftProblems` lists why it cannot be published as it is. The page shows store and optional sideload steps for Chrome, Firefox and Edge in the ' +
                'visitor’s browser language (en, es, pt-BR, fr, de, it, ru, ja, ko, zh-CN, ar), the visitor’s own ' +
                'browser first. Free. Requires project.read.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).getInstructionsPage(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'publish_install_instructions',
            description: 'Publish (or update) the project’s install instructions at a stable public URL ' +
                '(dojo.extenshi.io/instructions/<code>, or https://<custom domain>/instructions). Without `input` ' +
                'the form saved in the cabinet is published (a saved form that does not validate is refused with ' +
                'INSTRUCTIONS_INVALID and its field errors). Store URLs are the https listing links on ' +
                'chromewebstore.google.com, addons.mozilla.org and microsoftedge.microsoft.com; browsers without one ' +
                'are hidden unless none is set. The install steps are translated by Extenshi; `features` are ' +
                'translated with set_page_translations (surface instructions). Returns {publicCode, url}. Free. ' +
                'Requires hosted.write.',
            parameters: z.object({
                projectId: z.string().uuid(),
                input: z
                    .object({
                    extensionName: z.string().max(120),
                    storeUrls: z
                        .object({
                        chrome: z.string().max(2048).optional(),
                        firefox: z.string().max(2048).optional(),
                        edge: z.string().max(2048).optional(),
                    })
                        .optional(),
                    features: z.array(z.string().max(200)).max(20).optional(),
                    includeSideload: z.boolean().optional(),
                    supportUrl: z.string().max(2048).optional().describe('https:// URL or an email address.'),
                })
                    .optional(),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).publishInstructions(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'unpublish_install_instructions',
            description: 'Take the hosted install instructions offline. The code and URL are kept, so publishing again ' +
                'restores the same URL. Requires hosted.write.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).setInstructionsEnabled({ projectId: args.projectId, enabled: false }));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_page_translations',
            description: "The developer-written text on the project's hosted uninstall forms, welcome pages and install " +
                'instructions (the features list), for ' +
                'translation: per page {surface, publicCode, name, defaultLocale, locales, fields [{path, source, ' +
                'label, max, multiline}], status per language [{locale, translated, missing, outdated}], ' +
                'translations}. Languages are the project’s (manifest translations + the targetLocales decision); ' +
                'the install instructions page offers every language its steps come in. ' +
                'Built-in text (buttons, reasons, footers) is translated by Extenshi into en, es, pt-BR, fr, de, ' +
                'it, ru, ja, ko, zh-CN and ar and is not listed. A page is shown in the user’s browser language ' +
                'when the project offers it, otherwise in defaultLocale; an untranslated or outdated field shows ' +
                'its original. Requires project.read.',
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).getPageTranslations(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'set_page_translations',
            description: 'Save one language of one uninstall form, welcome page or install instructions page: `translations` ' +
                'maps field paths (from ' +
                'get_page_translations) to translated text; the server records which original each translates, so ' +
                'a later edit of the original marks it outdated. An empty string removes a translation. Unknown ' +
                'paths, over-long text and the default language are refused (TRANSLATIONS_INVALID). Placeholders ' +
                'and product names are kept as written. Returns the page’s updated translation status. Requires ' +
                'hosted.write.',
            parameters: z.object({
                projectId: z.string().uuid(),
                surface: z.enum(['uninstall', 'welcome', 'instructions']),
                publicCode: z.string().max(40).describe('The page code, from get_page_translations.'),
                locale: z.string().max(35).describe('BCP 47 tag or the _locales spelling (de, pt-BR, pt_BR).'),
                translations: z.record(z.string().max(80), z.string().max(2000)),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).setPageTranslations(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        const legalDocument = z
            .enum(['privacy_policy', 'license_terms'])
            .describe('privacy_policy (hosted policy, Pro) or license_terms (Extenshi Pay license terms & refunds).');
        add({
            name: 'get_legal_translations',
            description: "Translated copies of the project's published privacy policy or license terms. The English " +
                'version is the binding text and stays at the base URL; each translation lives at ' +
                '<url>/<locale>, says it is a translation for convenience and links to the English original. ' +
                'Extenshi translates its own template text (en, es, pt-BR, fr, de, it, ru, ja, ko, zh-CN, ar); ' +
                'what you translate is listed in fields: `section:<id>` for a section the author edited or added ' +
                '(translate the whole section, heading line included) and `answer:<field>` for free-text answers ' +
                'inside template sentences (e.g. a retention period). A section without a current translation is ' +
                'shown in English. Returns {defaultLocale: "en", locales, fields, status per language, ' +
                'translations, urls}. Languages are the project’s (manifest translations + targetLocales). ' +
                'Requires a published document; privacy_policy needs project.read, license_terms pay.read.',
            parameters: z.object({ projectId: z.string().uuid(), document: legalDocument }),
            execute: async ({ projectId, document }, context) => {
                try {
                    const client = bff(context);
                    return JSON.stringify(document === 'privacy_policy'
                        ? await client.getPrivacyPolicyTranslations({ projectId })
                        : await client.getLicenseTermsTranslations({ projectId }));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'set_legal_translations',
            description: 'Save one language of the privacy policy or license terms translation: `translations` maps field ' +
                'paths from get_legal_translations to text. A `section:<id>` text is accepted as exactly one ' +
                'section: its "## " heading line first ("# " for `section:lead`) and no other heading. ' +
                'The server records which English text each translates, so a later edit marks it outdated; an ' +
                'empty string removes one. Only the project’s languages are accepted (TRANSLATIONS_INVALID ' +
                'otherwise). Nothing changes in the English original. privacy_policy needs hosted.write and a ' +
                'Pro project; license_terms needs pay.write.',
            parameters: z.object({
                projectId: z.string().uuid(),
                document: legalDocument,
                locale: z.string().max(35).describe('BCP 47 tag or the _locales spelling (de, pt-BR, pt_BR).'),
                translations: z.record(z.string().max(300), z.string().max(16000)),
                sources: z
                    .record(z.string().max(300), z.string().max(64000))
                    .optional()
                    .describe('The `source` of each field as get_legal_translations returned it; a save whose English has changed since is refused (TRANSLATIONS_STALE).'),
            }),
            execute: async ({ document, ...args }, context) => {
                try {
                    const client = bff(context);
                    return JSON.stringify(document === 'privacy_policy'
                        ? await client.setPrivacyPolicyTranslations(args)
                        : await client.setLicenseTermsTranslations(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        // A local filesystem exists only where local artifacts do (stdio).
        const localFiles = caps.has('scan');
        add({
            name: 'upload_project_media',
            description: "Upload a logo or screenshot (PNG, JPEG or WebP, up to 2 MB) to the project's PUBLIC media store — " +
                'the same store the Dojo Page generator uploads into — and get a stable https URL that ' +
                'publish_landing_page accepts as screenshots[].url or logoUrl. The server checks the file by content ' +
                '(magic bytes), strips EXIF/XMP/text metadata and anything after the image end marker, bounds the ' +
                'dimensions (16–4096 px) and enforces a per-project quota (100 files / 50 MB, Dojo uploads included); ' +
                'identical bytes return the same URL without using quota. SVG is refused: rasterize it first ' +
                '(CLI: extenshi icon store-assets) or pass an inline logo as logoSvg. ' +
                (localFiles
                    ? 'Pass filePath (a file inside the workspace) or dataBase64 + mime. '
                    : 'Pass dataBase64 + mime (this connection has no access to local files). ') +
                'Store screenshots come from the CLI: extenshi screenshots. Returns JSON {url, sha256, mime, bytes, ' +
                'width, height, strippedBytes, deduplicated, quota}. Requires hosted.write. ' +
                `CLI: extenshi media upload <file> --project <id>. API reference: ${HOSTED_PAGES_DOCS}`,
            parameters: z.object({
                projectId: z.string().uuid(),
                filePath: z
                    .string()
                    .min(1)
                    .max(4096)
                    .optional()
                    .describe('Image file inside the workspace (local stdio server only).'),
                dataBase64: z
                    .string()
                    .min(1)
                    .max(Math.ceil(PROJECT_MEDIA_MAX_BYTES / 3) * 4 + 16)
                    .optional()
                    .describe('The raw image, base64-encoded (no data: prefix). Use with mime.'),
                mime: z.enum(PROJECT_MEDIA_MIME_TYPES).optional().describe('Required with dataBase64.'),
            }),
            execute: async (args, context) => {
                let payload;
                try {
                    if (args.filePath && args.dataBase64)
                        throw new ProjectMediaInputError('Pass filePath OR dataBase64, not both.');
                    if (args.filePath) {
                        if (!localFiles)
                            throw new ProjectMediaInputError('filePath works only with the local stdio server; send dataBase64 + mime instead.');
                        const read = await readWorkspaceImage(args.filePath);
                        payload = { mime: read.mime, dataBase64: read.dataBase64 };
                    }
                    else if (args.dataBase64) {
                        if (!args.mime)
                            throw new ProjectMediaInputError('mime is required with dataBase64.');
                        checkInlineImage(args.dataBase64, args.mime);
                        payload = { mime: args.mime, dataBase64: args.dataBase64 };
                    }
                    else
                        throw new ProjectMediaInputError('Pass filePath or dataBase64 + mime.');
                }
                catch (err) {
                    if (err instanceof ProjectMediaInputError)
                        throw new UserError(err.message);
                    throw err;
                }
                try {
                    return JSON.stringify(await bff(context).uploadProjectMedia({ projectId: args.projectId, ...payload }));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'list_hosted_pages',
            description: 'List the project registered hosted pages (homepage/support) with their verification status, content hash and last check time.',
            annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
            parameters: z.object({ projectId: z.string().uuid() }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).listHostedPages(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'record_project_evidence',
            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.' +
                PROVENANCE_HINT,
            annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: true },
            parameters: agentEvidenceSchema,
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).recordProjectEvidence(args));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'search_extensions',
            description: 'Search the cross-store extension catalog (Chrome, Firefox, Edge) with hybrid relevance. ' +
                'All filters (rating/reviews thresholds, store, category, pricing, risk, permissions, ' +
                'freshness, manifest version, trader status) are applied server-side by the catalog ' +
                'database, so a filtered request returns only the matching subset. `skip` pages through ' +
                'large result sets. ' +
                `${describeStoreConstraints()} ` +
                'Returns a compact list for market research and competitive analysis.',
            parameters: z.object({
                query: z.string().optional().describe('Free-text search query (name, description, keywords).'),
                stores: z
                    .array(z.enum(['CHROME', 'FIREFOX', 'EDGE']))
                    .optional()
                    .describe('Stores to search: CHROME, FIREFOX and/or EDGE.'),
                categories: z.array(z.string()).optional().describe('Catalog category slugs to include.'),
                pricing: z
                    .array(z.enum(['FREE', 'FREEMIUM', 'IN_APP_PURCHASES', 'SUBSCRIPTION']))
                    .optional()
                    .describe('Pricing models to include.'),
                risk: z
                    .array(z.enum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']))
                    .optional()
                    .describe('Risk categories to include.'),
                permissions: z.array(z.string()).optional().describe('Only extensions requesting these permissions.'),
                minRating: z.number().min(0).max(5).optional().describe('Only ratings ≥ this (0–5).'),
                maxRating: z.number().min(0).max(5).optional().describe('Only ratings ≤ this (0–5).'),
                minWeeklyDownloads: z
                    .number()
                    .min(0)
                    .optional()
                    .describe('Minimum weekly downloads — FIREFOX-ONLY metric (not reported by Chrome/Edge). ' +
                    'Chrome/Edge popularity is available through sortBy:"popular". A request combining ' +
                    'it with a Chrome/Edge-only `stores` filter is rejected.'),
                minReviews: z.number().min(0).optional().describe('Minimum number of store reviews/ratings.'),
                updatedWithin: z
                    .enum(['30d', '90d', '1y', 'stale'])
                    .optional()
                    .describe('Freshness: updated within 30d / 90d / 1y, or "stale" (older than 1y).'),
                manifestVersions: z
                    .array(z.union([z.literal(2), z.literal(3)]))
                    .optional()
                    .describe('Manifest version(s): 2 and/or 3.'),
                traderStatuses: z
                    .array(z.enum(['TRADER', 'NON_TRADER']))
                    .optional()
                    .describe('EU DSA trader status.'),
                monetizationModels: z
                    .array(z.enum(['FREE', 'ONE_TIME', 'SUBSCRIPTION', 'FREEMIUM', 'DONATIONS', 'ADS']))
                    .optional()
                    .describe('Author-declared monetization model (questionnaire).'),
                hasPaywall: z.boolean().optional().describe('Author-declared paywall present.'),
                isOpenSource: z.boolean().optional().describe('Author-declared open source.'),
                noTelemetry: z.boolean().optional().describe('Author-declared no telemetry.'),
                collectsHealthData: z.boolean().optional().describe('Author-declared collects health data.'),
                includeDelisted: z
                    .boolean()
                    .optional()
                    .describe('Whether extensions whose store listing was removed are included (default: excluded).'),
                sortBy: z
                    .enum(['relevance', 'popular', 'rating', 'recent', 'name', 'safety', 'trader', 'size'])
                    .optional()
                    .describe('Sort field (default: popular, or relevance when a query is given).'),
                sortOrder: z.enum(['asc', 'desc']).optional().describe('Sort direction (default: desc).'),
                skip: z.number().int().min(0).optional().describe('Offset for pagination (default: 0).'),
                limit: z.number().int().min(1).max(25).default(20).describe('Max results to return (1–25).'),
            }),
            execute: async (args, context) => {
                try {
                    const conflict = validateSearchFilters(args);
                    if (conflict)
                        throw new UserError(conflict);
                    const limit = args.limit ?? 20;
                    const result = await bff(context).searchExtensions({
                        query: args.query,
                        stores: args.stores,
                        categories: args.categories,
                        pricingModels: args.pricing,
                        riskCategories: args.risk,
                        permissions: args.permissions,
                        minRating: args.minRating,
                        maxRating: args.maxRating,
                        minWeeklyDownloads: args.minWeeklyDownloads,
                        minReviews: args.minReviews,
                        updatedWithin: args.updatedWithin,
                        manifestVersions: args.manifestVersions,
                        traderStatuses: args.traderStatuses,
                        monetizationModels: args.monetizationModels,
                        hasPaywall: args.hasPaywall,
                        isOpenSource: args.isOpenSource,
                        noTelemetry: args.noTelemetry,
                        collectsHealthData: args.collectsHealthData,
                        includeDelisted: args.includeDelisted,
                        sortBy: args.sortBy ?? (args.query ? 'relevance' : 'popular'),
                        sortOrder: args.sortOrder,
                        skip: args.skip,
                        take: limit,
                    });
                    return renderCatalogPayload(shapeSearch(result, limit));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_extension',
            description: 'Get full catalog detail for one extension: metadata, per-store ratings, install counts, ' +
                'categories, and a security badge. Identify it by numeric catalog `extension_id` OR by its ' +
                '`store_id` (the id in the store URL; add `store` for a Chrome/Edge id).',
            parameters: z.object({ ...EXTENSION_REF_SHAPE }),
            execute: async (args, context) => {
                try {
                    const client = bff(context);
                    const extensionId = await resolveExtensionId(client, args);
                    const result = await client.getExtensionById(extensionId);
                    if (!result)
                        throw new UserError(`No extension found with catalog ID ${extensionId}.`);
                    return renderCatalogPayload(shapeExtension(result));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_reviews',
            description: 'Get store user reviews for one extension from Firefox Add-ons and Edge Add-ons: star rating, ' +
                'a short review excerpt, date and language, plus a store-level `aggregate` (rating, count, ' +
                'reviews link). Chrome Web Store review rows are NOT returned (their text cannot be redistributed) — ' +
                'for a Chrome extension the `aggregate` (with a link to the store reviews tab) is the only public ' +
                'review content. Reviewer identity is intentionally omitted. ' +
                'Paginated newest-first by default (or by highest rating); `cursor` takes the `nextCursor` ' +
                'from a previous call. Cursors are sort-specific: a cursor is valid only with the `sort` ' +
                'it was issued for. Reads existing scraped reviews; `min_rating` narrows to positive or ' +
                'critical feedback.',
            parameters: z.object({
                ...EXTENSION_REF_SHAPE,
                limit: z.number().int().min(1).max(50).default(20).describe('Max reviews to return (1–50).'),
                cursor: z
                    .number()
                    .int()
                    .optional()
                    .describe('Pagination cursor: the `nextCursor` value returned by a previous call.'),
                language_id: z.number().int().optional().describe('Only reviews in this catalog language id.'),
                min_rating: z
                    .number()
                    .int()
                    .min(1)
                    .max(5)
                    .optional()
                    .describe('Only reviews with a star rating ≥ this (1–5).'),
                sort: z
                    .enum(['recent', 'rating'])
                    .optional()
                    .describe('Order by newest first (default) or highest rating.'),
            }),
            execute: async (args, context) => {
                try {
                    const client = bff(context);
                    const extensionId = await resolveExtensionId(client, args);
                    const limit = args.limit;
                    const result = await client.getReviews({
                        extensionId,
                        limit,
                        cursor: args.cursor,
                        languageId: args.language_id,
                        minRating: args.min_rating,
                        sort: args.sort ?? 'recent',
                    });
                    return renderCatalogPayload(shapeReviews(result, limit));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_security',
            description: 'Get the security analysis for an extension: safety score (0–100, higher = safer — the ' +
                'same coefficient the website shows), risk category, finding counts by ' +
                'severity, and the top grouped findings (scanner, rule, severity, count). Also returns ' +
                'an approximate install-dialog preview computed from the store-listed permissions ' +
                '(consolidated + deduped), available even for unscanned extensions. Reads existing scan results — does not trigger a new scan. ' +
                'Identify the extension by numeric `extension_id` OR `store_id` (+`store` for Chrome/Edge). ' +
                'NB: this tool costs 3 read credits (it fetches security, risk, and install-preview data).',
            parameters: z.object({ ...EXTENSION_REF_SHAPE }),
            execute: async (args, context) => {
                try {
                    const client = bff(context);
                    const extensionId = await resolveExtensionId(client, args);
                    // getExtensionById carries `installDialogPreview` (a manifest transform,
                    // independent of scanning) — fetch it alongside the scan data so the
                    // security view always includes the install prompt the user would see.
                    const [security, riskSummary, extension] = await Promise.all([
                        client.getSecurityData(extensionId).catch(() => null),
                        client.getRiskSummary(extensionId).catch(() => null),
                        client.getExtensionById(extensionId).catch(() => null),
                    ]);
                    const installDialogPreview = extension && typeof extension === 'object'
                        ? extension.installDialogPreview
                        : null;
                    return renderCatalogPayload(shapeSecurity(security, riskSummary, installDialogPreview));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_risk_by_store_ids',
            description: 'Look up the safety score and risk category for MANY extensions at once, addressed by their ' +
                'STORE ids (the id in the store URL) — the inventory case: you have a list of what a user has ' +
                `installed and need risk for all of it. Up to ${MAX_BATCH_EXTENSIONS} extensions per call, and ` +
                'the WHOLE call costs 1 read credit (not 1 per extension; get_security costs 3 credits ' +
                'per extension). Returns safety score (0–100, higher ' +
                '= safer, the same number the website shows), risk category, severity counts, last scan date ' +
                'and the catalog URL per extension. Each store id is resolved through its cross-store cluster ' +
                'to the same listing the extension page renders, so these answers agree with the website. ' +
                '`store` is REQUIRED per entry — Chrome and Edge ids share one format. An extension with no ' +
                'score has not been scanned yet (`scanned: false`); ids with no catalog listing come back ' +
                'under `notInCatalog`. Neither means "safe". The full findings list of ONE extension comes ' +
                'from get_security.',
            parameters: z.object({
                extensions: z
                    .array(z.object({
                    store_id: z
                        .string()
                        .min(1)
                        .max(100)
                        .describe('The extension id from its store URL, e.g. "cjpalhdlnbpafiamejdnhcphjbkeiagm".'),
                    store: z
                        .enum(['CHROME', 'FIREFOX', 'EDGE'])
                        .describe('Which store this id belongs to. Required — Chrome and Edge ids look identical.'),
                }))
                    .min(1)
                    .max(MAX_BATCH_EXTENSIONS)
                    .describe(`The extensions to look up (max ${MAX_BATCH_EXTENSIONS}). A longer list takes several ` +
                    `calls of up to ${MAX_BATCH_EXTENSIONS}; each call costs 1 read credit.`),
            }),
            execute: async (args, context) => {
                try {
                    const refs = args.extensions.map((e) => ({ storeId: e.store_id, store: e.store }));
                    const rows = await bff(context).getSecuritySummaryBatch({ extensions: refs });
                    return renderCatalogPayload(shapeStoreRiskBatch(rows, refs));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'market_overview',
            description: 'Aggregate catalog market intelligence. Called with NO arguments it returns a full ' +
                'CATALOG-WIDE overview: totals, store split, the category tree, and the extended facet ' +
                'breakdown — Manifest V2/V3 adoption, sensitive-permission histogram, security risk-tier ' +
                'distribution, trader status, update-recency, and review-count buckets. Pass a `query` ' +
                '(and/or store/category filters) to scope those facets to a search result set instead.',
            parameters: z.object({
                query: z.string().optional().describe('Optional query to scope facets to a search.'),
                stores: z.array(z.enum(['CHROME', 'FIREFOX', 'EDGE'])).optional(),
                categories: z.array(z.string()).optional(),
                facets: z
                    .array(z.enum(MARKET_FACETS))
                    .optional()
                    .describe('Facet groups to include: stats, stores, categories, extended, questionnaire, downloads; all groups when omitted.'),
                top_n: z
                    .number()
                    .int()
                    .min(1)
                    .max(MAX_MARKET_TOP_N)
                    .optional()
                    .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.`),
            }),
            execute: async (args, context) => {
                try {
                    const client = bff(context);
                    const shapeOpts = { facets: args.facets, topN: args.top_n };
                    const scoped = Boolean(args.query?.trim() || args.stores?.length || args.categories?.length);
                    // Search-scoped: progressive-narrowing facets over the match set.
                    if (scoped) {
                        const [stats, facets] = await Promise.all([
                            client.getStats().catch(() => null),
                            client
                                .getSearchFacets({ query: args.query, stores: args.stores, categories: args.categories })
                                .catch(() => null),
                        ]);
                        return renderCatalogPayload(shapeMarketOverview({ scope: 'search', stats: stats ?? undefined, facets: facets ?? undefined }, shapeOpts));
                    }
                    // Catalog-wide: the unscoped search facets are all-zero by design, so build the
                    // overview from the dedicated catalog-wide procedures (cached server-side).
                    const STORE_LABELS = {
                        CHROME: 'Chrome Web Store',
                        FIREFOX: 'Firefox Add-ons',
                        EDGE: 'Edge Add-ons',
                    };
                    const [stats, extended, categoryTree] = await Promise.all([
                        client.getStats().catch(() => null),
                        client.getExtendedFilterFacets().catch(() => null),
                        client.getCategoryTree().catch(() => null),
                    ]);
                    const storeDistribution = stats
                        ?.storeDistribution ?? [];
                    const stores = storeDistribution.map((r) => ({
                        name: r.store,
                        count: r.count,
                        label: STORE_LABELS[r.store] ?? r.store,
                    }));
                    return renderCatalogPayload(shapeMarketOverview({
                        scope: 'catalog-wide',
                        stats: stats ?? undefined,
                        facets: {
                            stores,
                            categoryTree: categoryTree ?? undefined,
                            extended: extended ?? undefined,
                        },
                        note: 'Monetization and download-volume facets are computed only for a query-scoped overview.',
                    }, shapeOpts));
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_credit_balance',
            description: 'Report your remaining Extenshi credits — for checking whether a large request fits the ' +
                'balance before running it. Returns every credit pool for your API key: `read` (spent one-per-call by ' +
                'search_extensions / get_extension / get_reviews / market_overview / get_risk_by_store_ids — the ' +
                'last one covers up to 40 extensions for that single credit; get_security costs 3) and ' +
                '`scan` (spent by scan_extension), plus `icon` and `inventory`. Each pool reports `remaining` — the ' +
                'number that actually gates calls (one-time free grant + purchased credits) — and `freeRemaining` ' +
                '(how much of the one-time, non-renewing signup grant is left). FREE to call: checking the balance ' +
                'spends no credit. Fetching N extensions takes read.remaining ≥ N (≥ 3N with get_security ' +
                'on each).',
            parameters: z.object({}),
            execute: async (_args, context) => {
                try {
                    const balance = await bff(context).getApiCallerBalance();
                    return JSON.stringify(balance, null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        // ── The developer's OWN projects ───────────────────────────────────────
        //
        // Extenshi holds the state of an extension project — its type, its
        // manifest, its live hosted pages — so an agent can read that instead of
        // being told it again in prose, and so what it builds matches what the
        // developer already configured. Both are FREE: credits pay for catalog
        // data about other people's extensions, never for your own work.
        add({
            name: 'list_my_projects',
            description: "List the extension projects owned by this API key — the developer's own workspaces on " +
                'extenshi.io. Returns id, name, status, target browsers, the bound GitHub repo (if any) and ' +
                'the claimed store listing (if any). Applies when the developer refers to "my extension" or ' +
                '"my project": the returned id is what get_project_state takes. FREE — reading your own ' +
                'projects spends no credit.',
            parameters: z.object({}),
            execute: async (_args, context) => {
                try {
                    return JSON.stringify(await bff(context).listMyProjects(), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_project_state',
            description: "Read one of the developer's own projects as state you can build from: the extension TYPES " +
                'they picked (popup / side panel / page enhancer / in-page assistant), the required ' +
                'permissions those types force, the exact manifest.json the project produces for each target ' +
                'browser, the files that type needs, live hosted URLs (uninstall survey), and an index of ' +
                'every saved tool state with its size. It reflects the site configuration at the time of the ' +
                'call; later changes on the site appear in a later read. The additive workspace envelope ' +
                'holds revisioned local observations separately from these scaffold drafts. It also returns ' +
                'the integration contract: `integration.file` is the exact content for `integration.path` ' +
                'that wires the extension to this project. Those bytes carry a fingerprint extenshi.io uses ' +
                "to tell the developer's edits from its own; a reassembled equivalent file lacks it, and the " +
                'site then stops managing the values in it. `integration.unwired` lists the links this ' +
                'project has not set up yet (no URLs exist for them). Tool-state payloads are NOT inlined by ' +
                'default (a single row can be 256 KiB); includeToolStates selects which keys to inline. ' +
                'FREE — spends no credit. Type definitions are in list_extension_templates.',
            parameters: z.object({
                projectId: z.string().describe('Project id from list_my_projects.'),
                includeToolStates: z
                    .array(z.string())
                    .optional()
                    .describe('Tool keys whose saved payload to inline, e.g. ["manifest-generator", "privacy-policy-generator"]. When omitted, only the index is returned.'),
            }),
            execute: async (args, context) => {
                try {
                    const state = await bff(context).getMyProjectState({
                        projectId: args.projectId,
                        includeToolStates: args.includeToolStates,
                    });
                    return JSON.stringify(state, null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_project_scaffold',
            description: "The starter extension one of the developer's own projects produces, as FILES ready to " +
                'write: manifest.json for the target browser, the background worker, the panel or ' +
                "content-script files that project's types need, placeholder icons, and " +
                'src/extenshi.config.js. The config carries a fingerprint that lets extenshi.io recognise ' +
                "the repository as this project's; a hand-rebuilt config lacks it, and the site then stops " +
                'managing those values. Intended for a NEW extension: it is a complete starter set whose ' +
                'files would replace same-named files in an existing repository. It is the same file set ' +
                "the site commits. One browser per call (default: the project's first target). " +
                'FREE — spends no credit.',
            parameters: z.object({
                projectId: z.string().describe('Project id from list_my_projects.'),
                browser: z
                    .enum(['chrome', 'firefox', 'edge'])
                    .optional()
                    .describe("Target browser; defaults to the project's first declared target."),
            }),
            execute: async (args, context) => {
                try {
                    const scaffold = await bff(context).getMyProjectScaffold({
                        projectId: args.projectId,
                        browser: args.browser,
                    });
                    return JSON.stringify(scaffold, null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'list_privacy_policy_versions',
            description: "List hosted privacy-policy versions for one of the developer's Pro projects: version number, " +
                'kind (generated / edited / ai_updated / reverted), and which one is live. The public URL is ' +
                'in get_project_state hostedArtifacts.privacyPolicy. Pro only — a free project returns ' +
                'PROJECT_PREMIUM_REQUIRED. Does not spend a credit.',
            parameters: z.object({
                projectId: z.string().describe('Project id from list_my_projects.'),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).listPrivacyPolicyVersions(args), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'get_privacy_policy_version',
            description: 'Read the markdown (and HTML) of one hosted privacy-policy version; version numbers come from ' +
                'list_privacy_policy_versions. Pro only. Does not spend a credit.',
            parameters: z.object({
                projectId: z.string().describe('Project id from list_my_projects.'),
                versionNumber: z
                    .number()
                    .int()
                    .positive()
                    .describe('Version number from list_privacy_policy_versions.'),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).getPrivacyPolicyVersion(args), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'publish_privacy_policy',
            description: 'Publish a hosted privacy policy for a Pro project. Changes the live public policy page. ' +
                'Without bodyMarkdown it generates the policy from the saved form and manifest (the first ' +
                'version is produced this way); with bodyMarkdown it publishes that edited (or AI-updated) ' +
                'draft at the same public URL. Once published, PRIVACY_POLICY_URL in the integration.file ' +
                'from get_project_state is the hosted URL. Pro only.',
            parameters: z.object({
                projectId: z.string().describe('Project id from list_my_projects.'),
                bodyMarkdown: z
                    .string()
                    .optional()
                    .describe('Full policy markdown. When omitted, the policy is generated from the saved form and manifest.'),
                kind: z
                    .enum(['generated', 'edited', 'ai_updated', 'reverted'])
                    .optional()
                    .describe('How this version was produced. Default: generated if no markdown, edited if markdown is set.'),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(await bff(context).publishPrivacyPolicy(args), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
        add({
            name: 'update_privacy_policy_with_ai',
            description: "Propose an updated privacy policy that keeps the author's custom wording and adds sections " +
                'required by new permissions or data practices. Returns proposedMarkdown only; nothing goes ' +
                'live. aiStep states whether the AI step ran (ran | did_not_run | discarded | not_needed) and ' +
                'notice says why; unless aiStep is ran, proposedMarkdown is a deterministic section merge, not an ' +
                'AI draft. conflictHeadings and missingCategories list what still needs a manual edit. Publishing ' +
                'is a separate step (publish_privacy_policy with the returned kind). Requires a policy that is ' +
                'already published. Pro only.',
            parameters: z.object({
                projectId: z.string().describe('Project id from list_my_projects.'),
            }),
            execute: async (args, context) => {
                try {
                    return JSON.stringify(describePrivacyAiUpdate(await bff(context).updatePrivacyPolicyWithAi(args)), null, 2);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
            },
        });
    }
    // ── Documentation (free; no API key required) ──────────────────────────────
    if (caps.has('docs')) {
        add({
            name: 'get_development_guide',
            description: 'For extension-development tasks: returns the complete tool inventory for THIS connection, ' +
                'the Extenshi service directory with access requirements and docs links, GitHub/code placement ' +
                'guidance, and the ordered workflow from idea and repository through implementation, assets, ' +
                'privacy, tests/CI, store submission and maintenance, plus store media: the scenes.json format for ' +
                'CLI screenshots (per-locale store screenshots) and listing artwork sizes per store. Distinguishes ' +
                'MCP tools from cabinet, CLI and external steps. Free; no key, account lookup or network request.',
            parameters: z.object({
                sections: z
                    .array(z.enum(['all', ...GUIDE_SECTIONS]))
                    .optional()
                    .describe('Full guide sections to include: tools, localActions, services, repository, workflow, handoff, storeMedia, or all. ' +
                    'Omitted: a compact overview with tool names, workflow stages and a table of contents.'),
            }),
            execute: async (args) => JSON.stringify(buildDevelopmentGuide(registeredTools, args?.sections), null, 2),
        });
        add({
            name: 'search_docs',
            description: 'Search the official Extenshi documentation (docs.extenshi.io) — product guides plus the ' +
                'full @extenshi/cli command reference (scan, review-risk, publish, login). Applies to ' +
                '"how do I…" questions and to exact CLI commands and flags. Free: reads public docs, no ' +
                'API key or quota required. Without a query it lists every available documentation page.',
            parameters: z.object({
                query: z
                    .string()
                    .optional()
                    .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.'),
                limit: z
                    .number()
                    .int()
                    .min(1)
                    .max(10)
                    .default(4)
                    .describe('Max documentation passages (page subsections) to return (1–10).'),
                max_chars: z
                    .number()
                    .int()
                    .min(MIN_EXCERPT_CHARS)
                    .max(MAX_EXCERPT_CHARS)
                    .optional()
                    .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.`),
            }),
            execute: async (args) => {
                try {
                    const query = args.query?.trim();
                    if (!query)
                        return await getDocsIndex(deps.cfg.docsUrl);
                    return await searchDocs(deps.cfg.docsUrl, query, {
                        limit: args.limit ?? 4,
                        maxChars: args.max_chars,
                    });
                }
                catch (err) {
                    // Both branches keep the origin as `cause` — a docs outage is a
                    // fault worth capturing, not an expected condition.
                    if (err instanceof DocsError)
                        throw userErrorFrom(err.message, err);
                    throw userErrorFrom(err instanceof Error ? err.message : String(err), err);
                }
            },
        });
        add({
            name: 'localize_workflow',
            description: 'Get the FREE local browser-extension localization workflow: prepare missing or stale ' +
                '_locales messages, translate with your own coding agent, apply validated translations ' +
                'and check placeholders, protected terms and listing risks. Returns commands and the ' +
                'JSON handoff contract. Static guidance only: no API key, network or Extenshi credits; ' +
                'translation uses your agent provider. Includes release availability and human review gates.',
            parameters: z.object({}),
            execute: async () => renderLocalizeWorkflow(),
        });
        add({
            name: 'generate_icon_workflow',
            description: 'Get the recommended FREE local workflow for creating a browser-extension icon: the ' +
                'agent draws the SVG itself, then `@extenshi/cli icon preview` renders an offline ' +
                'verification page (Chrome/Firefox/Edge toolbar mockups, palette switcher with contrast ' +
                'warnings, store-size matrix, PNG/ZIP export). Returns the icon design requirements ' +
                '(sizes, 16px legibility rules, light/dark survival) and exact commands. Static content: ' +
                'no API key, no network, no credits.',
            parameters: z.object({
                extension_name: z
                    .string()
                    .max(120)
                    .optional()
                    .describe('Extension display name to inline into the preview command (optional).'),
            }),
            execute: async (args) => renderIconWorkflow({ extensionName: args.extension_name }),
        });
        add({
            name: 'generate_welcome_page_workflow',
            description: 'Get the design brief for a browser-extension welcome page — the page a user lands on ' +
                'right after installing. Returns the one action the page is built to drive (pin the extension, ' +
                'keep the new-tab change, use it on a site), exactly which illustrations to produce for ' +
                'that goal, how to capture and crop them, how to place numbered/arrow markers showing ' +
                'where to click, the limits enforced on save, and the block JSON format. Store screenshots ' +
                'Extenshi already hosts (from get_extension) can serve as illustrations. Static content: no ' +
                'API key, no network, no credits.',
            parameters: z.object({
                extension_name: z.string().max(120).optional().describe('Extension display name.'),
                goal: z
                    .enum(['PIN_EXTENSION', 'NEW_TAB_OPT_IN', 'VISIT_SITE', 'CUSTOM'])
                    .optional()
                    .describe('The single action the page drives. PIN_EXTENSION (default) fits ~90% of extensions; ' +
                    'NEW_TAB_OPT_IN for new-tab replacements; VISIT_SITE when the extension injects a ' +
                    'widget into a site.'),
                what_it_does: z
                    .string()
                    .max(1000)
                    .optional()
                    .describe('One or two sentences on what the extension actually does.'),
                target_site: z
                    .string()
                    .max(200)
                    .optional()
                    .describe('For VISIT_SITE: the site the extension injects into, e.g. youtube.com.'),
                store_screenshots: z
                    .array(z.string().max(1000))
                    .max(10)
                    .optional()
                    .describe('Screenshot URLs from get_extension. Offered to the agent as ready-made illustration ' +
                    'material that needs no upload.'),
                existing_steps: z
                    .array(z.string().max(200))
                    .max(10)
                    .optional()
                    .describe('Steps the author already wrote, so the brief adds visuals instead of rewriting.'),
                accent_color: z
                    .string()
                    .max(9)
                    .optional()
                    .describe("The page's accent colour as hex, so drawn illustrations match it."),
            }),
            execute: async (args) => renderWelcomeWorkflow({
                extensionName: args.extension_name,
                goal: args.goal,
                whatItDoes: args.what_it_does,
                targetSite: args.target_site,
                storeScreenshots: args.store_screenshots,
                existingSteps: args.existing_steps,
                accentColor: args.accent_color,
            }),
        });
        add({
            name: 'generate_landing_page',
            description: 'Generate a static landing page (homepage) for a browser extension as one self-contained HTML ' +
                'file: no JavaScript, escaped text, http(s)-only links (the support link may also be a mailto: ' +
                'address), and store buttons only for the store ' +
                'URLs given. Same generator as the cabinet Page generator, so a saved `page-generator` form ' +
                'from get_project_state maps field for field. Optional logo (inline SVG, validated and embedded ' +
                'as an image, or an image URL), screenshots and the canonical homepage URL for Open Graph and ' +
                'JSON-LD. Returns JSON {html, bytes, warnings, nextSteps}: warnings list input that was dropped ' +
                'or clamped; nextSteps cover hosting the file on HTTPS and registering the URL with ' +
                'upsert_hosted_page, which feeds HOMEPAGE_URL. Extenshi does not host the page. With schemaVersion 2 ' +
                'the page is built from sections, theme tokens, SEO and locales (see the field descriptions); the ' +
                'result adds problems [{path, severity, message, fix}] and localePages. Static content: ' +
                'no API key, no network, no credits.',
            parameters: z.object(LANDING_FORM_SHAPE),
            execute: async (args) => JSON.stringify(renderGenerateLandingPage(args)),
        });
        add({
            name: 'list_extension_templates',
            description: 'The kinds of browser extension you can build, with the permissions each one REQUIRES: ' +
                'Popup (toolbar window), Side panel (docked beside the page), Page enhancer (runs on sites ' +
                'you list), In-page assistant (your own UI over any site). Types combine, and the required ' +
                'permissions are the union. Applies when choosing manifest permissions: it gives the minimum ' +
                'each shape needs (store review flags padded permissions). Also states the cross-browser ' +
                'rules (Chromium side_panel vs Firefox sidebar_action) and the rule that a manifest may not ' +
                'name a file the package lacks. ' +
                "FREE: no API key, no quota. A specific project's chosen types are in get_project_state.",
            parameters: z.object({
                types: z
                    .array(z.string())
                    .optional()
                    .describe('Type ids to include (popup, sidepanel, content, overlay); all four when omitted.'),
            }),
            execute: async (args) => renderExtensionTemplates(args.types),
        });
    }
    // ── Action tool (paid; key + LOCAL filesystem required; stdio only) ─────────
    if (caps.has('scan')) {
        add({
            name: 'scan_extension',
            description: 'Run a pre-publish security scan on a local extension artifact (.zip/.crx/.xpi) and ' +
                'return the report. Uses one scan from your one-time free allowance (3 scans) or a ' +
                'purchased scan credit once that runs out. Streams live per-scanner progress.',
            parameters: z.object({
                artifact_path: z
                    .string()
                    .describe('Absolute or relative path to the built extension artifact (≤50 MB).'),
                // Optional association with a catalog listing — by numeric id OR store id.
                // Reuse the shared ref validators but override the descriptions: here the
                // ref is an optional ASSOCIATION for the scan, not a lookup key, so the
                // shared "from search_extensions results" wording would mislead an agent.
                extension_id: EXTENSION_REF_SHAPE.extension_id.describe('Optional numeric catalog ID to associate this scan with a catalog listing.'),
                store_id: EXTENSION_REF_SHAPE.store_id.describe('Optional: associate this scan with a catalog listing by its store id (the id in the ' +
                    'store URL; add `store` for a Chrome/Edge id).'),
                store: EXTENSION_REF_SHAPE.store,
            }),
            execute: async (args, context) => {
                const apiKey = requireApiKey(context);
                // Resolve the optional catalog association BEFORE spending a scan credit,
                // so a mistyped store_id fails fast instead of after the scan runs.
                let extensionId;
                try {
                    extensionId = await resolveOptionalExtensionId(bff(context), args);
                }
                catch (err) {
                    return readError(err, missingKeyMessage);
                }
                try {
                    const report = await scanArtifact({
                        artifactPath: args.artifact_path,
                        apiKey,
                        scanUrl: deps.cfg.scanUrl,
                        extensionId: extensionId?.toString(),
                        onProgress: (p) => {
                            if (typeof p.total === 'number' && p.total > 0) {
                                void context.reportProgress({ progress: p.completed ?? 0, total: p.total });
                            }
                        },
                    });
                    // The scan report goes out as-is (defanged + labelled as data: finding
                    // text quotes the artifact's own code). It used to be passed through
                    // shapeExtension — the catalog-DETAIL shaper — whose field list has
                    // none of the report's keys, so every scan came back as
                    // {"snapshots": []} after the credit was spent.
                    return renderCatalogPayload(report);
                }
                catch (err) {
                    // Keep the ScanError as `cause`: scanErrorMessage() renders 402/429
                    // and a 500 alike, so only the origin's status still says which of
                    // those is a fault worth capturing.
                    if (err instanceof ScanError)
                        throw userErrorFrom(scanErrorMessage(err, missingKeyMessage), err);
                    throw err;
                }
            },
        });
    }
    if (caps.has('publish')) {
        add({
            name: 'publish_extension',
            description: 'Publish an extension artifact (.zip/.crx/.xpi) to Chrome Web Store, Firefox AMO, and/or Edge Add-ons: ' +
                'uploads and submits a new version to each selected store. ' +
                'FREE and fully local: the upload goes from this machine straight to the store APIs using store ' +
                'credentials from the MCP server environment (CHROME_APP_ID/CHROME_CLIENT_ID/CHROME_CLIENT_SECRET/' +
                'CHROME_REFRESH_TOKEN, FIREFOX_ADDON_GUID/FIREFOX_JWT_ISSUER/FIREFOX_JWT_SECRET, ' +
                'EDGE_PRODUCT_ID/EDGE_CLIENT_ID/EDGE_CLIENT_SECRET/EDGE_TENANT_ID). The upload itself is local, but ' +
                'publishing is in an active testing phase: a quick Extenshi access check runs first (it identifies your ' +
                'account by EXTENSHI_API_KEY). Edge submissions are polled to a terminal status. For Firefox, ' +
                'listing_path turns the upload into a listed AMO submission with metadata: first-time add-on creation ' +
                '(gated by allow_new_addon or the project decision), per-locale name/summary/description, categories, ' +
                'license, homepage, support email and URL, privacy policy, release and reviewer notes, reviewer source ' +
                '(source_path) and previews (screenshots_dir); the result lists each step. The artifact is not ' +
                'security-scanned by this tool (scanning is scan_extension).',
            parameters: z.object({
                artifact_path: z
                    .string()
                    .describe('Path to the packaged extension (.zip for Chrome/Edge, .xpi/.zip for Firefox).'),
                stores: z
                    .array(z.enum(['chrome', 'firefox', 'edge']))
                    .optional()
                    .describe('Target stores; defaults to every store that has complete credentials in the environment.'),
                firefox_artifact_path: z
                    .string()
                    .optional()
                    .describe('Separate Firefox artifact (.xpi); defaults to artifact_path.'),
                release_notes: z.string().optional().describe('Release notes for stores that accept them.'),
                listing_path: z
                    .string()
                    .optional()
                    .describe('Firefox: AMO listing file (JSON with per-locale fields, or the AMO.md that generate-listing --store ' +
                    'firefox writes, placeholders filled). Makes the Firefox submission a listed one with metadata: ' +
                    'the add-on is created on first submission (see allow_new_addon) or its listing is edited, then ' +
                    'privacy policy, release notes, reviewer notes, source and previews are applied.'),
                source_path: z
                    .string()
                    .optional()
                    .describe('Firefox: reviewer source archive (.zip/.tar.gz/.tgz/.tar.bz2) for the new version.'),
                screenshots_dir: z
                    .string()
                    .optional()
                    .describe('Firefox: AMO previews in file-name order: a directory of .png/.jpg/.gif files, or the extenshi ' +
                    'screenshots output directory (its firefox/<locale>/ set, default locale first).'),
                allow_new_addon: z
                    .boolean()
                    .optional()
                    .describe('Firefox: permits creating a NEW AMO add-on, whose id becomes permanent. With project_id the ' +
                    "project's decided amo.addonId governs creation instead."),
                addon_id: z
                    .string()
                    .max(255)
                    .optional()
                    .describe('Firefox: the AMO add-on id ({GUID} or name@domain), stated explicitly. Needed with allow_new_addon ' +
                    'when there is no project_id; fills FIREFOX_ADDON_GUID when the environment has none.'),
                replace_screenshots: z
                    .boolean()
                    .optional()
                    .describe('Firefox: replaces the previews of an existing AMO add-on with screenshots_dir.'),
                project_id: z
                    .string()
                    .uuid()
                    .optional()
                    .describe('Dojo project id. Its owner-decided amo.addonId gates first-time AMO creation and has to equal FIREFOX_ADDON_GUID.'),
                extension_id: z
                    .number()
                    .optional()
                    .describe('Numeric catalog ID — checks publish-beta access against this specific extension.'),
                validate_only: z
                    .boolean()
                    .optional()
                    .describe('Only check which store credentials are configured and valid; publish nothing.'),
            }),
            execute: async (args, context) => {
                try {
                    if (args.validate_only) {
                        const checks = await validateStoreCredentials(args.stores);
                        return JSON.stringify({ checks }, null, 2);
                    }
                    // Publish-access gate: `publish` is in an active testing phase. One BFF
                    // preflight evaluates the `publish-access` PostHog flag for this developer
                    // / extension. Fails open on any transport error — only a definitive
                    // server "no" blocks here (see checkPublishAccess).
                    const creds = storeCredentialsFor(args.addon_id);
                    const storeIds = {};
                    if (creds.chrome)
                        storeIds.chrome = creds.chrome.appId;
                    if (creds.firefox)
                        storeIds.firefox = creds.firefox.addonGuid;
                    if (creds.edge)
                        storeIds.edge = creds.edge.productId;
                    const access = await checkPublishAccess({
                        bffUrl: deps.cfg.bffUrl,
                        apiKey: deps.getApiKey?.(context) ?? null,
                        storeIds,
                        extensionId: args.extension_id,
                    });
                    if (!access.allowed) {
                        throw new UserError(access.message ??
                            "Publishing is in an active testing phase and isn't available for your account yet.");
                    }
                    let firefoxListing;
                    if (args.listing_path || args.source_path || args.screenshots_dir) {
                        if (!args.listing_path)
                            throw new UserError('source_path and screenshots_dir need listing_path, the AMO listing they belong to.');
                        let decision = null;
                        if (args.project_id) {
                            const view = (await bff(context).getDecisions({ projectId: args.project_id }));
                            decision = view.decisions?.find((d) => d.key === 'amo.addonId') ?? null;
                        }
                        firefoxListing = {
                            listingPath: args.listing_path,
                            sourcePath: args.source_path,
                            screenshotsDir: args.screenshots_dir,
                            replaceScreenshots: args.replace_screenshots,
                            allowCreate: amoCreateAllowed({
                                addonGuid: creds.firefox?.addonGuid ?? '',
                                allowNewAddon: args.allow_new_addon,
                                addonId: args.addon_id,
                                projectId: args.project_id,
                                decision,
                            }),
                        };
                    }
                    void context.reportProgress({ progress: 0, total: 1 });
                    const result = await publishArtifact({
                        artifactPath: args.artifact_path,
                        stores: args.stores,
                        firefoxArtifactPath: args.firefox_artifact_path,
                        releaseNotes: args.release_notes,
                        addonId: args.addon_id,
                        firefoxListing,
                    });
                    void context.reportProgress({ progress: 1, total: 1 });
                    return JSON.stringify(result, null, 2);
                }
                catch (err) {
                    if (err instanceof PublishSetupError)
                        throw new UserError(err.message);
                    throw err;
                }
            },
        });
    }
}
//# sourceMappingURL=tools.js.map