Skip to content

Progressive tool discovery for the CPZAI MCP server - #9

Merged
chris-cpz merged 2 commits into
mainfrom
feat/mcp-capability-expansion
Sep 15, 2026
Merged

chris-cpz merged 2 commits into
mainfrom
feat/mcp-capability-expansion

Conversation

@chris-cpz

@chris-cpz chris-cpz commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Three changes, one theme: the tool catalogue should be the one this caller can actually use.

1. Progressive tool discovery

31 tools and ~27KB of JSON Schema landed in every client's context on every request. Clients that defer tool loading themselves (Claude Code, and the MCP connector's mcp_toolset.default_config.defer_loading) were already fine. Everything else paid in full, and tool-selection accuracy degrades past roughly thirty tools.

POST /mcp is unchanged. New POST /mcp/compact:

  • search_tools: natural-language or keyword query, optional category, returns each match with its full input and output schema. Searches names, titles, descriptions, parameter names and parameter descriptions, so unsettled or entity_id finds the tool that takes it.
  • call_tool: invokes a discovered tool and returns its result unchanged.
  • Advertised up front: every state-changing tool, plus list_accounts, list_positions, list_orders, get_market_data.

call_tool takes read-only tools only. A client gates approval on the tool name it can see, so routing an order through a generic dispatcher would hide it from exactly the check meant to catch it. It also refuses an already-advertised read tool, answers an unknown name with near matches, and validates arguments against the real schema before dispatch.

2. Scope-aware tools/list

tools/list described the whole server to every credential. The spec permits the advertised set to vary by the authorization presented, since credentials are per-request input rather than connection state. Scopes come from GET /me (needs CPZ-Lab/cpzai#1635), cached per credential digest for 60s with a periodic sweep and a size bound, looked up only for tools/list, never on the call path, under a 3s budget of its own so a slow API costs an unfiltered list rather than a stalled client.

search_tools will not return an out-of-scope tool and call_tool will not dispatch one.

Unknown is not none. If the lookup fails, or the REST API has no scopes field yet, the full catalogue is advertised and the reason is logged. Verified against an unreachable API, so the two deploys are order independent. Enforcement stays in the REST API.

3. Declared output schemas

24 tools declare the envelope they actually return ({data, count}, {data}, {message}), so upstream drift fails loudly instead of returning a plausible-looking object. Only shapes this server controls are declared: the action tools that proxy elsewhere, plus place_order and create_connection, promise nothing. The SDK skips output validation on isError results, so error envelopes are unaffected.

Measured

Endpoint Full scopes data scope only
/mcp 31 tools, 31,290 B 10 tools, ~9,400 B
/mcp/compact 15 tools, 13,220 B 6 tools, ~5,500 B

Not two catalogues

captureTools runs the existing registerTools / registerExpandedTools against a recorder, then the permitted subset is replayed onto the server. One definition per tool; advertised, searchable and dispatchable surfaces cannot drift.

Also

SDK 1.27.1 to 1.30.0. Still protocol 2025-11-25: the 2026-07-28 implementation ships as separate @modelcontextprotocol/server@beta packages that are explicitly not production ready with APIs that may still change, so it stays off an order-placing surface until it stabilizes. Its headline change, a stateless core, is already how this server runs.

api-client gains an optional timeoutMs so a caller can set its own request budget.

Verification

  • 156 tests pass, 21 new across tests/tool-search.test.ts and tests/scopes.test.ts. Includes: dispatch matches a direct call and applies the tool's own argument transforms; place_order through call_tool is refused and never reaches the API; a data-scoped key gets exactly the 10 expected tools; an identity-only credential gets get_profile alone; scope lookup caches per credential and returns null loudly on failure.
  • Smoke-tested over HTTP on both paths: tools/list returns 31 and 15 tools, 24 and 7 with output schemas; unauthenticated POST still returns 401 with the WWW-Authenticate challenge; with an unreachable REST API both paths still serve the full catalogue and log the reason.
  • tool-catalog.json is generated from full mode and is unchanged.

New cpzai://guides/discovery resource and README sections document both endpoints, the defer_loading config for the MCP connector, and the scope behaviour.

Deploy

Not git-triggered. S3 zip, CodeBuild aquila-mcp-build, then aws ecs update-service --cluster aquila-mcp --service aquila-mcp --force-new-deployment.

31 tools and ~27KB of JSON Schema landed in every client's context on
every request, and tool-selection accuracy falls off past roughly thirty
tools. Clients that can defer loading (Claude Code, the MCP connector's
mcp_toolset defer_loading) were fine; everything else paid in full.

POST /mcp/compact advertises 15 tools instead of 31 (12KB vs 27KB, a 55%
cut): search_tools finds any deferred read-only tool by name, title,
description, parameter name or parameter description and returns its full
input schema, and call_tool dispatches it. POST /mcp is unchanged.

call_tool takes read-only tools only. A client gates approval on the tool
name it can see, so routing an order through a generic dispatcher would
hide it from the check meant to catch it: place_order, execute_strategy,
create_connection, the webhook writes and every other mutation stay
first-class tools in both modes. It also refuses an already-advertised
read tool, answers an unknown name with near matches, and validates
arguments against the real schema before dispatch.

Tools are captured once and replayed, so the advertised catalogue and the
dispatchable catalogue cannot drift. New cpzai://guides/discovery resource
documents both endpoints, including the defer_loading config for the MCP
connector.
Three things the catalogue was not doing.

SCOPES. tools/list described the whole server to every credential, so a
key scoped to data was still shown place_order and a model would plan
around tools that 403 when it reaches them. The spec permits the
advertised set to vary by the authorization on the request, since
credentials are per-request input rather than connection state. Scopes
come from GET /me, cached per credential digest for 60s with a periodic
sweep and a size bound, looked up only for tools/list and never on the
call path, under a 3s budget of its own so a slow API costs an unfiltered
list rather than a stalled client. A data key now sees 10 tools instead
of 31; an identity-only token sees get_profile. search_tools will not
return an out-of-scope tool and call_tool will not dispatch one.

Unknown is not none: if the lookup fails, or the REST API has no scopes
field yet, the full catalogue is advertised and the reason is logged.
Verified against an unreachable API. Enforcement stays in the REST API.

OUTPUT SCHEMAS. 24 tools now declare the envelope they actually return,
so upstream drift fails loudly instead of returning a plausible object.
Only shapes this server controls are declared: the action tools that
proxy elsewhere, place_order and create_connection promise nothing. The
SDK skips output validation on isError results, so error envelopes are
unaffected. Costs ~4KB on the full endpoint, nothing on a deferred one.

SDK 1.30.0 (was 1.27.1). Still protocol 2025-11-25; the 2026-07-28
implementation ships as separate beta packages that are explicitly not
production ready, so it stays off the order path until it stabilizes.

Adds api-client timeoutMs so a caller can set its own request budget.
@chris-cpz
chris-cpz force-pushed the feat/mcp-capability-expansion branch from 0cf6190 to 3269020 Compare September 15, 2026 08:55
@chris-cpz
chris-cpz merged commit 35211f6 into main Sep 15, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant