Model Context Protocol server for QTSurfer — exposes account, live trading, backtesting and market data as AI-accessible tools over stdio JSON-RPC 2.0.
Run a backtesting workflow from any MCP-capable AI assistant: list exchanges, explore instruments, submit a strategy, and get full execution metrics — all without leaving the chat.
- Stdio transport — compatible with Claude Code, OpenAI Codex, and any MCP client.
- Native binary — ~17 ms startup, ~44 MB, no JVM required. Available for Linux, macOS, and Windows.
- Fat JAR fallback — single file, runs anywhere with JDK 21+.
- Docker —
docker run -ifor containerised deployments (eclipse-temurin:21-jre-alpine, ~230 MB). - Long-lived API key, refresh handled for you — drop
QTSURFER_APIKEYonce in your MCP client config;sdk-javaexchanges it for a JWT on startup and refreshes transparently for the lifetime of the process. - Backed by
com.github.QTSurfer:sdk-java— authenticated account, live execution, compile → prepare → execute orchestration, retry and cancellation.
curl -fsSL https://raw.githubusercontent.com/QTSurfer/mcp-java/main/install.sh | bashThe installer detects your platform and picks the right delivery:
| Platform | What gets installed |
|---|---|
| Linux x86_64 | native binary |
| macOS arm64 (Apple Silicon) | native binary (quarantine flag removed automatically) |
| macOS x86_64 (Intel) · Linux arm64 | fat JAR + wrapper script (Java 21+ required; installer offers to install via SDKMAN if missing) |
Pin a version or override the destination:
VERSION=0.10.0 INSTALL_DIR=~/.local/bin \
curl -fsSL https://raw.githubusercontent.com/QTSurfer/mcp-java/main/install.sh | bashirm https://raw.githubusercontent.com/QTSurfer/mcp-java/main/install.ps1 | iexInstalls the native qtsurfer-mcp-windows-amd64.exe to %LOCALAPPDATA%\qtsurfer-mcp and adds it to your user PATH.
On unsupported architectures it falls back to the fat JAR and offers to install Java 21 via winget if missing.
curl -LO https://github.com/QTSurfer/mcp-java/releases/latest/download/qtsurfer-mcp-java.jar
java -jar qtsurfer-mcp-java.jar --helpdocker pull ghcr.io/qtsurfer/mcp-java:latest
# Run (MCP over stdio — pipe stdin/stdout)
docker run -i --rm -e QTSURFER_APIKEY=<your-api-key> ghcr.io/qtsurfer/mcp-java:latestGenerate a long-lived API key in the QTSurfer web app, then pass it to the
MCP server via the QTSURFER_APIKEY environment variable (or --apikey).
The server exchanges the API key for a short-lived JWT on startup and
refreshes it transparently for the lifetime of the process — no manual
rotation required.
If QTSURFER_APIKEY is missing or the initial exchange returns 401, the
server exits non-zero with a clear error before exposing any tools (your
MCP client UI will surface the failure).
Native binary:
{
"mcpServers": {
"qtsurfer": {
"type": "stdio",
"command": "/path/to/qtsurfer-mcp",
"env": { "QTSURFER_APIKEY": "<your-api-key>" }
}
}
}Fat JAR:
{
"mcpServers": {
"qtsurfer": {
"type": "stdio",
"command": "java",
"args": ["-jar", "/path/to/qtsurfer-mcp-java.jar"],
"env": { "QTSURFER_APIKEY": "<your-api-key>" }
}
}
}Docker:
{
"mcpServers": {
"qtsurfer": {
"type": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "QTSURFER_APIKEY", "ghcr.io/qtsurfer/mcp-java:latest"]
}
}
}[mcp_servers.qtsurfer]
command = "/path/to/qtsurfer-mcp"
[mcp_servers.qtsurfer.env]
QTSURFER_APIKEY = "<your-api-key>"Usage: qtsurfer-mcp [options] # native binary
java -jar qtsurfer-mcp-java.jar [options] # fat JAR
Options:
--url <base-url> API base URL (default: https://api.qtsurfer.net/v1)
Override with QTS_URL env var
--apikey <key> Long-lived API key
(default: QTSURFER_APIKEY env var)
--upload-root <dir> Permit dataset upload files only beneath this directory
(default: QTSURFER_UPLOAD_ROOT; disabled when absent)
--download-root <dir> Permit market-data output only beneath this directory
(default: QTSURFER_DOWNLOAD_ROOT; disabled when absent)
--stub Use in-memory stub (no backend required)
--help Print this message and exit
MCP transport: stdio (stdin/stdout JSON-RPC 2.0)
| Tool | Description |
|---|---|
upload_dataset |
Atomically create or version a dataset, stream a guarded local CSV or Parquet file and start ingest; never exposes a presigned URL |
import_dataset / get_dataset_import |
Fetch DEX history into a dataset and poll its asynchronous fetch and ingest state |
list_datasets / get_dataset |
List datasets or read one dataset's metadata and current version |
get_dataset_upload |
Poll the asynchronous ingest after an upload |
finalize_dataset_upload |
Retry finalization after a successful PUT whose original MCP response was lost |
delete_dataset |
Delete a caller-owned dataset and its versions |
list_exchanges |
List available exchanges (e.g. binance, binancefutures) |
list_instruments |
List instruments for an exchange with per-data-type coverage windows and market info |
download_tickers / download_klines |
Stream one UTC-hour segment into a guarded local file; returns path metadata only |
submit_backtest |
Compile a Java strategy and submit a backtesting run against an instrument or ready dataset; returns a job ID |
storeSignals |
Optional on selected submit_backtest or submit_sweep calls; disabled by default because retained signals consume storage quota |
get_job_status |
Status and full execution metrics for a job — this session's, or any job on the platform given its exchangeId |
get_equity_curve |
Equity curve of a completed run as compact JSON, downsampled to a point budget |
list_jobs |
List jobs from the current session, optionally filtered by status |
submit_sweep |
Run one strategy across a parameter grid on an instrument or ready dataset, optionally walk-forward validated; returns a sweep ID |
get_sweep_status |
Progress and a capped, plateau-ranked leaderboard for a sweep |
get_sweep_run_equity_curve |
Bounded normalized equity curve for one retained sweep trial |
cancel_sweep |
Stop a running sweep between parameter vectors, keeping the rows already scored |
get_sweep_sensitivity |
Which parameter mattered: marginals per axis, or one named interaction surface |
list_strategies |
List every strategy registered under this account, most recently compiled first |
delete_strategy |
Release a registered strategy |
get_strategy_code |
Fetch the exact source last registered for a strategy id |
get_account / get_account_usage |
Read account tier limits and current dataset, strategy, signal and shared-storage usage |
start_live / get_live / stop_live |
Start a strategy's live run, inspect sandbox/live state, and request it to stop |
list_live / list_public_live |
Page through all your own runs or browse currently-running public runs separately |
update_live / update_live_params |
Change run visibility/metadata or queue updates to declared strategy parameters |
get_live_signals |
Read retained signals oldest-first with optional time/instrument filters and cursor pagination |
Use get_account for tier caps and get_account_usage for current consumption before workflows
that retain data. Dataset bytes, strategy bytes, and retained signal bytes share the account's
storage quota. Live signal relay is off by default; request it only when a downstream consumer
needs the retained signal history, and check usage again before enabling it for many runs.
{}The empty argument object above is the input to either account tool.
Compile a strategy first, then call start_live with its strategyId, exchange, segment, and
instruments (a non-empty symbol array). The server starts every run in SANDBOX; inspect it with
get_live and poll until its state changes. Optional inputs are type (ticker by default, or
kline), initial params, visibility (private by default), name, description, and relay
(false by default). The only supported venue type is centralized exchange (cx). instruments
may be ['*'] only if the account tier allows every instrument. stop_live takes strategyId and
returns the desired stop state; poll get_live until actual state settles.
Use list_live for all owned runs (including sandbox/stopped); list_public_live is a different
catalog containing only public runs currently running and never exposes their owner/strategy.
Both accept optional cursor and limit (default 20, API maximum 100); pass nextCursor back
unchanged to fetch the next page.
update_live takes runId plus at least one of visibility, name, or description.
update_live_params takes runId and a non-empty params object containing only declared strategy
properties; changes take effect at an event boundary. get_live_signals accepts runId, optional
sinceMs, instrument, opaque cursor, and limit; cursor takes precedence over sinceMs. Read
oldest-first, pass returned nextCursor unchanged, and if retention expires a cursor, restart from
the reported availableSinceMs (deduplicate signalId if combining with another signal feed).
Example tool arguments:
{
"strategyId": "strategy-123",
"exchange": "binance",
"segment": "spot",
"instruments": ["ETH/USDT"],
"params": {"emaFast": 12},
"relay": false
}Pass those arguments to start_live; then poll get_live with strategyId. To read history,
call get_live_signals with { "runId": "run-123", "instrument": "ETH/USDT", "limit": 100 };
continue by passing its returned nextCursor unchanged as cursor.
This stdio request/response MCP does not open a WebSocket or mint a connection token. Use a direct
WebSocket client for real-time signal subscriptions; use get_live_signals here for retained history.
Dataset upload and market-data export are deliberately local-stdio capabilities, with separate
least-privilege roots. Set --upload-root /absolute/path (or QTSURFER_UPLOAD_ROOT) for readable
dataset files; set --download-root /absolute/path (or QTSURFER_DOWNLOAD_ROOT) for writable
market-data output. Each capability is disabled independently when its root is absent. Every upload
filePath is canonicalised, must remain under its root and must be a readable regular file, so
traversal and symlink escapes are rejected. A download outputPath must be relative to the download
root, have an existing non-symlink parent, and does not overwrite an existing file unless
overwrite=true; it is written through a temporary file and atomically moved into place. The upload
tool only returns dataset/upload/ingest IDs — never the short-lived presigned storage URL.
For uploads only, mount the upload directory read-only. Add a separate deliberately writable download directory when needed:
docker run -i --rm -e QTSURFER_APIKEY \
-e QTSURFER_UPLOAD_ROOT=/uploads -e QTSURFER_DOWNLOAD_ROOT=/downloads \
-v "$PWD/datasets:/uploads:ro" -v "$PWD/exports:/downloads" \
ghcr.io/qtsurfer/mcp-java:latestOnce get_dataset_upload returns READY, submit a run with datasetId; omit exchangeId and
instrument, because MCP derives the reserved exchangeId=user. datasetVersionId pins a prior
ready version. equityCurve is accepted by both run tools and is passed through to the API.
A sweep runs the same strategy once per parameter vector and ranks the results as one job, which is not the same thing as a loop of backtests. What the loop cannot produce:
- a plateau ranking rather than a raw one — a point's score is the worst run in its neighbourhood, so a spike that does not survive small parameter moves ranks low;
- a deflated Sharpe per row, discounting for how many vectors were tried;
- a probability of backtest overfitting for the search as a whole;
- walk-forward validation, where the answer is one row per fold scored out-of-sample instead of a ranked grid;
- sensitivity marginals, which say whether an axis moved the objective at all — a question the leaderboard cannot answer, since a sweep can spend its whole budget on an axis that did nothing and the top rows will not show it.
submit_sweep blocks until the platform accepts the sweep: it compiles the strategy and prepares
the dataset first, which takes as long as it takes on a long window. The read/cancel tools work on
sweeps submitted in the current session. If the sweep was requested with retained curves,
get_sweep_run_equity_curve reads one runIx with a 1,000-point default (10,000 absolute MCP
maximum); it asks the platform for compact differential data but returns normalized absolute points.
> list_exchanges
Available exchanges:
- binance: Binance — Binance spot exchange
- binancefutures: Binance Futures — Binance perpetual futures exchange
> list_instruments exchangeId=binance
Instruments on binance (142 total):
- BTC/USDT (last: 84250.50) data: 2026-03-17 → 2026-05-17
- ETH/USDT (last: 3120.75) data: 2026-03-17 → 2026-05-17
...
> submit_backtest exchangeId=binance instrument=BTC/USDT from=2026-05-10 to=2026-05-16 strategyCode=<...>
Backtest submitted. Job ID: abc123
Use get_job_status with jobId="abc123" to poll results.
> get_job_status jobId=abc123
Job abc123: COMPLETED
Exchange: binance | Instrument: BTC/USDT
=== Results ===
P&L: +42.7500
Trades: 156 (win rate: 58.3%)
Sharpe: 1.245 | Sortino: 1.872
CAGR: 15.34%
Max Drawdown: 8.75%
Signals: 100000
> submit_sweep exchangeId=binance instrument=BTC/USDT from=2026-01-01 to=2026-03-31
params={"rsiPeriod":{"from":7,"to":28,"step":1},"stopLossPct":{"values":[1,2,3]}}
Sweep submitted. Sweep ID: sw-d4748fdb
Runs: 66 across 1 shard(s)
Seed: 42 (resubmit this seed to replay the same draw)
Poll with get_sweep_status using sweepId="sw-d4748fdb".
> get_sweep_status sweepId=sw-d4748fdb topN=3
Sweep sw-d4748fdb: COMPLETED
Objective: sharpe | Order: ranked | Ranking applied: plateau
Progress: 66/66 runs
PBO: 0.320 over 8 splits — probability the in-sample winner lands below median out-of-sample; ...
Leaderboard: showing 3 of 25 rows carried by the platform's response; it reports 66 row(s)
available and flags its own leaderboard as truncated. 22 carried row(s) not shown — raise topN.
#1 runIx=41 plateau=1.4000 (neighbours=4) sharpe=1.6000 dSharpe=0.9700 pnl=150.0000 trades=90
#2 runIx=17 plateau=1.3700 (neighbours=3) sharpe=1.5700 dSharpe=0.9500 pnl=146.0000 trades=89
#3 runIx=52 plateau=1.3400 (neighbours=4) sharpe=1.5400 dSharpe=0.9300 pnl=142.0000 trades=88
> get_sweep_sensitivity sweepId=sw-d4748fdb
Marginals (one axis at a time, every other axis collapsed):
rsiPeriod:
7.0 → best 1.5000 mean 0.9000 worst 0.2000 (n=4)
...
stopLossPct:
1 → best 1.5000 mean 0.9000 worst 0.2000 (n=4)
...
Strategies are plain Java classes compiled server-side — no local Java compiler required. Each one extends a strategy base class chosen by its data source; AbstractTickerStrategy (live tickers) is the most common.
import com.wualabs.qtsurfer.engine.strategy.AbstractTickerStrategy;
public class MyStrategy extends AbstractTickerStrategy {
@Override
protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
// define indicators and trade logic here
}
}The QTSurfer Strategy Skills are the source of truth for writing strategies — the full base-class family, indicator catalogue, window listeners, state management, signal emission, worked examples, and advanced patterns. Install:
npx skills add QTSurfer/strategy-skillsgit clone https://github.com/QTSurfer/mcp-java.git
cd mcp-java
# Fat JAR
mvn package -DskipTests # → target/mcp-java-*.jar
# Unit + integration tests
mvn verify
# Native binary (requires GraalVM 21+)
mvn -Pnative -DskipTests package native:compile-no-fork # → target/qtsurfer-mcp
# Native via Docker (Linux x86_64, no local GraalVM needed)
docker build --platform linux/amd64 -f Dockerfile.native -t qtsurfer/mcp-native .
docker cp $(docker create qtsurfer/mcp-native):/app/qtsurfer-mcp ./qtsurfer-mcpRequires JDK 21+ (GraalVM for native) and Maven 3.8+.
Apache-2.0 — see LICENSE.