UNPKG

@withpica/mcp-server

Version:

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

225 lines • 12.1 kB
/** * Layered Discovery Tools — ADR-149 Phase 5d * * When MCP_LAYERED_DISCOVERY=1, the catalog server handshake exposes only: * pica_sign_in, pica_sign_out, pica_discover, pica_tool_details, pica_execute * * Agents use pica_discover → pica_tool_details → pica_execute to progressively * discover and invoke the full tool set without overwhelming the context window. */ import type { ToolDefinition, ToolExecutor, ToolExecutorContext, ToolMetadata, ToolResult } from "./index.js"; /** * 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 declare 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. */ export declare const STOP_WORDS: ReadonlySet<string>; interface CategoryDefinition { description: string; tools: string[]; depends_on: string[]; } /** * 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 declare const CATEGORIES: Record<string, CategoryDefinition>; /** * 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 declare function singularise(token: string): string; export declare class DiscoveryTools { private executeToolFn; private 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. */ private 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. */ private isToolVisible?; constructor(executeToolFn: (name: string, args: Record<string, unknown>, ctx?: ToolExecutorContext) => Promise<ToolResult>, getToolDef: (name: string) => { definition: ToolDefinition; executor: ToolExecutor; metadata?: ToolMetadata; } | undefined, /** * 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?: (() => string[]) | undefined, /** * 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?: ((name: string) => boolean) | undefined); private isVisible; /** Shortlist / detail keys added only when the tool is NOT callable here. */ private surfaceFlags; /** * 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. */ private suggestToolNames; /** Registered AND callable by this caller on this surface. */ private canSuggest; /** * 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. */ private notFoundAnswer; getTools(): Array<{ definition: ToolDefinition; executor: ToolExecutor; }>; private buildDiscoverTool; /** * 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. */ private matchDiscovery; /** * A single-line description for a match row: the first sentence of the * tool's description, capped to keep the shortlist scannable. */ private oneLineDescription; private buildToolDetailsTool; private buildToolDetail; private buildExecuteTool; } export {}; //# sourceMappingURL=discovery.d.ts.map