import { TOOL_DESCRIPTION_MAX_BYTES } from '../constants'; import { JMESPATH_SCHEMA } from '../jmespath'; import type { PublicToolShape, ToolDef } from '../types'; import { compressDescription, utf8ByteLength } from '../utils'; import { CACHE_TOOLS } from './cache-tools'; import { NLP_TOOLS } from './nlp-tools'; import { RPC_TOOLS } from './rpc-tools'; // Merged tool registry — cache tools first (no `_execute`), then RPC tools // (with `_execute`), then the NLP utilities. Order is observable: `tools/list` // emits tools in this same order, and `describe_tool({tool_name: 'nonexistent'})` // returns the available-list sorted before responding. NLP_TOOLS is appended // last so extracting it from rpc-tools.ts left every other tool's position // unchanged. export const TOOL_REGISTRY: ToolDef[] = [...CACHE_TOOLS, ...RPC_TOOLS, ...NLP_TOOLS]; // Public shape for tools/list — strips internal _-prefixed fields, adds MCP // annotations, and injects the universal `summary` flag (issue #3678) into // every cache tool's advertised schema. Cache tools are uniformly summarisable; // RPC/_execute tools have bespoke response shapes and aren't covered. export const SUMMARY_SCHEMA = { type: 'boolean', description: 'Return counts + 3-item samples instead of full lists. Useful when you only need shape/size or want to budget context before drilling in.', } as const; // Collision guard — fail fast at module load if a future PR hand-declares // `jmespath` (or `summary` on a cache tool) on a tool's inputSchema. The // universal injection below would silently overwrite the hand-declared // version; failing loud forces the author to resolve the duplication. for (const tool of TOOL_REGISTRY) { const props = tool.inputSchema.properties; if (props && 'jmespath' in props) { throw new Error(`api/mcp/registry/index.ts: tool "${tool.name}" declares its own 'jmespath' property — collides with universal JMESPATH_SCHEMA injection. Remove the per-tool declaration.`); } if (tool._execute === undefined && props && 'summary' in props) { throw new Error(`api/mcp/registry/index.ts: cache tool "${tool.name}" declares its own 'summary' property — collides with universal SUMMARY_SCHEMA injection. Remove the per-tool declaration.`); } } // Shared public-shape builder (v1.5.0). SINGLE source of truth for what // `tools/list` and `describe_tool` emit. Both surfaces go through this // helper so they can never drift. // // Always recursively deep-clones property schemas AND the injected // SUMMARY_SCHEMA / JMESPATH_SCHEMA consts via `structuredClone`. Without // this, mutating any returned property (including nested `enum` / `items.enum` // arrays, e.g. `get_market_data.asset_classes.items.enum`) would corrupt // the registry or the module-level schema consts. Codex Round 2 explicitly // flagged shallow `{ ...prop }` as insufficient for these shapes. // // `_*`-prefixed internal fields (_apiPaths, _cacheKeys, _seedMetaKey, // _maxStaleMin, _freshnessChecks, _coverageKeys, _postFilter, _execute) // are NEVER enumerated — the function only constructs a fresh object with // the public-shape fields (name, description, inputSchema, annotations). // // `opts.compressDescriptions` — when true (the tools/list call path), // the tool's top-level `description` is run through compressDescription. // When false (the describe_tool call path), full text is preserved. export function buildPublicTool( tool: ToolDef, opts: { compressDescriptions: boolean }, ): PublicToolShape { const isCacheTool = tool._execute === undefined; // Recursively clone each property schema. Handles both direct `enum: [...]` // arrays and nested `items.enum: [...]` arrays — both shapes appear in // TOOL_REGISTRY (e.g. get_market_data's `asset_class.items.enum` and // `get_news_intelligence.topic.enum`). `structuredClone` is a Web Platform // global on Vercel edge + Node 18+ (no polyfill needed). const clonedProperties: Record = {}; for (const [key, value] of Object.entries(tool.inputSchema.properties)) { clonedProperties[key] = structuredClone(value); } // Inject the universal schemas as CLONES, not bare references, so that // mutating `result.inputSchema.properties.jmespath.description` doesn't // corrupt the module-level JMESPATH_SCHEMA const. if (isCacheTool) { clonedProperties.summary = structuredClone(SUMMARY_SCHEMA); } clonedProperties.jmespath = structuredClone(JMESPATH_SCHEMA); const description = opts.compressDescriptions ? compressDescription(tool.description, TOOL_DESCRIPTION_MAX_BYTES) : tool.description; const publicTool: PublicToolShape = { name: tool.name, description, inputSchema: { type: tool.inputSchema.type, properties: clonedProperties, required: [...tool.inputSchema.required], }, // Deep-clone for the same reason as inputSchema.properties — mutating the // returned object must not corrupt the module-level outputSchema literal. outputSchema: structuredClone(tool.outputSchema), // Per-tool annotations declared on each registry entry (v1.7.0). // Deep-cloned so a mutating client can't poison the registry literal — // matches the inputSchema.properties + outputSchema treatment above. annotations: structuredClone(tool.annotations), }; // MCP Apps (`io.modelcontextprotocol/ui`) — translate the tool's internal // `_uiResourceUri` into the spec-reserved public `_meta`. Emit BOTH the // nested `ui.resourceUri` (current form) and the flat `ui/resourceUri` // (deprecated legacy alias) so hosts on either revision resolve the shell. // Only tools with an interactive UI surface carry `_meta`; every other tool // omits it entirely (no empty object on the wire). if (tool._uiResourceUri) { publicTool._meta = { ui: { resourceUri: tool._uiResourceUri }, 'ui/resourceUri': tool._uiResourceUri, }; } return publicTool; } export const TOOL_LIST_RESPONSE = TOOL_REGISTRY.map((tool) => buildPublicTool(tool, { compressDescriptions: true })); // Tools-list payload is static at module load — precompute its wire size so // the per-session `mcp.tools_list_emitted` telemetry line doesn't re-stringify // ~5 KB on every initialize. export const TOOL_LIST_BYTES = utf8ByteLength(JSON.stringify(TOOL_LIST_RESPONSE));