Model Context Protocol server for backtest-kit. Lets an LLM agent (Claude, or any MCP client) watch your live trading portfolio and open, average or close positions β through five 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/sdk
In 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.
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 five tools, and the open command carries only symbol, position and a human-readable description. 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.
get_status, open_position, close_position, average_position, notify_user; nothing else is exposed, and --tools narrows even that.commitCreateSignal / commitClosePending machinery.IMCPCallbacks (onStatus, onPositionOpen, onPositionClose) fire after each accepted effect with the raw data it was built from.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 -y @backtest-kit/mcp@latest --host 127.0.0.1 --port 60051
{
"mcpServers": {
"trading-signals": {
"command": "npx",
"args": ["@backtest-kit/mcp@latest", "--host", "127.0.0.1", "--port", "60051"]
}
}
}
or
npx -y @backtest-kit/mcp@latest --sse 8081
claude mcp add --transport sse trading-signals http://localhost:8081/sse
| 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 |
--tools |
β | Comma-separated tool names to expose; omitted means all five |
--sse [PORT] |
β | Serve the MCP protocol over SSE instead of stdio; defaults to port 8080 |
Resolution order for host and port: 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.
--toolsBy default the stdio server registers all five tools. --tools takes a comma-separated list of tool names and registers only those β the rest never appear in the agent's tool list at all, so no prompt engineering is needed to keep the agent away from them:
# Read-only agent: it can watch the portfolio but not touch it
npx @backtest-kit/mcp --tools get_status
# Observe and exit, but never open or average
npx @backtest-kit/mcp --tools get_status,close_position,notify_user
{
"mcpServers": {
"trading-signals": {
"command": "npx",
"args": ["@backtest-kit/mcp", "--tools", "get_status,close_position"]
}
}
}
Valid names are exactly the tool names the agent sees: get_status, open_position, close_position, average_position, notify_user. An unknown name fails the startup with the list of unknown and available names β a typo silently dropping a tool would only surface later as odd agent behaviour.
This is transport-level narrowing, independent of the engine's permissions field in the MCP schema. The two compose: --tools decides what the agent is offered, permissions decides what the engine accepts. Restricting a tool here is convenient for running several agents against one trading process with different mandates; enforcing the boundary for real belongs in the schema, which the agent cannot bypass.
--sseBy default the server speaks stdio: the agent spawns the process and talks to it over stdin/stdout. --sse swaps that for SSE over HTTP, so a client can attach across the network instead of owning the process:
# Default port 8080
npx @backtest-kit/mcp --sse
# Explicit port
npx @backtest-kit/mcp --sse 9000
Two endpoints are exposed:
| Endpoint | Method | Purpose |
|---|---|---|
/sse |
GET | Opens the long-lived event stream; responds with the sessionId to post to |
/messages?sessionId=β¦ |
POST | Delivers one clientβserver JSON-RPC message |
SSE is a two-channel transport: the GET stream stays open for the whole session and carries every serverβclient message, while the client POSTs its own messages to the second endpoint. The sessionId handed out on connect correlates the two, so several clients can attach at once β each gets its own session and closing one does not disturb the others.
Point a connector that bridges an external MCP endpoint to a local HTTP one at http://localhost:8080/sse. The startup line is written to stderr, never stdout, so the same binary can be piped either way without corrupting a JSON-RPC stream.
--sse composes with everything else β --tools still narrows the surface, --host / --port still point at the trading bridge (those describe where the engine lives, which is unrelated to how the agent connects):
npx @backtest-kit/mcp --tools get_status --sse 9000
The optional value is positional, so a path immediately after the flag is read as the port:
npx @backtest-kit/mcp --sse ./strategy.ts # β fails: "./strategy.ts" is not a port
npx @backtest-kit/mcp ./strategy.ts --sse # β path first, then the flag
A non-port value fails the startup rather than being ignored β silently treating it as "no port given" would swallow the strategy path and start with nothing loaded.
| 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.
| 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), description |
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, description |
Queues a market close of the active position. Fails if there is nothing to close. |
| average_position | symbol |
Adds a DCA entry at market price with the configured entry cost. Fails if there is no active position. |
| notify_user | symbol, description |
Attaches a description to the active position, surfaced back in get_status. Fails if there is no active position. |
Every command is queued and executes on a live tick, so its effect shows up in get_status after roughly 5 minutes β the tool descriptions tell the agent not to resubmit meanwhile. The description arguments render as markdown, so detailed multi-line write-ups are encouraged over one-liners.
Use --tools (see Configuration) to expose only a subset of them.
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.
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 / commitClosePending
The 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/mcp/commit_average_buy |
POST | { symbol } |
MCP.commitAverageBuy(dto) |
/api/v1/mcp/commit_signal_notify |
POST | { symbol, note } |
MCP.commitSignalNotify(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.
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 selection via helpers/getArgs.ts), tools/get_status.tool.ts, tools/open_position.tool.ts, tools/close_position.tool.ts, tools/average_position.tool.ts, tools/notify_user.tool.ts (zod-validated arguments, isError mapping).
HTTP bridge β config/router.ts (micro + router + CORS, /api/v1/mcp/* mount), routes/mcp.ts (five POST handlers, always-200 envelope), routes/health.ts.
Service layer (lib/services/):
command/MCPCommandService.ts β HTTP client used by the tools (fetchApi against CC_MCP_HOST:CC_MCP_PORT).public/MCPPublicService.ts β server-side entry: resolves mcpName (CC_MCP_NAME or first schema), validates arguments, delegates down.private/MCPPrivateService.ts β thin logging proxy over the backtest-kit MCP singleton.base/LoggerService.ts β no-op by default (keeps stdio stdout clean); swap via setLogger.DI & config β lib/core/{di,provide,types}.ts, lib/index.ts (container bootstrap), utils/omit.ts (log payload trimming).
Fork / PR on GitHub.
MIT Β© tripolskypetr