@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
225 lines • 12.1 kB
TypeScript
/**
* 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