@backtest-kit/mcp
v18.3.1
Published
Model Context Protocol server for backtest-kit: lets an LLM agent observe the live trading portfolio and open or close positions through guarded tools
Downloads
1,484
Maintainers
Readme
🤖 @backtest-kit/mcp
Model Context Protocol server for backtest-kit. Lets an LLM agent (Claude, or any MCP client) watch your live trading portfolio and open or close positions — through three guarded tools, while the trading engine keeps every level, limit and validation on its side.
📚 Docs · 🌟 Reference implementation · 🐙 GitHub
npm install @backtest-kit/mcp backtest-kit @modelcontextprotocol/sdkIn the trading process — register an MCP schema and expose the HTTP bridge:
import { addMCPSchema } from 'backtest-kit';
import { serve } from '@backtest-kit/mcp';
addMCPSchema({
mcpName: 'my-mcp',
strategyName: 'my-strategy', // the strategy whose live instances the agent sees
});
serve(); // HTTP bridge on 127.0.0.1:60051 (CC_MCP_HOST / CC_MCP_PORT)In the MCP client (e.g. .mcp.json for Claude Code) — run the stdio server:
{
"mcpServers": {
"trading-signals": {
"command": "npx",
"args": ["@backtest-kit/mcp"],
"env": { "CC_MCP_HOST": "127.0.0.1", "CC_MCP_PORT": "60051" }
}
}
}The agent gets get_status, open_position and close_position. Your strategy code does not change.
Why
The agent decides when and which way — the engine decides everything else. An LLM given raw exchange API keys is a liability: one hallucinated parameter and it buys the wrong size at the wrong price with no stop. Here the agent's whole vocabulary is three tools, and the open command carries only symbol, position and a human-readable note. Take profit, stop-loss and entry cost are computed by backtest-kit (fixed 50% moonbag TP; hard stop snapped to a 2.5% grid strictly below CC_MAX_STOPLOSS_DISTANCE_PERCENT; cost from the MCP schema). Every command passes the same validation chain — MCP → strategy → risk profiles → actions — as any other signal source, and an open against a symbol that already holds a position is rejected by the engine, not by prompt engineering.
Text-first status built for LLM consumption. get_status returns one message per traded symbol with the current price, invested balance, the queued entry order, the active position with unrealized PnL and the queued close order. Empty slots are stated explicitly ("Entry queue: empty") so the model never has to guess whether a field was omitted or just missing — the difference between an agent that reasons and one that hallucinates. A custom getMessages in the schema can replace or extend the default renderer, including base64 chart images, which map 1:1 onto MCP image content blocks.
Two processes, one contract. The stdio MCP server lives in the agent's world and holds no trading state; it forwards every call over HTTP to the trading process. Handlers always answer 200 — transport success is not operation success — with the outcome in an envelope: error is an empty string on success and the engine's exact message (MCP Error: symbol BTCUSDT is not enabled for trading) on failure, which the tool relays to the agent as an isError result it can read and react to.
- 🛠️ Three guarded tools —
get_status,open_position,close_position; nothing else is exposed. - 🧷 Engine-owned levels — moonbag TP/SL and entry cost are computed server-side; the agent cannot override them.
- 💬 Human-readable portfolio — per-symbol text messages with explicit empty slots; images supported.
- 🔌 Process isolation — stdio server ↔ HTTP bridge ↔ trading engine; stdout carries only JSON-RPC.
- ✅ At-most-once semantics — commands reuse backtest-kit's
commitCreateSignal/commitClosePendingmachinery. - 🧪 Testable —
IMCPCallbacks(onStatus,onPositionOpen,onPositionClose) fire after each accepted effect with the raw data it was built from.
Configuration
import { setConfig } from '@backtest-kit/mcp';
setConfig({
CC_MCP_HOST: '127.0.0.1',
CC_MCP_PORT: 60051,
CC_MCP_NAME: 'my-mcp',
});| Variable | Default | Description |
|----------|---------|-------------|
| CC_MCP_HOST | 127.0.0.1 | Host the HTTP bridge listens on (trading process) and connects to (stdio process) |
| CC_MCP_PORT | 60051 | Port of the HTTP bridge |
| CC_MCP_NAME | (empty) | MCP schema to use when several are registered; empty = the first registered schema |
Values passed to setConfig() always take precedence over env vars.
When the package runs as the stdio MCP server (npx @backtest-kit/mcp, the backtest-kit-mcp command, or node build/index.mjs), the bridge address can be passed as CLI arguments instead of env vars:
npx @backtest-kit/mcp --host 127.0.0.1 --port 60051{
"mcpServers": {
"trading-signals": {
"command": "npx",
"args": ["@backtest-kit/mcp", "--host", "127.0.0.1", "--port", "60051"]
}
}
}| Argument | Overrides | Description |
|----------|-----------|-------------|
| --host | CC_MCP_HOST | Host of the HTTP bridge to connect to |
| --port | CC_MCP_PORT | Port of the HTTP bridge; a non-numeric value is ignored |
Resolution order: CLI arguments → setConfig() → env vars → defaults (127.0.0.1:60051).
CLI arguments apply only in binary mode — an entrypoint guard (helpers/getEntry.ts) makes sure that when the package is imported as a library, the host process's argv never leaks into the configuration.
API reference
| Export | Description |
|--------|-------------|
| serve(callback?) | Start the HTTP bridge in the trading process (singleshot; safe to call twice). |
| getRouter() | The underlying request handler — mount it into your own HTTP server instead of serve(). |
| setConfig(config) | Override host/port/name at runtime. |
| getConfig() | The current merged configuration (env + any setConfig overrides). |
| setLogger(logger) | Replace the internal no-op logger with your own implementation. |
| lib | The IoC container (mcpCommandService, mcpPublicService, mcpPrivateService) for advanced wiring and tests. |
Running the package binary (npx @backtest-kit/mcp, the installed backtest-kit-mcp command, or node build/index.mjs) starts the stdio MCP server — that side needs no imports, only CC_MCP_HOST/CC_MCP_PORT pointing at the trading process.
The 3 tools
| Tool | Arguments | What the agent gets |
|------|-----------|---------------------|
| get_status | — | One message per traded symbol: current price, invested balance, queued entry order, active position with unrealized PnL (% and USD), queued close order. |
| open_position | symbol, position (long | short), note | Opens at market price with engine-computed TP/SL/cost. Fails if the symbol is not live-enabled or already has an active position. |
| close_position | symbol, note | Queues a market close of the active position. Fails if there is nothing to close. |
Every failure reaches the agent as an isError tool result carrying the engine's exact error message — the agent is expected to call get_status first and react to rejections, not retry blindly.
How it works
agent (Claude / any MCP client)
└─ stdio JSON-RPC ─ backtest-kit-mcp (this package, binary)
tools/*.tool.ts
└─ MCPCommandService ── HTTP POST ──► serve() (this package, imported)
routes/mcp.ts
└─ MCPPublicService ─► MCP.* (backtest-kit)
└─ Live.commitCreateSignal / commitClosePendingThe stdio process never touches trading state — it only speaks HTTP. The trading process registers schemas, runs Live, and answers on /api/v1/mcp/*. Both roles ship in one package: importing it gives you serve(), executing it starts the stdio server (an entrypoint guard makes the side-effect import a no-op in library mode).
HTTP handlers never signal operation failure through status codes. Every response is 200 with:
{ "data": …, "status": "ok", "error": "", "requestId": "…", "serviceName": "…" }
{ "status": "error", "error": "MCP Error: no active position for BTCUSDT" }MCPCommandService throws when error is non-empty; the tool catches and returns the message to the agent as isError. Transport-level failures (engine down, wrong port) surface the same way via fetchApi's exception.
| Endpoint | Method | Body data | Maps to |
|----------|--------|-------------|---------|
| /api/v1/mcp/get_status | POST | — | MCP.getStatus(mcpName) |
| /api/v1/mcp/commit_position_open | POST | { symbol, position, note } | MCP.commitPositionOpen(dto) |
| /api/v1/mcp/commit_position_close | POST | { symbol, note } | MCP.commitPositionClose(dto) |
| /api/v1/health/health_check | GET | — | uptime / memory / CPU snapshot |
Request envelope: { clientId, serviceName, userId, requestId, data }. The mcpName is resolved server-side: CC_MCP_NAME if set, otherwise the first registered schema — the agent never needs to know it.
Internal architecture (complete source map)
Public surface — functions/serve.function.ts (serve/getRouter), functions/setup.function.ts (setLogger), config/params.ts (setConfig/getConfig), index.ts re-exports + lib container.
Stdio server — main/entry.ts (McpServer + StdioServerTransport, entrypoint-guarded via helpers/getEntry.ts), tools/get_status.tool.ts, tools/open_position.tool.ts, tools/close_position.tool.ts (zod-validated arguments, isError mapping).
HTTP bridge — config/router.ts (micro + router + CORS, /api/v1/mcp/* mount), routes/mcp.ts (three POST handlers, always-200 envelope), routes/health.ts.
Service layer (lib/services/):
command/MCPCommandService.ts— HTTP client used by the tools (fetchApiagainstCC_MCP_HOST:CC_MCP_PORT).public/MCPPublicService.ts— server-side entry: resolvesmcpName(CC_MCP_NAMEor first schema), validates arguments, delegates down.private/MCPPrivateService.ts— thin logging proxy over the backtest-kitMCPsingleton.base/LoggerService.ts— no-op by default (keeps stdio stdout clean); swap viasetLogger.
DI & config — lib/core/{di,provide,types}.ts, lib/index.ts (container bootstrap), utils/omit.ts (log payload trimming).
🤝 Contribute
Fork / PR on GitHub.
📜 License
MIT © tripolskypetr
