From 09ea2ea83a8673797e8d9331fed13eb489b2506b Mon Sep 17 00:00:00 2001 From: Chris Preuss Date: Tue, 15 Sep 2026 04:34:36 -0400 Subject: [PATCH 1/2] feat(mcp): progressive tool discovery on a compact endpoint 31 tools and ~27KB of JSON Schema landed in every client's context on every request, and tool-selection accuracy falls off past roughly thirty tools. Clients that can defer loading (Claude Code, the MCP connector's mcp_toolset defer_loading) were fine; everything else paid in full. POST /mcp/compact advertises 15 tools instead of 31 (12KB vs 27KB, a 55% cut): search_tools finds any deferred read-only tool by name, title, description, parameter name or parameter description and returns its full input schema, and call_tool dispatches it. POST /mcp is unchanged. call_tool takes read-only tools only. A client gates approval on the tool name it can see, so routing an order through a generic dispatcher would hide it from the check meant to catch it: place_order, execute_strategy, create_connection, the webhook writes and every other mutation stay first-class tools in both modes. It also refuses an already-advertised read tool, answers an unknown name with near matches, and validates arguments against the real schema before dispatch. Tools are captured once and replayed, so the advertised catalogue and the dispatchable catalogue cannot drift. New cpzai://guides/discovery resource documents both endpoints, including the defer_loading config for the MCP connector. --- README.md | 41 ++++++- package-lock.json | 7 +- package.json | 5 +- src/capabilities.ts | 36 +++++- src/index.ts | 16 ++- src/server.ts | 28 ++++- src/tool-registry.ts | 224 ++++++++++++++++++++++++++++++++++++++ src/tool-search.ts | 179 ++++++++++++++++++++++++++++++ tests/server.test.ts | 4 +- tests/tool-search.test.ts | 193 ++++++++++++++++++++++++++++++++ 10 files changed, 712 insertions(+), 21 deletions(-) create mode 100644 src/tool-registry.ts create mode 100644 src/tool-search.ts create mode 100644 tests/tool-search.test.ts diff --git a/README.md b/README.md index ec1fc9d..ee292b1 100644 --- a/README.md +++ b/README.md @@ -105,22 +105,59 @@ Eligible GET calls can retry transient upstream failures within a bounded timeou |---|---|---| | Resource | `cpzai://guides/tool-usage` | Tool selection, pagination, and capability boundaries | | Resource | `cpzai://guides/permissions` | Resource permissions and execution boundaries | +| Resource | `cpzai://guides/discovery` | Endpoints, deferred loading, and the search conventions | | Prompt | `review_portfolio` | Portfolio review workflow; optional `account_id` | | Prompt | `analyze_strategy` | Strategy investigation; required `strategy_id` | Requesting a prompt returns instructions; it does not execute a workflow. Discover these using standard MCP methods. There is no `/mcp/info` endpoint. +## Progressive tool discovery + +The full catalogue is 31 tools and roughly 27 KB of JSON Schema, which every client pays for on every request unless it can defer tool loading. Tool-selection accuracy also falls off once a model is choosing between more than about thirty tools. There are two endpoints, serving identical tools under different discovery models. + +### `POST /mcp`: full catalogue + +`tools/list` returns every tool. Use this when the client defers loading itself: it needs the definitions in order to defer them. With Claude's MCP connector, pair `defer_loading` with the tool search tool and keep the everyday reads loaded: + +```json +{ + "type": "mcp_toolset", + "mcp_server_name": "cpzai", + "default_config": { "defer_loading": true }, + "configs": { + "list_accounts": { "defer_loading": false }, + "list_positions": { "defer_loading": false }, + "place_order": { "defer_loading": false } + } +} +``` + +Deferred definitions are still sent on every request; they stay out of the model's context until a search discovers them, and the prompt prefix is untouched, so the cache survives. Claude Code applies its own tool search to this endpoint with no configuration. + +### `POST /mcp/compact`: server-side discovery + +For clients with no deferral of their own. `tools/list` returns 15 tools instead of 31 (about 12 KB instead of 27 KB, a 55% cut): + +- `search_tools`: natural-language or keyword query, optional `category`, returns each match with its **full input schema**. Searches tool names, titles, descriptions, parameter names, and parameter descriptions, so a field name such as `unsettled` or `entity_id` finds the tool that accepts it. Categories: strategies, backtests, orders, positions, accounts, market-data, risk, data, webhooks, middle-office, profile. +- `call_tool`: invokes a discovered tool by name with an arguments object matching its `input_schema`, and returns that tool's result unchanged. +- Every state-changing tool (`place_order`, `execute_strategy`, `create_strategy`, `update_strategy`, `create_connection`, `create_webhook`, `delete_webhook`, `sync_portfolio`, `compute_risk`) plus the read anchors `list_accounts`, `list_positions`, `list_orders`, `get_market_data`. + +`call_tool` dispatches **read-only tools only**. A client gates approval on the tool name it can see, so routing an order through a generic dispatcher would hide it from the check meant to catch it. It also refuses a read-only tool that is already advertised, and answers an unknown name with near matches rather than a guess. Arguments are validated against the real tool schema before dispatch; a rejection returns the offending paths and that tool's schema. + +Tool names, arguments, results, pagination, and scopes are identical on both endpoints. Discovery reads no account data and places no order. Presence in `tools/list` is not proof that the caller's key holds the scope for a tool; see `cpzai://guides/permissions`. + ## Architecture ```text Remote MCP client - → POST /mcp (fresh stateless server per request) + → POST /mcp (full catalogue) or POST /mcp/compact (search_tools + call_tool) + → fresh stateless server per request → user's API credentials, resolved from headers or encrypted OAuth token → CPZ REST API /functions/v1/rest-api/v1 → existing user-scoped platform handlers ``` -`src/tools.ts` and `src/expanded-tools.ts` own the public MCP schemas; `src/capabilities.ts` defines guide resources and workflow prompts. They are distinct from internal Simons tools and are not imported from a shared schema package. The server proxies to the existing REST adapter rather than querying user data with a service-role key. The hosted service also supports authenticated Simons chat proxy routes; those routes are not additional MCP tools. +`src/tools.ts` and `src/expanded-tools.ts` own the public MCP schemas; `src/capabilities.ts` defines guide resources and workflow prompts; `src/tool-registry.ts` captures those same schemas as data and `src/tool-search.ts` turns them into the compact surface, so the advertised and dispatchable catalogues cannot drift apart. They are distinct from internal Simons tools and are not imported from a shared schema package. The server proxies to the existing REST adapter rather than querying user data with a service-role key. The hosted service also supports authenticated Simons chat proxy routes; those routes are not additional MCP tools. ## Local development diff --git a/package-lock.json b/package-lock.json index 54f0d38..f630e82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@cpzai/mcp-server", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cpzai/mcp-server", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "@sentry/node": "^10.41.0", "express": "^4.21.2", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.1" }, "devDependencies": { "@types/express": "^5.0.1", diff --git a/package.json b/package.json index 771e5bb..47afe01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cpzai/mcp-server", - "version": "1.2.0", + "version": "1.3.0", "description": "CPZAI MCP server \u2014 AI agent access to quantitative trading strategies, backtests, multi-broker order routing, portfolios, and risk analytics.", "license": "MIT", "homepage": "https://ai.cpz-lab.com", @@ -36,7 +36,8 @@ "@modelcontextprotocol/sdk": "^1.12.1", "@sentry/node": "^10.41.0", "express": "^4.21.2", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.1" }, "devDependencies": { "@types/express": "^5.0.1", diff --git a/src/capabilities.ts b/src/capabilities.ts index 9e50009..65655e0 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -1,7 +1,14 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -export const SERVER_INSTRUCTIONS = `CPZAI provides user-scoped trading and research tools. Use tools/list for the current catalog and read cpzai://guides/tool-usage and cpzai://guides/permissions before planning a workflow. Paginate list results; a page is not the entire portfolio. Read-only review does not require order submission or strategy execution. execute_strategy can place real orders. Inspect account environment and tradable status before any user-authorized trading. A failed or timed-out mutation can have an unknown outcome: reconcile orders before considering another submission. Never infer zero positions, prices, or risk from an error or missing data.`; +const BASE_INSTRUCTIONS = `CPZAI provides user-scoped trading and research tools. Use tools/list for the current catalog and read cpzai://guides/tool-usage and cpzai://guides/permissions before planning a workflow. Paginate list results; a page is not the entire portfolio. Read-only review does not require order submission or strategy execution. execute_strategy can place real orders. Inspect account environment and tradable status before any user-authorized trading. A failed or timed-out mutation can have an unknown outcome: reconcile orders before considering another submission. Never infer zero positions, prices, or risk from an error or missing data.`; + +const COMPACT_INSTRUCTIONS = `This endpoint advertises a compact surface. tools/list carries every state-changing tool plus the everyday reads; the remaining read-only tools load on demand. Call search_tools to find them; it returns each match with its full input schema. Then invoke the match with call_tool. call_tool dispatches read-only tools only; anything that places orders, executes strategies, or writes credentials or webhooks is advertised under its own name and must be called directly. Read cpzai://guides/discovery for the categories and the search conventions. An empty search result means no tool matched that wording, never that the capability is absent.`; + +/** Server instructions, which differ by endpoint: see ToolMode in server.ts. */ +export function serverInstructions(mode: 'full' | 'compact' = 'full'): string { + return mode === 'compact' ? `${BASE_INSTRUCTIONS}\n\n${COMPACT_INSTRUCTIONS}` : BASE_INSTRUCTIONS; +} const TOOL_USAGE = `# CPZAI tool usage @@ -41,10 +48,37 @@ Legacy read/write/trade scopes are expanded by the REST API. A scope is a resour Middle-office reads default to personal scope. Supplying entity_id selects an organization only after active membership verification. Monetary amounts returned as strings must remain exact. A 401 requires valid authentication, 403 indicates insufficient permission, and provider or platform failures must remain visible. `; +const DISCOVERY = `# CPZAI tool discovery + +Two endpoints serve the same tools with different discovery models. + +## https://mcp.cpz-lab.com/mcp (full catalogue) +Every tool appears in tools/list. Use this endpoint when the client defers tool loading itself. With Claude's MCP connector, set \`defer_loading\` once on the toolset and pair it with the tool search tool: + +\`\`\`json +{"type": "mcp_toolset", "mcp_server_name": "cpzai", "default_config": {"defer_loading": true}, + "configs": {"list_accounts": {"defer_loading": false}, "list_positions": {"defer_loading": false}}} +\`\`\` + +Deferred definitions are still sent on every request; they are kept out of the model's context until a search discovers them, and the prompt prefix is not disturbed, so the cache survives. + +## https://mcp.cpz-lab.com/mcp/compact (server-side progressive discovery) +tools/list carries every state-changing tool, the read anchors (list_accounts, list_positions, list_orders, get_market_data), and two discovery tools. Use this endpoint when the client has no deferral mechanism of its own. + +- \`search_tools\` takes a natural-language query and/or a category and returns matching tools with their full input schemas. Categories: strategies, backtests, orders, positions, accounts, market-data, risk, data, webhooks, middle-office, profile. Search covers tool names, titles, descriptions, parameter names, and parameter descriptions, so searching for a field name such as \`entity_id\` or \`unsettled\` finds the tool that accepts it. +- \`call_tool\` invokes a discovered tool by name with an arguments object matching its input_schema. It refuses anything that is not read-only, and refuses a read-only tool that is already advertised: call that one by name. + +Results from call_tool are the tool's own result, unchanged: same structuredContent, same isError semantics. + +## Conventions on both endpoints +Tool names, arguments, results, pagination, and scopes are identical. Discovery never reads account data and never places an order. A tool present in tools/list is not proof the caller's key holds the scope for it; see cpzai://guides/permissions. +`; + export function registerCapabilities(server: McpServer) { for (const [name, uri, title, text] of [ ['tool-usage', 'cpzai://guides/tool-usage', 'CPZAI Tool Usage', TOOL_USAGE], ['permissions', 'cpzai://guides/permissions', 'CPZAI Permissions', PERMISSIONS], + ['discovery', 'cpzai://guides/discovery', 'CPZAI Tool Discovery', DISCOVERY], ]) { server.registerResource(name, uri, { title, description: title, mimeType: 'text/markdown' }, async resourceUri => ({ contents: [{ uri: resourceUri.href, mimeType: 'text/markdown', text }], diff --git a/src/index.ts b/src/index.ts index 696a45e..c318674 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV || 'production', - release: process.env.SENTRY_RELEASE || 'cpzai-mcp-server@1.2.0', + release: process.env.SENTRY_RELEASE || 'cpzai-mcp-server@1.3.0', tracesSampleRate: 0.2, profilesSampleRate: 0.1, }); @@ -354,9 +354,13 @@ app.post(['/simons/stream', '/simons/chat'], cors, async (req, res) => { // ── MCP ───────────────────────────────────────────────────────── -app.options('/mcp', cors, (_req, res) => res.sendStatus(204)); +app.options(['/mcp', '/mcp/compact'], cors, (_req, res) => res.sendStatus(204)); -app.post('/mcp', cors, async (req, res, next) => { +// Two paths, one handler. /mcp/compact advertises a small surface and hands +// the rest to search_tools/call_tool; /mcp advertises everything. The mode is +// the URL rather than a header so that a client's cached tool list can never +// belong to a different surface than the one it is calling. +app.post(['/mcp', '/mcp/compact'], cors, async (req, res, next) => { // Challenge unauthenticated requests at the HTTP layer (MCP auth spec). // Returning 200 with in-band tool errors — the old behavior — meant OAuth- // capable clients never learned they should authenticate, so the only way @@ -378,7 +382,7 @@ app.post('/mcp', cors, async (req, res, next) => { return; } - const server = createMcpServer(req); + const server = createMcpServer(req, req.path === '/mcp/compact' ? 'compact' : 'full'); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => { void server.close().catch(error => console.error('[mcp] failed to close request transport', { error })); @@ -391,11 +395,11 @@ app.post('/mcp', cors, async (req, res, next) => { } }); -app.get('/mcp', cors, async (_req, res) => { +app.get(['/mcp', '/mcp/compact'], cors, async (_req, res) => { res.writeHead(405).end(JSON.stringify({ error: 'Method not allowed. Use POST for Streamable HTTP.' })); }); -app.delete('/mcp', cors, async (_req, res) => { +app.delete(['/mcp', '/mcp/compact'], cors, async (_req, res) => { res.writeHead(405).end(JSON.stringify({ error: 'Method not allowed. Sessions are stateless.' })); }); diff --git a/src/server.ts b/src/server.ts index 0529ec4..56d2ad9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,13 +1,31 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Request } from 'express'; import { registerTools } from './tools.js'; -import { registerCapabilities, SERVER_INSTRUCTIONS } from './capabilities.js'; +import { registerCapabilities, serverInstructions } from './capabilities.js'; +import { advertise, captureTools } from './tool-registry.js'; +import { advertisedTools, registerToolSearch } from './tool-search.js'; -export function createMcpServer(req: Request) { - const server = new McpServer({ name: 'cpzai-mcp-server', version: '1.2.0' }, { - instructions: SERVER_INSTRUCTIONS, +/** + * `full` advertises every tool, which is what a client with its own deferred + * loading (Claude's tool search, the MCP connector's `defer_loading`) wants: + * it needs the definitions in order to defer them. `compact` advertises the + * state-changing tools plus a few read anchors and hands the rest to + * `search_tools`/`call_tool`, for clients with no deferral of their own. + */ +export type ToolMode = 'full' | 'compact'; + +export function createMcpServer(req: Request, mode: ToolMode = 'full') { + const server = new McpServer({ name: 'cpzai-mcp-server', version: '1.3.0' }, { + instructions: serverInstructions(mode), }); - registerTools(server, req); + + // One definition of every tool. Capture first, then decide what reaches + // tools/list, so the advertised surface and the dispatchable surface can + // never drift apart. + const captured = captureTools(server => registerTools(server, req)); + if (mode === 'compact') registerToolSearch(server, captured); + advertise(server, advertisedTools(captured, mode)); + registerCapabilities(server); return server; } diff --git a/src/tool-registry.ts b/src/tool-registry.ts new file mode 100644 index 0000000..657b131 --- /dev/null +++ b/src/tool-registry.ts @@ -0,0 +1,224 @@ +/** + * Tool capture and search, so the server can advertise a small surface and let + * a client discover the rest on demand. + * + * `registerTools` and `registerExpandedTools` write straight onto an McpServer. + * That is the right shape for the full catalogue, but progressive discovery + * needs the definitions as DATA (a searchable index and a dispatcher) before + * deciding which of them reach `tools/list`. `captureTools` runs the same + * registration functions against a recorder, so there is exactly one definition + * of every tool and no second list to drift. + */ +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ZodTypeAny, ZodObject, ZodRawShape } from 'zod'; + +export interface CapturedToolConfig { + title?: string; + description?: string; + inputSchema?: ZodTypeAny; + outputSchema?: ZodTypeAny; + annotations?: ToolAnnotations; +} + +export type ToolHandler = ( + args: Record, + extra?: unknown, +) => CallToolResult | Promise; + +export interface CapturedTool { + name: string; + config: CapturedToolConfig; + handler: ToolHandler; +} + +/** Run a registration function against a recorder instead of a live server. */ +export function captureTools(register: (server: McpServer) => void): CapturedTool[] { + const captured: CapturedTool[] = []; + const recorder = { + registerTool(name: string, config: CapturedToolConfig, handler: ToolHandler) { + captured.push({ name, config, handler }); + return undefined; + }, + // Capture is tools-only; a registration function that also declared + // resources or prompts would silently lose them, so fail loudly instead. + registerResource() { + throw new Error('captureTools records tools only; register resources on the server directly'); + }, + registerPrompt() { + throw new Error('captureTools records tools only; register prompts on the server directly'); + }, + } as unknown as McpServer; + register(recorder); + return captured; +} + +/** Replay captured tools onto a real server, in capture order. */ +export function advertise(server: McpServer, tools: CapturedTool[]) { + for (const tool of tools) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (server as any).registerTool(tool.name, tool.config, tool.handler); + } +} + +export function isReadOnly(tool: CapturedTool): boolean { + return tool.config.annotations?.readOnlyHint === true; +} + +/** + * The same JSON Schema the SDK would publish for this tool in `tools/list`. + * Search results carry it verbatim so a client can call a discovered tool + * without a second round trip to fetch its schema. + */ +export function inputJsonSchema(tool: CapturedTool): Record { + const schema = tool.config.inputSchema; + if (!schema) return { type: 'object', additionalProperties: false }; + return zodToJsonSchema(schema as ZodObject, { + strictUnions: true, + pipeStrategy: 'input', + }) as Record; +} + +/** Coarse grouping, used for filtering searches and for listing what exists. */ +export const TOOL_CATEGORIES = [ + 'strategies', + 'backtests', + 'orders', + 'positions', + 'accounts', + 'market-data', + 'risk', + 'data', + 'webhooks', + 'middle-office', + 'profile', +] as const; + +export type ToolCategory = (typeof TOOL_CATEGORIES)[number]; + +const CATEGORY_BY_TOOL: Record = { + list_strategies: 'strategies', + get_strategy: 'strategies', + create_strategy: 'strategies', + update_strategy: 'strategies', + execute_strategy: 'strategies', + get_backtest_results: 'backtests', + get_backtest_result: 'backtests', + list_orders: 'orders', + get_order: 'orders', + place_order: 'orders', + list_positions: 'positions', + sync_portfolio: 'positions', + list_accounts: 'accounts', + get_market_data: 'market-data', + get_bars: 'market-data', + compute_risk: 'risk', + list_risk_snapshots: 'risk', + get_risk_snapshot: 'risk', + list_connections: 'data', + create_connection: 'data', + list_data_files: 'data', + get_data_file: 'data', + list_webhooks: 'webhooks', + create_webhook: 'webhooks', + delete_webhook: 'webhooks', + list_deals: 'middle-office', + list_lifecycle_events: 'middle-office', + list_cash_flows: 'middle-office', + list_journal_entries: 'middle-office', + list_fund_periods: 'middle-office', + get_profile: 'profile', +}; + +export function categoryOf(name: string): ToolCategory | undefined { + return CATEGORY_BY_TOOL[name]; +} + +/** + * Searchable text for one tool: name, title, description, and, as the tool + * search tool in the Claude API does, parameter names and their descriptions, + * so "unsettled" or "entity_id" finds the tool that takes them. + */ +function haystack(tool: CapturedTool): { name: string; label: string; body: string } { + const schema = inputJsonSchema(tool); + const properties = (schema.properties ?? {}) as Record; + const params = Object.entries(properties) + .map(([key, value]) => `${key} ${value?.description ?? ''}`) + .join(' '); + return { + name: tool.name.toLowerCase(), + label: (tool.config.title ?? '').toLowerCase(), + body: `${tool.config.description ?? ''} ${params}`.toLowerCase(), + }; +} + +// Underscores are separators, not word characters: a model searching for +// "list_bars" or "entity_id" should reach get_bars and the tools that take an +// entity id, which a single-token match never would. +const TERM = /[a-z0-9]+/g; + +/** + * Term-frequency scoring over name, title and body, weighted so a hit in the + * tool's own name outranks a passing mention in someone else's description. + * A query that tokenizes to nothing (punctuation only) matches nothing rather + * than returning an arbitrary slice of the catalogue. + */ +export function searchTools( + tools: CapturedTool[], + query: string, + options: { limit?: number; category?: ToolCategory } = {}, +): CapturedTool[] { + const limit = Math.min(Math.max(options.limit ?? 5, 1), 25); + const pool = options.category + ? tools.filter(tool => categoryOf(tool.name) === options.category) + : tools; + + const terms = [...query.toLowerCase().matchAll(TERM)].map(match => match[0]).filter(term => term.length > 1); + // An empty query with a category filter is a legitimate "what is in here?"; + // an empty query with no filter is not, and returning the whole catalogue + // would defeat the point of deferring it. + if (terms.length === 0) return options.category ? pool.slice(0, limit) : []; + + const scored = pool + .map(tool => { + const fields = haystack(tool); + let score = 0; + for (const term of terms) { + if (fields.name.includes(term)) score += 4; + if (fields.label.includes(term)) score += 2; + const occurrences = fields.body.split(term).length - 1; + if (occurrences > 0) score += Math.min(occurrences, 3); + } + return { tool, score }; + }) + .filter(entry => entry.score > 0); + + // Stable within a score band: ties resolve by catalogue order, so the same + // query returns the same tools in the same order on every request. + scored.sort((a, b) => b.score - a.score || tools.indexOf(a.tool) - tools.indexOf(b.tool)); + return scored.slice(0, limit).map(entry => entry.tool); +} + +export interface ToolDescriptor { + name: string; + title?: string; + description?: string; + category?: ToolCategory; + read_only: boolean; + callable_via: 'call_tool' | 'direct'; + input_schema: Record; +} + +export function describeTool(tool: CapturedTool): ToolDescriptor { + const readOnly = isReadOnly(tool); + return { + name: tool.name, + ...(tool.config.title !== undefined ? { title: tool.config.title } : {}), + ...(tool.config.description !== undefined ? { description: tool.config.description } : {}), + ...(categoryOf(tool.name) !== undefined ? { category: categoryOf(tool.name) } : {}), + read_only: readOnly, + callable_via: readOnly ? 'call_tool' : 'direct', + input_schema: inputJsonSchema(tool), + }; +} diff --git a/src/tool-search.ts b/src/tool-search.ts new file mode 100644 index 0000000..28cac5a --- /dev/null +++ b/src/tool-search.ts @@ -0,0 +1,179 @@ +/** + * Progressive tool discovery. + * + * The full catalogue is ~31 tools and ~27 KB of JSON Schema. Every client that + * cannot defer tool loading pays that on every request, and tool-selection + * accuracy degrades once a model is choosing between more than about thirty + * tools. Compact mode advertises a small surface (the two tools below, the + * everyday read anchors, and every state-changing tool) and lets the model + * find the rest with `search_tools` and call them through `call_tool`. + * + * `call_tool` dispatches READ-ONLY tools only. A client gates approval on the + * tool name it can see, so routing an order through a generic dispatcher would + * hide it from exactly the check that is supposed to catch it. Every tool that + * writes (orders, strategy execution, credentials, webhooks) stays a + * first-class tool in every mode, visible by name before it is approved. + * + * Claude clients that support `defer_loading` (the MCP connector's + * `mcp_toolset.default_config`, or Claude Code's own tool search) should use + * the full endpoint with deferral instead: the API expands tool definitions + * inline and preserves the cached prompt prefix. Compact mode is for the + * clients that have no such mechanism. + */ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { formatResult, invalidArguments } from './tool-result.js'; +import { + TOOL_CATEGORIES, + type CapturedTool, + type ToolCategory, + categoryOf, + describeTool, + isReadOnly, + searchTools, +} from './tool-registry.js'; + +/** + * Kept loaded in compact mode. These are the reads that start almost every + * session, so deferring them would cost a search round trip on the common path. + */ +export const COMPACT_ANCHORS = new Set([ + 'list_accounts', + 'list_positions', + 'list_orders', + 'get_market_data', +]); + +/** Tools advertised in `tools/list` for the given mode. */ +export function advertisedTools(tools: CapturedTool[], mode: 'full' | 'compact'): CapturedTool[] { + if (mode === 'full') return tools; + return tools.filter(tool => !isReadOnly(tool) || COMPACT_ANCHORS.has(tool.name)); +} + +/** Tools reachable only through search plus `call_tool` in the given mode. */ +export function deferredTools(tools: CapturedTool[], mode: 'full' | 'compact'): CapturedTool[] { + const advertised = new Set(advertisedTools(tools, mode).map(tool => tool.name)); + return tools.filter(tool => !advertised.has(tool.name)); +} + +const readOnlyAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +}; + +export function registerToolSearch(server: McpServer, tools: CapturedTool[]) { + const byName = new Map(tools.map(tool => [tool.name, tool])); + const deferred = deferredTools(tools, 'compact'); + const deferredNames = new Set(deferred.map(tool => tool.name)); + + server.registerTool('search_tools', { + title: 'Search CPZAI Tools', + description: + `Find the CPZAI tools for a task and get their full input schemas. This server advertises its state-changing tools and the everyday reads up front; ${deferred.length} further read-only tools load on demand through this search. Searches tool names, titles, descriptions, parameter names, and parameter descriptions. Categories: ${TOOL_CATEGORIES.join(', ')}. Call the returned tool with call_tool. Searching does not read any account data.`, + inputSchema: z.object({ + query: z.string().trim().max(500).optional().describe('What you are trying to do, in natural language or keywords (e.g. "unsettled cash flows", "historical bars"). Omit only when filtering by category.'), + category: z.enum(TOOL_CATEGORIES).optional().describe('Restrict to one category; with no query this lists that category.'), + limit: z.number().int().min(1).max(25).optional().describe('Maximum tools to return (default 5, maximum 25).'), + }), + annotations: readOnlyAnnotations, + }, async (args) => { + const query = args.query ?? ''; + if (!query && !args.category) { + return invalidArguments('Provide a query, a category, or both.'); + } + const matches = searchTools(tools, query, { + limit: args.limit, + category: args.category as ToolCategory | undefined, + }); + return formatResult({ + ok: true, + status: 200, + data: { + query: query || null, + category: args.category ?? null, + count: matches.length, + // A search that matches nothing is a result, not an error: the model + // should widen the query rather than conclude the capability is absent. + tools: matches.map(tool => describeTool(tool)), + ...(matches.length === 0 + ? { note: 'No tool matched. Try broader keywords or a category from the tool description.' } + : {}), + }, + }); + }); + + server.registerTool('call_tool', { + title: 'Call a Discovered Tool', + description: + 'Invoke a read-only CPZAI tool discovered with search_tools, passing its arguments exactly as its input_schema describes. Read-only tools only: tools that place orders, execute strategies, or write credentials and webhooks are advertised under their own names and must be called directly so you and the user see what is being approved. Returns the tool\'s own result unchanged.', + inputSchema: z.object({ + name: z.string().trim().min(1).max(128).describe('Tool name exactly as returned by search_tools.'), + arguments: z.record(z.unknown()).optional().describe('Arguments object matching that tool\'s input_schema. Omit for a tool that takes none.'), + }), + annotations: readOnlyAnnotations, + }, async (args) => { + const tool = byName.get(args.name); + if (!tool) { + const suggestions = searchTools(tools, args.name, { limit: 5 }).map(match => match.name); + return formatResult({ + ok: false, + status: 404, + data: { + error: `Unknown tool: ${args.name}`, + code: 'unknown_tool', + ...(suggestions.length > 0 ? { did_you_mean: suggestions } : {}), + }, + }); + } + + if (!isReadOnly(tool)) { + return formatResult({ + ok: false, + status: 403, + data: { + error: `${tool.name} changes state and is not dispatchable through call_tool. It is advertised as its own tool: call ${tool.name} directly.`, + code: 'not_dispatchable', + }, + }); + } + + // Belt and braces: a read-only tool that is already advertised should be + // called by name, so the client's own logging and approval see it. + if (!deferredNames.has(tool.name)) { + return formatResult({ + ok: false, + status: 400, + data: { + error: `${tool.name} is already available as its own tool: call ${tool.name} directly.`, + code: 'call_directly', + }, + }); + } + + const schema = tool.config.inputSchema; + const parsed = schema + ? (schema as z.ZodTypeAny).safeParse(args.arguments ?? {}) + : ({ success: true, data: {} } as const); + if (!parsed.success) { + return formatResult({ + ok: false, + status: 400, + data: { + error: `Invalid arguments for ${tool.name}`, + code: 'invalid_arguments', + issues: (parsed.error as z.ZodError).issues.map(issue => ({ + path: issue.path.join('.'), + message: issue.message, + })), + input_schema: describeTool(tool).input_schema, + }, + }); + } + + return tool.handler(parsed.data as Record); + }); +} + +export { categoryOf }; diff --git a/tests/server.test.ts b/tests/server.test.ts index 85d201f..77d0e69 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -28,14 +28,14 @@ describe('CPZAI MCP protocol', () => { }); it('negotiates tools, resources, prompts and workflow instructions without an upstream request', async () => { - expect(client.getServerVersion()?.version).toBe('1.2.0'); + expect(client.getServerVersion()?.version).toBe('1.3.0'); expect(client.getInstructions()).toContain('unknown outcome'); expect(client.getServerCapabilities()).toMatchObject({ tools: {}, resources: {}, prompts: {} }); const { tools } = await client.listTools(); expect(tools).toHaveLength(31); expect(new Set(tools.map(tool => tool.name)).size).toBe(31); expect((await client.listResources()).resources.map(resource => resource.uri)).toEqual([ - 'cpzai://guides/tool-usage', 'cpzai://guides/permissions', + 'cpzai://guides/tool-usage', 'cpzai://guides/permissions', 'cpzai://guides/discovery', ]); expect((await client.listPrompts()).prompts.map(prompt => prompt.name)).toEqual(['review_portfolio', 'analyze_strategy']); expect(fetchMock).not.toHaveBeenCalled(); diff --git a/tests/tool-search.test.ts b/tests/tool-search.test.ts new file mode 100644 index 0000000..1e539f5 --- /dev/null +++ b/tests/tool-search.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { Request } from 'express'; +import { createMcpServer } from '../src/server.js'; + +const HEADERS = { 'x-cpz-key': 'test-key', 'x-cpz-secret': 'test-secret', 'x-request-id': 'mcp-test' }; + +async function connect(mode: 'full' | 'compact') { + const server = createMcpServer({ headers: HEADERS } as unknown as Request, mode); + const client = new Client({ name: 'discovery-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { server, client }; +} + +function structured(result: unknown): Record { + return (result as { structuredContent: Record }).structuredContent; +} + +describe('progressive tool discovery', () => { + let fetchMock: ReturnType; + let open: Array<{ server: { close(): Promise }; client: Client }>; + + beforeEach(() => { + fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: [], count: 0 }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + open = []; + }); + + afterEach(async () => { + for (const { server, client } of open) { + await client.close(); + await server.close(); + } + vi.unstubAllGlobals(); + }); + + async function compactClient() { + const pair = await connect('compact'); + open.push(pair); + return pair.client; + } + + it('advertises every tool in full mode and no discovery tools', async () => { + const pair = await connect('full'); + open.push(pair); + const names = (await pair.client.listTools()).tools.map(tool => tool.name); + expect(names).toHaveLength(31); + expect(names).not.toContain('search_tools'); + expect(names).not.toContain('call_tool'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('advertises every state-changing tool plus read anchors in compact mode', async () => { + const client = await compactClient(); + const { tools } = await client.listTools(); + const names = tools.map(tool => tool.name); + + // Anything that can move money or write credentials stays visible by name, + // so the client gates approval on the tool the user actually sees. + for (const write of [ + 'place_order', 'execute_strategy', 'create_strategy', 'update_strategy', + 'create_connection', 'create_webhook', 'delete_webhook', 'sync_portfolio', 'compute_risk', + ]) { + expect(names).toContain(write); + } + expect(names).toContain('search_tools'); + expect(names).toContain('call_tool'); + for (const anchor of ['list_accounts', 'list_positions', 'list_orders', 'get_market_data']) { + expect(names).toContain(anchor); + } + // Deferred: discoverable, not advertised. + for (const deferred of ['list_cash_flows', 'get_bars', 'list_strategies', 'get_profile']) { + expect(names).not.toContain(deferred); + } + expect(JSON.stringify(tools).length).toBeLessThan(13_000); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('finds a deferred tool by a parameter name and returns its full input schema', async () => { + const client = await compactClient(); + const result = await client.callTool({ name: 'search_tools', arguments: { query: 'unsettled cash flows' } }); + const data = structured(result); + const names = data.tools.map((tool: { name: string }) => tool.name); + expect(names).toContain('list_cash_flows'); + const match = data.tools.find((tool: { name: string }) => tool.name === 'list_cash_flows'); + expect(match.read_only).toBe(true); + expect(match.callable_via).toBe('call_tool'); + expect(match.category).toBe('middle-office'); + expect(Object.keys(match.input_schema.properties)).toContain('unsettled'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('lists a category when given no query, and reports an empty match honestly', async () => { + const client = await compactClient(); + const listed = structured(await client.callTool({ name: 'search_tools', arguments: { category: 'backtests', limit: 25 } })); + expect(listed.tools.map((tool: { name: string }) => tool.name).sort()) + .toEqual(['get_backtest_result', 'get_backtest_results']); + + const empty = structured(await client.callTool({ name: 'search_tools', arguments: { query: 'zzzznotatool' } })); + expect(empty.count).toBe(0); + expect(empty.tools).toEqual([]); + expect(empty.note).toMatch(/No tool matched/); + expect((await client.callTool({ name: 'search_tools', arguments: { query: 'zzzznotatool' } }) as { isError?: boolean }).isError) + .toBeUndefined(); + }); + + it('requires a query or a category', async () => { + const client = await compactClient(); + const result = await client.callTool({ name: 'search_tools', arguments: {} }); + expect((result as { isError?: boolean }).isError).toBe(true); + expect(structured(result).code).toBe('invalid_arguments'); + }); + + it('dispatches a discovered read-only tool with the same result as a direct call', async () => { + const client = await compactClient(); + const result = await client.callTool({ + name: 'call_tool', + arguments: { name: 'list_cash_flows', arguments: { unsettled: true, limit: 10 } }, + }); + expect((result as { isError?: boolean }).isError).toBeUndefined(); + expect(structured(result)).toEqual({ data: [], count: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('/cash-flows'); + expect(url).toContain('unsettled=true'); + expect(url).toContain('limit=10'); + }); + + it('refuses to dispatch a state-changing tool, and never reaches the API', async () => { + const client = await compactClient(); + const result = await client.callTool({ + name: 'call_tool', + arguments: { + name: 'place_order', + arguments: { + account_id: 'acct-1', broker_credential_id: '123e4567-e89b-42d3-a456-426614174000', + symbol: 'AAPL', side: 'buy', order_type: 'market', quantity: 1, + }, + }, + }); + expect((result as { isError?: boolean }).isError).toBe(true); + expect(structured(result).code).toBe('not_dispatchable'); + expect(structured(result).error).toContain('call place_order directly'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('sends an already-advertised read tool back to its own name', async () => { + const client = await compactClient(); + const result = await client.callTool({ name: 'call_tool', arguments: { name: 'list_accounts' } }); + expect((result as { isError?: boolean }).isError).toBe(true); + expect(structured(result).code).toBe('call_directly'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('names near matches for an unknown tool instead of guessing', async () => { + const client = await compactClient(); + const result = await client.callTool({ name: 'call_tool', arguments: { name: 'list_bars' } }); + expect((result as { isError?: boolean }).isError).toBe(true); + expect(structured(result).code).toBe('unknown_tool'); + expect(structured(result).did_you_mean).toContain('get_bars'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('validates dispatched arguments against the real tool schema', async () => { + const client = await compactClient(); + const result = await client.callTool({ + name: 'call_tool', + arguments: { name: 'get_data_file', arguments: { id: 'not-a-uuid' } }, + }); + expect((result as { isError?: boolean }).isError).toBe(true); + const data = structured(result); + expect(data.code).toBe('invalid_arguments'); + expect(data.issues[0].path).toBe('id'); + expect(data.input_schema.properties.id).toBeDefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('applies the tool\'s own argument transforms when dispatching', async () => { + const client = await compactClient(); + await client.callTool({ name: 'call_tool', arguments: { name: 'get_bars', arguments: { symbols: ['soxx'] } } }); + const [, init] = fetchMock.mock.calls[0] as [string, { body: string }]; + expect(JSON.parse(init.body).symbols).toEqual(['SOXX']); + }); + + it('tells the model how to use the compact surface in the server instructions', async () => { + const client = await compactClient(); + expect(client.getInstructions()).toContain('search_tools'); + expect(client.getInstructions()).toContain('call_tool dispatches read-only tools only'); + }); +}); From 326902065ed8e936be4c7c6e992e2da1d15a54c3 Mon Sep 17 00:00:00 2001 From: Chris Preuss Date: Tue, 15 Sep 2026 04:51:16 -0400 Subject: [PATCH 2/2] feat(mcp): scope-aware tools/list, declared output schemas, SDK 1.30.0 Three things the catalogue was not doing. SCOPES. tools/list described the whole server to every credential, so a key scoped to data was still shown place_order and a model would plan around tools that 403 when it reaches them. The spec permits the advertised set to vary by the authorization on the request, since credentials are per-request input rather than connection state. Scopes come from GET /me, cached per credential digest for 60s with a periodic sweep and a size bound, looked up only for tools/list and never on the call path, under a 3s budget of its own so a slow API costs an unfiltered list rather than a stalled client. A data key now sees 10 tools instead of 31; an identity-only token sees get_profile. search_tools will not return an out-of-scope tool and call_tool will not dispatch one. Unknown is not none: if the lookup fails, or the REST API has no scopes field yet, the full catalogue is advertised and the reason is logged. Verified against an unreachable API. Enforcement stays in the REST API. OUTPUT SCHEMAS. 24 tools now declare the envelope they actually return, so upstream drift fails loudly instead of returning a plausible object. Only shapes this server controls are declared: the action tools that proxy elsewhere, place_order and create_connection promise nothing. The SDK skips output validation on isError results, so error envelopes are unaffected. Costs ~4KB on the full endpoint, nothing on a deferred one. SDK 1.30.0 (was 1.27.1). Still protocol 2025-11-25; the 2026-07-28 implementation ships as separate beta packages that are explicitly not production ready, so it stays off the order path until it stabilizes. Adds api-client timeoutMs so a caller can set its own request budget. --- README.md | 17 ++++- package-lock.json | 10 +-- package.json | 4 +- src/api-client.ts | 19 ++++- src/capabilities.ts | 9 ++- src/index.ts | 19 ++++- src/scopes.ts | 144 +++++++++++++++++++++++++++++++++++ src/server.ts | 24 +++++- src/tool-output.ts | 64 ++++++++++++++++ src/tool-registry.ts | 20 ++++- tests/scopes.test.ts | 153 ++++++++++++++++++++++++++++++++++++++ tests/tool-search.test.ts | 11 ++- 12 files changed, 474 insertions(+), 20 deletions(-) create mode 100644 src/scopes.ts create mode 100644 src/tool-output.ts create mode 100644 tests/scopes.test.ts diff --git a/README.md b/README.md index ee292b1..4766cb6 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Tool annotations describe side effects. Actual authorization is enforced by the ### Results and errors -Tool responses preserve readable JSON text and include structured content. Check `isError` before using the result. Malformed upstream responses and provider failures stay errors. Request IDs support tracing without logging credentials. +Tool responses preserve readable JSON text and include structured content. Most read tools declare an `outputSchema` so a client can validate what came back: list routes return `{data, count}`, single-record routes return `{data}`, and a delete returns `{message}`. The action tools that proxy to another service (`execute_strategy`, `sync_portfolio`, `compute_risk`, `get_market_data`, `get_bars`) and the two writes with their own handlers (`place_order`, `create_connection`) declare no output schema, because their shape is not this server's to promise. Output validation is skipped for error results. Check `isError` before using the result. Malformed upstream responses and provider failures stay errors. Request IDs support tracing without logging credentials. Eligible GET calls can retry transient upstream failures within a bounded timeout. Writes are not automatically retried. A timeout after order submission leaves the result unconfirmed; inspect existing orders and the broker before retrying. The public order route does not provide a general end-to-end idempotency guarantee. @@ -144,7 +144,20 @@ For clients with no deferral of their own. `tools/list` returns 15 tools instead `call_tool` dispatches **read-only tools only**. A client gates approval on the tool name it can see, so routing an order through a generic dispatcher would hide it from the check meant to catch it. It also refuses a read-only tool that is already advertised, and answers an unknown name with near matches rather than a guess. Arguments are validated against the real tool schema before dispatch; a rejection returns the offending paths and that tool's schema. -Tool names, arguments, results, pagination, and scopes are identical on both endpoints. Discovery reads no account data and places no order. Presence in `tools/list` is not proof that the caller's key holds the scope for a tool; see `cpzai://guides/permissions`. +### Scope-aware discovery + +`tools/list` carries only the tools the calling credential's scopes permit. A `data` key sees 10 tools, not 31; an identity-only OAuth token sees `get_profile` alone; `search_tools` will not return an out-of-scope tool and `call_tool` will not dispatch one. Scopes come from `GET /me` on the REST API, cached per credential for 60 seconds and looked up only for `tools/list`, never on the call path. + +If the lookup fails, or the REST API deployment predates the `scopes` field, the full catalogue is advertised and the reason is logged: unknown is not the same as none. Filtering is discovery, not enforcement. The REST API remains the only thing that decides what a credential may touch, so a tool that is present can still return 403. + +Measured surfaces: + +| Endpoint | Full scopes | `data` scope only | +|---|---|---| +| `/mcp` | 31 tools, ~31 KB | 10 tools, ~9 KB | +| `/mcp/compact` | 15 tools, ~13 KB | 6 tools, ~6 KB | + +Tool names, arguments, results and pagination are identical on both endpoints. Discovery reads no account data and places no order. ## Architecture diff --git a/package-lock.json b/package-lock.json index f630e82..d8c45d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.3.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.30.0", "@sentry/node": "^10.41.0", "express": "^4.21.2", "zod": "^3.24.2", @@ -551,12 +551,12 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", diff --git a/package.json b/package.json index 47afe01..59ed561 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@cpzai/mcp-server", "version": "1.3.0", - "description": "CPZAI MCP server \u2014 AI agent access to quantitative trading strategies, backtests, multi-broker order routing, portfolios, and risk analytics.", + "description": "CPZAI MCP server — AI agent access to quantitative trading strategies, backtests, multi-broker order routing, portfolios, and risk analytics.", "license": "MIT", "homepage": "https://ai.cpz-lab.com", "repository": { @@ -33,7 +33,7 @@ "export:catalog": "tsx scripts/export-tool-catalog.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.30.0", "@sentry/node": "^10.41.0", "express": "^4.21.2", "zod": "^3.24.2", diff --git a/src/api-client.ts b/src/api-client.ts index 1136057..c22c3f4 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -18,6 +18,12 @@ export interface ApiCallOptions { apiKey: string; apiSecret: string; requestId?: string; + /** + * Request budget for this call, overriding CPZ_API_TIMEOUT_MS. Discovery + * calls use a short one so a slow API degrades the tool list rather than + * making a client wait out the default budget. + */ + timeoutMs?: number; } export interface ApiResult { @@ -104,9 +110,18 @@ export async function callRestApi(opts: ApiCallOptions): Promise { }; const configuredTimeout = process.env.CPZ_API_TIMEOUT_MS; - const timeoutMs = configuredTimeout === undefined ? DEFAULT_TIMEOUT_MS : Number(configuredTimeout); + const timeoutMs = opts.timeoutMs !== undefined + ? opts.timeoutMs + : configuredTimeout === undefined ? DEFAULT_TIMEOUT_MS : Number(configuredTimeout); if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) { - return failure(500, 'invalid_timeout_configuration', `CPZ_API_TIMEOUT_MS must be an integer between 1 and ${MAX_TIMEOUT_MS}.`, 0); + return failure( + 500, + 'invalid_timeout_configuration', + opts.timeoutMs !== undefined + ? `timeoutMs must be an integer between 1 and ${MAX_TIMEOUT_MS}.` + : `CPZ_API_TIMEOUT_MS must be an integer between 1 and ${MAX_TIMEOUT_MS}.`, + 0, + ); } let url: URL; diff --git a/src/capabilities.ts b/src/capabilities.ts index 65655e0..f3ad084 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -12,7 +12,7 @@ export function serverInstructions(mode: 'full' | 'compact' = 'full'): string { const TOOL_USAGE = `# CPZAI tool usage -Discover the server's registered schemas with tools/list. Successful results include structuredContent and an equivalent JSON text block. Tool failures set isError; inspect error, code, and request_id when present. +Discover the server's registered schemas with tools/list. Successful results include structuredContent and an equivalent JSON text block. Most read tools also declare an outputSchema: list routes return {data, count}, single-record routes return {data}, and a delete returns {message}. Action tools that proxy to another service declare no output schema because their shape is not this server's to promise. Tool failures set isError; inspect error, code, and request_id when present. ## Pagination and data provenance Most list tools accept limit (1–100, default 50) and offset (default 0). Advance offset by the number of returned records and continue until a page shorter than the requested limit. A count is the current page size, not the total. Lists are live views: concurrent writes can change pages. list_accounts defaults to 100; the other list tools default to 50. Use an explicit limit for consistent paging. get_bars uses a separate limit of 1–10000 bars per symbol. Preserve provider timestamps and currency units; middle-office minor-unit amounts are strings to preserve integer precision. Never replace missing or failed data with zeros. @@ -31,7 +31,7 @@ No generic HTTP/SQL tool, order cancellation, sandbox dry_run, apply_patch, SDK const PERMISSIONS = `# CPZAI credentials and permissions -Connect to https://mcp.cpz-lab.com/mcp over stateless Streamable HTTP using OAuth or X-CPZ-Key and X-CPZ-Secret. The REST API validates credentials and enforces user ownership, scopes, and applicable subscription checks on each data operation. Tool discovery describes the full server surface; it is not proof that a particular key can call every tool. +Connect to https://mcp.cpz-lab.com/mcp over stateless Streamable HTTP using OAuth or X-CPZ-Key and X-CPZ-Secret. The REST API validates credentials and enforces user ownership, scopes, and applicable subscription checks on each data operation. tools/list is filtered to the scopes your credential holds when they can be determined, so a tool that is absent is one this key cannot call. When the scope lookup is unavailable the full catalogue is advertised, which is not a grant: enforcement is always the REST API, and a tool that is present can still return 403. | Tools | Accepted resource scopes (any listed) | | --- | --- | @@ -70,8 +70,11 @@ tools/list carries every state-changing tool, the read anchors (list_accounts, l Results from call_tool are the tool's own result, unchanged: same structuredContent, same isError semantics. +## Scope filtering applies to both endpoints +tools/list carries only the tools the calling credential's scopes permit, when those scopes can be determined: a data-scoped key is not shown the order surface, and search_tools does not return it either. If the scope lookup is unavailable the full catalogue is advertised, because unknown is not the same as none. Filtering is discovery, not enforcement; the REST API decides what a credential may touch. + ## Conventions on both endpoints -Tool names, arguments, results, pagination, and scopes are identical. Discovery never reads account data and never places an order. A tool present in tools/list is not proof the caller's key holds the scope for it; see cpzai://guides/permissions. +Tool names, arguments, results, and pagination are identical. Discovery never reads account data and never places an order. See cpzai://guides/permissions for the scope vocabulary. `; export function registerCapabilities(server: McpServer) { diff --git a/src/index.ts b/src/index.ts index c318674..95dc0fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import express from 'express'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { extractCredentials } from './tools.js'; import { createMcpServer } from './server.js'; +import { resolveScopes } from './scopes.js'; import { getOAuthMetadata, getProtectedResourceMetadata, @@ -354,6 +355,12 @@ app.post(['/simons/stream', '/simons/chat'], cors, async (req, res) => { // ── MCP ───────────────────────────────────────────────────────── +/** True when this JSON-RPC body (single or batched) asks for the tool list. */ +function requestsToolList(body: unknown): boolean { + const messages = Array.isArray(body) ? body : [body]; + return messages.some(message => (message as { method?: unknown } | null)?.method === 'tools/list'); +} + app.options(['/mcp', '/mcp/compact'], cors, (_req, res) => res.sendStatus(204)); // Two paths, one handler. /mcp/compact advertises a small surface and hands @@ -382,7 +389,17 @@ app.post(['/mcp', '/mcp/compact'], cors, async (req, res, next) => { return; } - const server = createMcpServer(req, req.path === '/mcp/compact' ? 'compact' : 'full'); + // Scopes are looked up only for discovery. A tools/call already carries its + // own enforcement downstream, and adding a /me round trip to the order path + // would buy nothing but latency. The lookup is cached per credential. + const scopes = requestsToolList(req.body) + ? await resolveScopes(creds.apiKey, creds.apiSecret, typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'] : undefined) + : null; + + const server = createMcpServer(req, { + mode: req.path === '/mcp/compact' ? 'compact' : 'full', + scopes, + }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => { void server.close().catch(error => console.error('[mcp] failed to close request transport', { error })); diff --git a/src/scopes.ts b/src/scopes.ts new file mode 100644 index 0000000..275ad5b --- /dev/null +++ b/src/scopes.ts @@ -0,0 +1,144 @@ +/** + * Scope-aware tool discovery. + * + * The REST API enforces scopes on every call, but tools/list used to describe + * the whole server to every credential. A key scoped to `data` was still shown + * place_order, so a model would plan a workflow around tools that 403 the + * moment it reaches them. The MCP spec permits the advertised tool set to vary + * by the authorization presented on the request, because credentials are + * per-request input rather than connection state, so the honest catalogue is + * the one this key can actually call. + * + * Filtering is a discovery improvement, never an enforcement boundary: the + * REST API remains the only thing that decides what a credential may touch. + */ +import { createHash } from 'node:crypto'; +import { callRestApi } from './api-client.js'; + +/** + * Scopes accepted per tool, mirroring RESOURCE_SCOPES in the REST API + * (supabase/functions/rest-api/resources.ts). A tool needs ANY of them. + * A tool absent from this map is never filtered out. + */ +export const TOOL_SCOPES: Record = { + list_strategies: ['strategies'], + get_strategy: ['strategies'], + create_strategy: ['strategies'], + update_strategy: ['strategies'], + get_backtest_results: ['strategies'], + get_backtest_result: ['strategies'], + list_orders: ['orders'], + get_order: ['orders'], + place_order: ['orders'], + execute_strategy: ['orders'], + list_accounts: ['trading_credentials'], + list_positions: ['trading_credentials', 'orders'], + sync_portfolio: ['trading_credentials', 'orders'], + compute_risk: ['trading_credentials', 'orders'], + list_risk_snapshots: ['trading_credentials', 'orders'], + get_risk_snapshot: ['trading_credentials', 'orders'], + list_deals: ['trading_credentials', 'orders'], + list_lifecycle_events: ['trading_credentials', 'orders'], + list_cash_flows: ['trading_credentials', 'orders'], + list_journal_entries: ['trading_credentials', 'orders'], + list_fund_periods: ['trading_credentials', 'orders'], + list_data_files: ['data'], + get_data_file: ['data'], + get_market_data: ['data'], + get_bars: ['data'], + list_connections: ['data'], + create_connection: ['data'], + // Webhooks span every event family, so the REST API accepts any resource + // scope. Identity-only tokens still hold none of them. + list_webhooks: ['strategies', 'data', 'orders', 'trading_credentials', 'data_connections', 'storage', 'simons'], + create_webhook: ['strategies', 'data', 'orders', 'trading_credentials', 'data_connections', 'storage', 'simons'], + delete_webhook: ['strategies', 'data', 'orders', 'trading_credentials', 'data_connections', 'storage', 'simons'], + // get_profile, search_tools and call_tool need no resource scope. +}; + +export function isAllowed(toolName: string, scopes: ReadonlySet): boolean { + const accepted = TOOL_SCOPES[toolName]; + if (!accepted) return true; + return accepted.some(scope => scopes.has(scope)); +} + +// A discovery call must not wait out the default 20s API budget: a slow REST +// API should cost the caller an unfiltered tool list, not a stalled client. +const LOOKUP_TIMEOUT_MS = 3_000; +const TTL_MS = 60_000; +const SWEEP_MS = 300_000; +const MAX_ENTRIES = 1_000; + +interface CacheEntry { scopes: Set; expiresAt: number } +const cache = new Map(); + +/** Periodic sweep, so an idle process does not hold expired entries forever. */ +const sweep = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of cache) if (entry.expiresAt <= now) cache.delete(key); +}, SWEEP_MS); +sweep.unref?.(); + +/** Credentials are never used as a map key; only a digest of them is. */ +function cacheKey(apiKey: string, apiSecret: string): string { + return createHash('sha256').update(`${apiKey}:${apiSecret}`).digest('hex'); +} + +export function clearScopeCache() { + cache.clear(); +} + +/** + * The scopes this credential holds, or null when they cannot be determined. + * + * Null is not "no scopes" and must not be read as one: it means the lookup + * failed, or the REST API predates the `scopes` field on /me. The caller then + * advertises the full catalogue, which is the behaviour this server has always + * had. The failure is logged rather than swallowed. + */ +export async function resolveScopes( + apiKey: string, + apiSecret: string, + requestId?: string, +): Promise | null> { + if (!apiKey || !apiSecret) return null; + const key = cacheKey(apiKey, apiSecret); + + const hit = cache.get(key); + if (hit && hit.expiresAt > Date.now()) return hit.scopes; + if (hit) cache.delete(key); + + const result = await callRestApi({ + method: 'GET', path: '/me', apiKey, apiSecret, requestId, timeoutMs: LOOKUP_TIMEOUT_MS, + }); + if (!result.ok) { + console.error('[scopes] /me lookup failed; advertising the full tool catalogue', { + status: result.status, + request_id: requestId, + }); + return null; + } + + const raw = (result.data as { data?: { scopes?: unknown } } | null)?.data?.scopes; + if (!Array.isArray(raw)) { + // An older REST API deployment has no scopes field. Say so once per + // lookup rather than silently behaving as though the key holds nothing. + console.warn('[scopes] /me returned no scopes field; advertising the full tool catalogue', { + request_id: requestId, + }); + return null; + } + + const scopes = new Set(raw.map(scope => String(scope).toLowerCase())); + + // Bound the cache: keys are credential digests, so an attacker spraying + // credentials could otherwise grow it without limit. Oldest insertion goes + // first, which is also the entry closest to expiry. + while (cache.size >= MAX_ENTRIES) { + const oldest = cache.keys().next(); + if (oldest.done) break; + cache.delete(oldest.value); + } + cache.set(key, { scopes, expiresAt: Date.now() + TTL_MS }); + return scopes; +} diff --git a/src/server.ts b/src/server.ts index 56d2ad9..24304f0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { registerTools } from './tools.js'; import { registerCapabilities, serverInstructions } from './capabilities.js'; import { advertise, captureTools } from './tool-registry.js'; import { advertisedTools, registerToolSearch } from './tool-search.js'; +import { isAllowed } from './scopes.js'; /** * `full` advertises every tool, which is what a client with its own deferred @@ -14,7 +15,20 @@ import { advertisedTools, registerToolSearch } from './tool-search.js'; */ export type ToolMode = 'full' | 'compact'; -export function createMcpServer(req: Request, mode: ToolMode = 'full') { +export interface ServerOptions { + mode?: ToolMode; + /** + * Scopes held by the calling credential, or null/undefined when they could + * not be determined. Null means unknown, never empty: the full catalogue is + * advertised in that case, as this server has always done. Enforcement lives + * in the REST API either way. + */ + scopes?: Set | null; +} + +export function createMcpServer(req: Request, options: ToolMode | ServerOptions = {}) { + const { mode = 'full', scopes = null } = typeof options === 'string' ? { mode: options } as ServerOptions : options; + const server = new McpServer({ name: 'cpzai-mcp-server', version: '1.3.0' }, { instructions: serverInstructions(mode), }); @@ -23,8 +37,12 @@ export function createMcpServer(req: Request, mode: ToolMode = 'full') { // tools/list, so the advertised surface and the dispatchable surface can // never drift apart. const captured = captureTools(server => registerTools(server, req)); - if (mode === 'compact') registerToolSearch(server, captured); - advertise(server, advertisedTools(captured, mode)); + // Scope filtering happens before the mode split so that search_tools never + // offers, and call_tool never dispatches, a tool this key cannot call. + const permitted = scopes ? captured.filter(tool => isAllowed(tool.name, scopes)) : captured; + + if (mode === 'compact') registerToolSearch(server, permitted); + advertise(server, advertisedTools(permitted, mode)); registerCapabilities(server); return server; diff --git a/src/tool-output.ts b/src/tool-output.ts new file mode 100644 index 0000000..8252570 --- /dev/null +++ b/src/tool-output.ts @@ -0,0 +1,64 @@ +/** + * Declared output schemas. + * + * The envelope is a property of the REST adapter, not of each tool: every CRUD + * read returns `{data, count?}` or `{data}`, and a delete returns `{message}`. + * Declaring it once here keeps the 25 tool definitions from repeating the same + * three shapes, and gives clients something to validate against, which is how + * upstream drift becomes a loud failure instead of a plausible-looking object. + * + * Only shapes this server can actually guarantee are declared. The action + * endpoints (execute_strategy, sync_portfolio, compute_risk, get_market_data, + * get_bars) and the two writes that proxy to their own handlers (place_order, + * create_connection) return whatever the downstream edge function returns, so + * they declare nothing rather than promise a shape we do not control. + * + * The SDK skips output validation when a result carries isError, so the error + * envelope `{error, code}` is unaffected. + */ +import { z } from 'zod'; + +/** One page of records, as returned by every CRUD list route. */ +const listEnvelope = z.object({ + data: z.array(z.unknown()), + count: z.number().int().optional(), +}).passthrough(); + +/** A single record, as returned by get-by-id and by the generic write routes. */ +const recordEnvelope = z.object({ + data: z.unknown(), +}).passthrough(); + +/** Deletion acknowledgement. */ +const messageEnvelope = z.object({ + message: z.string(), +}).passthrough(); + +export const OUTPUT_SCHEMAS: Record = { + list_strategies: listEnvelope, + get_backtest_results: listEnvelope, + list_orders: listEnvelope, + list_positions: listEnvelope, + list_accounts: listEnvelope, + list_connections: listEnvelope, + list_risk_snapshots: listEnvelope, + list_webhooks: listEnvelope, + list_data_files: listEnvelope, + list_deals: listEnvelope, + list_lifecycle_events: listEnvelope, + list_cash_flows: listEnvelope, + list_journal_entries: listEnvelope, + list_fund_periods: listEnvelope, + + get_strategy: recordEnvelope, + get_data_file: recordEnvelope, + get_backtest_result: recordEnvelope, + get_order: recordEnvelope, + get_risk_snapshot: recordEnvelope, + get_profile: recordEnvelope, + create_strategy: recordEnvelope, + update_strategy: recordEnvelope, + create_webhook: recordEnvelope, + + delete_webhook: messageEnvelope, +}; diff --git a/src/tool-registry.ts b/src/tool-registry.ts index 657b131..0d172b4 100644 --- a/src/tool-registry.ts +++ b/src/tool-registry.ts @@ -10,6 +10,7 @@ * of every tool and no second list to drift. */ import { zodToJsonSchema } from 'zod-to-json-schema'; +import { OUTPUT_SCHEMAS } from './tool-output.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import type { ZodTypeAny, ZodObject, ZodRawShape } from 'zod'; @@ -54,11 +55,17 @@ export function captureTools(register: (server: McpServer) => void): CapturedToo return captured; } +/** The tool's config with its declared output envelope attached, if it has one. */ +export function withOutputSchema(tool: CapturedTool): CapturedToolConfig { + const outputSchema = OUTPUT_SCHEMAS[tool.name]; + return outputSchema ? { ...tool.config, outputSchema } : tool.config; +} + /** Replay captured tools onto a real server, in capture order. */ export function advertise(server: McpServer, tools: CapturedTool[]) { for (const tool of tools) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (server as any).registerTool(tool.name, tool.config, tool.handler); + (server as any).registerTool(tool.name, withOutputSchema(tool), tool.handler); } } @@ -208,6 +215,7 @@ export interface ToolDescriptor { read_only: boolean; callable_via: 'call_tool' | 'direct'; input_schema: Record; + output_schema?: Record; } export function describeTool(tool: CapturedTool): ToolDescriptor { @@ -220,5 +228,15 @@ export function describeTool(tool: CapturedTool): ToolDescriptor { read_only: readOnly, callable_via: readOnly ? 'call_tool' : 'direct', input_schema: inputJsonSchema(tool), + // A discovered tool should arrive with the same contract tools/list would + // have given it, output shape included. + ...(OUTPUT_SCHEMAS[tool.name] + ? { + output_schema: zodToJsonSchema(OUTPUT_SCHEMAS[tool.name] as ZodObject, { + strictUnions: true, + pipeStrategy: 'output', + }) as Record, + } + : {}), }; } diff --git a/tests/scopes.test.ts b/tests/scopes.test.ts new file mode 100644 index 0000000..4e64543 --- /dev/null +++ b/tests/scopes.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { Request } from 'express'; +import { createMcpServer } from '../src/server.js'; +import { clearScopeCache, isAllowed, resolveScopes, TOOL_SCOPES } from '../src/scopes.js'; + +const HEADERS = { 'x-cpz-key': 'test-key', 'x-cpz-secret': 'test-secret', 'x-request-id': 'mcp-test' }; + +async function toolNames(options: { mode?: 'full' | 'compact'; scopes?: Set | null }) { + const server = createMcpServer({ headers: HEADERS } as unknown as Request, options); + const client = new Client({ name: 'scope-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const { tools } = await client.listTools(); + const names = tools.map(tool => tool.name); + await client.close(); + await server.close(); + return names; +} + +describe('scope-aware discovery', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + clearScopeCache(); + fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: [], count: 0 }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + clearScopeCache(); + }); + + it('advertises only what a data-scoped key can call', async () => { + const names = await toolNames({ scopes: new Set(['data']) }); + expect(names).toEqual([ + 'list_data_files', 'get_data_file', 'list_connections', 'create_connection', + 'get_market_data', 'get_bars', 'list_webhooks', 'create_webhook', 'delete_webhook', 'get_profile', + ]); + // The tools that would 403 are gone, including the whole order surface. + for (const gone of ['place_order', 'list_orders', 'list_positions', 'list_strategies', 'list_cash_flows']) { + expect(names).not.toContain(gone); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the trading surface for a trade-scoped key', async () => { + const names = await toolNames({ scopes: new Set(['orders', 'trading_credentials']) }); + expect(names).toContain('place_order'); + expect(names).toContain('list_positions'); + expect(names).toContain('list_cash_flows'); + expect(names).not.toContain('list_strategies'); + expect(names).not.toContain('get_bars'); + }); + + it('advertises the full catalogue when scopes are unknown, which is not the same as none', async () => { + expect(await toolNames({ scopes: null })).toHaveLength(31); + expect(await toolNames({})).toHaveLength(31); + // An identity-only credential holds no resource scope and gets only the + // tools that need none. + expect(await toolNames({ scopes: new Set() })).toEqual(['get_profile']); + }); + + it('hides out-of-scope tools from search and dispatch in compact mode', async () => { + const server = createMcpServer({ headers: HEADERS } as unknown as Request, { + mode: 'compact', + scopes: new Set(['data']), + }); + const client = new Client({ name: 'scope-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const search = await client.callTool({ name: 'search_tools', arguments: { query: 'cash flows settlement' } }); + expect((search as { structuredContent: { count: number } }).structuredContent.count).toBe(0); + + const dispatch = await client.callTool({ name: 'call_tool', arguments: { name: 'list_cash_flows' } }); + expect((dispatch as { isError?: boolean }).isError).toBe(true); + expect((dispatch as { structuredContent: { code: string } }).structuredContent.code).toBe('unknown_tool'); + expect(fetchMock).not.toHaveBeenCalled(); + + await client.close(); + await server.close(); + }); + + it('every tool that maps to a scope names one the REST API recognizes', async () => { + const vocabulary = new Set([ + 'strategies', 'data', 'orders', 'trading_credentials', 'data_connections', 'storage', 'simons', + ]); + for (const [tool, accepted] of Object.entries(TOOL_SCOPES)) { + expect(accepted.length, tool).toBeGreaterThan(0); + for (const scope of accepted) expect(vocabulary, `${tool}: ${scope}`).toContain(scope); + } + expect(isAllowed('search_tools', new Set())).toBe(true); + expect(isAllowed('get_profile', new Set())).toBe(true); + }); +}); + +describe('scope lookup', () => { + beforeEach(() => clearScopeCache()); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + clearScopeCache(); + }); + + it('reads the expanded scopes from /me and caches them per credential', async () => { + const fetchMock = vi.fn(async () => new Response( + JSON.stringify({ data: { id: 'u1', scopes: ['orders', 'trading_credentials'] } }), + { status: 200 }, + )); + vi.stubGlobal('fetch', fetchMock); + + const first = await resolveScopes('key', 'secret', 'req-1'); + expect([...(first ?? [])].sort()).toEqual(['orders', 'trading_credentials']); + const second = await resolveScopes('key', 'secret', 'req-2'); + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect((fetchMock.mock.calls[0] as [string])[0]).toContain('/me'); + + // A different credential is a different cache entry. + await resolveScopes('other-key', 'other-secret', 'req-3'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('returns null and says so loudly when the lookup fails', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'nope' }), { status: 401 }))); + expect(await resolveScopes('key', 'secret', 'req-4')).toBeNull(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('/me lookup failed'), expect.objectContaining({ status: 401 })); + }); + + it('returns null against a REST API that does not yet report scopes', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ data: { id: 'u1', email: 'a@b.c' } }), + { status: 200 }, + ))); + expect(await resolveScopes('key', 'secret', 'req-5')).toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no scopes field'), expect.anything()); + }); + + it('never looks up scopes without both halves of a credential', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + expect(await resolveScopes('', '', 'req-6')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tool-search.test.ts b/tests/tool-search.test.ts index 1e539f5..baabb4e 100644 --- a/tests/tool-search.test.ts +++ b/tests/tool-search.test.ts @@ -75,7 +75,16 @@ describe('progressive tool discovery', () => { for (const deferred of ['list_cash_flows', 'get_bars', 'list_strategies', 'get_profile']) { expect(names).not.toContain(deferred); } - expect(JSON.stringify(tools).length).toBeLessThan(13_000); + // Compact carries well under half the bytes of the full catalogue. + const full = createMcpServer({ headers: HEADERS } as unknown as Request, 'full'); + const fullClient = new Client({ name: 'budget', version: '1.0.0' }); + const [c, s] = InMemoryTransport.createLinkedPair(); + await full.connect(s); + await fullClient.connect(c); + const fullBytes = JSON.stringify((await fullClient.listTools()).tools).length; + await fullClient.close(); + await full.close(); + expect(JSON.stringify(tools).length).toBeLessThan(fullBytes * 0.5); expect(fetchMock).not.toHaveBeenCalled(); });