πŸ€– @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, average or close positions β€” through five guarded tools, while the trading engine keeps every level, limit and validation on its side.

Ask DeepWiki npm TypeScript

πŸ“š 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.

  • πŸ› οΈ Five guarded tools β€” get_status, open_position, close_position, average_position, notify_user; nothing else is exposed, and --tools narrows even that.
  • 🧷 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 / commitClosePending machinery.
  • πŸ§ͺ Testable β€” IMCPCallbacks (onStatus, onPositionOpen, onPositionClose) fire after each accepted effect with the raw data it was built from.

Explicit parameters & environment variables
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.

Command-line arguments (stdio server or sse)

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.

Narrowing the tool surface with --tools

By 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.

Serving over the network with --sse

By 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.


Two-process architecture
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).

Always-200 envelope

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.

Endpoints
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.


Layers & files

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