Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Tool annotations describe side effects. Actual authorization is enforced by the

### Results and errors

Tool responses preserve readable JSON text and include structured content. Check `isError` before using the result. Malformed upstream responses and provider failures stay errors. Request IDs support tracing without logging credentials.
Tool responses preserve readable JSON text and include structured content. Most read tools declare an `outputSchema` so a client can validate what came back: list routes return `{data, count}`, single-record routes return `{data}`, and a delete returns `{message}`. The action tools that proxy to another service (`execute_strategy`, `sync_portfolio`, `compute_risk`, `get_market_data`, `get_bars`) and the two writes with their own handlers (`place_order`, `create_connection`) declare no output schema, because their shape is not this server's to promise. Output validation is skipped for error results. Check `isError` before using the result. Malformed upstream responses and provider failures stay errors. Request IDs support tracing without logging credentials.

Eligible GET calls can retry transient upstream failures within a bounded timeout. Writes are not automatically retried. A timeout after order submission leaves the result unconfirmed; inspect existing orders and the broker before retrying. The public order route does not provide a general end-to-end idempotency guarantee.

Expand All @@ -105,22 +105,72 @@ Eligible GET calls can retry transient upstream failures within a bounded timeou
|---|---|---|
| Resource | `cpzai://guides/tool-usage` | Tool selection, pagination, and capability boundaries |
| Resource | `cpzai://guides/permissions` | Resource permissions and execution boundaries |
| Resource | `cpzai://guides/discovery` | Endpoints, deferred loading, and the search conventions |
| Prompt | `review_portfolio` | Portfolio review workflow; optional `account_id` |
| Prompt | `analyze_strategy` | Strategy investigation; required `strategy_id` |

Requesting a prompt returns instructions; it does not execute a workflow. Discover these using standard MCP methods. There is no `/mcp/info` endpoint.

## Progressive tool discovery

The full catalogue is 31 tools and roughly 27 KB of JSON Schema, which every client pays for on every request unless it can defer tool loading. Tool-selection accuracy also falls off once a model is choosing between more than about thirty tools. There are two endpoints, serving identical tools under different discovery models.

### `POST /mcp`: full catalogue

`tools/list` returns every tool. Use this when the client defers loading itself: it needs the definitions in order to defer them. With Claude's MCP connector, pair `defer_loading` with the tool search tool and keep the everyday reads loaded:

```json
{
"type": "mcp_toolset",
"mcp_server_name": "cpzai",
"default_config": { "defer_loading": true },
"configs": {
"list_accounts": { "defer_loading": false },
"list_positions": { "defer_loading": false },
"place_order": { "defer_loading": false }
}
}
```

Deferred definitions are still sent on every request; they stay out of the model's context until a search discovers them, and the prompt prefix is untouched, so the cache survives. Claude Code applies its own tool search to this endpoint with no configuration.

### `POST /mcp/compact`: server-side discovery

For clients with no deferral of their own. `tools/list` returns 15 tools instead of 31 (about 12 KB instead of 27 KB, a 55% cut):

- `search_tools`: natural-language or keyword query, optional `category`, returns each match with its **full input schema**. Searches tool names, titles, descriptions, parameter names, and parameter descriptions, so a field name such as `unsettled` or `entity_id` finds the tool that accepts it. Categories: strategies, backtests, orders, positions, accounts, market-data, risk, data, webhooks, middle-office, profile.
- `call_tool`: invokes a discovered tool by name with an arguments object matching its `input_schema`, and returns that tool's result unchanged.
- Every state-changing tool (`place_order`, `execute_strategy`, `create_strategy`, `update_strategy`, `create_connection`, `create_webhook`, `delete_webhook`, `sync_portfolio`, `compute_risk`) plus the read anchors `list_accounts`, `list_positions`, `list_orders`, `get_market_data`.

`call_tool` dispatches **read-only tools only**. A client gates approval on the tool name it can see, so routing an order through a generic dispatcher would hide it from the check meant to catch it. It also refuses a read-only tool that is already advertised, and answers an unknown name with near matches rather than a guess. Arguments are validated against the real tool schema before dispatch; a rejection returns the offending paths and that tool's schema.

### Scope-aware discovery

`tools/list` carries only the tools the calling credential's scopes permit. A `data` key sees 10 tools, not 31; an identity-only OAuth token sees `get_profile` alone; `search_tools` will not return an out-of-scope tool and `call_tool` will not dispatch one. Scopes come from `GET /me` on the REST API, cached per credential for 60 seconds and looked up only for `tools/list`, never on the call path.

If the lookup fails, or the REST API deployment predates the `scopes` field, the full catalogue is advertised and the reason is logged: unknown is not the same as none. Filtering is discovery, not enforcement. The REST API remains the only thing that decides what a credential may touch, so a tool that is present can still return 403.

Measured surfaces:

| Endpoint | Full scopes | `data` scope only |
|---|---|---|
| `/mcp` | 31 tools, ~31 KB | 10 tools, ~9 KB |
| `/mcp/compact` | 15 tools, ~13 KB | 6 tools, ~6 KB |

Tool names, arguments, results and pagination are identical on both endpoints. Discovery reads no account data and places no order.

## Architecture

```text
Remote MCP client
→ POST /mcp (fresh stateless server per request)
→ POST /mcp (full catalogue) or POST /mcp/compact (search_tools + call_tool)
→ fresh stateless server per request
→ user's API credentials, resolved from headers or encrypted OAuth token
→ CPZ REST API /functions/v1/rest-api/v1
→ existing user-scoped platform handlers
```

`src/tools.ts` and `src/expanded-tools.ts` own the public MCP schemas; `src/capabilities.ts` defines guide resources and workflow prompts. They are distinct from internal Simons tools and are not imported from a shared schema package. The server proxies to the existing REST adapter rather than querying user data with a service-role key. The hosted service also supports authenticated Simons chat proxy routes; those routes are not additional MCP tools.
`src/tools.ts` and `src/expanded-tools.ts` own the public MCP schemas; `src/capabilities.ts` defines guide resources and workflow prompts; `src/tool-registry.ts` captures those same schemas as data and `src/tool-search.ts` turns them into the compact surface, so the advertised and dispatchable catalogues cannot drift apart. They are distinct from internal Simons tools and are not imported from a shared schema package. The server proxies to the existing REST adapter rather than querying user data with a service-role key. The hosted service also supports authenticated Simons chat proxy routes; those routes are not additional MCP tools.

## Local development

Expand Down
17 changes: 9 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@cpzai/mcp-server",
"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.",
"version": "1.3.0",
"description": "CPZAI MCP server — AI agent access to quantitative trading strategies, backtests, multi-broker order routing, portfolios, and risk analytics.",
"license": "MIT",
"homepage": "https://ai.cpz-lab.com",
"repository": {
Expand Down Expand Up @@ -33,10 +33,11 @@
"export:catalog": "tsx scripts/export-tool-catalog.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@sentry/node": "^10.41.0",
"express": "^4.21.2",
"zod": "^3.24.2"
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.1"
},
"devDependencies": {
"@types/express": "^5.0.1",
Expand Down
19 changes: 17 additions & 2 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface ApiCallOptions {
apiKey: string;
apiSecret: string;
requestId?: string;
/**
* Request budget for this call, overriding CPZ_API_TIMEOUT_MS. Discovery
* calls use a short one so a slow API degrades the tool list rather than
* making a client wait out the default budget.
*/
timeoutMs?: number;
}

export interface ApiResult {
Expand Down Expand Up @@ -104,9 +110,18 @@ export async function callRestApi(opts: ApiCallOptions): Promise<ApiResult> {
};

const configuredTimeout = process.env.CPZ_API_TIMEOUT_MS;
const timeoutMs = configuredTimeout === undefined ? DEFAULT_TIMEOUT_MS : Number(configuredTimeout);
const timeoutMs = opts.timeoutMs !== undefined
? opts.timeoutMs
: configuredTimeout === undefined ? DEFAULT_TIMEOUT_MS : Number(configuredTimeout);
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
return failure(500, 'invalid_timeout_configuration', `CPZ_API_TIMEOUT_MS must be an integer between 1 and ${MAX_TIMEOUT_MS}.`, 0);
return failure(
500,
'invalid_timeout_configuration',
opts.timeoutMs !== undefined
? `timeoutMs must be an integer between 1 and ${MAX_TIMEOUT_MS}.`
: `CPZ_API_TIMEOUT_MS must be an integer between 1 and ${MAX_TIMEOUT_MS}.`,
0,
);
}

let url: URL;
Expand Down
43 changes: 40 additions & 3 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

export const SERVER_INSTRUCTIONS = `CPZAI provides user-scoped trading and research tools. Use tools/list for the current catalog and read cpzai://guides/tool-usage and cpzai://guides/permissions before planning a workflow. Paginate list results; a page is not the entire portfolio. Read-only review does not require order submission or strategy execution. execute_strategy can place real orders. Inspect account environment and tradable status before any user-authorized trading. A failed or timed-out mutation can have an unknown outcome: reconcile orders before considering another submission. Never infer zero positions, prices, or risk from an error or missing data.`;
const BASE_INSTRUCTIONS = `CPZAI provides user-scoped trading and research tools. Use tools/list for the current catalog and read cpzai://guides/tool-usage and cpzai://guides/permissions before planning a workflow. Paginate list results; a page is not the entire portfolio. Read-only review does not require order submission or strategy execution. execute_strategy can place real orders. Inspect account environment and tradable status before any user-authorized trading. A failed or timed-out mutation can have an unknown outcome: reconcile orders before considering another submission. Never infer zero positions, prices, or risk from an error or missing data.`;

const COMPACT_INSTRUCTIONS = `This endpoint advertises a compact surface. tools/list carries every state-changing tool plus the everyday reads; the remaining read-only tools load on demand. Call search_tools to find them; it returns each match with its full input schema. Then invoke the match with call_tool. call_tool dispatches read-only tools only; anything that places orders, executes strategies, or writes credentials or webhooks is advertised under its own name and must be called directly. Read cpzai://guides/discovery for the categories and the search conventions. An empty search result means no tool matched that wording, never that the capability is absent.`;

/** Server instructions, which differ by endpoint: see ToolMode in server.ts. */
export function serverInstructions(mode: 'full' | 'compact' = 'full'): string {
return mode === 'compact' ? `${BASE_INSTRUCTIONS}\n\n${COMPACT_INSTRUCTIONS}` : BASE_INSTRUCTIONS;
}

const TOOL_USAGE = `# CPZAI tool usage

Discover the server's registered schemas with tools/list. Successful results include structuredContent and an equivalent JSON text block. Tool failures set isError; inspect error, code, and request_id when present.
Discover the server's registered schemas with tools/list. Successful results include structuredContent and an equivalent JSON text block. Most read tools also declare an outputSchema: list routes return {data, count}, single-record routes return {data}, and a delete returns {message}. Action tools that proxy to another service declare no output schema because their shape is not this server's to promise. Tool failures set isError; inspect error, code, and request_id when present.

## Pagination and data provenance
Most list tools accept limit (1–100, default 50) and offset (default 0). Advance offset by the number of returned records and continue until a page shorter than the requested limit. A count is the current page size, not the total. Lists are live views: concurrent writes can change pages. list_accounts defaults to 100; the other list tools default to 50. Use an explicit limit for consistent paging. get_bars uses a separate limit of 1–10000 bars per symbol. Preserve provider timestamps and currency units; middle-office minor-unit amounts are strings to preserve integer precision. Never replace missing or failed data with zeros.
Expand All @@ -24,7 +31,7 @@ No generic HTTP/SQL tool, order cancellation, sandbox dry_run, apply_patch, SDK

const PERMISSIONS = `# CPZAI credentials and permissions

Connect to https://mcp.cpz-lab.com/mcp over stateless Streamable HTTP using OAuth or X-CPZ-Key and X-CPZ-Secret. The REST API validates credentials and enforces user ownership, scopes, and applicable subscription checks on each data operation. Tool discovery describes the full server surface; it is not proof that a particular key can call every tool.
Connect to https://mcp.cpz-lab.com/mcp over stateless Streamable HTTP using OAuth or X-CPZ-Key and X-CPZ-Secret. The REST API validates credentials and enforces user ownership, scopes, and applicable subscription checks on each data operation. tools/list is filtered to the scopes your credential holds when they can be determined, so a tool that is absent is one this key cannot call. When the scope lookup is unavailable the full catalogue is advertised, which is not a grant: enforcement is always the REST API, and a tool that is present can still return 403.

| Tools | Accepted resource scopes (any listed) |
| --- | --- |
Expand All @@ -41,10 +48,40 @@ Legacy read/write/trade scopes are expanded by the REST API. A scope is a resour
Middle-office reads default to personal scope. Supplying entity_id selects an organization only after active membership verification. Monetary amounts returned as strings must remain exact. A 401 requires valid authentication, 403 indicates insufficient permission, and provider or platform failures must remain visible.
`;

const DISCOVERY = `# CPZAI tool discovery

Two endpoints serve the same tools with different discovery models.

## https://mcp.cpz-lab.com/mcp (full catalogue)
Every tool appears in tools/list. Use this endpoint when the client defers tool loading itself. With Claude's MCP connector, set \`defer_loading\` once on the toolset and pair it with the tool search tool:

\`\`\`json
{"type": "mcp_toolset", "mcp_server_name": "cpzai", "default_config": {"defer_loading": true},
"configs": {"list_accounts": {"defer_loading": false}, "list_positions": {"defer_loading": false}}}
\`\`\`

Deferred definitions are still sent on every request; they are kept out of the model's context until a search discovers them, and the prompt prefix is not disturbed, so the cache survives.

## https://mcp.cpz-lab.com/mcp/compact (server-side progressive discovery)
tools/list carries every state-changing tool, the read anchors (list_accounts, list_positions, list_orders, get_market_data), and two discovery tools. Use this endpoint when the client has no deferral mechanism of its own.

- \`search_tools\` takes a natural-language query and/or a category and returns matching tools with their full input schemas. Categories: strategies, backtests, orders, positions, accounts, market-data, risk, data, webhooks, middle-office, profile. Search covers tool names, titles, descriptions, parameter names, and parameter descriptions, so searching for a field name such as \`entity_id\` or \`unsettled\` finds the tool that accepts it.
- \`call_tool\` invokes a discovered tool by name with an arguments object matching its input_schema. It refuses anything that is not read-only, and refuses a read-only tool that is already advertised: call that one by name.

Results from call_tool are the tool's own result, unchanged: same structuredContent, same isError semantics.

## Scope filtering applies to both endpoints
tools/list carries only the tools the calling credential's scopes permit, when those scopes can be determined: a data-scoped key is not shown the order surface, and search_tools does not return it either. If the scope lookup is unavailable the full catalogue is advertised, because unknown is not the same as none. Filtering is discovery, not enforcement; the REST API decides what a credential may touch.

## Conventions on both endpoints
Tool names, arguments, results, and pagination are identical. Discovery never reads account data and never places an order. See cpzai://guides/permissions for the scope vocabulary.
`;

export function registerCapabilities(server: McpServer) {
for (const [name, uri, title, text] of [
['tool-usage', 'cpzai://guides/tool-usage', 'CPZAI Tool Usage', TOOL_USAGE],
['permissions', 'cpzai://guides/permissions', 'CPZAI Permissions', PERMISSIONS],
['discovery', 'cpzai://guides/discovery', 'CPZAI Tool Discovery', DISCOVERY],
]) {
server.registerResource(name, uri, { title, description: title, mimeType: 'text/markdown' }, async resourceUri => ({
contents: [{ uri: resourceUri.href, mimeType: 'text/markdown', text }],
Expand Down
Loading
Loading