@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
1,960 lines • 99.3 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { currentCatalogue, runInCatalogue, } from "@withpica/mcp-sdk";
import { CATALOGUE_ARG, CATALOGUE_PROPERTY, catalogueRefusal, settleCatalogueAnswer, takesCatalogue, } from "./catalogue-argument.js";
import { WorksTools } from "./works.js";
import { PeopleTools } from "./people.js";
import { ApprovalsTools } from "./approvals.js";
import { GroupsTools } from "./groups.js";
import { ShareSendTools } from "./share-send.js";
import { RecordingsTools } from "./recordings.js";
import { SearchTools } from "./search.js";
import { LicensingTools } from "./licensing.js";
import { BookingsTools } from "./bookings.js";
import { ShowsTools } from "./shows.js";
import { CreditsTools } from "./credits.js";
import { AudioFilesTools } from "./audio-files.js";
import { MultimediaTools } from "./multimedia.js";
import { AgreementsTools } from "./agreements.js";
import { MemoryTools } from "./memory.js";
import { SkillsTools } from "./skills.js";
import { EnrichmentTools } from "./enrichment.js";
import { BulkTools } from "./bulk.js";
import { ExportTools } from "./exports.js";
import { DuplicatesTools, coerceMergeEntityType } from "./duplicates.js";
import { DirectoryTools } from "./directory.js";
import { CollaboratorsTools } from "./collaborators.js";
import { CollaborationsTools } from "./collaborations.js";
import { UsersTools } from "./users.js";
import { ImportDocumentTools } from "./import-documents.js";
import { UploadTools } from "./uploads.js";
import { FilesTools } from "./files.js";
import { ReleaseNotesTools } from "./release-notes.js";
import { DocumentTools } from "./documents.js";
import { ImportTools } from "./import.js";
import { SendTools } from "./send.js";
import { ComparisonTools } from "./comparisons.js";
import { AssetsTools } from "./assets.js";
import { SessionsTools } from "./sessions.js";
import { AnalyticsTools } from "./analytics.js";
import { NotificationsTools } from "./notifications.js";
import { NotesTools } from "./notes.js";
import { ProjectsTools } from "./projects.js";
import { ReleasesTools } from "./releases.js";
import { ReleaseRichTools } from "./release-rich.js";
import { CalendarTools } from "./calendar.js";
import { SplitSheetsTools } from "./split-sheets.js";
import { AgreementTypesTools } from "./agreement-types.js";
import { expandAliases, getAliasFor, isDeprecatedMeta, TOOL_ALIASES, } from "./deprecations.js";
import { DashboardTools } from "./dashboard.js";
import { SettingsTools } from "./settings.js";
import { FeedbackTools } from "./feedback.js";
import { StorageConfigTools } from "./storage-config.js";
import { SubscriptionTools } from "./subscription.js";
import { OnboardingTools } from "./onboarding.js";
import { AgentHygieneTools } from "./agent-hygiene.js";
import { ReportIssueTools } from "./report-issue.js";
import { MyReportedIssuesTools } from "./my-reported-issues.js";
import { MyRecentQuestionsTools } from "./my-recent-questions.js";
import { AgentIdentityTools } from "./agent-identity.js";
import { RoyaltiesTools } from "./royalties.js";
import { RightsTools } from "./rights.js";
import { RelationshipTools } from "./relationships.js";
import { HandoverTools } from "./handovers.js";
import { StatementsTools } from "./statements.js";
import { SelectionsTools } from "./selections.js";
import { ShareLinksTools } from "./share-links.js";
import { SharingTools } from "./sharing.js";
import { ExplainabilityTools } from "./explainability.js";
import { AccessSimulateTools } from "./access-simulate.js";
import { DisputesTools } from "./disputes.js";
import { CustodyTools } from "./custody.js";
import { ConsentTools } from "./consent.js";
import { IntegrityTools } from "./integrity.js";
import { RecordingCustodyTools } from "./recording-custody.js";
import { RecordingSamplesTools } from "./recording-samples.js";
import { RegisterTools } from "./register.js";
import { PurchasesTools } from "./purchases.js";
import { TelegramTools } from "./telegram.js";
import { PublishersTools } from "./publishers.js";
import { LabelsTools } from "./labels.js";
import { PartyClaimsTools } from "./party-claims.js";
import { ProfileClaimsTools } from "./profile-claims.js";
import { WorkThemesTools } from "./work-themes.js";
import { AuthTools } from "./auth.js";
import { SignupTools } from "./signup.js";
import { AppTools } from "./app-tools.js";
import { AuditTools } from "./audit.js";
import { DiscoveryTools } from "./discovery.js";
import { SyncPlacementsTools } from "./sync-placements.js";
import { SyncFitTools } from "./sync-fit.js";
import { readCredentials } from "@withpica/mcp-utils";
import { formatError, ToolExecutionError } from "@withpica/mcp-utils";
import { guardToolResult } from "@withpica/mcp-utils";
import { narrowToolResultForRefs } from "@withpica/mcp-utils";
import { getToolMetadata } from "./metadata.js";
import { getRecoveryHint } from "./recovery-hints.js";
import { generateConfirmationToken, validateAndConsumeToken, } from "@withpica/mcp-utils";
import { rejectUnknownSections } from "./section-validation.js";
import { countSignedSplitSheets, countSignedSplitSheetsForWorks, signedSplitSheetLineForWork, signedSplitSheetLineForWorks, } from "./signed-split-sheet-preview.js";
import { buildSessionState, incrementSessionMutations, resetSinceLastBriefing, } from "@withpica/mcp-utils";
/**
* ADR-230 — derive the MCP-standard 3-value risk classification from the
* declared tier. Sourced once at registration and at audit-write time so
* the contract that hints, audit row, and confirmation requirement all
* agree is mechanical.
*/
export function tierToRiskLevel(tier) {
switch (tier) {
case "read":
return "safe";
case "draft":
case "write":
return "mutating";
case "destructive":
return "destructive";
}
}
/**
* ADR-230 — derive the required API-key scope from the declared tier.
*
* - `read` → `read:<resource>` (write:* satisfies via `hasScope`).
* - `draft|write` → `write:<resource>`.
* - `destructive` → `destructive:*` (admin satisfies via `hasScope`); the
* write:<resource> scope is also required for the
* underlying write surface.
*
* Returns the *additional* scope tier beyond per-resource. Resource scope
* is composed at call-site by `lib/services/mcp-scopes.ts`.
*/
export function tierToScopeKind(tier) {
switch (tier) {
case "read":
return "read";
case "draft":
case "write":
return "write";
case "destructive":
return "destructive";
}
}
/**
* Build a tool description with metadata injected.
* Format: "[category] original description"
*/
export function injectMetadataIntoDescription(description, metadata) {
// Category tag helps the agent understand tool grouping.
// Risk prefixes removed — annotations (readOnlyHint, destructiveHint) handle this structurally.
// Behavioral guidance (tell user what you're doing) is in server instructions (one copy).
return `[${metadata.category}] ${description}`;
}
export const CATEGORY_DISPLAY = {
catalog: "manage your catalog",
enrichment: "enrich your metadata",
business: "handle business and agreements",
discovery: "search and explore",
media: "manage photos, audio, and video",
comms: "send and share",
settings: "manage your account and team",
};
/**
* Lift error attribution (code + message) off an `isError` ToolResult for
* audit telemetry. Prefers `structuredContent.error_code`/`error_message`;
* falls back to parsing the first text content item as a JSON error
* envelope (`error_code`/`error` + `error_message`/`message`) — the shape
* the pica_execute dispatcher and fail-quiet wrappers return. Without the
* fallback those errors landed in mcp telemetry with a NULL code, so the
* ops_issues pipeline (which groups on a code) never saw them — 26 opaque
* ChatGPT dispatcher failures in 14 days, invisible to triage. Exported
* for unit testing.
*/
export function extractErrorAttribution(result) {
let code;
let message;
const sc = result?.structuredContent;
if (sc && typeof sc === "object") {
if (typeof sc.error_code === "string")
code = sc.error_code;
if (typeof sc.error_message === "string")
message = sc.error_message;
}
if (!code) {
const firstText = result?.content?.find((c) => c.type === "text" && typeof c.text === "string")?.text;
if (firstText && firstText.trimStart().startsWith("{")) {
try {
const envelope = JSON.parse(firstText);
const envCode = envelope.error_code ?? envelope.error;
if (typeof envCode === "string")
code = envCode;
const envMessage = envelope.error_message ?? envelope.message;
if (!message && typeof envMessage === "string")
message = envMessage;
}
catch {
// Not a JSON envelope — the raw text is still what the agent saw;
// captured below instead of leaving the row message-less.
}
}
}
// Last resort (excellence agent-surface audit 2026-07-16): 7 external
// execute-path errors landed with NULL error_message — whatever the agent
// saw, ops couldn't. If attribution found no structured message, stamp
// the first text content (truncated) so telemetry always carries the
// agent-visible error.
if (!message) {
const rawText = result?.content?.find((c) => c.type === "text" && typeof c.text === "string")?.text;
if (rawText)
message = rawText.slice(0, 500);
}
return { code, message };
}
/**
* Sanitize tool parameters for `mcp_audit_log.parameters`. Pure function
* so it's directly testable; the registry's private method delegates here.
*
* Security audit 2026-05-11 P2. The audit log is queryable by anyone with
* team-portal access. Tools like `pica_sign_in`, `pica_share_links_*`,
* and `team_comms_send` previously landed bare emails / share tokens /
* OAuth refresh tokens in the log because the only redaction was a
* `delete sanitized.confirmation_token` + length truncation.
*
* Deny-list is keyed off the param name (case-insensitive substring
* match) — covers `password`, `api_key`, `token`, `secret`,
* `authorization`, plus `confirmation_token` via the `token` substring.
* Sister keys like `refresh_token` / `id_token` / `access_token` /
* `share_token` / `client_secret` also match.
*
* Email values (regardless of param key) get the local-part masked to
* the first two characters: `jane@example.com` → `ja***@example.com`.
* Locals ≤ 2 chars become all-asterisk.
*
* Strings > 500 chars are truncated to 500 + `...[truncated]`.
*
* Exported under `_internal` so test files can exercise the function
* without instantiating a `ToolRegistry`.
*/
function sanitizeAuditParams(args) {
const sanitized = { ...args };
const denyKeySubstrings = [
"password",
"api_key",
"token",
"secret",
"authorization",
];
for (const [key, value] of Object.entries(sanitized)) {
const lowerKey = key.toLowerCase();
if (denyKeySubstrings.some((needle) => lowerKey.includes(needle))) {
sanitized[key] = "[redacted]";
continue;
}
if (typeof value === "string" &&
/^[^\s@]{1,64}@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value)) {
const atIdx = value.indexOf("@");
const local = value.slice(0, atIdx);
const domain = value.slice(atIdx);
const maskedLocal = local.length <= 2
? "*".repeat(local.length)
: `${local.slice(0, 2)}***`;
sanitized[key] = `${maskedLocal}${domain}`;
continue;
}
if (typeof value === "string" && value.length > 500) {
sanitized[key] = value.substring(0, 500) + "...[truncated]";
}
}
return sanitized;
}
export const _internal = { sanitizeAuditParams };
/**
* Authoring metadata that must never cross the wire.
*
* `ToolDefinition` is two things wearing one type: the MCP `Tool` a client
* consumes, and the compile-time declarations our own lint reads. `listTools()`
* spreads `...definition` three times, so every field on it — including the
* second kind — was being published to every connected agent.
*
* `vernacular_reason` is the reason this exists. It is prose written for us,
* and on ~30 tools it read "Specialist context; not yet covered by the Creator
* Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4
* substrate writes." No exported surface names an ADR: an ADR number is an
* internal coordinate, it means nothing to the reader, and it invites an agent
* to repeat it back to a user as if it were vocabulary they should know.
*
* Nothing downstream reads these off the wire — `scripts/lint-mcp-tools.ts`
* parses source, and every test reads `tool.definition` from the registry
* directly. `annotationOverrides` is dropped for a plainer reason: it has
* already been merged into `annotations` by the time we get here, so on the
* wire it is a duplicate of a field the client is also being handed.
*
* Deliberately NOT stripped: `tier`, `scope`, `audiences`, `workflows`. Those
* are short machine slugs a client can act on, not prose about our roadmap.
*/
const INTERNAL_ONLY_FIELDS = [
"vernacular_kind",
"vernacular_reason",
"annotationOverrides",
];
/** Drop authoring-only fields from a definition on its way to a client. */
function toWireDefinition(definition) {
const wire = { ...definition };
for (const field of INTERNAL_ONLY_FIELDS)
delete wire[field];
return wire;
}
/**
* Tools whose effect leaves PICA — email, invites, Telegram, the send hub.
* Pattern-derived so a new tool inherits the classification without an edit.
* Feeds BOTH the `createsExternalSideEffects` extended annotation and the
* MCP-spec `openWorldHint`, which is the same claim in the spec's words.
*/
const EXTERNAL_SIDE_EFFECT_PATTERNS = [
"telegram",
"_send_",
"_send",
"send_message",
"send_resend",
"team_comms_send",
"_notify_user",
"_invite",
];
function hasExternalSideEffects(toolName) {
return EXTERNAL_SIDE_EFFECT_PATTERNS.some((p) => toolName.includes(p));
}
/**
* The four MCP-spec annotation hints plus `title`, derived from one place.
*
* Before this existed the derivation lived inline in `listTools()`, so the
* layered-discovery surface (`pica_tool_details` → `buildToolDetail` in
* discovery.ts) fell back to the tool's *declared* `annotations` — which most
* tools never declare, so it answered `annotations: null` for the large
* majority of the registry while the flat surface answered real hints for the
* same tool (measured 2026-08-24). Connector clients only ever see the layered
* surface, so they could not tell a delete from a read.
*
* ADR-230 — declared `tier` is the source of truth for the two hints that
* describe AUTHORITY. Declared `annotations.readOnlyHint` /
* `annotations.destructiveHint` are NOT read: they would let a declaration
* contradict the tier that actually drives the confirmation token and the
* `destructive:*` scope. `metadata.risk` is a quiet fallback for a tool that
* somehow bypassed lint Rule 13.
*
* Two hints a declaration CAN set, because neither describes authority and
* nothing enforces against either:
*
* - `openWorldHint` — a declaration may RAISE it (never lower it); the name
* patterns in `EXTERNAL_SIDE_EFFECT_PATTERNS` raise it on their own.
* - `idempotentHint` — a declared value WINS outright (ruling 2026-08-24).
* The tier-derived value is a guess from `TOOL_METADATA.retry_safe` plus
* "destructive is never idempotent", and it was wrong on four tools whose
* authors had said otherwise in the definition — `pica_storage_disconnect`
* is destructive-tier and its own description reads "Idempotent — calling
* on an already-PICA-managed org is a no-op". The author knows; the
* heuristic does not.
*
* `title` does not describe authority either: a declared `annotations.title`
* wins, then `TOOL_METADATA.display_name`. Before 2026-10-08 the declaration
* was discarded, and sign_in/out declared none, so all five tools on the
* layered surface (the only tools a connector lists) advertised their raw
* names as titles. `src/__tests__/tools/tool-titles.test.ts` fails a tool
* with neither, and a catalog tool whose declared title disagrees with its
* display_name (pica_tool_details shows both).
*
* `annotationOverrides` still merges last, so it beats all of the above.
*
* Agreement between the two surfaces is pinned by "listTools() and
* pica_tool_details agree on the four core hints" in
* `src/__tests__/tools/tool-details-contract.test.ts`; the precedence rules
* themselves are pinned by the synthetic-definition cases in the same file.
* `scripts/ci/check-agent-readability.mjs` (`tier-annotation-mismatch`)
* applies the identical rule when it looks for declarations the derivation
* silently discards.
*/
export function deriveAnnotations(definition) {
const metadata = getToolMetadata(definition.name);
const declaredTier = definition.tier;
const openWorldHint = hasExternalSideEffects(definition.name) ||
definition.annotations?.openWorldHint === true;
// Advisory-hint overrides (the pica_execute dispatcher is the only current
// user) merged last so they win over the tier-derived hints.
const overrides = definition.annotationOverrides ?? {};
// A declared idempotentHint wins over the tier-derived guess (see header).
// `{}` when undeclared, so the spread is a no-op rather than an `undefined`
// that would clobber the computed value.
const declaredIdempotent = typeof definition.annotations?.idempotentHint === "boolean"
? { idempotentHint: definition.annotations.idempotentHint }
: {};
const declaredTitle = definition.annotations?.title?.trim() || undefined;
if (declaredTier && metadata) {
return {
title: declaredTitle ?? metadata.display_name,
readOnlyHint: declaredTier === "read",
destructiveHint: declaredTier === "destructive",
idempotentHint: declaredTier !== "destructive" && metadata.retry_safe,
openWorldHint,
...declaredIdempotent,
...overrides,
};
}
if (metadata) {
return {
title: declaredTitle ?? metadata.display_name,
readOnlyHint: metadata.risk === "safe",
destructiveHint: metadata.risk === "destructive",
idempotentHint: metadata.risk !== "destructive" && metadata.retry_safe,
openWorldHint,
...declaredIdempotent,
...overrides,
};
}
// No TOOL_METADATA entry — the lobby meta-tools (sign_in/out, discover,
// tool_details, execute). Source from declared tier; default to read when
// tier is somehow missing (lint blocks; defensive only).
const tierForHints = declaredTier ?? "read";
return {
title: declaredTitle ?? definition.name,
readOnlyHint: tierForHints === "read",
destructiveHint: tierForHints === "destructive",
idempotentHint: false,
openWorldHint,
...declaredIdempotent,
...overrides,
};
}
export class ToolRegistry {
tools;
pica;
config;
reinitializeCallback;
signOutCallback;
auditLogger;
callerContext;
/** Caller-specific "can run it here" judgement — see setToolVisibility. */
toolVisibility;
constructor(pica, config, reinitializeCallback, callerContext, signOutCallback) {
this.pica = pica;
this.config = config;
this.reinitializeCallback = reinitializeCallback;
this.signOutCallback = signOutCallback;
this.callerContext = callerContext ?? {
callerIdentity: "unknown",
transport: "stdio",
};
this.tools = new Map();
this.registerAllTools();
}
setAuditLogger(logger) {
this.auditLogger = logger;
}
setCallerContext(context) {
this.callerContext = context;
}
/**
* Install the caller's "can run it on this connection" judgement so
* discovery ranks and flags with the predicate the dispatcher enforces.
* The HTTP route passes `allowedToolNames.has` (ADR-248: audiences ∋ http
* AND scope held) — and only while `isInnerGateEnforced()`
* (lib/services/mcp-inner-gate-shadow.ts) is true, because in
* `PICA_EXECUTE_INNER_GATE_MODE=shadow` pica_execute lets a scope-gated
* inner tool run and a "not callable" flag would then be wrong in the
* worst direction. Without a predicate, `isToolVisibleOnSurface` falls
* back to audience only: a tool is visible when its declared audiences
* include the caller's surface (lobby-mode → "lobby", else the
* transport). 2026-08-26: a ChatGPT caller was ranked five finance-gated
* tools first and read one's schema three times before leaving.
*/
setToolVisibility(predicate) {
this.toolVisibility = predicate;
}
isToolVisibleOnSurface(name) {
if (this.toolVisibility)
return this.toolVisibility(name);
const def = this.tools.get(name)?.definition;
if (!def)
return false;
const surface = this.config?.lobbyMode
? "lobby"
: this.callerContext.transport;
return def.audiences?.includes(surface) ?? false;
}
/**
* Read clientInfo from the MCP `initialize` handshake. SDK populates this
* on the per-request `Server` for HTTP and on the long-lived `Server` for
* stdio; either way getClientVersion() returns the same shape after the
* handshake completes. Returns empty when ctx is absent or the handshake
* hasn't run (lobby-mode dispatches that bypass the per-request transport).
*
* Stamped onto audit rows as provenance only — never used as a permission
* boundary, since clientInfo is self-declared by the client.
*/
extractClientInfo(ctx) {
const info = ctx?.server?.getClientVersion?.();
if (!info)
return {};
return {
client_name: typeof info.name === "string" ? info.name : undefined,
client_version: typeof info.version === "string" ? info.version : undefined,
};
}
/**
* Register all available tools
*/
registerAllTools() {
// Auth tools — always registered (lobby + authenticated mode)
if (this.config) {
const authTools = new AuthTools(this.config, this.reinitializeCallback || (() => { }), this.signOutCallback || (() => { }));
authTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-211 P1 — Signup tool. Registered in lobby alongside the auth
// pair so unauthenticated callers (stdio without API key, HTTP
// without Bearer) have a discoverable signup verb. Stays registered
// post-auth too — the tool itself is harmless once you're signed in
// (mints a link for a new account, not your existing one) and that
// matches the ADR-211 § Primitive A 2026-04-30 amendment which
// makes Phase 1 lobby additive (3 tools), not replacement.
//
// Transport is read from callerContext so stdio + HTTP transports
// stamp their JWTs correctly without per-call config plumbing.
const signupTools = new SignupTools({
apiUrl: this.config.picaApiUrl,
transport: this.callerContext.transport,
});
signupTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
}
// In lobby mode, only auth + signup tools are available
if (this.config?.lobbyMode || !this.pica) {
return;
}
const pica = this.pica;
// Works tools
const worksTools = new WorksTools(pica);
worksTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// People tools
const peopleTools = new PeopleTools(pica);
peopleTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Approvals tools — pica_approval_status (human-in-the-loop poll)
const approvalsTools = new ApprovalsTools(pica);
approvalsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Groups tools (ADR-223)
const groupsTools = new GroupsTools(pica);
groupsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Share-send tool (ADR-231)
const shareSendTools = new ShareSendTools(pica);
shareSendTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Recordings tools
const recordingsTools = new RecordingsTools(pica);
recordingsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Search tools
const searchTools = new SearchTools(pica);
searchTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Licensing tools
const licensingTools = new LicensingTools(pica);
licensingTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Bookings tools (ADR-289 Wave C1) — internal-team booking enquiries
const bookingsTools = new BookingsTools(pica);
bookingsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Shows tools (ADR-313) — live shows and tour credits
const showsTools = new ShowsTools(pica);
showsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Credits tools
const creditsTools = new CreditsTools(pica);
creditsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Audio Files tools
const audioFilesTools = new AudioFilesTools(pica);
audioFilesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Multimedia tools
const multimediaTools = new MultimediaTools(pica);
multimediaTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Agreements tools
const agreementsTools = new AgreementsTools(pica);
agreementsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Memory tools
const memoryTools = new MemoryTools(pica);
memoryTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Skills tools (ADR-140 Phase 2b — fallback tool lane; ADR-328)
// ADR-271 Phase 2: pass org-authored extra skills from caller context
const skillsTools = new SkillsTools(this.callerContext?.extraSkills ?? []);
skillsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Enrichment tools
const enrichmentTools = new EnrichmentTools(pica);
enrichmentTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Bulk operations tools
const bulkTools = new BulkTools(pica);
bulkTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Export tools
const exportTools = new ExportTools(pica);
exportTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Duplicates tools
const duplicatesTools = new DuplicatesTools(pica);
duplicatesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Directory tools
const directoryTools = new DirectoryTools(pica);
directoryTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Collaborator tools
const collaboratorsTools = new CollaboratorsTools(pica);
collaboratorsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-236 — cross-org credit visibility for recipients
const collaborationsTools = new CollaborationsTools(pica);
collaborationsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-237 — opt-in public handles + bio + avatar (cross-org discovery)
const usersTools = new UsersTools(pica);
usersTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Upload tools (generic file upload — images, videos, documents)
const uploadTools = new UploadTools(pica);
uploadTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// File delivery tools — outbound sharing with recipient accountability
// and revocation. Wraps /admin/files/deliver + /deliver/[id]/revoke.
const filesTools = new FilesTools(pica);
filesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Release notes — agent-facing platform changelog. Reads CHANGELOG.md
// embedded at build time. Disambiguated from catalog "what's new" by
// name + description NOT-FOR disclaimer.
const releaseNotesTools = new ReleaseNotesTools(pica);
releaseNotesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Document tools (AI analysis)
const documentTools = new DocumentTools(pica);
documentTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Bulk import tools (CSV import pipeline)
const importTools = new ImportTools(pica);
importTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Comparison tools (ADR-116 Phase 2: enrichment + registration comparison)
const comparisonTools = new ComparisonTools(pica);
comparisonTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Send Hub tools (messages, invites, file requests, audio shares)
const sendTools = new SendTools(pica);
sendTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Import document tools
const importDocumentTools = new ImportDocumentTools(pica);
importDocumentTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Physical assets tools (equipment, instruments, studio gear)
const assetsTools = new AssetsTools(pica);
assetsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Work sessions tools (studio session tracking)
const sessionsTools = new SessionsTools(pica);
sessionsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Analytics tools (carbon, provenance, diligence)
const analyticsTools = new AnalyticsTools(pica);
analyticsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Notifications tools
const notificationsTools = new NotificationsTools(pica);
notificationsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Notes tools (catalog notes on works, people, general observations)
const notesTools = new NotesTools(pica);
notesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Team management tools removed 2026-06-11 (founder decision, ADR-248):
// /api/admin/team/* is session-only by design — every bearer/MCP caller
// received a hard 401. Team membership stays human-only via /settings.
// Projects tools (albums, EPs, compilations — grouping works)
const projectsTools = new ProjectsTools(pica);
projectsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Releases tools (albums, EPs, singles with dates, labels, UPCs)
const releasesTools = new ReleasesTools(pica);
releasesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-208 Phase 3 Primitive D — release rich render (image + markdown)
const releaseRichTools = new ReleaseRichTools(pica);
releaseRichTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Calendar tools
const calendarTools = new CalendarTools(pica);
calendarTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Split sheets & recording splits tools
const splitSheetsTools = new SplitSheetsTools(pica);
splitSheetsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Agreement types tools (templates, producer agreements, work-for-hire)
const agreementTypesTools = new AgreementTypesTools(pica);
agreementTypesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Dashboard & discovery tools
const dashboardTools = new DashboardTools(pica);
dashboardTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Settings tools (credits history, storage, org profile)
const settingsTools = new SettingsTools(pica);
settingsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Feedback tools (ADR-184 slice 8 — pica_submit_feedback)
const feedbackTools = new FeedbackTools(pica);
feedbackTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Storage config tools (ADR-217 — Primitives B + C; Primitive A
// pica_storage_status lives in settings.ts because it shipped earlier
// alongside the original settings cluster).
const storageConfigTools = new StorageConfigTools(pica);
storageConfigTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Subscription tools — the three this class registers:
// pica_subscription_status and pica_subscription_manage (ADR-210 Phase 2:
// the explicit billing read + action surface complementing the ambient
// _meta.session_state.billing_slice from Phase 1), plus pica_billing_pay
// (spec 2026-08-24 WS-B: mints a pay link for what is owed).
const subscriptionTools = new SubscriptionTools(pica);
subscriptionTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Onboarding tools — pica_acknowledge_onboarding gates OAuth-arrived
// users until the agent walks them through identity/security/intent.
const onboardingTools = new OnboardingTools(pica);
onboardingTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Agent hygiene tools — pica_introduce_self gates OAuth-Bearer +
// raw-API-key callers until they declare identity (ADR-247 PR (a)).
// Cookie + grant callers are carved out at the router level.
const agentHygieneTools = new AgentHygieneTools(pica);
agentHygieneTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Report-issue tool (ADR-196 Phase 3 — pica_report_issue)
const reportIssueTools = new ReportIssueTools(pica);
reportIssueTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// My-reported-issues tool (ADR-196 Phase 4a — pica_my_reported_issues)
// — closed-loop counterpart to pica_report_issue. Org-scoped polling.
const myReportedIssuesTools = new MyReportedIssuesTools(pica);
myReportedIssuesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// My-recent-questions tool (ADR-226 Phase 5 — pica_my_recent_questions)
// — user-scoped read of the Phase 4 substrate (assistant_interactions
// joined with mcp_sessions for client identity). Atlas-resolved entry
// for "what did I ask the AI?".
//
// ADR-230 post-ship cleanup — the tier resolver is the registry's
// own `getToolTier` shape (declared `tier` on each tool definition).
// Lint Rule 13 makes tier required for every customer MCP tool, so
// this resolver returns a populated value for all known tools and
// `undefined` only for unknown / meta tools — `dominantTier` falls
// back to "read" in that case.
const myRecentQuestionsTools = new MyRecentQuestionsTools(pica, (name) => this.tools.get(name)?.definition.tier);
myRecentQuestionsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Agent identity tools (ADR-185 Part 1 — session-auth-only MCP surface).
// The three tools refuse grant-auth callers at the HTTP API layer;
// the stdio path carries an API key so behaviour is identical either
// way.
const agentIdentityTools = new AgentIdentityTools(pica);
agentIdentityTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Royalties & statements tools (ADR-139 Step 2)
const royaltiesTools = new RoyaltiesTools(pica);
royaltiesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Programmable rights (ADR-321 §9) — agents propose, humans confirm.
const rightsTools = new RightsTools(pica);
rightsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Statements ingestion tools (ADR-277 — agent-driven royalty statement ingest)
const statementsTools = new StatementsTools(pica);
statementsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Saved catalog selections tools (ADR-319)
const selectionsTools = new SelectionsTools(pica);
selectionsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Share links tools (ADR-139 Step 3)
const shareLinksTools = new ShareLinksTools(pica);
shareLinksTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Sharing trace tool (ADR-232 § Decision 2)
const sharingTools = new SharingTools(pica);
sharingTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Explainability tools (ADR-232 § Decision 3)
const explainabilityTools = new ExplainabilityTools(pica);
explainabilityTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Access simulator — read-only "who would see what?" preview.
const accessSimulateTools = new AccessSimulateTools(pica);
accessSimulateTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Disputes tools (ADR-139 Step 3)
const disputesTools = new DisputesTools(pica);
disputesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Custody tools (ADR-158 Plan C)
const custodyTools = new CustodyTools(pica);
custodyTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Consent readiness (ADR-300 — the read side of ADR-292's ledger).
// Read-only: consent writes stay human-session only.
const consentTools = new ConsentTools(pica);
consentTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Registration integrity floor waive tool (spec 2026-07-09, Task 10 —
// flagged spec deviation, the one new tool the floor program ships).
const integrityTools = new IntegrityTools(pica);
integrityTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Recording custody tools (ADR-262 — master-side control chain)
const recordingCustodyTools = new RecordingCustodyTools(pica);
recordingCustodyTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Recording sample provenance tools (ADR-259 — marketplace samples)
const recordingSamplesTools = new RecordingSamplesTools(pica);
recordingSamplesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Register-set tool (ADR-259 — one-call DAW set registration)
const registerTools = new RegisterTools(pica);
registerTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Purchases tools (credit checkout)
const purchasesTools = new PurchasesTools(pica);
purchasesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Telegram tools (user notification channel)
const telegramTools = new TelegramTools(pica);
telegramTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Publishers tools (lookup + create)
const publishersTools = new PublishersTools(pica);
publishersTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Labels tools (read-only lookup; ADR-174 Decision 3)
const labelsTools = new LabelsTools(pica);
labelsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Party claims tools (ADR-268 WS1 — claim flow for unclaimed party orgs)
const partyClaimsTools = new PartyClaimsTools(pica);
partyClaimsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Profile claims tools (claim your profile: invite with a preview, check)
const profileClaimsTools = new ProfileClaimsTools(pica);
profileClaimsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Work themes tools (directory topics from the owner: propose, review)
const workThemesTools = new WorkThemesTools(pica);
workThemesTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// App tools (MCP Apps — interactive UI cards)
const appTools = new AppTools(pica);
appTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Audit tools (recent MCP tool executions — ADR-154 F6)
// ADR-181 Phase 1: ported from the (now-retired)
// @withpica/mcp-server-settings sub-server as part of the customer
// MCP surface consolidation. The sub-server was the only place this
// tool shipped before consolidation; every other sub-server tool
// already existed in mcp-server/ so only pica_audit_list had to move.
// Phase 3 (2026-04-19) tombstoned the sub-package on npm + registry
// and deleted the source tree.
const auditTools = new AuditTools(pica);
auditTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-222 sync placements
const syncPlacementsTools = new SyncPlacementsTools(pica);
syncPlacementsTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// Sync fit (where songs sit against productions in progress; read-only)
const syncFitTools = new SyncFitTools(pica);
syncFitTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-333 — who looks after whose catalogue (both sides).
const relationshipTools = new RelationshipTools(pica, this.config?.picaApiUrl);
relationshipTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-333 D5 — a catalogue built for a client, then handed over.
const handoverTools = new HandoverTools(pica, this.config?.picaApiUrl);
handoverTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
// ADR-333 D7 — every tool that acts on a catalogue's own records takes an
// optional `catalogue` (./catalogue-argument.ts). Added here, once, so
// tools/list, pica_tool_details and pica_execute all see the same schema,
// and before aliases are cloned so an alias carries it too.
for (const tool of this.tools.values()) {
if (!takesCatalogue(tool.definition))
continue;
tool.definition = {
...tool.definition,
inputSchema: {
...tool.definition.inputSchema,
properties: {
...tool.definition.inputSchema.properties,
[CATALOGUE_ARG]: CATALOGUE_PROPERTY,
},
},
};
}
// Discovery meta-tools — registered last so pica_tool_details can look up
// all tool definitions. Only active when discoveryMode is enabled.
if (this.config?.discoveryMode) {
const discoveryTools = new DiscoveryTools((name, args, ctx) => this.executeTool(name, args, ctx), (name) => this.tools.get(name), () => [...this.tools.keys()], (name) => this.isToolVisibleOnSurface(name));
discoveryTools.getTools().forEach((tool) => {
this.tools.set(tool.definition.name, tool);
});
}
// Silent-alias expansion — register deprecated names as hidden clones that
// route to their canonical. Must run last so every canonical exists.
expandAliases(this.tools, TOOL_ALIASES);
}
/**
* ADR-230 — declared tier lookup by tool name. Used by the HTTP MCP
* dispatcher (`app/api/mcp/route.ts`) to enforce the elevated
* `destructive:*` scope on top of the resource-level scope from
* `lib/services/mcp-scopes`. Returns undefined for unknown tools or
* tools that haven't declared tier yet (legacy tolerance).
*/
getToolTier(name) {
return this.tools.get(name)?.definition.tier;
}
/**
* ADR-248 — declared resource scope (or `"always"`) lookup by tool name.
* Used by the HTTP MCP dispatcher to compose the required scope set from
* the tool's own declaration instead of a separate inverse map. Returns
* undefined for unknown tools.
*/
getToolScope(name) {
return this.tools.get(name)?.definition.scope;
}
/** The tool's `alsoRequires` scopes (empty when it declares none). */
getToolAlsoRequires(name) {
return [...(this.tools.get(name)?.definition.alsoRequires ?? [])];
}
/**
* ADR-248 — every registered tool's raw definition, WITHOUT the
* `discoveryMode` filter that {@link listTools} applies and without the
* description/annotation transformation. This is the source the HTTP
* dispatcher folds over to derive the authenticated tool surface
* (`audiences ∋ "http"` AND scope held) — it must see the full set even
* in layered-discovery mode, where `listTools()` returns only the meta
* tools. Reflects lobby/discovery REGISTRATION (what's in the registry),
* just not the listing filter.
*/
allDefinitions() {
return Array.from(this.tools.values()).map((t) => t.definition);
}
/**
* List all available tools with write-safety prefixes injected.
* When discoveryMode is enabled, only the 5 handshake-visible tools are returned.
* All other tools remain registered and callable via pica_execute.
*/
listTools() {
let toolsToList = Array.from(this.tools.values());
if (this.config?.discoveryMode) {
const META_TOOL_NAMES = new Set([
"pica_sign_in",
"pica_sign_out",
"pica_discover",
"pica_tool_details",
"pica_execute",
]);
toolsToList = toolsToList.filter((t) => META_TOOL_NAMES.has(t.definition.name));
}
// Hide deprecated (aliased) tools from the flat listing. They remain
// registered and callable via executeTool so live callers don't break;
// they are just no longer advertised. Layered discovery hides them via
// discovery.ts CATEGORIES separately.
toolsToList = toolsToList.filter((t) => !isDeprecatedMeta(getToolMetadata(t.definition.name)) &&
getAliasFor(t.definition.name) === undefined);
return toolsToList
.map((tool) => {
let definition = tool.definition;
const metadata = getToolMetadata(definition.name);
// ADR-230 — declared tier is the source of truth for hints + risk.
// Lint Rule 13 enforces tier presence on every customer MCP tool;
// metadata.risk is a quiet fallback for the unlikely case of a tool
// that bypasses lint.
const declaredTier = definition.tier;
const isDestructive = declaredTier
? declaredTier === "destructive"
: metadata?.risk === "destructive";
// ADR-199 — write-tier tools may opt into the same confirmation token
// via previewMode:"two_step_token" (e.g. pica_file_deliver). They must
// advertise confirmation_token in their schema too, so schema-driven
// clients know it's a legal parameter on the confirming call.
const usesConfirmationToken = isDestructive || definition.previewMode === "two_step_token";
if (usesConfirmationToken) {
definition = {
...definition,
inputSchema: {
...definition.inputSchema,
properties: {
...definition.inputSchema.properties,
confirmation_token: {
type: "string",
description: "Confirmation token from a previous call. Required to execute this operation. " +
"Call this tool once without a token to get a preview and token, then call again with the token to confirm.",
},
},
},
};
}
// Normalize _meta.ui.resourceUri → legacy "ui/resourceUri" key for older MCP Apps clients
if (definition._meta?.ui?.resourceUri &&
!definition._meta["ui/resourceUri"]) {
definition = {
...definition,
_meta: {
...definition._meta,
"ui/resourceUri": definition._meta.ui.resourceUri,
},
};
}
// ADR-230 — derive hints from declared tier when present (every
// tool registered in mcp-server/src/tools/*.ts has tier as of
// PR-1; the metadata branch stays as a defensive code path).
if (declaredTier && metadata) {
return {
...definition,
description: injectMetadataIntoDescription(tool.definition.description, metadata),
annotations: {
...deriveAnnotations(definition),
// ADR-199 extended annotation classes — read by ChatGPT/Claude.ai
// connectors directly. Derived from declared tier so the four
// surfaces (hint, riskLevel, audit row, scope) all agree.
...this.deriveExtendedAnnotations(definition, metadata, undefined, declaredTier),
},
};
}
if (metadata) {
return {
...definition,
description: injectMetadataIntoDescription(tool.definition.description, metadata),
annotations: {
...deriveAnnotations(definition),
...this.deriveExtendedAnnotations(definition, metadata),
},
};
}
// ADR-230 — tools without a TOOL_METADATA entry (the 5 lobby
// meta-tools — sign_in/out/discover/tool_details/execute) still
// declare tier. Source hints from declared tier; default to read
// when tier is somehow missing (lint blocks; defensive only).
const tierForHints = declaredTier ?? "read";
const annotations = {
...deriveAnnotations(definition),
// ADR-199: derive the extended classes from declared tier so the
// category/risk surface stays aligned with the rest of the
// hint/audit/scope chain.
...this.deriveExtendedAnnotations(definition, null, undefined, tierForHints),
};
// No risk prefix needed — annotations handle this structurally
return { ...definition, annotations };
// Every branch above spreads `...definition`, so the strip is applied
// once here rather than three times inside the map.
})
.map(toWireDefinition);
}
/**
* ADR-199 — derive the extended annotation classes (riskLevel, category,
* requiresConfirmation, createsExternalSideEffects, returnsPaginated,
* requiresFollowUpInspection) from existing metadata + tool name patterns.
*
* Called from listTools() for every tool. Pure derivation: zero per-tool
* edits required for the retrofit. Future tool authors can override by
* setting fields directly on definition.annotations.
*/
deriveExtendedAnnotations(definition, metadata, _classification, declaredTier) {
const name = definition.name;
// ADR-230 — declared tier is the source of truth. metadata.risk
// remains a quiet fallback for any tool that bypasses lint Rule 13.
// (The `_classification` parameter is retained as an unused
// positional slot so legacy call signatures continue to compile;
// its value is now ignored — classifyTool was removed in PR-3.)
const risk = declaredTier
? tierToRiskLevel(declaredTier)
: (metadata?.risk ?? "safe");
// Side-effecting external systems: telegram, send-hub, comms, broadcasts.
// Shared with deriveAnnotations() so this class and the spec's
// `openWorldHint` cannot drift apart (EXTERNAL_SIDE_EFFECT_PATTERNS above).
const createsExternalSideEffects = hasExternalSideEffects(name);
// Paginated reads: every *_query and *_list tool.
const returnsPaginated = /_query$|_list$|_search/.test(name);
// Tools that mutate state are followed by a verifying inspect call when
// the agent wants to confirm — the gap report flagged this as a useful
// class for ChatGPT to surface in the UI.
const requiresFollowUpInspection = risk === "mutating" || risk === "destructive";
// ADR-230 — destructive tier always requires confirmation. ADR-199 — a
// `write`-tier tool may opt into the same two-step confirmation token via
// `previewMode: "two_step_token"` (e.g. pica_file_deliver: a write, but
// recipient_email is typo-catastrophic), getting the friction without the
// destructive:* scope. Both the advertised annotation (here) and the
// executeTool gate honour this flag.
const requiresConfirmation = definition.previewMode === "two_step_token" ||
(declaredTier ? declaredTier === "destructive" : risk === "destructive");
return {
riskLevel: risk,
category: metadata?.category,
requiresConfirmation,
createsExternalSideEffects,
returnsPaginated,
requiresFollowUpInspection,
};
}
/**
* Build a human-readable preview of what a destructive operation will affect.
*/
async buildDestructivePreview(name, args) {
const metadata = getToolMetadata(name);
const displayName = metadata?.display_name || name;
// buildDestructivePreview is only called during tool execution,
// which is gated behind lobby mode check — pica is always available here.
const pica = this.pica;
try {
switch (name) {
case "pica_file_deliver": {
// Not destructive — a write that opts into the two-step token
// (ADR-199). The preview surfaces recipient_email because that is
// the typo-catastrophic field the confirmation exists to catch.
return {
action: "deliver file (email a download link)",
target: String(args.recipient_email ?? "(no recipient)"),
warning: "verify the recipient email is correct — this emails a time-limited download link for the file. the link is revocable (pica_file_revoke_delivery), but a download that already happened cannot be un-sent.",
display_name: displayName,
};
}
case "pica_works_delete": {
const [work, signedSheets] = await Promise.all([
pica.works.get(args.id).catch(() => null),
countSignedSplitSheets(pica, args.id).catch(() => null),
]);
const title = work?.title || args.id;
const signedLine = signedSplitSheetLineForWork(signedSheets);
return {
action: "delete work",
target: title,
warning: "this will remove linked credits, agreement links, and audio files. recordings will be unlinked. this cannot be undone for works without verified identifiers." +
(signedLine ? ` ${signedLine}` : ""),
display_name: displayName,
};
}
case "pica_works_bulk_delete": {
const ids = Array.isArray(args.ids) ? args.ids : [];
const count = ids.length;
const signedLine = signedSplitSheetLineForWorks(await countSignedSplitSheetsForWorks(pica, ids));
return {
action: "bulk delete works",
target: `${count} works`,
warning: `this will delete ${count} work(s) and cascade to their credits and agreement links. this cannot be undone for works without verified identifiers.` +
(signedLine ? ` ${signedLine}` : ""),
display_name: displayName,
};
}
case "pica_works_bulk_move_to_performances": {
const count = args.ids?.length || 0;
return {
action: "bulk reclassify works → performances",
target: `${count} ${count === 1 ? "work" : "works"}`,
warning: `this creates ${count} new live_performance row(s) and removes the source work(s) from the catalog. for works with verified identifiers (ISRC/ISWC/MLC) the source is soft-deleted — credits and agreement links survive but are orphaned to the removed work; for unverified works the deletion is permanent. performance metadata is sparse by default and will need filling in.`,
display_name: displayName,
};
}
case "pica_people_delete": {
const person = await pica.people.get(args.id).catch(() => null);
const personName = person?.first_name
? `${person.first_name} ${person.last_name || ""}`.trim()
: args.id;
return {
action: "remove person",
target: personName,
warning: "the person record will be soft-deleted. credits and agreements will remain linked.",
display_name: displayName,
};
}
case "pica_people_bulk_delete": {
const count = args.ids?.length || 0;
return {
action: "bulk delete people",
target: `${count} ${count === 1 ? "person" : "people"}`,
warning: `this will soft-delete ${count} person record(s). credits and agreements remain linked to the removed records.`,
display_name: displayName,
};
}
case "pica_people_split_identity": {
// ops 482424b7 — name who shares the identity and what moves, so
// the confirming human sees the blast radius before the token.
const preview = await pica.people
.splitIdentityPreview(args.id)
.catch(() => null);
const personName = preview?.person?.name || args.id;
const shared = Array.isArray(preview?.shared_with)
? preview.shared_with.map((p) => p.name || p.id)
: [];
const moving = Array.isArray(args.move_identifiers)
? args.move_identifiers
: [];
const relinkCount = Array.isArray(args.relink_split_ids)
? args.relink_split_ids.length
: 0;
return {
action: "give a person their own identity",
target: shared.length
? `${personName} (currently sharing an identity with ${shared.join(", ")})`
: personName,
warning: `${personName} gets a new global identity. ` +
(moving.length
? `these identifiers move with them: ${moving.join(", ")}. `
: "no identifiers move — every identifier stays with the original identity. ") +
(relinkCount
? `${relinkCount} unattributed split row(s) will be credited to them. `
: "") +
"custody records are not rewritten. the audit log keeps every moved value, but this is not undone automatically.",
display_name: displayName,
};
}
case "pica_agreements_delete": {
const agreementResult = await pica.agreements
.get(args.id)
.catch(() => null);
const title = agreementResult?.agreement?.title || args.id;
return {
action: "delete agreement",
target: title,
warning: "this will remove all linked work and party records. this cannot be undone.",
display_name: displayName,
};
}
case "pica_merge_duplicates": {
const loserCount = args.loser_ids?.length || 0;
let entityType = "entities";
try {
entityType = coerceMergeEntityType(args.entity_type).canonical;
}
catch {
// Unknown entity_type — fall back to generic noun in the warning
// copy. Tool execution itself will surface the validation error.
}
return {
action: `merge ${entityType}`,
target: `${loserCount} ${entityType} into winner`,
warning: `all credits, recordings, and agreements from ${loserCount} loser(s) will be moved to the winner. loser records will be permanently deleted. this cannot be undone.`,
display_name: displayName,
};
}
case "pica_rights_propose": {
// ADR-321 §9 — not a delete: the confirmation exists because this
// emails every party the rule affects. Nothing is active until each
// one confirms from their own email.
const work = await pica.works.get(args.work_id).catch(() => null);
const song = work?.title ? `"${work.title}"` : "the song";
const what = args.rule === "master"
? `the master rule for a recording of ${song}`
: args.rule === "share"
? `a ${String(args.kind ?? "share").replace(/_/g, " ")} share rule on ${song}`
: `the song rule for ${song}`;
return {
action: "propose a rights rule",
target: what,
warning: "this emails every party the rule affects and asks them to confirm. nothing is locked until every party confirms by email — PICA never confirms on anyone's behalf and no money moves.",
display_name: displayName,
};
}
case "pica_projects_delete": {
const project = await pica.projects?.get(args.id).catch(() => null);
const projectName = project?.name || args.id;
return {
action: "delete project",
target: projectName,
warning: "linked works will be unlinked but not deleted. this cannot be undone.",
display_name: displayName,
};
}
// ── ADR-199 Stream 5b — 7 fall-through cases now specific ──
case "pica_recordings_delete": {
const recording = await pica.recordings
?.get?.(args.id)
.catch(() => null);
const title = recording?.title || args.id;
const isrc = recording?.isrc;
return {
action: "delete recording",
target: isrc ? `${title} (${isrc})` : title,
warning: "the recording will be removed from its work and any release. credits attached to this recording will be detached. this cannot be undone.",
display_name: displayName,
};
}
case "pica_share_links_delete": {
const link = await pica.shareLinks.get(args.id).catch(() => null);
const linkData = link?.data || link;
const linkName = linkData?.name || args.id;
return {
action: "delete share link",
target: linkName,
warning: "the link will become inaccessible immediately. recipients with the URL will see a 'no longer available' page. view / download history is removed. to keep the audit trail, prefer pica_share_links_update with is_active=false.",
display_name: displayName,
};
}
case "pica_agreement_types_delete": {
return {
action: "delete agreement type",
target: args.id,
warning: "the agreement type will be removed from the catalog. existing agreements that reference this type will keep their type string but lose the template definition. this cannot be undone.",
display_name: displayName,
};
}
case "pica_notes_delete": {
return {
action: "delete note",
target: args.id,
warning: "the note will be permanently removed. attachments referenced by the note are not deleted. this cannot be undone.",
display_name: displayName,
};
}
case "pica_physical_assets_delete": {
const asset = await pica.physicalAssets
?.get?.(args.id)
.catch(() => null);
const assetName = asset?.name || asset?.title || args.id;
return {
action: "delete physical asset",
target: assetName,
warning: "the asset record and any linked recording / work associations will be removed. this cannot be undone — re-create from scratch if you need it back.",
display_name: displayName,
};
}
case "pica_memory_delete": {
return {
action: "delete memory entry",
target: args.id,
warning: "the memory entry will be permanently removed and will not appear in future agent context. this cannot be undone.",
display_name: displayName,
};
}
case "pica_delete_my_account": {
return {
action: "delete account",
target: "your account and organisation",
warning: "this is permanent. your auth user, organisation membership, API keys, OAuth tokens, agent grants, and notification preferences will all be removed. catalog data (works, recordings, credits) is retained per data-retention policy. this cannot be undone.",
display_name: displayName,
};
}
case "pica_revoke_agent_grant": {
const grantId = args.grant_id || args.id;
return {
action: "revoke agent grant",
target: grantId,
warning: "the grant token will stop authenticating immediately. any in-flight agent calls using this grant will fail at next request. this cannot be undone — issue a new grant if needed.",
display_name: displayName,
};
}
case "pica_collaborators_invite_revoke": {
const inviteId = args.invite_id || args.id;
return {
action: "revoke collaboration invite",
target: inviteId,
warning: "the invite will become unclaimable immediately. both the magic-link URL and the one-time code will return 'invite no longer valid'. an existing-user recipient will see a revocation event on their next agent prompt. this cannot be undone — issue a new invite if needed.",
display_name: displayName,
};
}
default: {
return {
action: displayName,
target: args.id || "unknown",
warning: "this cannot be undone.",
display_name: displayName,
};
}
}
}
catch {
return {
action: displayName,
target: args.id || "unknown",
warning: "this cannot be undone.",
display_name: displayName,
};
}
}
/**
* Sanitize tool parameters for audit logging — delegates to the
* pure-function impl below so it can be exercised directly in tests.
*/
sanitizeParams(args) {
return sanitizeAuditParams(args);
}
/**
* ADR-208 Primitive B — opaque per-org cache key.
*
* Stdio: `config.picaApiKey` is the org-bound API key from credentials,
* unique per org. HTTP non-discovery: `callerContext.callerIdentity` is
* `http:${authContext.keyId}` set by `app/api/mcp/route.ts`, also unique
* per org. This is the cross-org cache isolation invariant required by
* acceptance criterion 4 — two orgs sharing a worker process never share
* a cacheKey. Returns `null` (and the wrapper skips session_state) when
* neither identifier is present.
*/
getSessionCacheKey() {
const apiKey = this.config?.picaApiKey;
if (apiKey)
return apiKey;
if (this.callerContext.callerIdentity &&
this.callerContext.callerIdentity !== "unknown") {
return this.callerContext.callerIdentity;
}
return null;
}
/**
* ADR-208 Primitive B + ADR-208 Phase 2 + ADR-210 Phase 1 — fetch the
* org-scoped counts + billing slice via `pica.catalogStats()`. The
* endpoint composes the billing slice server-side via
* `subscriptionService.getOrgBillingSlice` and the pending-uploads count
* via the new `organisations.pending_uploads_count` column so stdio +
* HTTP transports stay symmetric.
*
* `completeness_pct` is stubbed at 0 in Phase 1 and will be wired in a
* Phase 1.5 follow-up. The stub keeps the shape contract green without
* forcing a separate endpoint extension into this PR.
*
* Fail-soft: when `this.pica` is missing (HTTP-discovery without bearer
* scope, mostly), every count is 0 and the billing slice falls back to
* a conservative "trial" shape so the agent doesn't see misleading
* "active" billing for an uninitialized context.
*/
fetchSessionCounts = async () => {
if (!this.pica) {
return {
works_count: 0,
recordings_count: 0,
completeness_pct: 0,
pending_uploads: 0,
billing_state: "trial",
plan: "free_runway",
current_tier: null,
capacity_pct: 0,
};
}
const stats = await this.pica.catalogStats();
const billing = stats.billing_slice;
// ADR-217 Primitive D — storage slice from /api/admin/catalog/stats.
// Optional in the response so older servers without ADR-217 keep
// working — when missing, omit the key entirely (vs fabricating a
// misleading "pica_s3 / untested" envelope that would suppress an
// agent's "you might want to configure storage" nudge).
const storage = stats.storage_slice;
const counts = {
works_count: stats.works?.total ?? 0,
recordings_count: stats.recordings?.total ?? 0,
// Phase 1.5 stub — completeness_pct ships separately. Shape preserved.
completeness_pct: 0,
pending_uploads: stats.pending_uploads_count ?? 0,
// ADR-210 Phase 1 — billing slice from /api/admin/catalog/stats. The
// endpoint emits a "trial" fallback on its own billing-side outage; if
// the field is missing entirely (older server pre-Phase-1), mirror the
// same conservative shape locally rather than fabricate "active".
billing_state: billing?.billing_state ?? "trial",
plan: billing?.plan ?? "free_runway",
current_tier: billing?.current_tier ?? null,
capacity_pct: billing?.capacity_pct ?? 0,
...(storage
? {
storage_provider: {
provider: storage.provider,
is_byoc: storage.is_byoc,
last_test_status: storage.last_test_status,
},
}
: {}),
};
// ADR-218 Primitive A — propagate the optional notifications summary
// when the catalog-stats response carries it. Older API servers
// (pre-ADR-218) omit the field; we leave the envelope key off in that
// case so the agent doesn't see a fabricated zero.
if (stats.notifications) {
counts.notifications = stats.notifications;
}
return counts;
};
/**
* P3c — attach `_meta.schemas` (workflow-resource URIs) to a successful
* workflow-tagged tool result. Mirrors `attachSessionState`'s merge
* semantics: spread existing `_meta`, add the new key, leave other
* keys (e.g. `session_state`) untouched.
*
* Applies to ALL workflow-tagged tools (Option X), including read
* companions like `pica_sessions_query`, so the agent gets the schema
* pointer no matter which verb they hit first. Skips when:
* - the tool's `workflows` is or contains only `"infrastructure"`
* (no schema resource exists), or
* - the result is an error (the workflow didn't progress).
*
* Fail-open like `attachSessionState`: derivation is pure today, but
* any future throw must not turn a successful tool result into a
* failure.
*/
attachSchemaHints(result, workflows) {
try {
const tags = Array.isArray(workflows) ? workflows : [workflows];
const schemas = tags
.filter((t) => t !== "infrastructure")
.map((t) => `pica://schemas/${t}`);
if (schemas.length === 0)
return;
const existingMeta = result._meta ?? {};
result._meta = {
...existingMeta,
schemas,
};
}
catch {
// Match attachSessionState shape — fail-open. The hint is a
// discoverability aid, never load-bearing for the tool's own work.
}
}
/**
* ADR-208 Primitive B — bump the session-mutation counters and attach
* the resolved `_meta.session_state` to a successful write-tool result.
* Mutates `result` in-place to keep the existing return contract.
* Fail-open: any error here is swallowed — ambient state is best-effort
* and must not turn a successful mutation into a tool failure.
*/
async attachSessionState(result, toolName) {
const cacheKey = this.getSessionCacheKey();
if (!cacheKey)
return;
// Not in a client's catalogue: the counts read (catalog/stats) does not
// open through a relationship, so it would be refused, and that refusal
// would be judged as part of the tool's own answer (settleCatalogueAnswer
// reads every request the call made). The counts would be the caller's
// own catalogue's anyway, not the client's.
if (currentCatalogue() !== undefined)
return;
try {
incrementSessionMutations(cacheKey);
const sessionState = await buildSessionState(cacheKey, this.fetchSessionCounts);
if (!sessionState)
return;
const existingMeta = result._meta ?? {};
result._meta = {
...existingMeta,
session_state: sessionState,
};
}
catch {
// Swallow — ADR-208 explicitly mandates fail-open. The counter has
// already advanced; not attaching session_state on this turn just
// means the agent gets the bumped counters on the next mutation.
void toolName;
}
}
/**
* Execute a tool by name. `ctx.server` carries the transport-scoped
* MCP `Server` reference so executors that need `server.elicitInput()`
* (ADR-200 Phase 1) can reach it. Each per-request HTTP server is
* different; passing it through positionally avoids registry-level
* mutable state that would race between concurrent requests.
*/
async executeTool(name, args, ctx) {
// ADR-333 D7 — a call naming a client catalogue runs entirely inside it:
// the confirmation preview, the executor and the session-state read all
// make their requests for that catalogue. The argument stays in the
// confirmation token's arguments (so a token confirmed for one client
// cannot be replayed for another) and in the audit row, but is removed
// before the executor, which would otherwise send it on to the app.
const catalogue = args?.[CATALOGUE_ARG];
// pica_execute may carry the wrapped tool's arguments flat, `catalogue`
// among them; it reaches this method again for the wrapped tool, which
// is where the argument is checked and applied.
if (catalogue === undefined || name === "pica_execute") {
return this.executeToolInCatalogue(name, args, args, ctx);
}
const tool = this.tools.get(name);
if (!tool) {
throw new ToolExecutionError(`Tool not found: ${name}`);
}
const refusal = catalogueRefusal(tool.definition, catalogue);
if (refusal) {
return {
content: [
{
type: "text",
text: JSON.stringify({ error: "CATALOGUE_NOT_SUPPORTED", message: refusal }, null, 2),
},
],
isError: true,
};
}
const { [CATALOGUE_ARG]: _catalogue, ...executorArgs } = args;
const { value, report } = await runInCatalogue(catalogue, () => this.executeToolInCatalogue(name, args, executorArgs, ctx));
return settleCatalogueAnswer(name, value, report);
}
/**
* `executeTool` after the catalogue is settled. `args` are the arguments as
* called (gates, tokens, audit); `executorArgs` are what the tool receives.
*/
async executeToolInCatalogue(name, args, executorArgs, ctx) {
const startTime = Date.now();
const tool = this.tools.get(name);
if (!tool) {
throw new ToolExecutionError(`Tool not found: ${name}`);
}
// ops dfa810f8 — an unknown `sections` name used to match nothing in the
// executor and answer a successful `{}`. Refuse it here, once, for every
// tool that declares a closed `sections` list (./section-validation.ts).
const sectionsRefusal = rejectUnknownSections(name, tool.definition.inputSchema, args);
if (sectionsRefusal)
return sectionsRefusal;
// Task A0 — resolve the canonical identity for GATE PURPOSES ONLY. An
// alias clone's `tool.executor` applies `adaptArgs` internally, but the
// confirmation gate below (buildDestructivePreview / generate+validate
// token) previously ran on the alias name + raw args, so a destructive
// canonical aliased with `adaptArgs` would preview/tokenize the WRONG
// arg shape. `gateName`/`gateArgs` fix that; the executor call further
// down stays on the raw `name`/`args` (no double-adapt).
const alias = getAliasFor(name);
const gateName = alias?.canonical ?? name;
const gateArgs = alias?.adaptArgs ? alias.adaptArgs(args) : args;
const metadata = getToolMetadata(name);
// ADR-230 — declared tier is the source of truth for the freshness +
// confirmation gates and the audit-log surfaces. Fall back to
// metadata.risk only if the tool somehow lacks tier (lint blocks
// missing tier on creator + team + directory MCPs as of PR-2).
const declaredTier = tool.definition.tier;
const fallbackRisk = metadata?.risk ?? "safe";
const auditTier = declaredTier ??
(fallbackRisk === "safe"
? "read"
: fallbackRisk === "destructive"
? "destructive"
: "write");
const auditRiskLevel = tierToRiskLevel(auditTier);
const isDestructive = auditTier === "destructive";
// ADR-199 — a `write`-tier tool may opt into the destructive two-step
// confirmation token via previewMode, WITHOUT the destructive:* scope or
// the 7-day freshness gate (both stay keyed on isDestructive below).
const requiresTwoStepToken = tool.definition.previewMode === "two_step_token";
const needsConfirmation = isDestructive || requiresTwoStepToken;
// ── Session freshness gate (ADR-151) ──
if (isDestructive && this.config && !this.config.lobbyMode) {
const creds = readCredentials(this.config.credentialsPath);
if (creds?.authenticated_at) {
const authAge = Date.now() - new Date(creds.authenticated_at).getTime();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
if (authAge > sevenDays) {
return {
content: [
{
type: "text",
text: 'this action requires re-verification — say "sign me in" to confirm your identity',
},
],
isError: true,
};
}
}
}
// ── Confirmation gate (ADR-230 destructive tier + ADR-199 two_step_token) ──
if (needsConfirmation) {
const confirmationToken = args.confirmation_token;
if (!confirmationToken) {
// First call — generate preview and return confirmation challenge.
// ADR-230 PR-4 — token state lives in Postgres for HTTP MCP under
// Fluid Compute (or in-memory for stdio); both paths via the
// active ConfirmationStore singleton, so callers stay unchanged.
const token = await generateConfirmationToken(gateName, gateArgs);
const preview = await this.buildDestructivePreview(gateName, gateArgs);
const response = {
content: [
{
type: "text",
text: JSON.stringify({
status: "confirmation_required",
confirmation_token: token,
expires_in: 120,
preview,
}, null, 2),
},
],
structuredContent: {
status: "confirmation_required",
confirmation_token: token,
expires_in: 120,
preview,
},
};
// Fire-and-forget audit for confirmation challenge
this.auditLogger
?.logToolExecution({
tool_name: name,
tool_category: metadata?.category || "unknown",
tier: auditTier,
risk_level: auditRiskLevel,
parameters: this.sanitizeParams(args),
result_status: "confirmation_required",
confirmation_token: token,
execution_time_ms: Date.now() - startTime,
caller_identity: this.callerContext.callerIdentity,
transport: this.callerContext.transport,
...this.extractClientInfo(ctx),
})
.catch(() => { }); // Never block on audit
return response;
}
// Second call — validate token (Postgres-backed under Fluid Compute
// since ADR-230 PR-4; the active store handles cross-instance state).
const validation = await validateAndConsumeToken(confirmationToken, gateName, gateArgs);
if (!validation.valid) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: validation.reason?.includes("expired")
? "CONFIRMATION_EXPIRED"
: "CONFIRMATION_INVALID",
message: validation.reason,
retry_safe: false,
suggestion: "start the operation again without the confirmation token",
}, null, 2),
},
],
isError: true,
};
}
// Token valid — fall through to execute
}
// ── Normal execution ──
try {
const result = await tool.executor(executorArgs, ctx);
// Guard response size — envelope-aware (ops 41ff656c / 893e3e29): list
// responses over the 100KB cap get whole-item trimming + a real resume
// cursor (next_offset / recovery_hint) in both views; non-list shapes
// keep the legacy text truncation.
if (result?.content) {
const schemaProps = tool.definition.inputSchema?.properties ?? {};
guardToolResult(result, name, {
offsetSupported: "offset" in schemaProps,
offset: typeof args?.offset === "number" ? args.offset : 0,
limit: typeof args?.limit === "number" ? args.limit : undefined,
});
}
// ADR-208 Primitive B — ambient session-state on write-tool results.
// Read tools (`tier: "read"`) skip this entirely so the high-volume
// read path stays cheap. `result.isError === true` also skips because
// a failed mutation didn't change catalog state.
const isWriteTool = auditRiskLevel !== "safe";
if (isWriteTool && result && !result.isError) {
await this.attachSessionState(result, name);
}
// P3c — schema-resource hint on every workflow-tagged tool result
// (Option X — applies to read companions too so the agent gets the
// pointer regardless of which verb they hit first). Runs after
// `attachSessionState` so the two `_meta` keys co-exist via merge.
// Skipped on errors (workflow didn't progress) and on infrastructure
// tools (handled inside `attachSchemaHints`).
if (result && !result.isError) {
this.attachSchemaHints(result, tool.definition.workflows);
}
// Soft deprecation hint — when the invoked name is an alias, tell the agent
// which canonical to prefer. Never errors; routing already happened.
// `alias` was already resolved at the top of this method (gate-purposes
// computation, Task A0) — same `name`, so reuse it instead of a second
// getAliasFor lookup.
if (alias && result && !result.isError) {
result._meta = {
...(result._meta ?? {}),
deprecation: {
use: alias.canonical,
removal_target: alias.removal_target,
},
};
}
// ADR-208 acceptance criterion 5 — briefing run resets the
// since_last_briefing counter. Handled in the wrapper so the briefing
// tool itself stays oblivious to the cacheKey it doesn't own.
if (name === "pica_dashboard_briefing" && result && !result.isError) {
const cacheKey = this.getSessionCacheKey();
if (cacheKey)
resetSinceLastBriefing(cacheKey);
}
// Fire-and-forget audit.
// `isError: true` results (e.g. fail-quiet-to-ops wrappers that
// caught an SDK error, validation rejections, or session-freshness
// gates) get `result_status: "error"` here so mcp_audit_log
// telemetry counts and the ops_issues auto-capture pipeline see
// them. Executors can opt into structured error attribution by
// setting `structuredContent.error_code` + `structuredContent.error_message`
// — anything else just lands as an isError row with no detail,
// which is fine for the input-validation case (ops_issues
// upsertFromAuditError requires a code to group on, so generic
// validation rejections stay out of the ops bucket by default).
const resultIsError = result?.isError === true;
const { code: resultErrorCode, message: resultErrorMessage } = resultIsError
? extractErrorAttribution(result)
: { code: undefined, message: undefined };
// 2026-09-01 stdio-relay wire change: forward the narrowed result
// projection so the relay ingest can attribute import wins (created
// work ids live in the RESULT for the import tools; their params
// carry no id) and, since 2026-09-04, create wins (`createdId`).
// narrowToolResultForRefs is the allow-list — null for every other
// tool, so nothing else changes on the wire.
const resultProjection = resultIsError
? null
: narrowToolResultForRefs(name, args || {}, result);
this.auditLogger
?.logToolExecution({
tool_name: name,
tool_category: metadata?.category || "unknown",
tier: auditTier,
risk_level: auditRiskLevel,
parameters: this.sanitizeParams(args),
result_status: resultIsError ? "error" : "success",
...(resultErrorCode ? { error_code: resultErrorCode } : {}),
...(resultErrorMessage ? { error_message: resultErrorMessage } : {}),
execution_time_ms: Date.now() - startTime,
caller_identity: this.callerContext.callerIdentity,
transport: this.callerContext.transport,
...this.extractClientInfo(ctx),
...(resultProjection ? { result_projection: resultProjection } : {}),
})
.catch(() => { }); // Never block on audit
return result;
}
catch (error) {
const formatted = formatError(error);
const parsed = JSON.parse(formatted.text);
// Attach recovery hint if available for this tool + error code
const hint = getRecoveryHint(name, parsed.error);
if (hint) {
parsed.suggestion = hint.suggestion;
if (hint.next_tool) {
parsed.next_tool = hint.next_tool;
}
}
// Fire-and-forget audit for errors
this.auditLogger
?.logToolExecution({
tool_name: name,
tool_category: metadata?.category || "unknown",
tier: auditTier,
risk_level: auditRiskLevel,
parameters: this.sanitizeParams(args),
result_status: "error",
error_code: parsed.error,
// Stage A — forward the parsed body's message so mcp_audit_log
// captures the underlying cause. Without this every error row had
// error_message=null, leaving UNKNOWN_ERROR rows opaque.
error_message: typeof parsed.message === "string" ? parsed.message : undefined,
execution_time_ms: Date.now() - startTime,
caller_identity: this.callerContext.callerIdentity,
transport: this.callerContext.transport,
...this.extractClientInfo(ctx),
})
.catch(() => { }); // Never block on audit
return {
content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }],
isError: true,
};
}
}
}
//# sourceMappingURL=index.js.map