From ef85763feb1413769a1cc9496c72d782fcc4ed25 Mon Sep 17 00:00:00 2001 From: Chris Preuss Date: Mon, 14 Sep 2026 01:05:37 -0400 Subject: [PATCH] feat(mcp): expand tool coverage and harden agent workflows --- README.md | 256 +++++++++++++------------- package-lock.json | 4 +- package.json | 7 +- scripts/export-tool-catalog.ts | 73 ++++++++ server.json | 10 +- src/api-client.ts | 251 ++++++++++++++++++++------ src/capabilities.ts | 71 ++++++++ src/expanded-tools.ts | 165 +++++++++++++++++ src/index.ts | 39 ++-- src/server.ts | 13 ++ src/tool-result.ts | 18 ++ src/tools.ts | 156 ++++++++++------ tests/api-client.test.ts | 294 ++++++++++++++++++++++++++++++ tests/expanded-tools.test.ts | 124 +++++++++++++ tests/server.test.ts | 121 +++++++++++++ tests/tool-catalog.test.ts | 24 +++ tests/tools.test.ts | 18 +- tool-catalog.json | 319 +++++++++++++++++++++++++++++++++ 18 files changed, 1683 insertions(+), 280 deletions(-) create mode 100644 scripts/export-tool-catalog.ts create mode 100644 src/capabilities.ts create mode 100644 src/expanded-tools.ts create mode 100644 src/server.ts create mode 100644 src/tool-result.ts create mode 100644 tests/api-client.test.ts create mode 100644 tests/expanded-tools.test.ts create mode 100644 tests/server.test.ts create mode 100644 tests/tool-catalog.test.ts create mode 100644 tool-catalog.json diff --git a/README.md b/README.md index a99cd45..ec1fc9d 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,16 @@ -

CPZAI MCP Server

+# CPZAI MCP Server -

- Model Context Protocol server for AI agent access to CPZAI -

+A Model Context Protocol adapter for [CPZAI](https://ai.cpz-lab.com): strategies, saved backtests, broker accounts, orders, portfolio positions, market data, risk, provider connections, and middle-office reporting. -

- TypeScript - CI - MCP v1.0 - Streamable HTTP - License: MIT -

+**Hosted endpoint:** `https://mcp.cpz-lab.com/mcp` +**Transport:** stateless Streamable HTTP +**Current source:** version 1.2.0 — 31 tools, two guide resources, two workflow prompts. ---- +This README describes the updated source, not a completed production deployment. Use your connected client's `tools/list`, `resources/list`, and `prompts/list` to verify deployed capabilities. The previous source registered 21 tools; old references to 18 tools or a proposed 29-tool release were inaccurate. -A production-grade [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes [CPZAI](https://ai.cpz-lab.com) capabilities — strategy management, backtesting, multi-broker order routing (Alpaca / Interactive Brokers / FIX), portfolios, risk analytics, market data — as tools for any MCP-compatible AI agent (Claude, Cursor, GPT, etc.). +## Connect -The hosted endpoint at `https://mcp.cpz-lab.com/mcp` is the only supported way to connect. This repo exists for transparency: anyone wiring their broker API keys through an AI agent should be able to read the exact code that handles those keys. - -## Quick Start - -You'll need a CPZ API key + secret. Create one at [ai.cpz-lab.com/settings/api-keys](https://ai.cpz-lab.com/settings/api-keys). - -### Cursor - -Add to `.cursor/mcp.json`: +Create a key and secret in **Settings → API Keys** at [CPZAI](https://ai.cpz-lab.com). In a client supporting remote MCP with custom headers, configure: ```json { @@ -32,154 +18,162 @@ Add to `.cursor/mcp.json`: "cpzai": { "url": "https://mcp.cpz-lab.com/mcp", "headers": { - "X-CPZ-Key": "your_cpz_key", - "X-CPZ-Secret": "your_cpz_secret" + "X-CPZ-Key": "YOUR_KEY", + "X-CPZ-Secret": "YOUR_SECRET" } } } } ``` -### Claude Desktop - -Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: +Use your client's equivalent connector settings when its configuration format differs. OAuth-capable clients can connect to the same MCP URL and complete sign-in using PKCE S256. Discovery endpoints: + +- `https://mcp.cpz-lab.com/.well-known/oauth-authorization-server` +- `https://mcp.cpz-lab.com/.well-known/oauth-protected-resource` + +OAuth access tokens are opaque and accepted by this server. Do not pass them directly to the REST API. Legacy `Bearer cpz_key_…SECRET` credentials are disabled by default; use header authentication or OAuth. + +## Tool catalog + +| Domain | Tool | Purpose | +|---|---|---| +| Strategies | `list_strategies` | status, strategy_type, title | +| Strategies | `get_strategy` | Read one strategy | +| Strategies | `create_strategy` | Create a strategy | +| Strategies | `update_strategy` | Update selected strategy fields | +| Backtests | `get_backtest_results` | List saved backtest runs | +| Backtests | `get_backtest_result` | Read one saved backtest run | +| Orders | `list_orders` | status, symbol, side, strategy_id | +| Orders | `get_order` | Read one CPZ order record | +| Orders | `place_order` | Submit an order to the selected connected account | +| Portfolio | `list_positions` | account_id, symbol | +| Portfolio | `sync_portfolio` | Synchronize connected broker portfolios | +| Accounts | `list_accounts` | broker, environment, tradable | +| Connections | `list_connections` | connection_type | +| Connections | `create_connection` | Store an encrypted provider credential | +| Data files | `list_data_files` | name, status, file_type | +| Data files | `get_data_file` | Read uploaded file metadata | +| Market data | `get_market_data` | Fetch current quotes for symbols | +| Market data | `get_bars` | Fetch historical OHLCV bars | +| Risk | `compute_risk` | Compute and store a fresh risk snapshot | +| Risk | `list_risk_snapshots` | account_id | +| Risk | `get_risk_snapshot` | Read one saved risk snapshot | +| Execution | `execute_strategy` | Execute strategy code; may place orders | +| Middle office | `list_deals` | entity_id, fund, book, deal_id, product_kind, status | +| Middle office | `list_lifecycle_events` | entity_id, deal_id, kind, status, from, to | +| Middle office | `list_cash_flows` | entity_id, deal_id, fund, book, from, to, unsettled | +| Middle office | `list_journal_entries` | entity_id, deal_id, from, to | +| Middle office | `list_fund_periods` | entity_id, fund, status | +| Webhooks | `list_webhooks` | active | +| Webhooks | `create_webhook` | Subscribe an HTTPS endpoint to events | +| Webhooks | `delete_webhook` | Remove a webhook subscription | +| Profile | `get_profile` | Read your authenticated profile | + +Filters in the table are optional. Single-record tools require a CPZ record UUID returned by a corresponding list tool. `get_order` takes the CPZ record ID, not the broker order ID. File reads return metadata; they do not download file contents or sample rows. Backtest tools read saved runs; they do not start a backtest. + +### Pagination and data + +All list tools take `limit` (integer 1–100) and `offset` (nonnegative integer, default 0), plus any sorting fields in their advertised schemas. The default limit is 50, except `list_accounts`, which defaults to 100. Use an explicit limit when paging. REST `count` is the current page length, not a total. Continue paging until fewer than `limit` items are returned before reporting a complete portfolio. ```json -{ - "mcpServers": { - "cpzai": { - "url": "https://mcp.cpz-lab.com/mcp", - "headers": { - "X-CPZ-Key": "your_cpz_key", - "X-CPZ-Secret": "your_cpz_secret" - } - } - } -} +{"limit":100,"offset":0} ``` -### Claude (web / mobile) via OAuth +For the next page of the same list, use `{"limit":100,"offset":100}`. An upstream error is not an empty final page. + +`get_bars` accepts `symbols`, `timeframe`, `start`, `end`, `limit` (1–10,000 bars per symbol), and `feed` (`iex` or `sip`, subject to provider entitlements). Supported timeframes are `1Min`, `5Min`, `15Min`, `30Min`, `1Hour`, `2Hour`, `4Hour`, `1Day`, `1Week`, and `1Month`. + +Middle-office lists support optional `entity_id` for an entity book where the caller is an active member; otherwise they use the personal book. Preserve minor-unit decimal strings as exact values rather than converting them to JavaScript numbers. + +### Trading and side effects + +Use `list_accounts` to inspect `environment` and `tradable` before selecting an account. `tradable: true` filters to routable accounts; false or omitted includes all. Account discovery covers more providers than order routing. Live or paper execution follows the chosen account and platform controls. + +`place_order` requires positive quantities. Limit/stop-limit orders require `price`; stop/stop-limit orders require `stop_price`. Only `time_in_force: "day"` is exposed, because legacy routes support that duration only. Supported order types remain broker-dependent. `execute_strategy` can place trades and is not a signal preview or dry run. Invoke these tools only for trading the user has authorized. -The server speaks OAuth 2.1 + PKCE at `https://mcp.cpz-lab.com/.well-known/oauth-authorization-server`. Use Claude's "Connect a server" flow and point it at `https://mcp.cpz-lab.com/mcp`. +Tool annotations describe side effects. Actual authorization is enforced by the REST API's key, subscription, ownership, and resource-scope checks. Legacy `read`/`write`/`trade` permissions are expanded by that API into resource scopes. + +### 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. + +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. + +## Resources and prompts + +| Kind | Name | Purpose | +|---|---|---| +| Resource | `cpzai://guides/tool-usage` | Tool selection, pagination, and capability boundaries | +| Resource | `cpzai://guides/permissions` | Resource permissions and execution boundaries | +| 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. ## Architecture -``` -Agent (Cursor / Claude / GPT / custom) - │ - │ Streamable HTTP + X-CPZ-Key / X-CPZ-Secret - ▼ - mcp.cpz-lab.com - │ - ▼ - Node.js MCP server (stateless) ← this repo - │ - │ per-request API key validation - ▼ - CPZ Platform REST API +```text +Remote MCP client + → POST /mcp (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 ``` -**Design decisions:** - -- **Stateless transport.** Each `POST /mcp` creates a fresh server instance — no session tracking, scales horizontally without coordination. -- **Thin protocol adapter.** Validates the API key, then proxies tool calls to the CPZ REST API. Zero business logic in the MCP layer; what you see in `src/` is what runs in production. -- **Key auth.** Agents authenticate with CPZ platform API keys (`X-CPZ-Key` / `X-CPZ-Secret`) — the same keys used by the REST API and the [`cpz` Python SDK](https://pypi.org/project/cpz/). -- **Paper-only by default.** Live trading on a strategy requires explicit per-strategy promotion in the platform. - -## Available tools - -18 tools exposed today, organized by domain. - -| Tool | Description | Read-only | -|------|-------------|:---------:| -| **Strategies** | | | -| `list_strategies` | List trading strategies with filtering | ✓ | -| `get_strategy` | Get a specific strategy by ID | ✓ | -| `create_strategy` | Create a new trading strategy | | -| `update_strategy` | Update an existing strategy | | -| **Backtests** | | | -| `get_backtest_results` | List backtest run results | ✓ | -| **Orders & trading** | | | -| `list_orders` | List trading orders with filtering | ✓ | -| `place_order` | Place a new trading order | | -| `list_positions` | List current portfolio positions | ✓ | -| `sync_portfolio` | Trigger portfolio sync across brokers | | -| `list_accounts` | List connected trading accounts | ✓ | -| **Market data** | | | -| `get_market_data` | Real-time quotes (price, bid/ask, volume) | ✓ | -| **Risk** | | | -| `compute_risk` | Compute fresh risk snapshot (VaR, Sharpe, drawdown) | | -| `list_risk_snapshots` | List historical risk snapshots | ✓ | -| **Execution** | | | -| `execute_strategy` | Execute a strategy on the Python backend | | -| **Webhooks** | | | -| `list_webhooks` | List webhook subscriptions | ✓ | -| `create_webhook` | Subscribe to platform events | | -| `delete_webhook` | Remove a webhook subscription | | -| **User** | | | -| `get_profile` | Get authenticated user profile | ✓ | +`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. ## Local development ```bash -npm install - -export CPZ_API_BASE_URL=https://api.cpz-lab.com -export CPZ_SERVICE_KEY=your_service_key - +npm ci +export CPZ_API_BASE_URL=https://api.cpz-lab.com/functions/v1/rest-api +export MCP_BASE_URL=http://localhost:3001 +export MCP_TOKEN_SECRET=replace-with-a-strong-development-secret npm run dev -# server starts on http://localhost:3001 ``` -### Tests + type check +The client appends `/v1` to `CPZ_API_BASE_URL`. Its built-in default already includes `/functions/v1/rest-api`; setting the environment variable to the bare API domain is incorrect. No `CPZ_SERVICE_KEY` is required by this server. -```bash -npm test # single run -npm run test:watch # watch mode -npx tsc --noEmit # type check -``` +| Variable | Purpose | +|---|---| +| `PORT` | HTTP port (default 3001) | +| `CPZ_API_BASE_URL` | REST adapter base before `/v1` | +| `CPZ_API_TIMEOUT_MS` | Upstream request timeout budget (default 20000 ms) | +| `MCP_BASE_URL` | Public origin used in OAuth discovery and redirects | +| `MCP_TOKEN_SECRET` | Shared secret for encrypted OAuth tokens and registrations | +| `ALLOW_LEGACY_BEARER` | Enables legacy plaintext bearer format only when `true` | +| `SIMONS_UPSTREAM_URL` | Optional Simons proxy upstream override | +| `SENTRY_DSN` | Optional error reporting | + +OAuth registrations, codes, and tokens contain encrypted state. Production replicas must share `MCP_TOKEN_SECRET`. Without it, development uses a per-process random key and tokens cannot survive restarts or resolve on another replica. Refresh tokens have a 30-day lifetime; previous refresh tokens are not invalidated by rotation. Revoke the underlying CPZ API key to remove access. -### Build +### Verify and build ```bash -npm run build # compiles TS → dist/ -npm start # runs the compiled server +npm run export:catalog # refresh tool-catalog.json after schema/description changes +npm test +npm run build +npm start ``` +Tests cover registration, validation, routing, credentials, response errors, and OAuth behavior. No live order or strategy execution is required for local verification. + ### Docker ```bash docker build -t cpzai-mcp-server . docker run --rm -p 3001:3001 \ - -e CPZ_API_BASE_URL=https://api.cpz-lab.com \ - -e CPZ_SERVICE_KEY=your_service_key \ + -e MCP_BASE_URL=http://localhost:3001 \ + -e MCP_TOKEN_SECRET=replace-with-a-strong-development-secret \ cpzai-mcp-server ``` -## Security model - -- All connections HTTPS with TLS 1.3 at the edge. -- API credentials are forwarded in HTTP headers and **never logged**. -- Per-request API key validation against the platform's `api_keys` table. -- All data access is user-scoped via the `user_id` derived from the validated key. -- OAuth tokens (when used) are held in server memory only — never persisted. - -See the [privacy policy](https://mcp.cpz-lab.com/privacy) for the full data-handling story. - -## Self-hosting - -We don't currently support self-hosting. Use the hosted endpoint at `https://mcp.cpz-lab.com/mcp` with your CPZ API key. The hosted server is the same code in this repo plus AWS infrastructure (ALB, ECS Fargate, WAF, secrets) that's not part of this repo. - -If you have a strong reason to self-host (e.g. air-gapped trading desk), open an issue and we'll talk. - -## Contributing +## Scope and contributing -Issues and PRs welcome. Before submitting: +The hosted endpoint is the supported user connection. This repository is provided for transparency and local development; production self-hosting is not currently supported. The hosted infrastructure is maintained separately. -- `npx tsc --noEmit` must pass -- `npm test` must pass -- Keep changes minimal and aligned with existing patterns +CI compares `tool-catalog.json` with MCP discovery and checks every tool appears in this README. Export the same snapshot to the platform with `npm run export:catalog -- --output /path/to/cpzai/src/lib/mcp-tool-catalog.json`; its docs test checks the public tool table. -## License +Keep the public schemas, behavioral tests, README, and CPZAI's [agent integration docs](https://ai.cpz-lab.com/docs/agent-integration/overview) in sync. Do not advertise internal Simons tools or planned SDK lookup, sandbox, patch, cancellation, or middle-office write capabilities until they have implemented and tested public routes. [MIT](LICENSE) © CPZ Capital Ltd. diff --git a/package-lock.json b/package-lock.json index 9b8b6e1..54f0d38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cpzai/mcp-server", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cpzai/mcp-server", - "version": "1.0.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index f3e29ec..771e5bb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@cpzai/mcp-server", - "version": "1.0.0", - "description": "CPZAI MCP server — AI agent access to quantitative trading strategies, backtests, multi-broker order routing, portfolios, and risk analytics.", + "version": "1.2.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", "repository": { @@ -29,7 +29,8 @@ "start": "node dist/index.js", "dev": "tsx src/index.ts", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "export:catalog": "tsx scripts/export-tool-catalog.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/scripts/export-tool-catalog.ts b/scripts/export-tool-catalog.ts new file mode 100644 index 0000000..099c2e7 --- /dev/null +++ b/scripts/export-tool-catalog.ts @@ -0,0 +1,73 @@ +/** Export documentation metadata from the tools advertised over MCP itself. */ +import { writeFile, mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Request } from 'express'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import { createMcpServer } from '../src/server.js'; + +export interface ToolCatalogEntry { + name: string; + title?: string; + description?: string; + annotations?: ToolAnnotations; +} + +export async function readToolCatalog(): Promise { + // Discovery needs no credentials and never invokes a tool or the REST API. + const server = createMcpServer({ headers: {} } as Request); + const client = new Client({ name: 'cpzai-docs-catalog', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const catalog: ToolCatalogEntry[] = []; + let cursor: string | undefined; + const seenCursors = new Set(); + do { + const page = await client.listTools(cursor ? { cursor } : undefined); + for (const tool of page.tools) { + catalog.push({ + name: tool.name, + ...(tool.title !== undefined ? { title: tool.title } : {}), + ...(tool.description !== undefined ? { description: tool.description } : {}), + ...(tool.annotations !== undefined ? { annotations: tool.annotations } : {}), + }); + } + cursor = page.nextCursor; + if (cursor !== undefined) { + if (seenCursors.has(cursor) || seenCursors.size >= 100) throw new Error('Tool discovery pagination did not terminate.'); + seenCursors.add(cursor); + } + } while (cursor !== undefined); + return catalog.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + } finally { + await client.close(); + await server.close(); + } +} + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 0 && (args.length !== 2 || args[0] !== '--output' || !args[1])) { + throw new Error('Usage: tsx scripts/export-tool-catalog.ts [--output path]'); + } + const destination = args.length === 0 + ? fileURLToPath(new URL('../tool-catalog.json', import.meta.url)) + : resolve(args[1]); + const catalog = await readToolCatalog(); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8'); + console.log(`Exported ${catalog.length} MCP tools to ${destination}`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + // The imported OAuth module owns a long-lived sweep timer. This one-shot + // exporter exits only after discovery, transport cleanup, and the write finish. + main().then(() => process.exit(0)).catch(error => { + console.error('Tool catalog export failed:', error); + process.exit(1); + }); +} diff --git a/server.json b/server.json index 2860945..ba166e7 100644 --- a/server.json +++ b/server.json @@ -2,13 +2,13 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "com.cpz-lab.mcp/cpzai", "title": "CPZAI", - "description": "Build, backtest, and deploy quantitative trading strategies from your AI agent.", + "description": "Manage strategies, inspect saved backtests, trade, and review portfolio and middle-office data.", "websiteUrl": "https://ai.cpz-lab.com", "repository": { "url": "https://github.com/CPZ-Lab/cpzai-mcp-server", "source": "github" }, - "version": "1.1.0", + "version": "1.2.0", "remotes": [ { "type": "streamable-http", @@ -19,7 +19,11 @@ "io.modelcontextprotocol.registry/publisher-provided": { "tool": "manual-publish", "version": "1.1.0", - "categories": ["finance", "trading", "quant"], + "categories": [ + "finance", + "trading", + "quant" + ], "tags": [ "trading", "quant", diff --git a/src/api-client.ts b/src/api-client.ts index f7a09a6..1136057 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,25 +1,14 @@ -/** - * HTTP client for the CPZ platform REST API. - * - * The MCP server authenticates with the user's CPZ API key/secret and proxies - * tool calls to the REST API. Set CPZ_API_BASE_URL to override the default endpoint - * (useful for local development against a staging environment). - */ +/** HTTP client for the CPZ platform REST API. */ +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; -// api.cpz-lab.com is a CNAME onto the Supabase project, so the REST API is only -// reachable under the edge-function mount: /functions/v1/rest-api/v1/{resource}. -// The old default (https://api.cpz-lab.com/v1) skipped that mount and got -// Supabase's gateway 404 ({"error":"requested path is invalid"}) for EVERY call -// — which the OAuth consent handler surfaced as "Invalid API credentials", so -// the keyless sign-in could never succeed no matter what the key was. +// The API is mounted under the Supabase edge-function route, not /v1 alone. const DEFAULT_API_BASE = 'https://api.cpz-lab.com/functions/v1/rest-api'; - const REST_API_BASE = `${(process.env.CPZ_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, '')}/v1`; - -// No request may hang forever. fetch() without a signal waits indefinitely, so a -// stalled upstream would pin an OAuth sign-in or a tool call open with no error -// — the user just watches a spinner. Fail loudly instead. -const REQUEST_TIMEOUT_MS = parseInt(process.env.CPZ_API_TIMEOUT_MS || '20000', 10); +const DEFAULT_TIMEOUT_MS = 20_000; +const MAX_TIMEOUT_MS = 120_000; +const MAX_GET_ATTEMPTS = 3; +const MAX_RETRY_DELAY_MS = 5_000; export interface ApiCallOptions { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -28,64 +17,208 @@ export interface ApiCallOptions { query?: Record; apiKey: string; apiSecret: string; + requestId?: string; } export interface ApiResult { ok: boolean; status: number; data: unknown; + requestId: string; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasLogicalError(value: unknown, resource?: string): boolean { + if (!isObject(value)) return false; + return value.success === false || value.ok === false + || (value.error !== undefined && value.error !== null && value.error !== false && value.error !== '') + || (resource === 'execute' && value.hasPythonError === true) + || (resource === 'portfolio-sync' && Array.isArray(value.results) + && value.results.some(result => hasLogicalError(result))); +} + +function retryDelay(retryAfter: string | null, attempt: number): number { + const backoff = 200 * (2 ** (attempt - 1)) + Math.floor(Math.random() * 200); + if (retryAfter === null) return backoff; + const value = retryAfter.trim(); + const requested = /^\d+$/.test(value) + ? Number(value) * 1_000 + : Date.parse(value) - Date.now(); + return Number.isNaN(requested) ? backoff : Math.max(backoff, requested); } export async function callRestApi(opts: ApiCallOptions): Promise { - const url = new URL(`${REST_API_BASE}${opts.path}`); - if (opts.query) { - for (const [k, v] of Object.entries(opts.query)) { - url.searchParams.set(k, v); + // Restrict IDs to printable header-safe tokens, also preventing log injection. + const requestId = opts.requestId && /^[a-zA-Z0-9._:-]{1,128}$/.test(opts.requestId) + ? opts.requestId + : randomUUID(); + const readOnly = opts.method === 'GET'; + const resource = opts.path.split(/[/?#]/).filter(Boolean)[0] || 'unknown'; + const moneyPath = /^(orders|positions|accounts|connections|portfolio-sync|execute|risk-compute|risk-snapshots|billing|fills)$/.test(resource); + + const failure = ( + status: number, + code: string, + message: string, + attempt: number, + upstreamStatus?: number, + upstreamData?: unknown, + uncertain = false, + ): ApiResult => { + const upstream = isObject(upstreamData) ? upstreamData : {}; + if (moneyPath) { + // No credentials, query strings, payloads or raw provider errors in logs. + console.error('[CPZ MCP API] Request failed', { + request_id: requestId, + method: opts.method, + resource, + status, + upstream_status: upstreamStatus, + code, + attempt, + operation_outcome: uncertain ? 'unknown' : undefined, + }); } + return { + ok: false, + status, + requestId, + data: { + ...upstream, + ...(upstream.ok !== undefined ? { ok: false, upstream_ok: upstream.ok } : {}), + ...(upstream.success !== undefined ? { success: false, upstream_success: upstream.success } : {}), + error: upstream.error || message, + ...(upstream.code !== undefined ? { upstream_code: upstream.code } : {}), + code, + request_id: requestId, + ...(upstreamStatus !== undefined ? { upstream_status: upstreamStatus } : {}), + ...(uncertain ? { + operation_outcome: 'unknown', + guidance: 'The operation may have completed upstream. Check its current state before resubmitting; this client did not retry the mutation.', + } : {}), + }, + }; + }; + + const configuredTimeout = process.env.CPZ_API_TIMEOUT_MS; + const 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); + } + + let url: URL; + try { + url = new URL(`${REST_API_BASE}${opts.path}`); + if (opts.query) { + for (const [key, value] of Object.entries(opts.query)) url.searchParams.set(key, value); + } + } catch { + return failure(500, 'invalid_api_configuration', 'The CPZ API URL could not be constructed.', 0); + } + + let body: string | undefined; + try { + body = opts.body ? JSON.stringify(opts.body) : undefined; + } catch { + return failure(400, 'invalid_request_body', 'The request body could not be serialized as JSON.', 0); } const headers: Record = { 'X-CPZ-Key': opts.apiKey, 'X-CPZ-Secret': opts.apiSecret, 'Content-Type': 'application/json', + 'User-Agent': 'CPZ Lab (support@cpz-lab.com)', + 'x-request-id': requestId, }; + // One budget covers all attempts, backoff and response-body consumption. + const deadline = Date.now() + timeoutMs; + const maxAttempts = readOnly ? MAX_GET_ATTEMPTS : 1; - let resp: Response; - try { - resp = await fetch(url.toString(), { - method: opts.method, - headers, - body: opts.body ? JSON.stringify(opts.body) : undefined, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - } catch (err) { - const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'); - return { - ok: false, - status: timedOut ? 504 : 502, - data: { - error: timedOut - ? `The CPZ API did not respond within ${REQUEST_TIMEOUT_MS}ms. The call did NOT complete — do not treat this as an empty result.` - : `Could not reach the CPZ API: ${err instanceof Error ? err.message : String(err)}. The call did NOT complete — do not treat this as an empty result.`, - }, - }; - } + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + return failure(504, 'upstream_timeout', `The ${timeoutMs}ms CPZ API request budget expired before another attempt could start.`, attempt); + } + let result: ApiResult; + let transient = false; + let retryAfter: string | null = null; + let response: Response | undefined; + try { + response = await fetch(url.toString(), { + method: opts.method, + headers, + body, + signal: AbortSignal.timeout(remainingMs), + }); + retryAfter = response.headers.get('retry-after'); + transient = response.status === 429 || response.status >= 500; + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) throw error; + result = failure( + response.ok ? 502 : response.status, + 'invalid_upstream_response', + 'The CPZ API returned invalid JSON. No valid result is available.', + attempt, + response.status, + undefined, + !readOnly, + ); + // Non-JSON 429/5xx responses still qualify for safe GET retries. + if (readOnly && transient && attempt < maxAttempts) { + const waitMs = retryDelay(retryAfter, attempt); + if (waitMs <= MAX_RETRY_DELAY_MS && waitMs < deadline - Date.now()) { + await delay(waitMs); + continue; + } + } + return result; + } + if (response.ok && !hasLogicalError(parsed, resource)) { + return { ok: true, status: response.status, data: parsed, requestId }; + } + result = failure( + response.ok ? 502 : response.status, + response.ok ? 'upstream_logical_error' : 'upstream_http_error', + response.ok + ? resource === 'execute' && isObject(parsed) && parsed.hasPythonError === true + ? 'Strategy execution reported a Python error. Inspect execution_id and body for details.' + : resource === 'portfolio-sync' + ? 'Portfolio synchronization reported a failure. Inspect results for per-account outcomes.' + : 'The CPZ API reported an unsuccessful operation.' + : `The CPZ API returned HTTP ${response.status}.`, + attempt, + response.status, + parsed, + !readOnly && (response.ok || response.status >= 500), + ); + } catch (error) { + const timedOut = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'); + transient = !timedOut; + result = failure( + timedOut ? 504 : 502, + timedOut ? 'upstream_timeout' : 'upstream_network_error', + timedOut + ? `The CPZ API did not return a complete response within the ${timeoutMs}ms request budget.` + : 'The CPZ API response could not be received because of a network failure.', + attempt, + response?.status, + undefined, + !readOnly, + ); + } - // An unparseable body is a HARD failure — never paper over it with the - // upstream status. If we returned ok:true here, a caller (e.g. list_positions) - // could read a parse failure as an empty-but-successful result and conclude - // "no positions" when the truth is "we don't know". Fail loudly instead. - const PARSE_FAILED = Symbol('parse_failed'); - const parsed: unknown = await resp.json().catch(() => PARSE_FAILED); - if (parsed === PARSE_FAILED) { - return { - ok: false, - status: resp.status, - data: { - error: 'Upstream response could not be parsed as JSON. The call did NOT return valid data — do not treat this as an empty result.', - upstream_status: resp.status, - }, - }; + if (!readOnly || !transient || attempt === maxAttempts) return result; + const waitMs = retryDelay(retryAfter, attempt); + // Never retry earlier than Retry-After. Long vendor delays are surfaced to + // the caller instead of holding an MCP request open or violating backoff. + if (waitMs > MAX_RETRY_DELAY_MS || waitMs >= deadline - Date.now()) return result; + await delay(waitMs); } - return { ok: resp.ok, status: resp.status, data: parsed }; + throw new Error('Unreachable: every API attempt returns a result or retries.'); } diff --git a/src/capabilities.ts b/src/capabilities.ts new file mode 100644 index 0000000..9e50009 --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,71 @@ +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 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. + +## 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. + +## Research and inspection +Use list_data_files and get_data_file to discover stored metadata, columns, and existing previews. These tools do not download raw files or guarantee a five-row sample. Use get_strategy for stored code, get_backtest_results followed by get_backtest_result for stored runs, and get_order/get_risk_snapshot for individual records. Stored backtests are not a new backtest run. + +## Trading and recovery +Start with list_accounts; select a tradable account and confirm its environment matches the user's intent. place_order requires a positive quantity, price for limit/stop_limit, and stop_price for stop/stop_limit. Broker stop-order support varies. Only day duration is exposed because it is supported consistently across routes. execute_strategy executes code and can trade; it is not a read-only signal or dry-run tool. A user-authorized workflow should not automatically place another order after execution. + +The adapter never retries POST, PATCH, PUT, or DELETE. An unconfirmed mutation may have reached the platform or broker. Inspect orders and broker state before any resubmission; an empty local order page is not proof that a broker order failed. There is no end-to-end idempotent order retry guarantee on this interface. + +## Capability boundaries +No generic HTTP/SQL tool, order cancellation, sandbox dry_run, apply_patch, SDK lookup, or execution-log tool is exposed. Use only tools returned by tools/list. Resources and prompts provide guidance; they do not execute trades or grant additional permissions. +`; + +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. + +| Tools | Accepted resource scopes (any listed) | +| --- | --- | +| Strategy management and backtest reads | strategies | +| Data files, connections, quotes, historical bars | data | +| Account listing | trading_credentials | +| Orders and strategy execution | orders | +| Positions, risk, portfolio sync, middle-office reads | trading_credentials or orders | +| Webhooks | Any recognized resource scope | +| Profile | Authenticated identity | + +Legacy read/write/trade scopes are expanded by the REST API. A scope is a resource permission; it is not a client approval policy. Do not put broker/provider secrets into prompts. create_connection sends supplied credentials to the platform for encrypted storage; list_connections never returns them. create_webhook returns its signing secret once; store it securely. + +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. +`; + +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], + ]) { + server.registerResource(name, uri, { title, description: title, mimeType: 'text/markdown' }, async resourceUri => ({ + contents: [{ uri: resourceUri.href, mimeType: 'text/markdown', text }], + })); + } + + server.registerPrompt('review_portfolio', { + title: 'Review Portfolio', + description: 'Read-only portfolio review using accounts, paginated positions, orders, and stored risk snapshots.', + argsSchema: { account_id: z.string().trim().min(1).optional().describe('Optional account filter') }, + }, ({ account_id }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: + `Review my portfolio${account_id ? ` for account ${JSON.stringify(account_id)}` : ''}. Read cpzai://guides/tool-usage. List accounts, retrieve all pages of positions and relevant stored risk snapshots, and examine orders as permitted. Report account environments, timestamps, exposure, and missing or failed inputs. Keep currency amounts exact. This request is read-only; do not compute new snapshots, sync accounts, execute strategies, or submit orders.` } }], + })); + + server.registerPrompt('analyze_strategy', { + title: 'Analyze Strategy', + description: 'Inspect stored strategy code and backtest evidence without execution or edits.', + argsSchema: { strategy_id: z.string().uuid().describe('Strategy UUID') }, + }, ({ strategy_id }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: + `Analyze strategy ${strategy_id}. Read its code with get_strategy, page through get_backtest_results for this strategy, and inspect relevant get_backtest_result records. If data inputs need investigation, use list_data_files/get_data_file and list_connections. Evaluate the actual stored evidence, call out missing data and failed requests, and propose improvements. This request authorizes analysis only; do not update or execute the strategy, create connections, or place trades.` } }], + })); +} diff --git a/src/expanded-tools.ts b/src/expanded-tools.ts new file mode 100644 index 0000000..d4d524f --- /dev/null +++ b/src/expanded-tools.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { callRestApi } from './api-client.js'; +import { formatResult } from './tool-result.js'; + +interface Credentials { + apiKey: string; + apiSecret: string; + requestId?: string; +} + +const readOnlyAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +}; + +const pagination = { + limit: z.number().int().min(1).max(100).optional().describe('Maximum records in this page (default 50, maximum 100).'), + offset: z.number().int().min(0).optional().describe('Records to skip (default 0). Increase to retrieve another page.'), + sort_order: z.enum(['asc', 'desc']).optional().describe('Sort direction (default desc).'), +}; + +const filterText = z.string().trim().min(1).max(200); +const entityId = z.string().uuid().optional().describe('Entity UUID. Requires active membership; omit for your personal book.'); +const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD').refine((value) => { + const parsed = new Date(`${value}T00:00:00.000Z`); + return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +}, 'Use a valid calendar date'); +const dateRange = { + from: date.optional().describe('Inclusive start date (YYYY-MM-DD).'), + to: date.optional().describe('Inclusive end date (YYYY-MM-DD); must be on or after from.'), +}; + +/** Read only resources that already exist on the authenticated REST API. */ +export function registerExpandedTools(server: McpServer, creds: Credentials) { + const list = async (path: string, args: Record) => { + if (typeof args.from === 'string' && typeof args.to === 'string' && args.from > args.to) { + return formatResult({ ok: false, status: 400, data: { error: 'from must be on or before to.' } }); + } + const query: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (value !== undefined) query[key] = String(value); + } + return formatResult(await callRestApi({ method: 'GET', path, query, ...creds })); + }; + + const get = async (path: string, id: string) => + formatResult(await callRestApi({ method: 'GET', path: `${path}/${encodeURIComponent(id)}`, ...creds })); + + server.registerTool('list_data_files', { + title: 'List Data Files', + description: 'Discover your uploaded datasets and their schema metadata, row counts, file types, and processing status. Returns metadata, not file contents. Requires the data scope.', + inputSchema: z.object({ + status: filterText.optional().describe('Exact processing status filter.'), + file_type: filterText.optional().describe('Exact file type filter, as stored on the dataset.'), + name: filterText.optional().describe('Case-insensitive partial match on the dataset name.'), + ...pagination, + sort_by: z.enum(['created_at', 'updated_at', 'name', 'file_size', 'rows_count']).optional().describe('Sort column (default created_at).'), + }), + annotations: readOnlyAnnotations, + }, async (args) => list('/data-files', args)); + + server.registerTool('get_data_file', { + title: 'Get Data File', + description: 'Get one uploaded dataset’s metadata, including column information and storage path. Does not download the file. Requires the data scope.', + inputSchema: z.object({ id: z.string().uuid().describe('Data file UUID from list_data_files.') }), + annotations: readOnlyAnnotations, + }, async ({ id }) => get('/data-files', id)); + + server.registerTool('get_backtest_result', { + title: 'Get Backtest Result', + description: 'Inspect one saved backtest run, including its configuration, result, code, and verification fields when available. Does not run a new backtest. Requires the strategies scope.', + inputSchema: z.object({ id: z.string().uuid().describe('Backtest run UUID from get_backtest_results.') }), + annotations: readOnlyAnnotations, + }, async ({ id }) => get('/backtests', id)); + + server.registerTool('get_order', { + title: 'Get Order', + description: 'Read one stored order record, including its reconciled status and fill fields. This is platform state, not a fresh broker query. Requires the orders scope.', + inputSchema: z.object({ id: z.string().uuid().describe('Platform order record UUID from list_orders; not the broker order ID.') }), + annotations: readOnlyAnnotations, + }, async ({ id }) => get('/orders', id)); + + server.registerTool('get_risk_snapshot', { + title: 'Get Risk Snapshot', + description: 'Inspect one saved portfolio risk snapshot and its recorded exposures, risk contributions, and analytics when available. Does not compute fresh risk. Requires trading_credentials or orders scope.', + inputSchema: z.object({ id: z.string().uuid().describe('Risk snapshot UUID from list_risk_snapshots.') }), + annotations: readOnlyAnnotations, + }, async ({ id }) => get('/risk-snapshots', id)); + + server.registerTool('list_deals', { + title: 'List OTC Deals', + description: 'List middle-office OTC deal records in your personal or entity book. LIVE status includes DRAFT and CONFIRMED deals. Requires trading_credentials or orders scope.', + inputSchema: z.object({ + entity_id: entityId, + fund: filterText.optional(), + book: filterText.optional(), + deal_id: filterText.optional().describe('Business deal identifier, not the database row UUID.'), + product_kind: filterText.optional().describe('Exact product kind, as stored on the deal.'), + status: filterText.optional().describe('Deal status, case-insensitive; LIVE includes DRAFT and CONFIRMED.'), + ...pagination, + sort_by: z.enum(['created_at', 'updated_at', 'trade_date', 'effective_date', 'termination_date', 'deal_id']).optional().describe('Sort column (default created_at).'), + }), + annotations: readOnlyAnnotations, + }, async (args) => list('/deals', args)); + + server.registerTool('list_lifecycle_events', { + title: 'List OTC Lifecycle Events', + description: 'Inspect scheduled OTC lifecycle events, their required inputs, dates, and processing status. Date filters apply to event_date. Requires trading_credentials or orders scope.', + inputSchema: z.object({ + entity_id: entityId, + deal_id: filterText.optional().describe('Business deal identifier.'), + kind: filterText.optional().describe('Event kind, case-insensitive.'), + status: filterText.optional().describe('Event status, case-insensitive.'), + ...dateRange, + ...pagination, + sort_by: z.enum(['created_at', 'event_date', 'payment_date', 'deal_id']).optional().describe('Sort column (default created_at).'), + }), + annotations: readOnlyAnnotations, + }, async (args) => list('/lifecycle-events', args)); + + server.registerTool('list_cash_flows', { + title: 'List OTC Cash Flows', + description: 'Inspect recorded OTC cash flows and settlement state. Date filters apply to payment_date. amount_minor is an exact integer string; preserve its precision. Requires trading_credentials or orders scope.', + inputSchema: z.object({ + entity_id: entityId, + deal_id: filterText.optional().describe('Business deal identifier.'), + fund: filterText.optional(), + book: filterText.optional(), + unsettled: z.boolean().optional().describe('Set true to return only cash flows without settled_at. False or omitted includes all.'), + ...dateRange, + ...pagination, + sort_by: z.enum(['created_at', 'payment_date', 'settled_at', 'deal_id']).optional().describe('Sort column (default created_at).'), + }), + annotations: readOnlyAnnotations, + }, async (args) => list('/cash-flows', args)); + + server.registerTool('list_journal_entries', { + title: 'List OTC Journal Entries', + description: 'Inspect middle-office ledger entry headers and audit hashes. Date filters apply to entry_date. Returns headers, not journal line items or a balance calculation. Requires trading_credentials or orders scope.', + inputSchema: z.object({ + entity_id: entityId, + deal_id: filterText.optional().describe('Business deal identifier.'), + ...dateRange, + ...pagination, + sort_by: z.enum(['created_at', 'entry_date', 'deal_id']).optional().describe('Sort column (default created_at).'), + }), + annotations: readOnlyAnnotations, + }, async (args) => list('/journal-entries', args)); + + server.registerTool('list_fund_periods', { + title: 'List OTC Fund Periods', + description: 'Inspect recorded fund accounting periods, NAV fields, subscriptions, redemptions, and P&L when available. Monetary minor-unit fields are exact integer strings; preserve their precision. Requires trading_credentials or orders scope.', + inputSchema: z.object({ + entity_id: entityId, + fund: filterText.optional(), + status: filterText.optional().describe('Period status, case-insensitive.'), + ...pagination, + sort_by: z.enum(['created_at', 'period_start', 'period_end', 'fund']).optional().describe('Sort column (default created_at).'), + }), + annotations: readOnlyAnnotations, + }, async (args) => list('/fund-periods', args)); +} diff --git a/src/index.ts b/src/index.ts index 416a122..696a45e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/node'; import express from 'express'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { registerTools, extractCredentials } from './tools.js'; +import { extractCredentials } from './tools.js'; +import { createMcpServer } from './server.js'; import { getOAuthMetadata, getProtectedResourceMetadata, @@ -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.0.0', + release: process.env.SENTRY_RELEASE || 'cpzai-mcp-server@1.2.0', tracesSampleRate: 0.2, profilesSampleRate: 0.1, }); @@ -35,7 +35,8 @@ app.use(express.urlencoded({ extended: true })); function cors(_req: express.Request, res: express.Response, next: express.NextFunction) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CPZ-Key, X-CPZ-Secret, X-Request-Id, MCP-Protocol-Version, MCP-Session-Id'); + res.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate, MCP-Session-Id, X-Request-Id'); next(); } @@ -353,7 +354,9 @@ app.post(['/simons/stream', '/simons/chat'], cors, async (req, res) => { // ── MCP ───────────────────────────────────────────────────────── -app.post('/mcp', async (req, res) => { +app.options('/mcp', cors, (_req, res) => res.sendStatus(204)); + +app.post('/mcp', 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 @@ -375,26 +378,24 @@ app.post('/mcp', async (req, res) => { return; } - const server = new McpServer({ - name: 'cpzai-mcp-server', - version: '1.0.0', - }); - - registerTools(server, req); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, + const server = createMcpServer(req); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on('close', () => { + void server.close().catch(error => console.error('[mcp] failed to close request transport', { error })); }); - - await server.connect(transport); - await transport.handleRequest(req, res, req.body); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + next(error); + } }); -app.get('/mcp', async (_req, res) => { +app.get('/mcp', cors, async (_req, res) => { res.writeHead(405).end(JSON.stringify({ error: 'Method not allowed. Use POST for Streamable HTTP.' })); }); -app.delete('/mcp', async (_req, res) => { +app.delete('/mcp', 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 new file mode 100644 index 0000000..0529ec4 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,13 @@ +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'; + +export function createMcpServer(req: Request) { + const server = new McpServer({ name: 'cpzai-mcp-server', version: '1.2.0' }, { + instructions: SERVER_INSTRUCTIONS, + }); + registerTools(server, req); + registerCapabilities(server); + return server; +} diff --git a/src/tool-result.ts b/src/tool-result.ts new file mode 100644 index 0000000..cf20197 --- /dev/null +++ b/src/tool-result.ts @@ -0,0 +1,18 @@ +import type { ApiResult } from './api-client.js'; + +/** Preserve the API envelope for clients that consume JSON and older text clients. */ +export function formatResult(result: Pick) { + const structuredContent = result.data !== null && typeof result.data === 'object' && !Array.isArray(result.data) + ? result.data as Record + : { data: result.data }; + const text = JSON.stringify(structuredContent, null, 2); + return { + content: [{ type: 'text' as const, text: result.ok ? text : `Error (${result.status}): ${text}` }], + structuredContent, + ...(result.ok ? {} : { isError: true }), + }; +} + +export function invalidArguments(message: string) { + return formatResult({ ok: false, status: 400, data: { error: message, code: 'invalid_arguments' } }); +} diff --git a/src/tools.ts b/src/tools.ts index 2b96fa2..f89e2bd 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -3,6 +3,21 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Request } from 'express'; import { callRestApi } from './api-client.js'; import { resolveAccessToken } from './oauth.js'; +import { formatResult, invalidArguments } from './tool-result.js'; +import { registerExpandedTools } from './expanded-tools.js'; + +const pageLimit = z.number().int().min(1).max(100).optional().describe('Page size (default 50, maximum 100)'); +const pageOffset = z.number().int().min(0).optional().describe('Rows to skip; increase by returned count for the next page'); +const identifier = z.string().uuid(); +const symbol = z.string().trim().min(1).max(32).regex(/^[A-Za-z0-9^][A-Za-z0-9./:_=^\-]*$/).transform(value => value.toUpperCase()); +const symbols = z.array(symbol).min(1).max(100); +const dateTime = z.string().refine(value => { + if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/.test(value)) return false; + const day = value.slice(0, 10); + const parsedDay = new Date(`${day}T00:00:00.000Z`); + return Number.isFinite(parsedDay.getTime()) && parsedDay.toISOString().slice(0, 10) === day + && (value.length === 10 || z.string().datetime({ offset: true }).safeParse(value).success); +}, 'Use a valid ISO 8601 date or timestamp with timezone'); /** * Extract CPZ API credentials from the incoming HTTP request. @@ -38,16 +53,10 @@ export function extractCredentials(req: Request): { apiKey: string; apiSecret: s return { apiKey: '', apiSecret: '' }; } -function formatResult(result: { ok: boolean; status: number; data: unknown }) { - const text = JSON.stringify(result.data, null, 2); - if (!result.ok) { - return { content: [{ type: 'text' as const, text: `Error (${result.status}): ${text}` }], isError: true }; - } - return { content: [{ type: 'text' as const, text }] }; -} - export function registerTools(server: McpServer, req: Request) { - const creds = extractCredentials(req); + const requestId = req.headers['x-request-id']; + const creds = { ...extractCredentials(req), requestId: typeof requestId === 'string' ? requestId : undefined }; + registerExpandedTools(server, creds); // ── Strategies ────────────────────────────────────────────── @@ -58,10 +67,10 @@ export function registerTools(server: McpServer, req: Request) { status: z.string().optional().describe('Filter by status (e.g. active, draft)'), strategy_type: z.string().optional().describe('Filter by strategy type'), title: z.string().optional().describe('Search in titles (partial match)'), - limit: z.number().optional().describe('Max results (1-100)'), - offset: z.number().optional().describe('Skip N results'), + limit: pageLimit, + offset: pageOffset, }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const query: Record = {}; if (args.status) query.status = args.status; @@ -76,8 +85,8 @@ export function registerTools(server: McpServer, req: Request) { server.registerTool('get_strategy', { title: 'Get Strategy', description: 'Get a specific strategy by ID.', - inputSchema: z.object({ id: z.string().describe('Strategy UUID') }), - annotations: { readOnlyHint: true }, + inputSchema: z.object({ id: identifier.describe('Strategy UUID') }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const result = await callRestApi({ method: 'GET', path: `/strategies/${args.id}`, ...creds }); return formatResult(result); @@ -87,7 +96,7 @@ export function registerTools(server: McpServer, req: Request) { title: 'Create Strategy', description: 'Create a new trading strategy.', inputSchema: z.object({ - title: z.string().describe('Strategy title'), + title: z.string().trim().min(1).max(200).describe('Strategy title'), description: z.string().optional(), strategy_type: z.string().optional().describe('e.g. momentum, mean_reversion'), python_code: z.string().optional().describe('Python code for the strategy'), @@ -103,15 +112,17 @@ export function registerTools(server: McpServer, req: Request) { title: 'Update Strategy', description: 'Update an existing strategy. Pass only the fields to change.', inputSchema: z.object({ - id: z.string().describe('Strategy UUID'), + id: identifier.describe('Strategy UUID'), title: z.string().optional(), description: z.string().optional(), + strategy_type: z.string().trim().min(1).optional(), python_code: z.string().optional(), status: z.string().optional(), }), annotations: { readOnlyHint: false }, }, async (args) => { const { id, ...body } = args; + if (Object.keys(body).length === 0) return invalidArguments('Provide at least one strategy field to update'); const result = await callRestApi({ method: 'PATCH', path: `/strategies/${id}`, body, ...creds }); return formatResult(result); }); @@ -122,11 +133,11 @@ export function registerTools(server: McpServer, req: Request) { title: 'Get Backtest Results', description: 'List backtest run results, optionally filtered by strategy.', inputSchema: z.object({ - strategy_id: z.string().optional().describe('Filter by strategy UUID'), - limit: z.number().optional(), - offset: z.number().optional(), + strategy_id: identifier.optional().describe('Filter by strategy UUID'), + limit: pageLimit, + offset: pageOffset, }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const query: Record = {}; if (args.strategy_id) query.strategy_id = args.strategy_id; @@ -143,13 +154,13 @@ export function registerTools(server: McpServer, req: Request) { description: 'List trading orders. Filter by status, symbol, side, or strategy.', inputSchema: z.object({ status: z.string().optional(), - symbol: z.string().optional(), + symbol: symbol.optional(), side: z.string().optional().describe('buy or sell'), - strategy_id: z.string().optional(), - limit: z.number().optional(), - offset: z.number().optional(), + strategy_id: identifier.optional(), + limit: pageLimit, + offset: pageOffset, }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const query: Record = {}; if (args.status) query.status = args.status; @@ -164,18 +175,26 @@ export function registerTools(server: McpServer, req: Request) { server.registerTool('place_order', { title: 'Place Order', - description: 'Place a new trading order through a connected broker.', + description: 'Place an order using a tradable account from list_accounts. Check its broker and environment; live accounts can trade real money. Stop orders and time-in-force support depend on the broker. After a timeout, inspect list_orders before retrying; submission may have succeeded.', inputSchema: z.object({ - account_id: z.string().describe('Trading account ID'), - broker_credential_id: z.string().describe('Broker credential ID'), - symbol: z.string().describe('Ticker symbol (e.g. AAPL)'), + account_id: z.string().trim().min(1).describe('Trading account ID from list_accounts'), + broker_credential_id: identifier.describe('Tradable broker credential ID from list_accounts'), + symbol: symbol.describe('Ticker symbol (e.g. AAPL)'), side: z.enum(['buy', 'sell']), order_type: z.enum(['market', 'limit', 'stop', 'stop_limit']), - quantity: z.number().describe('Number of shares'), - price: z.number().optional().describe('Limit price (required for limit orders)'), + quantity: z.number().finite().positive().describe('Positive quantity; fractional shares depend on the broker'), + price: z.number().finite().positive().optional().describe('Limit price; required for limit and stop_limit orders'), + stop_price: z.number().finite().positive().optional().describe('Trigger price; required for stop and stop_limit orders. Broker support varies.'), + time_in_force: z.literal('day').optional().describe('Day orders only; other durations are not consistently supported by all routes'), }), annotations: { destructiveHint: true, idempotentHint: false }, }, async (args) => { + if (['limit', 'stop_limit'].includes(args.order_type) && args.price === undefined) { + return invalidArguments('price is required for limit and stop_limit orders'); + } + if (['stop', 'stop_limit'].includes(args.order_type) && args.stop_price === undefined) { + return invalidArguments('stop_price is required for stop and stop_limit orders'); + } const result = await callRestApi({ method: 'POST', path: '/orders', body: args, ...creds }); return formatResult(result); }); @@ -184,16 +203,20 @@ export function registerTools(server: McpServer, req: Request) { server.registerTool('list_positions', { title: 'List Positions', - description: 'List current portfolio positions.', + description: 'List one page of portfolio positions. Continue with offset until a short page is returned before concluding a portfolio review.', inputSchema: z.object({ account_id: z.string().optional(), - symbol: z.string().optional(), + symbol: symbol.optional(), + limit: pageLimit, + offset: pageOffset, }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const query: Record = {}; if (args.account_id) query.account_id = args.account_id; if (args.symbol) query.symbol = args.symbol; + if (args.limit !== undefined) query.limit = String(args.limit); + if (args.offset !== undefined) query.offset = String(args.offset); const result = await callRestApi({ method: 'GET', path: '/positions', query, ...creds }); return formatResult(result); }); @@ -217,13 +240,19 @@ export function registerTools(server: McpServer, req: Request) { description: 'List connected trading accounts (broker credentials). Sensitive fields excluded.', inputSchema: z.object({ broker: z.string().optional().describe('Filter by broker (alpaca, ibkr)'), - environment: z.string().optional().describe('live or paper'), + environment: z.enum(['live', 'paper']).optional().describe('live or paper'), + tradable: z.boolean().optional().describe('true includes only tradable accounts; false or omitted includes all accounts'), + limit: pageLimit.describe('Page size (default 100, maximum 100)'), + offset: pageOffset, }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const query: Record = {}; if (args.broker) query.broker = args.broker; if (args.environment) query.environment = args.environment; + if (args.tradable !== undefined) query.tradable = String(args.tradable); + if (args.limit !== undefined) query.limit = String(args.limit); + if (args.offset !== undefined) query.offset = String(args.offset); const result = await callRestApi({ method: 'GET', path: '/accounts', query, ...creds }); return formatResult(result); }); @@ -244,11 +273,15 @@ export function registerTools(server: McpServer, req: Request) { description: 'List the user\'s configured data-provider connections. Credentials are never returned.', inputSchema: z.object({ connection_type: z.string().optional().describe('Filter by provider, e.g. polygon, databento'), + limit: pageLimit, + offset: pageOffset, }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const query: Record = {}; if (args.connection_type) query.connection_type = args.connection_type; + if (args.limit !== undefined) query.limit = String(args.limit); + if (args.offset !== undefined) query.offset = String(args.offset); const result = await callRestApi({ method: 'GET', path: '/connections', query, ...creds }); return formatResult(result); }); @@ -289,9 +322,9 @@ export function registerTools(server: McpServer, req: Request) { title: 'Get Market Data', description: 'Fetch real-time market data (price, bid/ask, volume) for one or more symbols.', inputSchema: z.object({ - symbols: z.array(z.string()).describe('Ticker symbols (e.g. ["AAPL", "MSFT"])'), + symbols: symbols.describe('Ticker symbols (e.g. ["AAPL", "MSFT"])'), }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const result = await callRestApi({ method: 'POST', @@ -306,21 +339,26 @@ export function registerTools(server: McpServer, req: Request) { title: 'Get Historical Bars', description: 'Fetch historical OHLCV bars for one or more symbols. Supports intraday (1Min–4Hour) and daily/weekly/monthly timeframes over a date range — the historical data the real-time quote tool cannot provide.', inputSchema: z.object({ - symbols: z.array(z.string()).describe('Ticker symbols (e.g. ["SOXX", "SOXL"])'), + symbols: symbols.describe('Ticker symbols (e.g. ["SOXX", "SOXL"])'), timeframe: z.enum(['1Min', '5Min', '15Min', '30Min', '1Hour', '2Hour', '4Hour', '1Day', '1Week', '1Month']) .optional().describe('Bar size (default 1Day)'), - start: z.string().optional().describe('Start date/time, ISO 8601 (e.g. 2024-01-01)'), - end: z.string().optional().describe('End date/time, ISO 8601 (e.g. 2026-06-30)'), - limit: z.number().optional().describe('Max bars per symbol (default 1000, max 10000)'), + feed: z.enum(['iex', 'sip']).optional().describe('Alpaca data feed; SIP requires the appropriate entitlement'), + start: dateTime.optional().describe('Start date/time, ISO 8601 (e.g. 2024-01-01)'), + end: dateTime.optional().describe('End date/time, ISO 8601 (e.g. 2026-06-30)'), + limit: z.number().int().min(1).max(10000).optional().describe('Max bars per symbol (default 1000, max 10000)'), }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { + if (args.start && args.end && Date.parse(args.start) > Date.parse(args.end)) { + return invalidArguments('start must be on or before end'); + } const result = await callRestApi({ method: 'POST', path: '/bars', body: { symbols: args.symbols, timeframe: args.timeframe, + feed: args.feed, start: args.start, end: args.end, limit: args.limit, @@ -351,13 +389,15 @@ export function registerTools(server: McpServer, req: Request) { description: 'List historical risk snapshots.', inputSchema: z.object({ account_id: z.string().optional(), - limit: z.number().optional(), + limit: pageLimit, + offset: pageOffset, }), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async (args) => { const query: Record = {}; if (args.account_id) query.account_id = args.account_id; if (args.limit) query.limit = String(args.limit); + if (args.offset !== undefined) query.offset = String(args.offset); const result = await callRestApi({ method: 'GET', path: '/risk-snapshots', query, ...creds }); return formatResult(result); }); @@ -368,7 +408,7 @@ export function registerTools(server: McpServer, req: Request) { title: 'Execute Strategy', description: 'Execute a strategy on the Python backend. Returns execution results.', inputSchema: z.object({ - strategy_id: z.string().describe('Strategy UUID to execute'), + strategy_id: identifier.describe('Strategy UUID to execute'), code: z.string().optional().describe('Optional Python code override'), }), annotations: { destructiveHint: true, idempotentHint: false }, @@ -381,11 +421,15 @@ export function registerTools(server: McpServer, req: Request) { server.registerTool('list_webhooks', { title: 'List Webhooks', - description: 'List configured webhook subscriptions.', - inputSchema: z.object({}), - annotations: { readOnlyHint: true }, - }, async () => { - const result = await callRestApi({ method: 'GET', path: '/webhooks', ...creds }); + description: 'List a page of configured webhook subscriptions. Signing secrets are excluded.', + inputSchema: z.object({ active: z.boolean().optional(), limit: pageLimit, offset: pageOffset }), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, async (args) => { + const query: Record = {}; + if (args.active !== undefined) query.active = String(args.active); + if (args.limit !== undefined) query.limit = String(args.limit); + if (args.offset !== undefined) query.offset = String(args.offset); + const result = await callRestApi({ method: 'GET', path: '/webhooks', query, ...creds }); return formatResult(result); }); @@ -393,14 +437,14 @@ export function registerTools(server: McpServer, req: Request) { title: 'Create Webhook', description: 'Subscribe to platform events via webhook. Returns the signing secret (save it — shown once).', inputSchema: z.object({ - url: z.string().url().describe('HTTPS endpoint to receive events'), + url: z.string().url().refine(value => new URL(value).protocol === 'https:', 'Webhook endpoint must use HTTPS').describe('HTTPS endpoint to receive events'), events: z.array(z.enum([ 'order.placed', 'order.filled', 'strategy.executed', 'backtest.completed', 'risk.alert', 'news.breaking', - ])).describe('Events to subscribe to'), + ])).min(1).describe('Events to subscribe to'), description: z.string().optional(), }), annotations: { readOnlyHint: false }, @@ -412,7 +456,7 @@ export function registerTools(server: McpServer, req: Request) { server.registerTool('delete_webhook', { title: 'Delete Webhook', description: 'Remove a webhook subscription.', - inputSchema: z.object({ id: z.string().describe('Webhook UUID') }), + inputSchema: z.object({ id: identifier.describe('Webhook UUID') }), annotations: { destructiveHint: true }, }, async (args) => { const result = await callRestApi({ method: 'DELETE', path: `/webhooks/${args.id}`, ...creds }); @@ -425,7 +469,7 @@ export function registerTools(server: McpServer, req: Request) { title: 'Get Profile', description: 'Get the current authenticated user profile.', inputSchema: z.object({}), - annotations: { readOnlyHint: true }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async () => { const result = await callRestApi({ method: 'GET', path: '/me', ...creds }); return formatResult(result); diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts new file mode 100644 index 0000000..af5c163 --- /dev/null +++ b/tests/api-client.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setTimeout as delay } from 'node:timers/promises'; +import { callRestApi, type ApiCallOptions } from '../src/api-client.js'; + +vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async () => undefined) })); + +const options: ApiCallOptions = { + method: 'GET', + path: '/strategies', + apiKey: 'private-api-key', + apiSecret: 'private-api-secret', + requestId: 'test-request-123', +}; +const json = (body: unknown, status = 200, headers?: HeadersInit) => new Response(JSON.stringify(body), { status, headers }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('CPZ_API_TIMEOUT_MS', '20000'); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(Math, 'random').mockReturnValue(0.5); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('REST client request contract', () => { + it('preserves successful data and forwards credentials, request ID, user-agent, query, and timeout', async () => { + const data = { data: [{ id: 'strategy-1' }], count: 1 }; + const fetchMock = vi.fn().mockResolvedValue(json(data)); + vi.stubGlobal('fetch', fetchMock); + const result = await callRestApi({ ...options, query: { title: 'alpha & beta', limit: '10' } }); + + expect(result).toEqual({ ok: true, status: 200, data, requestId: 'test-request-123' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(new URL(url).searchParams.get('title')).toBe('alpha & beta'); + expect(new URL(url).searchParams.get('limit')).toBe('10'); + expect(init.headers).toMatchObject({ + 'X-CPZ-Key': options.apiKey, + 'X-CPZ-Secret': options.apiSecret, + 'x-request-id': options.requestId, + 'User-Agent': 'CPZ Lab (support@cpz-lab.com)', + }); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(delay).not.toHaveBeenCalled(); + }); + + it.each([undefined, 'unsafe\nrequest-id', 'x'.repeat(129)])('generates a safe correlation ID for %j', async (requestId) => { + const fetchMock = vi.fn().mockResolvedValue(json({ data: [] })); + vi.stubGlobal('fetch', fetchMock); + const result = await callRestApi({ ...options, requestId }); + expect(result.requestId).toMatch(/^[a-f0-9-]{36}$/); + expect(fetchMock.mock.calls[0][1].headers['x-request-id']).toBe(result.requestId); + }); + + it.each(['', 'NaN', '-1', '0', '1.5', '120001', '20000garbage'])('rejects invalid timeout configuration %j without sending a request', async (timeout) => { + vi.stubEnv('CPZ_API_TIMEOUT_MS', timeout); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const result = await callRestApi(options); + expect(result).toMatchObject({ + ok: false, + status: 500, + data: { code: 'invalid_timeout_configuration', request_id: options.requestId }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('uses the configured bounded timeout for fetch', async () => { + vi.stubEnv('CPZ_API_TIMEOUT_MS', '120000'); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ data: [] }))); + await callRestApi(options); + expect(timeoutSpy).toHaveBeenCalledOnce(); + expect(timeoutSpy.mock.calls[0][0]).toBeGreaterThan(119000); + expect(timeoutSpy.mock.calls[0][0]).toBeLessThanOrEqual(120000); + }); +}); + +describe('honest failures', () => { + it.each(['TimeoutError', 'AbortError'])('marks a mutation %s as unknown outcome without replay', async (name) => { + const error = new Error('timeout'); + error.name = name; + const fetchMock = vi.fn().mockRejectedValue(error); + vi.stubGlobal('fetch', fetchMock); + const result = await callRestApi({ ...options, method: 'POST', path: '/orders', body: { quantity: 1 } }); + expect(result).toMatchObject({ + ok: false, + status: 504, + data: { code: 'upstream_timeout', operation_outcome: 'unknown', request_id: options.requestId }, + }); + expect(JSON.stringify(result.data)).not.toContain('did NOT complete'); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(delay).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + resource: 'orders', method: 'POST', request_id: options.requestId, operation_outcome: 'unknown', + })); + }); + + it('does not leak credentials, request bodies or raw transport errors in money-path logs', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error(`failed ${options.apiSecret}`)); + vi.stubGlobal('fetch', fetchMock); + const result = await callRestApi({ ...options, method: 'POST', path: '/execute', body: { secret: 'payload-secret' } }); + expect(result).toMatchObject({ status: 502, data: { operation_outcome: 'unknown', code: 'upstream_network_error' } }); + const logged = JSON.stringify(vi.mocked(console.error).mock.calls); + expect(logged).not.toContain(options.apiKey); + expect(logged).not.toContain(options.apiSecret); + expect(logged).not.toContain('payload-secret'); + expect(JSON.stringify(result)).not.toContain(options.apiSecret); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('returns 502 for a non-JSON HTTP 200 response and marks a mutation outcome unknown', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('gateway failure')); + vi.stubGlobal('fetch', fetchMock); + const result = await callRestApi({ ...options, method: 'POST', path: '/orders' }); + expect(result).toMatchObject({ + ok: false, status: 502, + data: { code: 'invalid_upstream_response', upstream_status: 200, operation_outcome: 'unknown' }, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('treats a response-body timeout as a timeout with an unknown mutation outcome', async () => { + const error = new Error('body stalled'); + error.name = 'TimeoutError'; + const response = json({}); + vi.spyOn(response, 'json').mockRejectedValue(error); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + expect(await callRestApi({ ...options, method: 'POST', path: '/orders' })).toMatchObject({ + ok: false, status: 504, data: { code: 'upstream_timeout', upstream_status: 200, operation_outcome: 'unknown' }, + }); + }); + + it.each([{ error: 'Provider down' }, { error: { message: 'Rejected' } }, { success: false }, { ok: false }])('rejects an HTTP 200 logical error envelope %j', async (body) => { + const fetchMock = vi.fn().mockResolvedValue(json(body)); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ + ok: false, status: 502, data: { code: 'upstream_logical_error', upstream_status: 200 }, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('does not confuse empty error fields or errors inside returned records with an envelope failure', async () => { + const data = { error: null, data: [{ error: 'Historical failure', success: false }] }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json(data))); + expect(await callRestApi(options)).toMatchObject({ ok: true, status: 200, data }); + }); + + it('surfaces Python execution errors while preserving execution identity and output', async () => { + const payload = { ok: true, execution_id: 'execution-123', status: 200, body: '{"error":"Invalid strategy"}', hasPythonError: true }; + const fetchMock = vi.fn().mockResolvedValue(json(payload)); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi({ ...options, path: '/execute', method: 'POST' })).toMatchObject({ + ok: false, + status: 502, + data: { ...payload, ok: false, upstream_ok: true, code: 'upstream_logical_error', operation_outcome: 'unknown' }, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([{ error: 'Account unavailable' }, { success: false }])('surfaces partial portfolio sync failures and retains successes: %j', async (failedAccount) => { + const payload = { success: true, results: [{ account_id: 'account-1', success: true, positions_synced: 5 }, { account_id: 'account-2', ...failedAccount }] }; + const fetchMock = vi.fn().mockResolvedValue(json(payload)); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi({ ...options, path: '/portfolio-sync', method: 'POST' })).toMatchObject({ + ok: false, status: 502, data: { ...payload, success: false, upstream_success: true, code: 'upstream_logical_error', operation_outcome: 'unknown' }, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ['/execute', { ok: true, hasPythonError: false, body: 'success' }], + ['/portfolio-sync', { success: true, results: [{ success: true, error: null }] }], + ['/backtests', { data: { hasPythonError: true, results: [{ error: 'Historical failure' }] } }], + ['/backtests', { hasPythonError: true, results: [{ error: 'Historical failure' }] }], + ])('does not recursively misclassify successful or historical data from %s', async (path, payload) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json(payload))); + expect(await callRestApi({ ...options, path })).toMatchObject({ ok: true, status: 200, data: payload }); + }); + + it('preserves provider error details while adding correlation and client classification', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ error: 'Not allowed', code: 'insufficient_scope', required: ['orders'] }, 403))); + expect(await callRestApi(options)).toMatchObject({ + ok: false, status: 403, + data: { error: 'Not allowed', code: 'upstream_http_error', upstream_code: 'insufficient_scope', required: ['orders'], request_id: options.requestId }, + }); + }); +}); + +describe('bounded GET-only retries', () => { + it('retries 429 after Retry-After and preserves one request ID across attempts', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(json({ error: 'Rate limited' }, 429, { 'Retry-After': '2' })) + .mockResolvedValueOnce(json({ data: [] })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: true }); + expect(delay).toHaveBeenCalledWith(2000); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls.map(([, init]) => init.headers['x-request-id'])).toEqual([options.requestId, options.requestId]); + expect(fetchMock.mock.calls[0][1].signal).not.toBe(fetchMock.mock.calls[1][1].signal); + }); + + it('honors an HTTP-date Retry-After', async () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 8, 14, 12, 0, 0)); + const fetchMock = vi.fn() + .mockResolvedValueOnce(json({ error: 'Unavailable' }, 503, { 'Retry-After': 'Mon, 14 Sep 2026 12:00:03 GMT' })) + .mockResolvedValueOnce(json({ data: [] })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: true }); + expect(delay).toHaveBeenCalledWith(3000); + }); + + it.each(['10', '99999999999999999999999999999999'])('returns a rate limit failure instead of retrying sooner than a long Retry-After %s', async (retryAfter) => { + const fetchMock = vi.fn().mockResolvedValue(json({ error: 'Rate limited' }, 429, { 'Retry-After': retryAfter })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: false, status: 429 }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(delay).not.toHaveBeenCalled(); + }); + + it('does not wait beyond the remaining request budget', async () => { + vi.stubEnv('CPZ_API_TIMEOUT_MS', '1000'); + const fetchMock = vi.fn().mockResolvedValue(json({ error: 'Rate limited' }, 429, { 'Retry-After': '2' })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: false, status: 429 }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(delay).not.toHaveBeenCalled(); + }); + + it('does not start another attempt if a delayed timer resumes after the deadline', async () => { + let now = Date.UTC(2026, 8, 14); + vi.spyOn(Date, 'now').mockImplementation(() => now); + vi.mocked(delay).mockImplementationOnce(async () => { now += 30_000; }); + const fetchMock = vi.fn() + .mockResolvedValueOnce(json({ error: 'Unavailable' }, 503)) + .mockResolvedValueOnce(json({ data: [] })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: false, status: 504, data: { code: 'upstream_timeout' } }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('limits transient server failures to three attempts with jittered backoff', async () => { + const fetchMock = vi.fn().mockImplementation(async () => json({ error: 'Unavailable' }, 503)); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: false, status: 503 }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(vi.mocked(delay).mock.calls).toEqual([[300], [500]]); + }); + + it('can recover from a non-JSON gateway error', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response('Bad gateway', { status: 502 })) + .mockResolvedValueOnce(json({ data: [] })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('retries a failed GET connection', async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error('connection reset')) + .mockResolvedValueOnce(json({ data: [] })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it.each([400, 401, 403, 404, 422])('does not retry HTTP %s', async (status) => { + const fetchMock = vi.fn().mockResolvedValue(json({ error: 'Request refused' }, status)); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi(options)).toMatchObject({ ok: false, status }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(delay).not.toHaveBeenCalled(); + }); + + it.each(['POST', 'PUT', 'PATCH', 'DELETE'] as const)('never replays %s after a 503 response', async (method) => { + const fetchMock = vi.fn().mockResolvedValue(json({ error: 'Unavailable' }, 503, { 'Retry-After': '0' })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi({ ...options, method })).toMatchObject({ ok: false, status: 503, data: { operation_outcome: 'unknown' } }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(delay).not.toHaveBeenCalled(); + }); + + it('never retries a rate-limited order submission', async () => { + const fetchMock = vi.fn().mockResolvedValue(json({ error: 'Rate limited' }, 429, { 'Retry-After': '0' })); + vi.stubGlobal('fetch', fetchMock); + expect(await callRestApi({ ...options, method: 'POST', path: '/orders' })).toMatchObject({ ok: false, status: 429 }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(delay).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/expanded-tools.test.ts b/tests/expanded-tools.test.ts new file mode 100644 index 0000000..433dc6c --- /dev/null +++ b/tests/expanded-tools.test.ts @@ -0,0 +1,124 @@ +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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerExpandedTools } from '../src/expanded-tools.js'; + +const id = '5a5d40e6-53f9-4be6-9ebc-d0f83a7f1b71'; +const entityId = '96043206-24a4-4d63-9cd7-04c51ccf7d7c'; + +describe('expanded tools over MCP', () => { + let client: Client; + let server: McpServer; + let fetchMock: ReturnType; + + beforeEach(async () => { + fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + server = new McpServer({ name: 'expanded-test', version: '1.0.0' }); + registerExpandedTools(server, { apiKey: 'test-key', apiSecret: 'test-secret', requestId: 'test-request' }); + client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + vi.unstubAllGlobals(); + }); + + it('advertises all ten additions as read-only, idempotent tools', async () => { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name).sort()).toEqual([ + 'get_backtest_result', 'get_data_file', 'get_order', 'get_risk_snapshot', + 'list_cash_flows', 'list_data_files', 'list_deals', 'list_fund_periods', + 'list_journal_entries', 'list_lifecycle_events', + ]); + for (const tool of tools) { + expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false, idempotentHint: true }); + expect(tool.inputSchema.type).toBe('object'); + } + }); + + it.each([ + ['get_data_file', '/data-files'], + ['get_backtest_result', '/backtests'], + ['get_order', '/orders'], + ['get_risk_snapshot', '/risk-snapshots'], + ])('%s retrieves the exact record through the existing endpoint', async (name, path) => { + const payload = { data: { id, result: { verified: false }, nullable_field: null } }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })); + const result = await client.callTool({ name, arguments: { id } }); + expect(result.isError).not.toBe(true); + const [rawUrl, init] = fetchMock.mock.calls[0]; + expect(new URL(rawUrl).pathname).toBe(`/functions/v1/rest-api/v1${path}/${id}`); + expect(init.method).toBe('GET'); + expect(init.headers).toMatchObject({ 'X-CPZ-Key': 'test-key', 'X-CPZ-Secret': 'test-secret' }); + expect(init.body).toBeUndefined(); + expect(JSON.parse((result.content as Array<{ text: string }>)[0].text)).toEqual(payload); + }); + + it.each([ + ['list_data_files', '/data-files', { status: 'ready', file_type: 'csv', name: 'Earnings', sort_by: 'name' }], + ['list_deals', '/deals', { entity_id: entityId, deal_id: 'IRS-1', fund: 'Alpha', book: 'Rates', product_kind: 'IRS', status: 'LIVE', sort_by: 'trade_date' }], + ['list_lifecycle_events', '/lifecycle-events', { entity_id: entityId, deal_id: 'IRS-1', kind: 'FIXING', status: 'PENDING', from: '2026-01-01', to: '2026-12-31', sort_by: 'event_date' }], + ['list_cash_flows', '/cash-flows', { entity_id: entityId, deal_id: 'IRS-1', fund: 'Alpha', book: 'Rates', unsettled: false, from: '2024-02-29', to: '2026-12-31', sort_by: 'payment_date' }], + ['list_journal_entries', '/journal-entries', { entity_id: entityId, deal_id: 'IRS-1', from: '2026-01-01', to: '2026-01-01', sort_by: 'entry_date' }], + ['list_fund_periods', '/fund-periods', { entity_id: entityId, fund: 'Alpha', status: 'CLOSED', sort_by: 'period_end' }], + ])('%s forwards supported filters and preserves zero/false values', async (name, path, filters) => { + const args = { ...filters, limit: 100, offset: 0, sort_order: 'asc' }; + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).not.toBe(true); + const [rawUrl, init] = fetchMock.mock.calls[0]; + const url = new URL(rawUrl); + expect(url.pathname).toBe(`/functions/v1/rest-api/v1${path}`); + expect(Object.fromEntries(url.searchParams)).toEqual(Object.fromEntries(Object.entries(args).map(([key, value]) => [key, String(value)]))); + expect(init.method).toBe('GET'); + }); + + it.each(['get_data_file', 'get_backtest_result', 'get_order', 'get_risk_snapshot'])('%s rejects malformed or path-like identifiers before HTTP', async (name) => { + const result = await client.callTool({ name, arguments: { id: '../accounts' } }); + expect(result.isError).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { offset: -1 }, { offset: 0.5 }, + { entity_id: 'not-an-entity-uuid' }, { sort_by: 'missing_column' }, + ])('rejects invalid list bounds or entity filters: %j', async (args) => { + const result = await client.callTool({ name: 'list_deals', arguments: args }); + expect(result.isError).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['2026-02-29', '2026-04-31', '2026-13-01', '09/14/2026', '2026-09-14T10:00:00Z'])('rejects invalid calendar dates: %s', async (from) => { + const result = await client.callTool({ name: 'list_lifecycle_events', arguments: { from } }); + expect(result.isError).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['list_lifecycle_events', 'list_cash_flows', 'list_journal_entries'])('%s refuses reversed date ranges before HTTP', async (name) => { + const result = await client.callTool({ name, arguments: { from: '2026-09-14', to: '2026-09-13' } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('from must be on or before to'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('preserves exact minor-unit strings without numerical coercion', async () => { + const payload = { data: [{ amount_minor: '9007199254740993123', economic_amount: null }] }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })); + const result = await client.callTool({ name: 'list_cash_flows', arguments: { unsettled: true } }); + expect(result.isError).not.toBe(true); + expect(JSON.parse((result.content as Array<{ text: string }>)[0].text)).toEqual(payload); + expect(new URL(fetchMock.mock.calls[0][0]).searchParams.get('unsettled')).toBe('true'); + }); + + it.each([403, 404, 429, 500])('surfaces upstream HTTP %i as an MCP error', async (status) => { + fetchMock.mockImplementation(async () => new Response(JSON.stringify({ error: 'Upstream refused the read' }), { status })); + const result = await client.callTool({ name: 'get_order', arguments: { id } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('Upstream refused the read'); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..85d201f --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,121 @@ +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 ID = '123e4567-e89b-42d3-a456-426614174000'; + +describe('CPZAI MCP protocol', () => { + let client: Client; + let server: ReturnType; + let fetchMock: ReturnType; + + beforeEach(async () => { + fetchMock = vi.fn(async () => new Response(JSON.stringify({ data: [], count: 0 }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + server = createMcpServer({ headers: { 'x-cpz-key': 'test-key', 'x-cpz-secret': 'test-secret', 'x-request-id': 'mcp-test' } } as unknown as Request); + client = new Client({ name: 'integration-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + vi.unstubAllGlobals(); + }); + + it('negotiates tools, resources, prompts and workflow instructions without an upstream request', async () => { + expect(client.getServerVersion()?.version).toBe('1.2.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', + ]); + expect((await client.listPrompts()).prompts.map(prompt => prompt.name)).toEqual(['review_portfolio', 'analyze_strategy']); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns readable resources and read-only prompts', async () => { + const resource = await client.readResource({ uri: 'cpzai://guides/tool-usage' }); + expect(resource.contents[0]).toMatchObject({ mimeType: 'text/markdown', text: expect.stringContaining('current page size') }); + const prompt = await client.getPrompt({ name: 'analyze_strategy', arguments: { strategy_id: ID } }); + expect(prompt.messages[0].content).toMatchObject({ type: 'text', text: expect.stringContaining('do not update or execute') }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['list_positions', 'list_accounts', 'list_connections', 'list_risk_snapshots', 'list_webhooks'])( + '%s forwards paging and keeps structured and text results equivalent', async name => { + const result = await client.callTool({ name, arguments: { limit: 10, offset: 20 } }); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(new URL(url).searchParams.get('limit')).toBe('10'); + expect(new URL(url).searchParams.get('offset')).toBe('20'); + expect(new Headers(init.headers).get('x-request-id')).toBe('mcp-test'); + expect(result.structuredContent).toEqual({ data: [], count: 0 }); + expect(JSON.parse((result.content as Array<{ text: string }>)[0].text)).toEqual(result.structuredContent); + }, + ); + + it('preserves false account and webhook filters', async () => { + await client.callTool({ name: 'list_accounts', arguments: { tradable: false, environment: 'paper' } }); + expect(new URL(fetchMock.mock.calls[0][0]).searchParams.get('tradable')).toBe('false'); + await client.callTool({ name: 'list_webhooks', arguments: { active: false } }); + expect(new URL(fetchMock.mock.calls[1][0]).searchParams.get('active')).toBe('false'); + }); + + it.each([ + ['list_positions', { limit: 0 }], + ['list_connections', { offset: -1 }], + ['list_accounts', { limit: 1.5 }], + ['list_strategies', { limit: 101 }], + ['get_strategy', { id: '../orders' }], + ['get_market_data', { symbols: [] }], + ['get_market_data', { symbols: ['AAPL,MSFT'] }], + ['get_bars', { symbols: ['AAPL'], start: '2026-02-30' }], + ['get_bars', { symbols: ['AAPL'], start: '2026-01-02', end: '2026-01-01' }], + ['get_bars', { symbols: ['AAPL'], limit: 10001 }], + ['create_webhook', { url: 'http://example.com', events: ['order.filled'] }], + ['create_webhook', { url: 'https://example.com', events: [] }], + ['update_strategy', { id: ID }], + ])('rejects invalid %s arguments before any API request', async (name, args) => { + const result = await client.callTool({ name: name as string, arguments: args as Record }); + expect(result.isError).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + const order = { account_id: 'paper-account', broker_credential_id: ID, symbol: 'aapl', side: 'buy', quantity: 1 }; + + it.each([ + { order_type: 'limit' }, + { order_type: 'stop' }, + { order_type: 'stop_limit', price: 105 }, + { order_type: 'stop_limit', stop_price: 100 }, + { order_type: 'market', quantity: -1 }, + { order_type: 'limit', price: 0 }, + { order_type: 'market', time_in_force: 'gtc' }, + ])('rejects invalid order terms %j', async terms => { + const result = await client.callTool({ name: 'place_order', arguments: { ...order, ...terms } }); + expect(result.isError).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('forwards both stop-limit prices and a normalized symbol', async () => { + const result = await client.callTool({ name: 'place_order', arguments: { ...order, order_type: 'stop_limit', price: 105, stop_price: 100, time_in_force: 'day' } }); + expect(result.isError).not.toBe(true); + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ symbol: 'AAPL', price: 105, stop_price: 100, time_in_force: 'day' }); + }); + + it('surfaces an uncertain order timeout without resubmitting', async () => { + fetchMock.mockRejectedValue(new DOMException('Timed out', 'TimeoutError')); + const result = await client.callTool({ name: 'place_order', arguments: { ...order, order_type: 'market' } }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ operation_outcome: 'unknown', request_id: 'mcp-test' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/tool-catalog.test.ts b/tests/tool-catalog.test.ts new file mode 100644 index 0000000..13cd45c --- /dev/null +++ b/tests/tool-catalog.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { readToolCatalog } from '../scripts/export-tool-catalog.js'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('published tool catalog', () => { + it('matches the actual MCP tools/list response without making API requests', async () => { + const fetchMock = vi.fn(() => { throw new Error('Catalog discovery must not make upstream HTTP requests'); }); + vi.stubGlobal('fetch', fetchMock); + const published = JSON.parse(await readFile(new URL('../tool-catalog.json', import.meta.url), 'utf8')); + const advertised = await readToolCatalog(); + expect(published).toEqual(advertised); + expect(fetchMock).not.toHaveBeenCalled(); + expect(new Set(advertised.map(tool => tool.name)).size).toBe(advertised.length); + }); + + it('documents every advertised tool by its exact name in the README', async () => { + const readme = await readFile(new URL('../README.md', import.meta.url), 'utf8'); + const advertised = await readToolCatalog(); + const undocumented = advertised.filter(tool => !readme.includes(`\`${tool.name}\``)).map(tool => tool.name); + expect(undocumented).toEqual([]); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 9891627..c142013 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -39,13 +39,23 @@ describe('registerTools', () => { 'create_webhook', 'delete_webhook', 'execute_strategy', + 'get_backtest_result', 'get_backtest_results', 'get_bars', + 'get_data_file', 'get_market_data', + 'get_order', 'get_profile', + 'get_risk_snapshot', 'get_strategy', 'list_accounts', + 'list_cash_flows', 'list_connections', + 'list_data_files', + 'list_deals', + 'list_fund_periods', + 'list_journal_entries', + 'list_lifecycle_events', 'list_orders', 'list_positions', 'list_risk_snapshots', @@ -55,7 +65,7 @@ describe('registerTools', () => { 'sync_portfolio', 'update_strategy', ]); - expect(registered).toHaveLength(21); + expect(registered).toHaveLength(31); }); it('extracts CPZ key/secret from X-CPZ-Key headers', () => { @@ -148,9 +158,3 @@ describe('api-client', () => { globalThis.fetch = originalFetch; }); }); - -describe('health endpoint', () => { - it('index module exports without error', async () => { - expect(true).toBe(true); - }); -}); diff --git a/tool-catalog.json b/tool-catalog.json new file mode 100644 index 0000000..f2cc928 --- /dev/null +++ b/tool-catalog.json @@ -0,0 +1,319 @@ +[ + { + "name": "compute_risk", + "title": "Compute Risk", + "description": "Compute a fresh risk snapshot for the portfolio. Returns VaR, Sharpe, drawdown, exposures, and risk score.", + "annotations": { + "readOnlyHint": false + } + }, + { + "name": "create_connection", + "title": "Create Data Connection", + "description": "Store a data-provider API key. The credential is encrypted server-side and can never be read back. One connection per provider.", + "annotations": { + "readOnlyHint": false, + "idempotentHint": false + } + }, + { + "name": "create_strategy", + "title": "Create Strategy", + "description": "Create a new trading strategy.", + "annotations": { + "readOnlyHint": false + } + }, + { + "name": "create_webhook", + "title": "Create Webhook", + "description": "Subscribe to platform events via webhook. Returns the signing secret (save it — shown once).", + "annotations": { + "readOnlyHint": false + } + }, + { + "name": "delete_webhook", + "title": "Delete Webhook", + "description": "Remove a webhook subscription.", + "annotations": { + "destructiveHint": true + } + }, + { + "name": "execute_strategy", + "title": "Execute Strategy", + "description": "Execute a strategy on the Python backend. Returns execution results.", + "annotations": { + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "get_backtest_result", + "title": "Get Backtest Result", + "description": "Inspect one saved backtest run, including its configuration, result, code, and verification fields when available. Does not run a new backtest. Requires the strategies scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_backtest_results", + "title": "Get Backtest Results", + "description": "List backtest run results, optionally filtered by strategy.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_bars", + "title": "Get Historical Bars", + "description": "Fetch historical OHLCV bars for one or more symbols. Supports intraday (1Min–4Hour) and daily/weekly/monthly timeframes over a date range — the historical data the real-time quote tool cannot provide.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_data_file", + "title": "Get Data File", + "description": "Get one uploaded dataset’s metadata, including column information and storage path. Does not download the file. Requires the data scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_market_data", + "title": "Get Market Data", + "description": "Fetch real-time market data (price, bid/ask, volume) for one or more symbols.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_order", + "title": "Get Order", + "description": "Read one stored order record, including its reconciled status and fill fields. This is platform state, not a fresh broker query. Requires the orders scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_profile", + "title": "Get Profile", + "description": "Get the current authenticated user profile.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_risk_snapshot", + "title": "Get Risk Snapshot", + "description": "Inspect one saved portfolio risk snapshot and its recorded exposures, risk contributions, and analytics when available. Does not compute fresh risk. Requires trading_credentials or orders scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "get_strategy", + "title": "Get Strategy", + "description": "Get a specific strategy by ID.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_accounts", + "title": "List Accounts", + "description": "List connected trading accounts (broker credentials). Sensitive fields excluded.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_cash_flows", + "title": "List OTC Cash Flows", + "description": "Inspect recorded OTC cash flows and settlement state. Date filters apply to payment_date. amount_minor is an exact integer string; preserve its precision. Requires trading_credentials or orders scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_connections", + "title": "List Data Connections", + "description": "List the user's configured data-provider connections. Credentials are never returned.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_data_files", + "title": "List Data Files", + "description": "Discover your uploaded datasets and their schema metadata, row counts, file types, and processing status. Returns metadata, not file contents. Requires the data scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_deals", + "title": "List OTC Deals", + "description": "List middle-office OTC deal records in your personal or entity book. LIVE status includes DRAFT and CONFIRMED deals. Requires trading_credentials or orders scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_fund_periods", + "title": "List OTC Fund Periods", + "description": "Inspect recorded fund accounting periods, NAV fields, subscriptions, redemptions, and P&L when available. Monetary minor-unit fields are exact integer strings; preserve their precision. Requires trading_credentials or orders scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_journal_entries", + "title": "List OTC Journal Entries", + "description": "Inspect middle-office ledger entry headers and audit hashes. Date filters apply to entry_date. Returns headers, not journal line items or a balance calculation. Requires trading_credentials or orders scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_lifecycle_events", + "title": "List OTC Lifecycle Events", + "description": "Inspect scheduled OTC lifecycle events, their required inputs, dates, and processing status. Date filters apply to event_date. Requires trading_credentials or orders scope.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_orders", + "title": "List Orders", + "description": "List trading orders. Filter by status, symbol, side, or strategy.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_positions", + "title": "List Positions", + "description": "List one page of portfolio positions. Continue with offset until a short page is returned before concluding a portfolio review.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_risk_snapshots", + "title": "List Risk Snapshots", + "description": "List historical risk snapshots.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_strategies", + "title": "List Strategies", + "description": "List all trading strategies. Supports filtering by status, type, and title search.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "list_webhooks", + "title": "List Webhooks", + "description": "List a page of configured webhook subscriptions. Signing secrets are excluded.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "place_order", + "title": "Place Order", + "description": "Place an order using a tradable account from list_accounts. Check its broker and environment; live accounts can trade real money. Stop orders and time-in-force support depend on the broker. After a timeout, inspect list_orders before retrying; submission may have succeeded.", + "annotations": { + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "sync_portfolio", + "title": "Sync Portfolio", + "description": "Trigger a portfolio sync across all connected broker accounts.", + "annotations": { + "readOnlyHint": false + } + }, + { + "name": "update_strategy", + "title": "Update Strategy", + "description": "Update an existing strategy. Pass only the fields to change.", + "annotations": { + "readOnlyHint": false + } + } +]