UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

2,015 lines • 97.6 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
// `deriveAnnotations` is a value import back into ./index.js, which imports
// this module — an ESM cycle. Safe because it is a hoisted function
// declaration: it is initialised before either module body runs, and it is
// only ever CALLED from inside a method here. Do not convert it to a const
// arrow function on the other side.
import { deriveAnnotations } from "./index.js";
import { getAliasFor, retiredToolAnswer } from "./deprecations.js";
import { getToolMetadata } from "./metadata.js";
import { RECOVERY_HINTS } from "./recovery-hints.js";
import { applyArgAliases } from "./arg-aliases.js";
import { buildWrappedArgsMissingEnvelope } from "./wrapped-args-envelope.js";
const SCHEMA_VERSION = "2026.1";
/**
 * Scopes excluded from `GRANT_EDITABLE_SCOPES` (lib/auth/grant-scopes.ts).
 * A standard agent grant can never hold one of these, so a tool that
 * requires it is discoverable but NOT callable until the user elects an
 * `admin` elevation from their own settings session. Surfaced as a
 * `callable_note` in pica_tool_details so agents learn the wall at
 * discovery time, not at failure time (audit P1 #6 — the royalties/finance
 * family is the motivating case).
 *
 * Mirrored here because mcp-server is an isolated package (ADR-144) and
 * cannot import from `lib/`. Keep in sync with lib/auth/grant-scopes.ts —
 * `destructive:*` is also non-grantable but is never a tool's declared
 * `scope` (it's composed at dispatch from `tier:"destructive"`), so it is
 * reported via the tier branch below rather than this set.
 */
const SCOPES_NOT_GRANTABLE_TO_STANDARD_AGENT = new Set([
    "read:finances",
    "write:finances",
    "write:agent_identity",
    "admin",
]);
/** Max matched tools returned by a `pica_discover({ query })` call. */
const DISCOVER_MATCH_LIMIT = 25;
/**
 * The fresh-org on-ramp, carried by BOTH pica_discover modes (ADR-294 L3).
 * Static and org-blind by design so the response stays cacheable: the
 * instruction is universal, not org data. One constant, because the ChatGPT
 * app moved to query mode on 2026-08-10 and for sixteen days this line lived
 * only on the full map — the client carrying 92% of tool calls never saw it
 * (FIX_LOG 2026-08-26). Pinned in discovery.test.ts for a hit, a miss and
 * the full map.
 */
export const FRESH_ORG_HINT = "on a fresh or empty org, run pica_dashboard_briefing first (through pica_execute where this surface routes calls that way). if the catalog is empty it names the fastest import: paste a spotify album or playlist link.";
/**
 * Words that describe the asker, not the entity. `pica_my_*` tools carry
 * "my" as their DOMAIN segment, so before this filter "start my music
 * catalogue and import my songs" led with pica_my_recent_questions,
 * pica_my_reported_issues and pica_export_my_data — three tools about the
 * caller, for a user who asked to import (prod agent_events, 2026-08-11).
 * Determiners, pronouns, prepositions, conjunctions, auxiliaries and the
 * LIGHT verbs (get / list / find / show / check / run / set …) that name no
 * entity — "get the isrc" is about the ISRC. Eight tools carry a light verb
 * as their first segment (pica_get_agent_activity, pica_find_duplicates,
 * pica_run_work_cascade …); for them the entity is the next segment, and
 * the verb still reaches them through the name text (+1). Domain verbs that
 * discriminate inside a domain (delete, create, update, query, import …)
 * stay. Before the light verbs were listed, "get the isrc from this
 * spotify link" led with pica_get_agent_activity once the noise words that
 * had been propping the import tool up were removed.
 */
// prettier-ignore — the compact rows encode the semantic grouping the
// comment above describes (determiners / pronouns / prepositions / verbs).
export const STOP_WORDS = new Set([
    "a",
    "an",
    "the",
    "my",
    "me",
    "mine",
    "our",
    "ours",
    "your",
    "yours",
    "his",
    "her",
    "hers",
    "their",
    "theirs",
    "its",
    "it",
    "i",
    "we",
    "you",
    "they",
    "them",
    "us",
    "this",
    "that",
    "these",
    "those",
    "and",
    "or",
    "of",
    "to",
    "for",
    "in",
    "on",
    "at",
    "by",
    "with",
    "from",
    "into",
    "across",
    "about",
    "as",
    "per",
    "is",
    "are",
    "am",
    "be",
    "was",
    "were",
    "do",
    "does",
    "did",
    "can",
    "could",
    "would",
    "should",
    "will",
    "shall",
    "please",
    "want",
    "need",
    "just",
    "also",
    "some",
    "any",
    "all",
    "get",
    "list",
    "find",
    "show",
    "see",
    "view",
    "check",
    "run",
    "make",
    "use",
    "look",
    "set",
]);
/**
 * Rows reserved at the tail of a capped shortlist for tools the caller
 * cannot run here — a floor, not a ceiling: flagged rows also fill whatever
 * the visible rows leave free, so a narrowly-scoped connection sees as many
 * rows as an open one. Visible-first sorting alone let them fall off the
 * end of a 25-row list, so an agent could conclude a capability was missing
 * rather than gated (the Ridwayne session: five finance tools, zero
 * callable). Pinned by discovery-ranking.test.ts.
 */
const FLAGGED_MATCH_ROWS = 5;
/**
 * Flag text for a tool the caller cannot run on this connection — surfaced
 * as `surface_note` beside `callable_on_this_surface: false` in the
 * shortlist row and in tool_details, so an agent learns the wall at
 * discovery time. Its own key: a scope or destructive-tier `callable_note`
 * carries the specific remediation (admin elevation, confirmation token)
 * and must survive alongside it. Factual; names the two causes the
 * predicate folds together (ADR-248: audiences ∋ surface AND scope held).
 */
const NOT_CALLABLE_ON_SURFACE_NOTE = "not callable on this connection — it is listed on a different surface (stdio / lobby / web) or needs a scope this connection does not hold. prefer a tool without this note; the user can reach it from withpica.com.";
/**
 * Hand-maintained category taxonomy for the catalog server.
 * Grouped by natural entity/capability prefix.
 * Cross-server depends_on declarations use "<server-slug>:<category>" format.
 *
 * Exported so `scripts/lint-mcp-tools.ts` can enforce taxonomy completeness
 * (every registered tool ∈ taxonomy ∪ archetype-allowlist) at
 * prepublishOnly time. Sealed after the bug class bit four times — silently
 * for `pica_upload` + `pica_download` since ADR-152, for `pica_submit_feedback`
 * since ADR-184, and again for `pica_report_issue` + `pica_my_reported_issues`
 * (ADR-196 Phase 3) which were the exact failure mode they were designed to
 * catch and so couldn't catch themselves. ~95 further pre-existing violators
 * are grandfathered in `scripts/mcp-tool-lint-baseline.json` for retrofit.
 */
export const CATEGORIES = {
    works: {
        description: "manage your musical works",
        tools: [
            "pica_works_query",
            "pica_works_inspect",
            "pica_works_create",
            "pica_register_set",
            "pica_works_update",
            "pica_works_delete",
            "pica_works_bulk_delete",
            "pica_works_bulk_update",
            "pica_works_bulk_link_artist",
            "pica_works_bulk_add_collaborator",
            "pica_work_writers_add",
            "pica_works_bulk_copy_credits",
            "pica_works_bulk_move_to_performances",
            "pica_works_verify",
            // ADR-264 WS2 — verify-before-assert for registration gaps.
            "pica_works_verify_registrations",
            // Provenance attestation — human-creation proof complement to ai_disclosure.
            "pica_works_attest",
            // ADR-226 Decision 12 cross-listing — Atlas routes
            // "am I ready to register?" to natural_domain="works".
            "pica_catalog_diligence",
        ],
        depends_on: [],
    },
    people: {
        description: "manage people in your catalog",
        tools: [
            "pica_people_query",
            "pica_people_inspect",
            "pica_people_create",
            "pica_people_update",
            "pica_people_delete",
            "pica_people_bulk_delete",
            "pica_people_split_identity",
            "pica_people_bulk_update_roles",
            "pica_people_bulk_add_tags",
            "pica_group_add_member",
            "pica_group_remove_member",
            "pica_group_list_members",
            "pica_person_list_groups",
            "pica_artist_projects",
            "pica_profile_claims_invite",
            "pica_profile_claims_status",
        ],
        depends_on: [],
    },
    groups: {
        description: "manage group → member relationships on people",
        tools: [
            "pica_group_add_member",
            "pica_group_remove_member",
            "pica_group_list_members",
            "pica_person_list_groups",
        ],
        depends_on: [],
    },
    recordings: {
        description: "manage recordings linked to works",
        tools: [
            "pica_recordings_query",
            "pica_recordings_inspect",
            "pica_recordings_create",
            "pica_recordings_update",
            "pica_recordings_delete",
            "pica_recordings_bulk_reassign_artist",
        ],
        depends_on: [],
    },
    credits: {
        description: "manage work credits, contributors, and first-party recording-side production credits",
        tools: [
            "pica_credits_list",
            "pica_credits_update",
            // pica_recording_credits_update is a superset (batch dispatch on
            // credits[] across a release's tracklist or an explicit recording
            // list, single call) — pica_recording_credits_bulk_update, once a
            // hidden alias of it, was removed in mcp-server 2.99.0.
            "pica_recording_credits_update",
            // ADR-226 Decision 12 cross-listing — Atlas routes "I want to claim my
            // credit on X" to natural_domain="credits".
            "pica_claim_credit",
            // ADR-232 atomic credit primitives — composable add/remove verbs over
            // the same substrate as pica_credits_update / _recording_credits_update,
            // but discrete (single-row writes, no replace-semantics).
            "pica_credit_add",
            "pica_credit_update",
            "pica_credit_remove",
            // Lightweight credit-confirmation send — alternative to
            // pica_split_sheet_send when only confirmation is needed (no formal doc).
            "pica_credits_send_for_attestation",
        ],
        depends_on: [],
    },
    sharing: {
        description: "explain why a user or org can see a work, recording, or person — surfaces the chain of reasons (org membership, custody chain, share links, cross-org grants); send works to named recipients via withPICA-branded email",
        tools: [
            // ADR-232 § Decision 2 — explainability aggregator.
            "pica_share_trace",
            // ADR-226 Decision 12 cross-listing — Atlas routes "send this song to
            // <name>" to natural_domain="sharing".
            "pica_share_send",
        ],
        depends_on: [],
    },
    explainability: {
        description: "explain dashboard / briefing count discrepancies — when a number doesn't match what the user sees elsewhere",
        tools: [
            // ADR-232 § Decision 3 — count discrepancy explainer.
            "pica_count_explain",
        ],
        depends_on: [],
    },
    multimedia: {
        description: "manage photos, videos, and media linked to works",
        tools: [
            "pica_multimedia_search",
            "pica_multimedia_create",
            "pica_multimedia_import_url",
            "pica_multimedia_link_youtube",
            "pica_multimedia_link_work",
            // ADR-226 Decision 12 cross-listing — Atlas routes "upload some session
            // photos" to natural_domain="multimedia".
            "pica_upload",
        ],
        depends_on: [],
    },
    audio: {
        description: "manage and analyze audio files",
        tools: [
            "pica_audio_query",
            "pica_audio_inspect",
            "pica_audio_presigned_upload",
            "pica_audio_complete_upload",
            "pica_audio_analyze",
            "pica_audio_identify",
            "pica_audio_assign",
            "pica_audio_bulk_assign_suggested",
            "pica_audio_dismiss_match",
            "pica_audio_update",
            // ops issue 5c53049a — per-file metadata for up to 200 files in one call.
            "pica_audio_bulk_update",
            "pica_audio_delete",
            // ADR-226 Decision 12 cross-listing — Atlas routes "can I upload audio
            // from here?" to natural_domain="audio".
            "pica_upload",
            // ADR-232 § Decision 4 — operational pipeline read.
            "pica_audio_pipeline_status",
        ],
        depends_on: [],
    },
    projects: {
        description: "manage albums, EPs, and work groupings",
        tools: [
            // pica_projects_list/_get were Tier 2 consolidation aliases of
            // pica_projects_query/_inspect — _get removed in 2.93.0, _list in 2.99.0.
            "pica_projects_query",
            "pica_projects_inspect",
            "pica_projects_create",
            "pica_projects_update",
            "pica_projects_delete",
            "pica_projects_attach_works",
            "pica_projects_participants",
            "pica_projects_multimedia",
        ],
        depends_on: [],
    },
    releases: {
        description: "manage releases with dates, labels, and UPCs",
        tools: [
            // pica_releases_list/_get were Tier 1 consolidation aliases of
            // pica_releases_query/_inspect, removed in mcp-server 2.99.0.
            "pica_releases_query",
            "pica_releases_inspect",
            "pica_releases_create",
            "pica_releases_update",
            "pica_releases_delete",
            // ADR-175 workflow tools
            // pica_releases_bulk_attach_tracks and pica_releases_attach_track_by_identifier
            // were Tier 1 consolidation aliases of pica_releases_attach_track, removed
            // in mcp-server 2.99.0.
            "pica_releases_move_track",
            "pica_releases_create_with_tracks",
            "pica_releases_completeness_check",
            // Track attach/detach + ordering
            "pica_releases_attach_track",
            "pica_releases_attach_recording_with_work",
            "pica_releases_detach_track",
            "pica_releases_list_tracks",
            "pica_releases_reorder_tracks",
            // ADR-208 Phase 3 Primitive D — rich render
            "pica_release_show",
        ],
        depends_on: [],
    },
    sessions: {
        description: "track work sessions and studio time",
        tools: [
            // pica_sessions_list/_get were REMOVED in the mcp-server 2.93.0 WS5
            // removal sweep (0 calls/30d) — subsumed by pica_sessions_query/_inspect
            // (ADR-149).
            "pica_sessions_query",
            "pica_sessions_inspect",
            "pica_sessions_create",
            "pica_sessions_types",
        ],
        depends_on: [],
    },
    notes: {
        description: "create and manage catalog notes",
        tools: [
            // pica_notes_list/_get were Tier 2 consolidation aliases of
            // pica_notes_query/_inspect — _get removed in 2.93.0, _list in 2.99.0.
            "pica_notes_query",
            "pica_notes_inspect",
            "pica_notes_create",
            "pica_notes_delete",
        ],
        depends_on: [],
    },
    duplicates: {
        description: "detect duplicate records and merge them (audio_files can be found but not merged — a duplicate file is deleted)",
        tools: ["pica_find_duplicates", "pica_merge_duplicates"],
        depends_on: [],
    },
    import: {
        description: "import catalog data from files and streaming services, and see what happened to emails forwarded to PICA",
        tools: [
            "pica_import_fields",
            "pica_import_template",
            "pica_import_analyze",
            "pica_import_validate",
            "pica_import_execute",
            "pica_import_streaming_link",
            "pica_import_documents_query",
            "pica_import_documents_inspect",
            "pica_import_emails_query",
            "pica_import_youtube_link",
            "pica_ingest_statement",
        ],
        depends_on: [],
    },
    export: {
        description: "export catalog data in various formats",
        tools: [
            "pica_export_catalog_csv",
            "pica_export_song_registration",
            "pica_export_industry_ready",
            "pica_export_catalog_asset_report",
            "pica_export_ai_consent",
            // The four diligence documents. Reachable from nowhere between the
            // /admin retirement (2026-06-01) and 2026-07-30; registering the tools
            // without listing them here would have left them undiscoverable to
            // layered-discovery agents, which is the same defect one layer down.
            "pica_export_snapshot",
            "pica_export_song_dossiers",
            "pica_export_ownership_record",
            "pica_export_rights_proof",
            "pica_export_diligence_pack",
            // Interactive download card (ADR-152) — pair with any export tool to
            // deliver the file directly. Lives here rather than in `uploads`
            // because it's the delivery side of the export flow.
            "pica_download",
            // ADR-226 Decision 12 cross-listing — Atlas routes "export my data"
            // questions to natural_domain="export"; primary home is `privacy`.
            "pica_export_my_data",
        ],
        depends_on: [
            "mcp-server-business:agreements",
            "mcp-server-business:splits",
        ],
    },
    search: {
        description: "search across the catalog and discover content",
        tools: [
            "pica_search_all",
            "pica_catalog_stats",
            "pica_graph_traverse",
            "pica_discoveries_query",
            "pica_discoveries_review",
            "pica_claim_credit",
            "pica_claim_custody",
            "pica_claim_artist",
        ],
        depends_on: [],
    },
    analytics: {
        description: "provenance, carbon, and catalog analytics",
        tools: [
            "pica_provenance_stats",
            "pica_provenance_work",
            "pica_provenance_certificate",
            "pica_provenance_certificate_get",
            "pica_carbon_footprint",
            "pica_carbon_live",
            "pica_catalog_diligence",
            // Reconciliation cascades + audit log + CAR preview
            "pica_run_person_cascade",
            "pica_run_work_cascade",
            "pica_cascade_health",
            "pica_car_preview",
            "pica_audit_list",
        ],
        depends_on: ["mcp-server-business:agreements"],
    },
    documents: {
        description: "analyse documents with ai",
        tools: [
            "pica_documents_analyse",
            // pica_documents_list/_read were Tier 2 consolidation aliases of
            // pica_documents_query/_inspect, removed in mcp-server 2.99.0.
            "pica_documents_query",
            "pica_documents_inspect",
            "pica_documents_link",
            "pica_documents_delete",
            // ADR-226 Decision 12 cross-listing — Atlas routes "upload the contract"
            // to natural_domain="documents".
            "pica_upload",
        ],
        depends_on: [],
    },
    calendar: {
        description: "view catalog calendar events",
        tools: ["pica_calendar_events"],
        depends_on: [],
    },
    publishers: {
        description: "look up and create publishers",
        tools: ["pica_publishers_query", "pica_publishers_create"],
        depends_on: [],
    },
    briefing: {
        description: "get your daily catalog briefing and dashboard",
        tools: [
            "pica_dashboard_attention",
            "pica_dashboard_briefing",
            "pica_catalog_health",
            "pica_catalog_health_plan",
            "pica_catalog_health_fix",
            // Registration integrity floor (spec 2026-07-09) — the briefing's
            // integrity section surfaces gaps; this is the one tool that resolves
            // them (waive, or revoke_waiver_id to undo).
            "pica_integrity_waive",
        ],
        depends_on: [],
    },
    enrichment: {
        description: "enrich your catalog metadata from external sources",
        tools: [
            // ADR-179 outcome-shaped resolvers own the enrichment surface;
            // the seven per-source tombstones were removed in ADR-154 F13.
            "pica_resolve_work",
            "pica_resolve_person",
            "pica_resolve_recording",
            "pica_resolve_release",
            "pica_people_triangulate",
            "pica_enrichment_candidates",
            "pica_enrichment_compare",
            "pica_registrations_compare",
            // Agent-proposed enrichment (ADR-178) routes through review gates
            "pica_enrichment_propose",
            "pica_enrichment_proposal_apply",
            "pica_enrichment_proposal_reject",
            "pica_enrichment_proposals_list",
            // ADR-264 C3: bulk review queue
            "pica_enrichment_proposals_bulk_reject",
            "pica_enrichment_proposals_bulk_apply",
            // ADR-289 Wave C2 — undo a wrong MusicBrainz match
            "pica_enrichment_clear",
        ],
        depends_on: [],
    },
    feedback: {
        description: "report problems and submit feedback to the PICA team — call proactively when you hit a workflow gap or the user says 'this is broken'",
        tools: [
            // Customer-facing issue + feedback surface (ADR-184 + ADR-196).
            // Listed here so the layered-discovery flow exposes them — without
            // this entry, agents using pica_discover cannot reach the tools and
            // resort to pica_notes_create as the closest match (a real
            // production failure caught 2026-04-25).
            "pica_report_issue",
            "pica_my_reported_issues",
            "pica_submit_feedback",
        ],
        depends_on: [],
    },
    onboarding: {
        description: "acknowledge MCP onboarding for OAuth-arrived users — call after walking the user through identity, security, and intent",
        tools: [
            // Listed so layered-discovery flows expose it; the auth-boundary
            // gate in /api/mcp also unconditionally allows this tool through
            // even when a customer key's scopes wouldn't ordinarily.
            "pica_acknowledge_onboarding",
        ],
        depends_on: [],
    },
    agent_hygiene: {
        // ADR-247 PR (a) introduce-self + PR (b) outcome log/recent.
        // Listed so layered-discovery surfaces the close-the-loop verbs;
        // the router gate exempts pica_introduce_self from the
        // introduction-required check (just like the onboarding tool above).
        description: "declare who you are, log workflow milestones, read what's been getting done, and check whether a deferred write has been approved — agent observability",
        tools: [
            "pica_introduce_self",
            "pica_outcome_log",
            "pica_outcomes_recent",
            "pica_approval_status",
        ],
        depends_on: [],
    },
    signup: {
        // ADR-211 Phase 1 — agent-discoverable signup verb. Listed so the
        // layered-discovery flow surfaces the unauthenticated entry point;
        // the public mint endpoint at /api/public/onboarding/signup-start
        // enforces rate limits + claim shape, and the lobby tool-registry
        // filter exposes only this tool (alongside pica_sign_in / pica_sign_out
        // per the 2026-04-30 amendment) until the user authenticates.
        description: "create a new withPICA account from inside the agent — for users without a connection key. existing users use pica_sign_in instead.",
        tools: ["pica_signup_start"],
        depends_on: [],
    },
    memory: {
        description: "save and recall workspace memories across sessions; review your recent assistant activity",
        tools: [
            "pica_memory_list",
            "pica_memory_search",
            "pica_memory_save",
            "pica_memory_delete",
            // ADR-289 Wave C2
            "pica_memory_update",
            // ADR-226 Phase 5 — auditability tool over the assistant_interactions
            // substrate. Atlas creator-entry for "what did I ask the AI?".
            "pica_my_recent_questions",
        ],
        depends_on: [],
    },
    skills: {
        // ADR-140 Phase 2b — downloadable methodologies via SEP-2640 bridge.
        // Meta-tool design: list + get, body-load lazy. Skills also reachable
        // as skill:// resources.
        description: "discover and load PICA's named methodologies (catalog-audit, register-my-works, prepare-for-sync, etc.) — triggered by phrases like 'audit my catalog'",
        tools: ["pica_skill_list", "pica_skill_get"],
        depends_on: [],
    },
    platform: {
        // Self-introspection: what is this MCP server, what shipped in it,
        // what changed recently. NOT for catalog activity — those queries
        // belong in `briefing` / `works` / `recordings`.
        description: "platform release notes for the PICA MCP server — what's new, what changed, version history. NOT for catalog activity (use briefing / works / recordings for that)",
        tools: ["pica_release_notes"],
        depends_on: [],
    },
    account: {
        // ADR-217 — added pica_storage_configure_start + pica_storage_disconnect
        // alongside the existing read tool so layered-discovery flows surface
        // the full BYOC storage trio in one bucket.
        // ADR-226 Decision 12 cross-listing — pica_delete_my_account is the
        // canonical "delete my account" tool; primary home is `privacy` but
        // Atlas natural_domain="account" puts it here for vernacular routing.
        description: "check storage and integrations; connect your own cloud storage (BYOC) or revert to PICA-managed",
        tools: [
            "pica_storage_status",
            "pica_storage_configure_start",
            "pica_storage_disconnect",
            "pica_delete_my_account",
        ],
        depends_on: [],
    },
    subscription: {
        // Reworded 2026-08-26 (WS-B). "pick a tier" described a capacity ladder
        // ADR-276 superseded, and neither "what does pica cost" nor "how do I pay"
        // appeared anywhere in this bucket — so the two questions a paying user
        // actually asks routed to no category at all.
        description: "what does pica cost, how do I pay, and what is pica holding — read " +
            "your billing state and get a pay link",
        tools: [
            "pica_subscription_status",
            "pica_billing_pay",
            "pica_subscription_manage",
        ],
        depends_on: [],
    },
    profile: {
        description: "manage your user, organisation, and directory profile",
        tools: [
            "pica_user_profile",
            "pica_organisation_profile",
            "pica_directory_settings",
            "pica_update_my_identity",
            "pica_update_organisation_profile",
            // Opt-in public handle / bio / avatar — cross-org discovery surface
            // (your handle is how other users find and invite you without learning your email)
            "pica_users_get_my_profile",
            "pica_users_set_handle",
            "pica_users_clear_handle",
            "pica_users_set_bio",
            "pica_users_set_avatar_url",
            "pica_users_import_bio_from_person",
            "pica_users_find_by_handle",
        ],
        depends_on: [],
    },
    privacy: {
        description: "self-service privacy & account controls — read/update marketing " +
            "opt-ins, request GDPR data export, delete your account",
        tools: [
            "pica_get_privacy_settings",
            "pica_update_privacy_settings",
            "pica_export_my_data",
            "pica_delete_my_account",
            // ADR-226 Decision 12 cross-listing — Atlas routes "AI training consent"
            // questions to natural_domain="privacy"; primary home is `export`.
            "pica_export_ai_consent",
        ],
        depends_on: [],
    },
    uploads: {
        description: "upload files to the platform and share them out via secure delivery links",
        tools: [
            // Interactive iframe card (ADR-152) — for harness clients that can
            // render UI. External clients (ChatGPT, Gemini) get told to upload
            // at withpica.com/inspect via the tool's description.
            "pica_upload",
            // Programmatic two-step upload — for when file details are known.
            "pica_upload_file",
            "pica_upload_complete",
            // Outbound delivery — first-class records with recipient
            // accountability and revocation. Wraps /admin/files/deliver.
            "pica_file_deliver",
            "pica_file_revoke_delivery",
        ],
        depends_on: [],
    },
    agreements: {
        description: "manage agreements, agreement templates, and agreement types",
        tools: [
            "pica_agreements_query",
            "pica_agreements_inspect",
            "pica_agreements_create",
            // ADR-289 follow-up — the entry point of the e-signature flow (the
            // only route that can set required_signers on create).
            "pica_agreements_create_from_template",
            "pica_agreements_update",
            "pica_agreements_delete",
            "pica_agreements_link_work",
            "pica_agreements_send_for_signature",
            "pica_agreement_source_splits",
            "pica_agreement_templates_duplicate",
            "pica_agreement_templates_render",
            "pica_agreement_templates_set_default",
            "pica_agreement_types_query",
            "pica_agreement_types_inspect",
            "pica_agreement_types_create",
            "pica_agreement_types_update",
            "pica_agreement_types_delete",
        ],
        depends_on: [],
    },
    collaborators: {
        description: "invite, accept, dispute, and claim-by-code catalog collaborators",
        tools: [
            "pica_collaborators_invite",
            "pica_collaborators_invite_bulk",
            "pica_collaborators_invite_resend",
            "pica_collaborators_invites_list",
            "pica_collaborators_incoming_list",
            "pica_collaborators_accept",
            "pica_collaborators_dispute",
            "pica_disputes_list",
            // ADR-236 — agent-mediated work sharing
            "pica_collaborators_claim_by_code",
            "pica_collaborators_decline_by_code",
            "pica_collaborators_invite_status",
            "pica_collaborators_invite_revoke",
            "pica_collaborations_received",
            // ADR-238 — agent-mediated counter-proposals
            "pica_collaborators_counter_propose",
            "pica_collaborators_accept_counter",
            "pica_collaborators_decline_counter",
            "pica_collaborators_counter_back",
        ],
        depends_on: [],
    },
    consent: {
        description: "read what the catalogue is cleared for under AI/usage consent, and who is blocking",
        tools: ["pica_consent_readiness", "pica_export_ai_consent"],
        depends_on: [],
    },
    custody: {
        description: "manage catalog custody claims and reconciliation",
        tools: [
            "pica_custody_list",
            "pica_custody_history",
            "pica_custody_claim",
            "pica_custody_respond",
            "pica_custody_reconcile",
            "pica_custody_resolve_pending",
            // ADR-262 — master-side recording custody (the control chain).
            "pica_recording_custody_assign",
            "pica_recording_provenance",
            // ADR-259 — marketplace sample provenance per recording.
            "pica_recording_samples_list",
            "pica_recording_samples_add",
            "pica_recording_samples_set_status",
            "pica_recording_samples_remove",
            // ADR-226 Decision 12 cross-listing — Atlas routes "claim custody"
            // questions to natural_domain="custody"; primary home is `search`.
            "pica_claim_custody",
        ],
        depends_on: [],
    },
    physical_assets: {
        description: "manage physical assets (master tapes, hardware, artefacts)",
        tools: [
            "pica_physical_assets_query",
            "pica_physical_assets_inspect",
            "pica_physical_assets_create",
            "pica_physical_assets_update",
            "pica_physical_assets_delete",
            "pica_physical_assets_export",
            "pica_physical_assets_stats",
            "pica_physical_assets_link_work",
            "pica_physical_assets_link_recording",
            "pica_physical_assets_unlink_work",
            "pica_physical_assets_unlink_recording",
        ],
        depends_on: [],
    },
    recording_splits: {
        description: "manage recording (master) splits between contributors",
        tools: [
            "pica_recording_splits_list",
            "pica_recording_splits_create",
            "pica_recording_splits_update",
            "pica_recording_splits_end",
            "pica_recording_splits_verify",
            // ADR-226 Decision 12 cross-listing — Atlas routes "split sheet" /
            // "how do I split this with my co-writer?" questions to
            // natural_domain="recording_splits"; primary home is `split_sheets`.
            "pica_split_sheet_generate",
            // ADR-226 Decision 12 cross-listing — Atlas routes "set the recording
            // splits" to natural_domain="recording_splits"; primary home is `credits`.
            "pica_recording_credits_update",
        ],
        depends_on: [],
    },
    royalties_statements: {
        description: "view royalties and ingested statements",
        tools: [
            "pica_royalties_list",
            "pica_royalties_get",
            // pica_statements_list/_get were Tier 2 consolidation aliases of
            // pica_statements_query/_inspect — _get removed in 2.93.0, _list in
            // 2.99.0.
            "pica_statements_query",
            "pica_statements_inspect",
            // ADR-226 Decision 12 cross-listing — Atlas routes "import a royalty
            // statement" to natural_domain="royalties_statements".
            "pica_ingest_statement",
            // ADR-277 — agent-driven statement ingestion: discovery + human-approval write.
            "pica_statements_pending",
            "pica_statement_ingest_document",
            // ADR-321 Phase 0 — "why isn't this song being paid?"
            "pica_royalties_blockers",
            // ADR-286 Task 8b (UNVERIFIED -- authored in worktree; verify in main checkout)
            "pica_royalties_unmatched",
            "pica_royalties_attribute",
        ],
        depends_on: [],
    },
    // ADR-321 §9 — programmable rights: agents propose, humans confirm by email.
    rights: {
        description: "programmable rights — propose a song, master or share rule for the parties to confirm, check who has confirmed, see what money would be held and why, and what income would pay each party (computed, never paid)",
        tools: [
            "pica_rights_inspect",
            "pica_rights_status",
            "pica_rights_propose",
            "pica_rights_statements",
            "pica_rights_record_income",
        ],
        depends_on: [],
    },
    selections: {
        description: "save, recall, and resolve named catalog selections — persisted predicates over the catalogue with an honest coverage report",
        tools: [
            "pica_selections_create",
            "pica_selections_query",
            "pica_selections_inspect",
            "pica_selections_update",
            "pica_selections_delete",
        ],
        depends_on: [],
    },
    share_links: {
        description: "create, inspect, update, and delete share links for catalog content",
        tools: [
            // pica_share_links_list was REMOVED in the mcp-server 2.93.0 WS5
            // removal sweep (0 calls/30d) — subsumed by pica_share_links_query
            // (ADR-149).
            "pica_share_links_create",
            "pica_share_links_query",
            "pica_share_links_inspect",
            "pica_share_links_update",
            "pica_share_links_delete",
        ],
        depends_on: [],
    },
    access_simulation: {
        description: "simulate who would see what before mutating — share-link previews and access decisions",
        tools: ["pica_access_simulate"],
        depends_on: [],
    },
    split_sheets: {
        description: "generate, send, and look up split sheets",
        tools: [
            "pica_split_sheet_generate",
            "pica_split_sheet_send",
            "pica_split_sheet_get",
            "pica_split_sheet_list",
        ],
        depends_on: [],
    },
    licensing: {
        description: "submit, list, and respond to license enquiries; search for sync",
        tools: [
            "pica_submit_license_enquiry",
            "pica_get_license_enquiry",
            "pica_list_license_enquiries",
            "pica_update_license_enquiry_status",
            "pica_search_for_sync",
        ],
        depends_on: [],
    },
    bookings: {
        description: "search, list, and respond to internal-team booking enquiries (session/production requests)",
        tools: ["pica_bookings_query", "pica_bookings_update"],
        depends_on: [],
    },
    sync_placements: {
        description: "manage sync placements (films/TV/ads/games licensing your works) — placement records, evidence, verification state",
        tools: [
            "pica_sync_placements_query",
            "pica_sync_placements_inspect",
            "pica_sync_placements_create",
            "pica_sync_placements_update",
            "pica_sync_placements_delete",
            "pica_sync_placements_cite",
        ],
        depends_on: [],
    },
    notifications: {
        description: "list and schedule notifications to users and people",
        tools: [
            "pica_notifications_list",
            "pica_notifications_mark_read",
            "pica_notify_user",
            "pica_notify_user_at",
            "pica_notify_user_cancel",
            "pica_notify_user_list_pending",
            "pica_notify_person",
            // ADR-218 — cross-transport awareness via discovery_events ledger
            "pica_notifications_recent",
            "pica_notification_acknowledge",
        ],
        depends_on: [],
    },
    messaging: {
        description: "send and resend messages to collaborators and contacts",
        tools: [
            "pica_send_message",
            "pica_send_resend",
            "pica_send_query",
            // ADR-289 Wave C2
            "pica_send_cancel",
        ],
        depends_on: [],
    },
    agent_identity: {
        description: "manage AI agent identities, grants, and activity",
        tools: [
            "pica_create_agent_identity",
            "pica_issue_agent_grant",
            "pica_revoke_agent_grant",
            "pica_list_my_agent_grants",
            "pica_get_agent_activity",
        ],
        depends_on: [],
    },
    telegram: {
        description: "connect and inspect Telegram channel for the workspace",
        tools: [
            "pica_telegram_connect",
            "pica_telegram_status",
            // ADR-289 Wave C2
            "pica_telegram_config",
        ],
        depends_on: [],
    },
    labels: {
        description: "look up and create record labels / distributors, and invite them to claim their organisation",
        tools: [
            "pica_labels_query",
            "pica_labels_create",
            "pica_party_claim_invite",
            "pica_party_claim_status",
        ],
        depends_on: [],
    },
    shows: {
        description: "log live shows and tour credits, and confirm setlist matches against your catalogue",
        tools: ["pica_log_show", "pica_shows_query", "pica_setlist_match"],
        depends_on: [],
    },
};
/**
 * Operational metadata defaults for pica_tool_details responses.
 * These are reasonable defaults — _query tools return pages, _inspect/_get return one.
 */
function getOperationalMeta(toolName) {
    if (toolName.endsWith("_query") || toolName.endsWith("_list")) {
        return { max_results_per_page: 100, paginated: true };
    }
    if (toolName.endsWith("_inspect") ||
        toolName.endsWith("_get") ||
        toolName.endsWith("_get_full")) {
        return { max_results_per_page: 1, paginated: false };
    }
    return {};
}
/**
 * Naive English singular of a search token.
 *
 * A wrong stem is NOT free. An `-es` strip turns `notes` into `not` — and
 * `pica_notes_*` are in CATEGORIES with `notes` as their domain segment, so
 * every query containing the ordinary English word "not" ("works not
 * registered") scored a +6 domain hit on the notes family. The same rule
 * stems `releases`→`releas`, which the query word `release` can never match,
 * defeating the feature in the other direction. Both shipped on 2026-08-24
 * and are pinned by the singularise unit tests.
 *
 * The rules, in order:
 *   - `ies` → `y`   (categories → category)
 *   - `s`   → ""    unless the token ends `ss`/`us`/`is`, none of which is an
 *                   English plural (address, status, analysis stay put)
 *
 * There is deliberately NO `-es` rule. Every domain segment in CATEGORIES is a
 * plain `-s` plural (works, notes, releases, documents, statements, credits,
 * shows…), so `-es` would only ever misfire: it cannot tell `releas+es` from
 * `release+s`, and the wrong branch (`releas`) is one a query word can never
 * reach. `boxes` therefore stems to `boxe` rather than `box` — harmless,
 * because both sides of every comparison run through this same function.
 *
 * Exported for direct unit assertion in
 * `src/__tests__/tools/discovery-ranking.test.ts` — the stem is load-bearing
 * enough that it needs testing without a whole registry behind it.
 */
export function singularise(token) {
    if (token.length > 3 && token.endsWith("ies"))
        return `${token.slice(0, -3)}y`;
    if (token.length > 2 && token.endsWith("s") && !/(?:ss|us|is)$/.test(token))
        return token.slice(0, -1);
    return token;
}
/** The token plus its stem, deduped — matching on either is a hit. */
function tokenVariants(token) {
    const stem = singularise(token);
    return stem === token ? [token] : [token, stem];
}
/**
 * Levenshtein distance, two rolling rows. Tool names are short (< 60 chars)
 * and the registry is a few hundred entries, so the O(n·m) form is well
 * inside budget for a once-per-failed-dispatch call.
 */
function editDistance(a, b) {
    if (a === b)
        return 0;
    if (a.length === 0)
        return b.length;
    if (b.length === 0)
        return a.length;
    let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
    let curr = new Array(b.length + 1);
    for (let i = 1; i <= a.length; i++) {
        curr[0] = i;
        for (let j = 1; j <= b.length; j++) {
            const substitution = prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
            curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, substitution);
        }
        [prev, curr] = [curr, prev];
    }
    return prev[b.length];
}
/** Length of the common leading substring — a typo late in a name keeps it. */
function sharedPrefixLength(a, b) {
    const max = Math.min(a.length, b.length);
    let i = 0;
    while (i < max && a[i] === b[i])
        i++;
    return i;
}
export class DiscoveryTools {
    executeToolFn;
    getToolDef;
    listToolNames;
    isToolVisible;
    constructor(executeToolFn, getToolDef, 
    /**
     * Registered tool names, for TOOL_NOT_FOUND near-match suggestions.
     * Optional so existing constructions keep compiling; without it the
     * unknown-tool envelope simply carries no suggestions.
     */
    listToolNames, 
    /**
     * Whether the CURRENT caller can run a tool on this connection. On HTTP
     * the route installs its `allowedToolNames` judgement (ADR-248:
     * audiences ∋ http AND scope held) via `ToolRegistry.setToolVisibility`;
     * the registry's default is audience-only. Absent = every registered
     * tool is treated as callable (legacy constructions, unit harnesses).
     * Consulted by the shortlist (rank visible first, flag the rest) and by
     * tool_details (flag) — never by pica_execute, which the dispatcher
     * already gates.
     */
    isToolVisible) {
        this.executeToolFn = executeToolFn;
        this.getToolDef = getToolDef;
        this.listToolNames = listToolNames;
        this.isToolVisible = isToolVisible;
    }
    isVisible(toolName) {
        return this.isToolVisible ? this.isToolVisible(toolName) : true;
    }
    /** Shortlist / detail keys added only when the tool is NOT callable here. */
    surfaceFlags(toolName) {
        return this.isVisible(toolName)
            ? {}
            : {
                callable_on_this_surface: false,
                surface_note: NOT_CALLABLE_ON_SURFACE_NOTE,
            };
    }
    /**
     * Rank registered tool names against a hallucinated or mistyped one.
     *
     * ChatGPT callers routinely invent tool names
     * (`pica_releases_attach_track_by_identifier`, `pica_credit_add`, …) and
     * retry variations instead of consulting pica_tool_details — 26 opaque
     * dispatcher errors in 14 days. Naming the closest real tools breaks that
     * guess-loop.
     *
     * Token overlap alone was not enough: `pica_works_inspekt` overlaps every
     * `pica_works_*` tool on exactly one token, so the top-3 slice fell back to
     * registration order and could drop the obvious answer. Edit distance
     * breaks that tie — pinned by "ranks the nearest EDIT-distance name first"
     * in `src/__tests__/tools/discovery.test.ts`.
     *
     * Deprecated aliases are excluded. The candidate pool is
     * `[...this.tools.keys()]` (index.ts, where DiscoveryTools is constructed),
     * which includes the hidden alias clones `expandAliases` registers — so a
     * typo one letter off an alias (`pica_releases_lists`) was answered with the
     * alias (`pica_releases_list`, since removed in 2.99.0) ranked above its own
     * canonical. Steering a confused caller onto a name we are trying to retire
     * is the opposite of what this envelope is for. TOOL_ALIASES is empty as of
     * 2.99.0, so the filter currently removes nothing; it stays for the next
     * deprecation.
     *
     * Tools the caller cannot run here are excluded too ({@link canSuggest}).
     * The pool is every REGISTERED name, and on HTTP the route narrows what a
     * caller may run through `setToolVisibility` (audience + scope). Without
     * this filter a caller lacking finance scope was offered hidden
     * statements/royalties tools for a statements typo — the same names the
     * hosted route's suggester (`app/api/mcp/tool-not-found.ts`) never offers,
     * because its pool is the caller's callable set.
     */
    suggestToolNames(input) {
        const candidates = (this.listToolNames?.() ?? []).filter((name) => getAliasFor(name) === undefined && this.canSuggest(name));
        const tokens = new Set(input
            .replace(/^pica_/, "")
            .split("_")
            .filter(Boolean));
        if (tokens.size === 0 || candidates.length === 0)
            return [];
        return (candidates
            .map((name) => ({
            name,
            overlap: name
                .replace(/^pica_/, "")
                .split("_")
                .filter((t) => tokens.has(t)).length,
            distance: editDistance(name, input),
            prefix: sharedPrefixLength(name, input),
        }))
            // A near-miss on spelling shares no whole token, so distance and prefix
            // have to be able to admit a candidate on their own.
            .filter((c) => c.overlap > 0 || c.distance <= 3 || c.prefix >= 8)
            .sort((a, b) => b.overlap - a.overlap ||
            a.distance - b.distance ||
            b.prefix - a.prefix ||
            a.name.localeCompare(b.name))
            .slice(0, 3)
            .map((c) => c.name));
    }
    /** Registered AND callable by this caller on this surface. */
    canSuggest(name) {
        return this.getToolDef(name) !== undefined && this.isVisible(name);
    }
    /**
     * The suggestions and leading note for a name that is not registered. A
     * retired name (RETIRED_TOOLS in deprecations.ts) gets its replacement
     * first (when callable) and a sentence saying what replaced it; any other
     * name gets the similarity ranking unchanged.
     *
     * What is shared with the hosted route (`app/api/mcp/tool-not-found.ts`)
     * is `retiredToolAnswer`, so both name the same replacement — pinned on
     * this side by `src/__tests__/tools/tool-details-contract.test.ts` and on
     * that side by `app/api/mcp/__tests__/tool-not-found.test.ts`. The
     * similarity lists are NOT shared: each side ranks with its own suggester
     * over its own pool, so for non-retired names the two may differ.
     */
    notFoundAnswer(input) {
        return retiredToolAnswer(input, this.suggestToolNames(input), (name) => this.canSuggest(name));
    }
    getTools() {
        return [
            this.buildDiscoverTool(),
            this.buildToolDetailsTool(),
            this.buildExecuteTool(),
        ];
    }
    buildDiscoverTool() {
        return {
            definition: {
                name: "pica_discover",
                scope: "always",
                audiences: ["stdio", "http"],
                tier: "read",
                description: "PICA helps people look after their songs, and everything that comes with them, on their own terms; asked what PICA is, say that plainly. " +
                    "Discover available tools. TWO modes: " +
                    'call with { query: "<the job in plain words>" } (e.g. "extract isrc from a spotify link", "delete a recording", "how do i pay pica") to get a ranked SHORTLIST of the tools matching that job, each with its category and a one-line description — cheaper than the full map and the fastest way to answer "is there a tool for X?". ' +
                    "Call with NO arguments to browse the full versioned category map (all categories with tool counts and names). " +
                    "Either way, use pica_tool_details next to get a tool's full schema before calling it.",
                workflows: "infrastructure",
                vernacular_kind: "specialist",
                vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                inputSchema: {
                    type: "object",
                    properties: {
                        query: {
                            type: "string",
                            description: "Optional. Describe the job in plain words to get a ranked shortlist of matching tools (matched against tool names, descriptions, triggers, and category names). Omit to return the full category map.",
                        },
                    },
                },
                annotations: {
                    title: "discover tools",
                    readOnlyHint: true,
                    destructiveHint: false,
                    idempotentHint: true,
                    openWorldHint: false,
                },
            },
            executor: async (args) => {
                const cacheUntil = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
                // Query-mode rows carry per-connection flags since 2026-08-26 (an
                // org's finance toggle changes them), so a shortlist is cacheable for
                // an hour, not the day the org-blind full map gets.
                const queryCacheUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString();
                const rawQuery = typeof args?.query === "string" ? args.query : "";
                const query = rawQuery.trim();
                // Filtered mode — a job-search shortlist. Additive: when no query is
                // supplied the response is byte-identical to the original full-map
                // behaviour (backwards compatible for every existing client).
                if (query.length > 0) {
                    const matches = this.matchDiscovery(query);
                    const matchedCategories = [
                        ...new Set(matches.map((m) => m.category)),
                    ];
                    const payload = {
                        schema_version: SCHEMA_VERSION,
                        cache_until: queryCacheUntil,
                        query,
                        match_count: matches.length,
                        matches: matches.map(({ tool, category, description }) => ({
                            tool,
                            category,
                            description,
                            ...this.surfaceFlags(tool),
                        })),
                        matched_categories: matchedCategories,
                        hint: (matches.length > 0
                            ? `${matches.length} tool(s) match "${query}". Call pica_tool_details({ tool: "<name>" }) for a full schema. Call pica_discover with no query to browse all ${Object.keys(CATEGORIES).length} categories.`
                            : `no tools matched "${query}". Try different words, or call pica_discover with no query to browse all ${Object.keys(CATEGORIES).length} categories before concluding a capability is missing.`) +
                            ` ${FRESH_ORG_HINT}`,
                    };
                    return {
                        content: [
                            {
                                type: "text",
                                text: JSON.stringify(payload, null, 2),
                            },
                        ],
                        structuredContent: payload,
                    };
                }
                // Full-map mode. Category shape is unchanged; the payload now also
                // carries a static `hint` (ADR-294 L3) — see below.
                const categories = {};
                for (const [key, cat] of Object.entries(CATEGORIES)) {
                    categories[key] = {
                        description: cat.description,
                        tool_count: cat.tools.length,
                        tools: cat.tools,
                        depends_on: cat.depends_on,
                    };
                }
                const payload = {
                    schema_version: SCHEMA_VERSION,
                    cache_until: cacheUntil,
                    // Static, org-blind by design (the response is cacheable): the
                    // fresh-org on-ramp is a universal instruction, not org data.
                    hint: FRESH_ORG_HINT,
                    categories,
                };
                return {
                    content: [
                        {
                            type: "text",
                            text: JSON.stringify(payload, null, 2),
                        },
                    ],
                    structuredContent: payload,
                };
            },
        };
    }
    /**
     * Rank tools against a plain-language job description. Case-insensitive
     * token scoring (no external deps):
     *
     *   +6  the token matches the tool's DOMAIN segment (the `_`-separated
     *       segment after `pica_` — `recordings` in `pica_recordings_delete`)
     *   +3  the token matches any other `_`-separated segment of the name
     *   +1  the token appears anywhere in the searchable text (name +
     *       description/triggers + category name + category description)
     *   +2  (once) an ADJACENT pair of typed query tokens appears, stemmed,
     *       in that text — a phrase names one concept ("pica score"), and
     *       single-token crumbs must not out-rank the tool that says the
     *       phrase; capped below the domain weight so prose never dethrones
     *       the tool that IS the domain
     *
     * Both sides are compared through {@link singularise}, so "recording" and
     * "recordings" are one term. Tools are deduped across cross-listed
     * categories (keeping the highest-scoring category), sorted by score then
     * name, and capped.
     *
     * The domain weight exists because the VERB is shared by dozens of tools
     * and the ENTITY is what the user actually named: before it, "delete a
     * recording" led with `pica_agreement_types_delete` and "read a document"
     * put `pica_notifications_mark_read` above every documents tool. Both are
     * pinned in `src/__tests__/tools/discovery-ranking.test.ts`, alongside the
     * flagship isrc-from-spotify pin the weighting must not disturb.
     */
    matchDiscovery(query) {
        const loweredQuery = query.toLowerCase();
        const tokens = query
            .toLowerCase()
            .split(/[^a-z0-9]+/)
            .filter((t) => t.length >= 2 && !STOP_WORDS.has(t))
            .map((t) => ({ raw: t, variants: tokenVariants(t) }));
        if (tokens.length === 0)
            return [];
        // Best score per tool, plus the category that produced it.
        const best = new Map();
        for (const [key, cat] of Object.entries(CATEGORIES)) {
            const catText = `${key} ${cat.description}`.toLowerCase();
            for (const toolName of cat.tools) {
                const def = this.getToolDef(toolName)?.definition;
                const metadata = getToolMetadata(toolName);
                const toolDesc = def?.description ?? metadata?.display_name ?? "";
                const segments = toolName
                    .replace(/^pica_/, "")
                    .split("_")
                    .filter(Boolean);
                // The entity the tool acts on. `pica_upload` has no verb segment, so
                // its only segment is both domain and verb — that is correct here.
                const domainVariants = new Set(segments.length > 0 ? tokenVariants(segments[0]) : []);
                const nameVariants = new Set(segments.flatMap(tokenVariants));
                const haystack = `${toolName.replace(/_/g, " ")} ${toolDesc} ${catText}`.toLowerCase();
                let score = 0;
                for (const { variants } of tokens) {
                    if (variants.some((v) => domainVariants.has(v)))
                        score += 6;
                    else if (variants.some((v) => nameVariants.has(v)))
                        score += 3;
                    if (variants.some((v) => haystack.includes(v)))
                        score += 1;
                }
                // Phrase bonus: +2, at most ONCE per tool — an ADJACENT pair of
                // query tokens that the user ACTUALLY TYPED (never a pair bridged
                // across a removed stop-word: "needs my attention" must not match a
                // tool that says "needs attention" in a go-elsewhere disclaimer),
                // with both halves stemmed for the haystack probe so "pica scores"
                // still finds "PICA score". +2 capped once, not +3 per pair: kept
                // BELOW the +6 domain differential so prose that merely mentions a
                // phrase ("preserving the audit trail" in a write tool) can tie but
                // never dethrone the tool that IS the domain — ties fall to the
                // name sort as before. The +3-per-pair draft measured +60/−2 on
                // the registry's own declared trigger phrases (379 cases); the cap,
                // the typed-pair guard and the stems close the two regressions that
                // review found — the winners are pinned in discovery-ranking.test.ts
                // (review 2026-09-01).
                for (let i = 0; i < tokens.length - 1; i++) {
                    const rawPair = `${tokens[i].raw} ${tokens[i + 1].raw}`;
                    if (!loweredQuery.includes(rawPair))
                        continue;
                    const stemPair = `${singularise(tokens[i].raw)} ${singularise(tokens[i + 1].raw)}`;
                    if (haystack.includes(stemPair)) {
                        score += 2;
                        break;
                    }
                }
                if (score === 0)
                    continue;
                const existing = best.get(toolName);
                if (!existing || score > existing.score) {
                    best.set(toolName, {
                        tool: toolName,
                        category: key,
                        description: this.oneLineDescription(toolName),
                        score,
                        visible: this.isVisible(toolName),
                    });
                }
            }
        }
        // Callable-here first, then score, then name. A tool the caller cannot
        // run keeps a row (it exists — the agent should not conclude the
        // capability is missing) but never leads. The cap holds at least
        // FLAGGED_MATCH_ROWS tail rows for those, and lets them fill any room
        // the visible rows leave, so the total never shrinks because of a scope.
        const ranked = [...best.values()].sort((a, b) => b.score - a.score || a.tool.localeCompare(b.tool));
        const visible = ranked.filter((m) => m.visible);
        const flagged = ranked.filter((m) => !m.visible);
        const flaggedShown = Math.min(flagged.length, Math.max(FLAGGED_MATCH_ROWS, DISCOVER_MATCH_LIMIT - visible.length));
        return [
            ...visible.slice(0, DISCOVER_MATCH_LIMIT - flaggedShown),
            ...flagged.slice(0, flaggedShown),
        ];
    }
    /**
     * A single-line description for a match row: the first sentence of the
     * tool's description, capped to keep the shortlist scannable.
     */
    oneLineDescription(toolName) {
        const def = this.getToolDef(toolName)?.definition;
        const metadata = getToolMetadata(toolName);
        const raw = (def?.description ?? metadata?.display_name ?? toolName).trim();
        const firstSentence = raw.split(/(?<=\.)\s/)[0] ?? raw;
        const candidate = firstSentence.length > 0 && firstSentence.length <= 160
            ? firstSentence
            : raw;
        return candidate.length <= 160
            ? candidate.trim()
            : `${candidate.slice(0, 157).trim()}...`;
    }
    buildToolDetailsTool() {
        return {
            definition: {
                name: "pica_tool_details",
                scope: "always",
                audiences: ["stdio", "http"],
                tier: "read",
                description: "Get full schema and metadata for one tool or all tools in a category. " +
                    "Pass either { tool: 'name' } or { category: 'name' } (from pica_discover). " +
                    "Returns schema, annotations, recovery hints, and operational metadata.",
                workflows: "infrastructure",
                vernacular_kind: "specialist",
                vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                inputSchema: {
                    type: "object",
                    properties: {
                        tool: {
                            type: "string",
                            description: "Specific tool name (e.g. pica_works_inspect)",
                        },
                        category: {
                            type: "string",
                            description: "Category name from pica_discover (e.g. works)",
                        },
                    },
                },
                annotations: {
                    title: "get tool details",
                    readOnlyHint: true,
                    destructiveHint: false,
                    idempotentHint: true,
                    openWorldHint: false,
                },
            },
            executor: async (args) => {
                if (args.tool) {
                    const detail = this.buildToolDetail(args.tool);
                    if (!detail) {
                        // ⚠️ Answer the near-miss the caller almost certainly meant.
                        // `pica_execute`'s own unknown-tool branch has offered
                        // `did_you_mean` since 2026-08-24, but THIS branch — the one an
                        // agent reaches when it looks a name up directly — only said "use
                        // pica_discover", and agents responded by guessing again. On prod
                        // that was `pica_credit_update` — one character from
                        // pica_credits_update, six occurrences across three orgs, ops
                        // issue 167bb804. (That name now EXISTS, added 2026-09-11 as the
                        // in-place edit verb, precisely because agents kept reaching for
                        // it. The suggestion path still matters for every other miss.)
                        //
                        // Same helper, same envelope keys as the execute branch, so an
                        // agent reads one shape wherever the question is asked.
                        const { suggestions, note } = this.notFoundAnswer(args.tool);
                        const envelope = {
                            error: "TOOL_NOT_FOUND",
                            error_code: "TOOL_NOT_FOUND",
                            tool: args.tool,
                            message: `no tool named '${args.tool}'.` +
                                (note ? ` ${note}` : "") +
                                (suggestions.length > 0
                                    ? ` Closest real tools: ${suggestions.join(", ")}.`
                                    : "") +
                                " Use pica_discover to see available categories — do not guess tool names.",
                            did_you_mean: suggestions,
                            suggestions,
                            next_tool: "pica_discover",
                            retry_safe: false,
                            retry_eligible: false,
                        };
                        return {
                            content: [
                                { type: "text", text: JSON.stringify(envelope, null, 2) },
                            ],
                            structuredContent: envelope,
                            isError: true,
                        };
                    }
                    return {
                        content: [{ type: "text", text: JSON.stringify(detail, null, 2) }],
                        structuredContent: detail,
                    };
                }
                if (args.category) {
                    const cat = CATEGORIES[args.category];
                    if (!cat) {
                        return {
                            content: [
                                {
                                    type: "text",
                                    text: JSON.stringify({
                                        error_code: "CATEGORY_NOT_FOUND",
                                        category: args.category,
                                        message: `no category '${args.category}' — use pica_discover to see available categories`,
                                        retry_eligible: false,
                                    }, null, 2),
                                },
                            ],
                            isError: true,
                        };
                    }
                    const details = cat.tools
                        .map((name) => this.buildToolDetail(name))
                        .filter((d) => d !== null);
                    const payload = {
                        category: args.category,
                        tool_count: details.length,
                        tools: details,
                    };
                    return {
                        content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
                        structuredContent: payload,
                    };
                }
                return {
                    content: [
                        {
                            type: "text",
                            text: JSON.stringify({
                                error_code: "VALIDATION_ERROR",
                                message: "provide either { tool: 'name' } or { category: 'name' }",
                                retry_eligible: false,
                            }, null, 2),
                        },
                    ],
                    isError: true,
                };
            },
        };
    }
    buildToolDetail(toolName) {
        const registered = this.getToolDef(toolName);
        const metadata = getToolMetadata(toolName);
        // Return null when the tool is completely unknown (not registered and not in metadata)
        if (!registered && !metadata) {
            return null;
        }
        // Collect all recovery hints for this tool (all error codes)
        const hints = RECOVERY_HINTS[toolName] ?? [];
        const definition = registered?.definition ?? null;
        // A deprecated alias is registered as a clone of its canonical, so the
        // schema/description below ARE the canonical's — the agent gets a working
        // tool plus the name it should migrate to, not a bare refusal.
        const deprecation = getAliasFor(toolName);
        // Gate visibility (audit P1 #6): surface the authority tier + required
        // scope so an agent knows BEFORE calling whether it holds the grant, and
        // an explicit note when the scope is unreachable for a standard agent
        // grant (the finance family) or the tool is destructive-tier (elevated
        // scope + confirmation token). Factual and short — no advice.
        const tier = definition?.tier ?? null;
        const requiredScope = definition?.scope ?? null;
        let callableNote;
        if (requiredScope &&
            SCOPES_NOT_GRANTABLE_TO_STANDARD_AGENT.has(requiredScope)) {
            callableNote =
                `requires the '${requiredScope}' scope, which is not part of a standard agent grant — ` +
                    "the user must elect admin elevation (settings → connections) before this tool is callable.";
        }
        else if (tier === "destructive") {
            callableNote =
                "destructive tier — requires the elevated 'destructive:*' scope " +
                    "(admin-elevated grants only) and a confirmation-token round-trip.";
        }
        return {
            tool: toolName,
            description: definition?.description ?? metadata?.display_name ?? toolName,
            // On the layered 3-tool surface the wrapped tool is NOT in the
            // caller's function list — models that fetch a schema here have
            // concluded "I can see the tool but can't execute it" and given up
            // (observed on ChatGPT, 2026-07-08). Spell out the execution path so
            // the next step is unavoidable.
            // When the caller cannot run the tool here, the line above would be
            // false in the worst direction — pica_execute refuses it (the route's
            // allowedToolNames gate) — so the invoke text says so instead.
            invoke: this.isVisible(toolName)
                ? `if ${toolName} is not in your callable function list, run it via the pica_execute tool: ` +
                    `{"tool": "${toolName}", "args": { ...arguments per the schema below }}. ` +
                    "Never tell the user the tool is unavailable — pica_execute executes every tool returned here."
                : `pica_execute will refuse ${toolName} on this connection (see surface_note). ` +
                    "Do not call it here; tell the user plainly what it needs and offer a tool without this flag.",
            schema: definition?.inputSchema ?? null,
            ...(definition?.outputSchema
                ? { outputSchema: definition.outputSchema }
                : {}),
            // Derived, never the raw declaration: most tools declare no
            // `annotations` at all, so reading the declaration answered `null` for
            // the large majority of registered tools while listTools() answered real
            // hints for the same tool (measured 2026-08-24). deriveAnnotations() is
            // the one derivation both surfaces read — see index.ts and
            // src/__tests__/tools/tool-details-contract.test.ts.
            annotations: definition ? deriveAnnotations(definition) : null,
            // Every category key this tool is listed under. Cross-listed tools
            // (ADR-226 Decision 12) appear in more than one, and an agent that
            // arrived via one category otherwise cannot see the others.
            discovery_categories: Object.entries(CATEGORIES)
                .filter(([, cat]) => cat.tools.includes(toolName))
                .map(([key]) => key),
            ...(deprecation
                ? {
                    deprecated: {
                        use: deprecation.canonical,
                        removal_target: deprecation.removal_target,
                    },
                }
                : {}),
            tier,
            required_scope: requiredScope,
            // A scope / destructive-tier note stays whatever the surface says:
            // its remediation (admin elevation, confirmation token) is the part an
            // agent can act on. The surface flag rides beside it under its own
            // key (`surface_note`), never in its place.
            ...(callableNote ? { callable_note: callableNote } : {}),
            ...this.surfaceFlags(toolName),
            metadata: metadata
                ? {
                    category: metadata.category,
                    risk: metadata.risk,
                    retry_safe: metadata.retry_safe,
                    display_name: metadata.display_name,
                }
                : null,
            recovery_hints: hints,
            operational: getOperationalMeta(toolName),
        };
    }
    buildExecuteTool() {
        return {
            definition: {
                name: "pica_execute",
                scope: "always",
                audiences: ["stdio", "http"],
                // tier stays "read" so a read-scoped key can dispatch read tools through
                // the wrapper; real scope + destructive confirmation are enforced on the
                // WRAPPED tool (executeTool + route.ts inner-gate shadow), not here.
                tier: "read",
                // ...but the dispatcher CAN route to write/destructive/external tools, so
                // its advertised hints must not claim read-only/closed-world. Override the
                // tier-derived hints (advisory only — no effect on enforcement).
                // destructiveHint is TRUE for the same reason: the wrapper can route to
                // delete/merge tools. OpenAI's app review (2026-10-02) flagged `false`
                // as understating a dispatcher that can cause hard-to-reverse change.
                annotationOverrides: {
                    readOnlyHint: false,
                    destructiveHint: true,
                    openWorldHint: true,
                },
                description: "Execute any catalog tool by name. " +
                    'The default call shape is NESTED: `{tool: "<name>", args: {<wrapped args>}}` — `args` is THE canonical field for the wrapped tool\'s arguments. ' +
                    'A FLAT shape is also accepted as a fallback: `{tool: "<name>", <wrapped args spread at top level>}` — any key other than `tool` / `args` / `arguments` is treated as a wrapped arg. ' +
                    "DO NOT pass the wrapped args under a property called `arguments` — some MCP clients (claude.ai web in particular) silently strip that key as a wire-protocol token, leaving the wrapped tool invoked with no arguments. " +
                    "If a required field of the wrapped tool is missing from the resolved args, pica_execute refuses before dispatch with WRAPPED_ARGS_MISSING, naming the missing fields and any unrecognised ones you sent. When args did arrive, that usually means a field named differently from the wrapped tool's schema, not lost in transit — read the wrapped tool's exact field names with pica_tool_details before its first call rather than guessing them. " +
                    "Use pica_discover to find categories, pica_tool_details to get the wrapped tool's schema.",
                workflows: "infrastructure",
                vernacular_kind: "specialist",
                vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                inputSchema: {
                    type: "object",
                    properties: {
                        tool: {
                            type: "string",
                            description: "Tool name to execute (e.g. pica_works_inspect). Use pica_discover + pica_tool_details to find available tools.",
                        },
                        args: {
                            type: "object",
                            additionalProperties: true,
                            description: "Wrapped tool arguments — canonical NESTED shape. The alternative FLAT shape passes the wrapped args as top-level properties alongside `tool` (any key other than `tool` / `args` / `arguments` is treated as a wrapped arg).",
                        },
                        arguments: {
                            type: "object",
                            additionalProperties: true,
                            description: "Discouraged alias for `args` — kept declared so the dispatcher can still pick it up if a client manages to pass it through. DO NOT rely on it: claude.ai web (and likely other MCP clients) silently strip any property named `arguments` as a wire-protocol token before the call reaches PICA. Prefer `args:` (nested) or the FLAT shape.",
                        },
                    },
                    required: ["tool"],
                    // additionalProperties: true so the FLAT call shape works — any
                    // key other than tool / args / arguments is treated as a wrapped
                    // arg by the dispatcher below. JSON Schema validators will not
                    // reject top-level keys they don't recognise.
                    additionalProperties: true,
                },
                annotations: {
                    title: "execute a catalog tool",
                    readOnlyHint: false,
                    // TRUE, matching `annotationOverrides.destructiveHint` above.
                    destructiveHint: true,
                    idempotentHint: false,
                    // TRUE, matching the `annotationOverrides.openWorldHint` above.
                    // This read `false` until 2026-08-24 while the override next to it
                    // said `true` — the override wins, so the declaration was never
                    // published and only misled the next reader of this file. The
                    // dispatcher wraps every tool, including the ones that email an
                    // external recipient, so `true` is the honest value.
                    openWorldHint: true,
                },
            },
            executor: async (args, ctx) => {
                const toolName = args.tool;
                // Three call shapes are accepted; preference order documented in the
                // schema description above.
                //
                //   NESTED:  {tool: "X", args: {...}}                ← canonical
                //   ALIAS:   {tool: "X", arguments: {...}}           ← discouraged; claude.ai strips
                //   FLAT:    {tool: "X", id: "Y", sections: [...]}   ← safety net
                //
                // History: a prod incident on 2026-05-23 had every pica_works_inspect /
                // pica_recordings_inspect from claude.ai web returning PG_INVALID_UUID.
                // Root cause: callers passed `arguments: {...}` which claude.ai strips
                // as a wire-protocol token before the call reaches PICA. The dispatcher
                // never saw the wrapped args. A schema alias alone cannot fix this
                // because the strip happens at the client. The FLAT shape lets agents
                // pass wrapped args at the top level where no field name conflicts
                // with the wire protocol — any key other than tool / args / arguments
                // is treated as a wrapped arg.
                const { tool: _t, args: _a, arguments: _ar, ...flatRest } = args;
                const nested = args.args;
                const aliased = args.arguments;
                const flat = Object.keys(flatRest).length > 0
                    ? flatRest
                    : undefined;
                const toolArgs = nested ?? aliased ?? flat ?? {};
                // Hoisted so `required` is available to applyArgAliases below.
                // Reused (not re-looked-up) by the TOOL_NOT_FOUND and
                // WRAPPED_ARGS_MISSING checks further down — one lookup, one source
                // of truth for "is this tool registered" for the rest of this call.
                const registered = toolName && typeof toolName === "string"
                    ? this.getToolDef(toolName)
                    : undefined;
                const required = registered?.definition?.inputSchema?.required ?? [];
                // D1 (2026-09-03): rename a tool's OWN <domain>_id (or an explicit
                // TOOL_ARG_ALIASES entry, e.g. pica_skill_get's `skill`) to the
                // schema's real required field name — never a foreign entity's id,
                // never overwriting a value already present. See arg-aliases.ts.
                // Agents sending a tool's own id under its natural name
                // (`audio_file_id` to pica_audio_update, `person_id` to
                // pica_people_inspect) previously hit WRAPPED_ARGS_MISSING with a
                // message blaming "client-side transport loss" — wrong diagnosis
                // every one of the 36 times it happened since July.
                const { args: aliasedArgs, applied: aliasesApplied } = applyArgAliases(toolName ?? "", toolArgs, required);
                if (!toolName || typeof toolName !== "string" || !toolName.trim()) {
                    return {
                        content: [
                            {
                                type: "text",
                                text: JSON.stringify({
                                    error_code: "VALIDATION_ERROR",
                                    tool: null,
                                    message: "missing required field: tool",
                                    retry_eligible: false,
                                }, null, 2),
                            },
                        ],
                        isError: true,
                    };
                }
                // Block SELF-invocation to prevent infinite loops.
                //
                // ⚠️ This set used to hold all three meta-tools, and refusing the two
                // read-only ones cost real calls: a connector that surfaces only
                // `pica_execute` plus the meta-tools teaches its agent that every call
                // goes through the wrapper, so it wraps discovery too. ChatGPT did
                // exactly that on prod — `pica_execute {tool: "pica_discover", args:
                // {query: "inspect works credits registrations splits releases audit
                // gaps"}}` — and got INVALID_TOOL for a call that was, in substance,
                // the very thing this dispatcher asks agents to do instead of guessing
                // tool names (ops issue 2178c9a8, 2026-09-09).
                //
                // Only `pica_execute` can recurse. `pica_discover` and
                // `pica_tool_details` are terminal reads that dispatch nothing, so
                // forwarding them is loop-free — they fall through to the normal
                // registered-tool path below, which resolves them from the same
                // registry (`getTools()` registers all three together).
                if (toolName === "pica_execute") {
                    return {
                        content: [
                            {
                                type: "text",
                                text: JSON.stringify({
                                    error_code: "INVALID_TOOL",
                                    tool: toolName,
                                    message: "pica_execute cannot invoke itself. Pass the tool you actually want as `tool` — pica_discover and pica_tool_details may be wrapped if that is the shape your client uses.",
                                    retry_eligible: false,
                                }, null, 2),
                            },
                        ],
                        isError: true,
                    };
                }
                // Unknown tool → refuse BEFORE dispatch with the closest real names.
                // Previously this fell through to executeToolFn, whose thrown
                // "Tool not found" surfaced as a bare EXECUTION_ERROR with no
                // guidance — agents (ChatGPT especially) responded by retrying
                // hallucinated variations instead of consulting the catalog.
                // structuredContent mirrors the envelope so telemetry and
                // structured-content-aware clients get the code without parsing text.
                //
                // The code is TOOL_NOT_FOUND, matching pica_tool_details' answer for
                // the same question and the ADR-149 Phase 5bcd design. It read
                // UNKNOWN_TOOL from 2026-07-03 until 2026-08-24; `suggestions` is kept
                // beside the new `did_you_mean` for callers written against that
                // shape. Both are asserted in
                // `src/__tests__/tools/discovery.test.ts`.
                //
                // `did_you_mean` is ALWAYS present, empty array included: a key that
                // only sometimes exists forces every caller to branch on `undefined`
                // before it can branch on "no suggestions".
                //
                // `retry_safe` and `retry_eligible` carry the same fact under two
                // names, deliberately. `retry_eligible` is the ADR-180 envelope field
                // that the five sibling envelopes in this file all emit, so dropping
                // it here alone would make one dispatcher error shaped unlike the
                // rest; `retry_safe` is the name the same fact wears in
                // `pica_tool_details.metadata.retry_safe`, which is where an agent
                // reads it everywhere else. Unifying the file on one key is a
                // separate change — do it for all six envelopes or none.
                if (!registered) {
                    const { suggestions, note } = this.notFoundAnswer(toolName);
                    const envelope = {
                        error: "TOOL_NOT_FOUND",
                        error_code: "TOOL_NOT_FOUND",
                        tool: toolName,
                        message: `${toolName} is not a registered tool.` +
                            (note ? ` ${note}` : "") +
                            (suggestions.length > 0
                                ? ` Closest real tools: ${suggestions.join(", ")}.`
                                : "") +
                            " Call pica_tool_details on the intended tool for its exact name and schema, or pica_discover to browse categories — do not guess tool names or argument shapes.",
                        did_you_mean: suggestions,
                        suggestions,
                        next_tool: "pica_discover",
                        retry_safe: false,
                        retry_eligible: false,
                    };
                    return {
                        content: [
                            { type: "text", text: JSON.stringify(envelope, null, 2) },
                        ],
                        structuredContent: {
                            error: envelope.error,
                            error_code: envelope.error_code,
                            error_message: envelope.message,
                            tool: toolName,
                            did_you_mean: suggestions,
                            suggestions,
                            next_tool: "pica_discover",
                            retry_safe: false,
                        },
                        isError: true,
                    };
                }
                // Fail loud when the dispatcher resolved to args that are MISSING
                // one or more required fields declared by the wrapped tool's schema.
                //
                // This covers two cases:
                //   1. Empty args (original 2026-05-23 incident) — `aliasedArgs` is
                //      `{}` and the wrapped tool needs fields. Surfaces as
                //      PG_INVALID_UUID. This case really is a transport question —
                //      there is nothing else an empty resolved-args object could be
                //      — so its message keeps asserting that.
                //   2. A NON-empty args object still missing a required field. This
                //      used to be asserted as "client-side partial strip" too, but
                //      D1 (2026-09-03) found that diagnosis wrong in all 36 cases
                //      logged since July: the caller sent the tool's own entity id
                //      under a plausible name (`audio_file_id` to
                //      pica_audio_update) that per-tool aliasing above resolves
                //      when unambiguous, or a misspelled/foreign key that aliasing
                //      correctly leaves alone. Nothing here can tell "transport
                //      loss" apart from "wrong key sent", so the message now names
                //      what pica_execute actually saw — the unrecognised fields and
                //      any alias it did apply — instead of asserting a cause it
                //      cannot know.
                //
                // (Unknown tools were rejected above, so `registered` resolves here;
                // `required` / `aliasedArgs` / `aliasesApplied` were computed once,
                // right after `toolArgs`, above. The message/structuredContent
                // themselves are built by `buildWrappedArgsMissingEnvelope` —
                // shared with the `requiredOneOf` guard below, D1 fix round 1.)
                const knownProps = Object.keys(registered?.definition?.inputSchema?.properties ?? {});
                if (required.length > 0) {
                    const missingRequired = required.filter((f) => !(f in aliasedArgs));
                    if (missingRequired.length > 0) {
                        const { text, structuredContent } = buildWrappedArgsMissingEnvelope({
                            toolName,
                            required,
                            missingRequired,
                            args: aliasedArgs,
                            knownProps,
                            aliasesApplied,
                        });
                        return {
                            content: [{ type: "text", text }],
                            // Mirrored into structuredContent — same shape as the
                            // TOOL_NOT_FOUND envelope above — so telemetry and
                            // structured-content-aware clients get the code plus the
                            // unrecognised/aliased-field detail without parsing text.
                            structuredContent,
                            isError: true,
                        };
                    }
                }
                // Same WRAPPED_ARGS_MISSING hard-reject for one-of-N alias groups that
                // JSON-Schema `required` can't express (feedback 6d516171). An inspect
                // tool accepting either `id` or `work_id` must declare `required:[]`,
                // which made the guard above blind to it — empty/stripped args slipped
                // through to a confusing downstream "invalid id" domain error. Honour
                // `requiredOneOf`: if any alias group has NONE of its members present,
                // reject before dispatch (extends the never-partial-apply guarantee to
                // alias-required tools, including any future mutating one).
                //
                // This is the shape most `*_inspect` tools declare (pica_works_inspect,
                // pica_recordings_inspect, pica_releases_inspect, pica_documents_inspect,
                // pica_projects_inspect, pica_notes_inspect, pica_sessions_inspect,
                // pica_search, and the bookings / licensing / shows / royalties
                // equivalents), so it shares `buildWrappedArgsMissingEnvelope` with the
                // `required`-list guard above (D1 fix round 1, 2026-09-03) — one place
                // decides the message/structuredContent for every WRAPPED_ARGS_MISSING,
                // not two copies that drift.
                {
                    const requiredOneOf = registered?.definition?.requiredOneOf;
                    if (Array.isArray(requiredOneOf) && requiredOneOf.length > 0) {
                        const unmetGroup = requiredOneOf.find((group) => Array.isArray(group) &&
                            group.length > 0 &&
                            !group.some((f) => f in aliasedArgs));
                        if (unmetGroup) {
                            const { text, structuredContent } = buildWrappedArgsMissingEnvelope({
                                toolName,
                                required,
                                missingRequired: [],
                                requiredOneOf,
                                args: aliasedArgs,
                                knownProps,
                                aliasesApplied,
                            });
                            return {
                                content: [{ type: "text", text }],
                                structuredContent,
                                isError: true,
                            };
                        }
                    }
                }
                try {
                    return await this.executeToolFn(toolName, aliasedArgs, ctx);
                }
                catch (err) {
                    const message = err instanceof Error ? err.message : String(err);
                    return {
                        content: [
                            {
                                type: "text",
                                text: JSON.stringify({
                                    error_code: "EXECUTION_ERROR",
                                    tool: toolName,
                                    message,
                                    retry_eligible: false,
                                }, null, 2),
                            },
                        ],
                        // Mirror the code/message so audit telemetry (and structured-
                        // content-aware clients) never see a code-less dispatcher error.
                        structuredContent: {
                            error_code: "EXECUTION_ERROR",
                            error_message: message,
                        },
                        isError: true,
                    };
                }
            },
        };
    }
}
//# sourceMappingURL=discovery.js.map