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. A deployment
that requires scopes returns 403 apikey_without_scopes for a key with none;
create a key with at least one resource/action scope in the web app and update
the MCP client's environment before restarting.
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 |
Import CEX market history or DEX pool history into a dataset and poll its asynchronous fetch and ingest state |
list_datasets / get_dataset |
List datasets (optionally including deleted entries) 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 |
Discover CEX/DEX venues, catalogue segments and live-enabled segments |
list_instruments |
List CEX instruments with per-data-type coverage windows and market info |
list_pools |
Discover DEX pool addresses, token addresses, fees, liquidity and market information |
download_tickers / download_klines |
Stream one recent UTC-hour segment into a guarded local file; return path, byte count and timestamp/provisional metadata |
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; the SDK waits for progress.pendingShards to reach 0 so rows already in flight are preserved |
get_sweep_sensitivity |
Which parameter mattered: marginals per axis, or one named interaction surface |
list_strategies |
List every strategy registered under this account; optionally include deleted entries |
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 (including the Cartesian sweep cap) 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 an optional secret plain-stream URL), and request it to stop |
get_live_run |
Inspect one owned run by its canonical id, including its last update and current optional counters |
rotate_live_stream / revoke_live_stream |
Replace or permanently revoke a run's secret plain-WebSocket signal URL |
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 |
send_live_command |
Send a transient event to a running strategy without restarting it |
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.
get_account also reports the API key's resource-to-actions scopes map. An empty map
means no grants. The API reports per-operation scopes without enforcing them, though a deployment
can require at least one scope when the MCP server exchanges its key for a token.
Call list_exchanges first: venueType is cex or dex, segments lists catalogues,
and liveSegments lists the segments that can feed a live run. A catalogue is not evidence
that its segment is live-enabled. Use list_instruments for CEX and list_pools for DEX:
{ "exchangeId": "robinhood", "segment": "uniswap-v3" }Pass this object to list_pools. Omit segment to select the venue's first protocol.
The catalogue contains at most 50 pools, ranked by 24-hour USD volume, with at least
$10,000 liquidity and hourly refreshes. Unknown prices, volumes and creation dates are absent,
not zero. volume24h is in quote tokens; volume24hUsd is in USD. Use a pool's id
(its address), not its symbol, when starting a live run; a valid unlisted pool may run too.
DEX catalogues cannot be passed directly to managed-data preparation or backtesting.
Compile a strategy first, then call start_live with its strategyId, exchange, and segment.
instruments is optional: omit it to use the instruments declared by the compiled QTScript
strategy, or all instruments in the selected exchange/segment if the strategy declares none. An
explicit non-empty array selects instruments; empty arrays and null are rejected. Pair matching
is case-insensitive; either side can be * (for example */USDT or BTC/*) when the account plan
allows wildcard selection. A first run starts in SANDBOX; a later run starts directly in LIVE
when a run of the same compilation was promoted and none of its runs was stopped for exceeding
resource limits, unless sandbox=true requests another trial. Recompiled strategies start in
SANDBOX again. Inspect the run with get_live and poll until its state changes. Optional
inputs are type (ticker by default, or kline), initial params, visibility (private by
default), sandbox (false by default), name, description, relay (false by default), and
warmFrom (integer seconds,
0–3600). warmFrom=0 disables market replay; omit it to retain the platform's automatic short
warmup. It is start-only, not an update_live_params property, and its effective value is shown by
get_live and get_live_run for runs created after this capability existed. Set stream=true only
when a plain-WebSocket consumer needs signals: it returns
a secret streamUrl and also enables relay. Treat that URL as a password; do not put it in logs or
untrusted prompts. venueType defaults to cex; CEX instruments
may include * on either side only if the account tier allows wildcard selection. stop_live takes
strategyId and returns the desired stop state; poll get_live until actual state settles.
For DEX, set venueType=dex, type=kline, and exactly one pool address in instruments;
omission and wildcards are invalid. Select exchange and segment from discovery, not by
guessing a chain/protocol tuple. For example, after choosing an actual discovered pool:
{
"strategyId": "strategy-123",
"venueType": "dex",
"exchange": "robinhood",
"segment": "uniswap-v3",
"type": "kline",
"instruments": ["0x0000000000000000000000000000000000000001"]
}Replace the example address with the chosen pool's id. Invalid tuples are rejected;
pool-resolution or capacity failures during startup settle as FAILED with desired state
STOPPED, without reserving a live slot. Poll the returned state rather than assuming acceptance
means the run is running. Warm-up replay runs at roughly six times normal speed; a full
3,600-second warm-up can take about ten minutes.
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.
list_datasets and list_strategies default to active entries; set includeDeleted=true to include
soft-deleted entries marked with deletedAt. get_account reports maxSweepCartesian, the
maximum allowed Cartesian combination count for a sweep.
get_live_run takes the canonical runId and adds updatedAtMs plus optional current counters to
the normal run state. rotate_live_stream replaces the secret stream URL; revoke_live_stream
permanently disables it for that run.
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).
send_live_command takes an owned runId, non-blank command, and optional arbitrary JSON
properties object. The strategy must implement CommandRequestHandler. Commands are transient,
not stored or replayed to later replicas; use update_live_params for persistent state. A 503
means the command was not sent; ambiguous network failures have no idempotency key, so avoid blind
retries. A successful acceptance is not confirmation that the strategy finished processing it.
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.
import_dataset defaults to type=dex, preserving the existing pool-history inputs
(network, contract, protocol, version). For CEX, pass type=cex, a discovered
exchange, segment=spot, and dataType=ticker or klines; do not include DEX fields.
{
"name": "BTC historical klines",
"instrument": "BTC/USDT",
"type": "cex",
"exchange": "binance",
"segment": "spot",
"dataType": "klines",
"from": "2026-10-01T00:00:00Z",
"to": "2026-10-01T01:00:00Z",
"cadence": "1m"
}Choose a range within get_account's import-range cap. CEX cadence accepts 1s, 5s,
1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 1w, or 1q.
Omission chooses the finest cadence held across the entire range; requesting a finer or
incompatible cadence fails asynchronously. DEX accepts 1s, 1m, 5m, or omission for
native events. Save the returned datasetId/importId and call get_dataset_import until
READY or FAILED; only a ready version can be backtested. Resume polling those ids after
an interrupted wait instead of creating another dataset. Imports consume dataset, shared-storage
and execution quotas; inspect account limits and usage first, and inspect the terminal error
for unavailable source history, invalid pools, cadence or execution-time failures.
download_tickers and download_klines take exchangeId, base, quote, hour
(YYYY-MM-DDTHH, UTC), outputPath, optional format (lastra by default, or parquet),
and overwrite (false by default). Only the last seven days are available here; use
import_dataset for older history. The result reports contentType, timestampUnit,
dataStatus, and dataThrough alongside the relative path and byte count. No binary bytes
are included in the tool reply. Lastra uses application/vnd.qtsurfer.lastra and microsecond
timestamps; Parquet timestamps remain milliseconds. dataThrough is an ISO-8601 UTC instant.
dataStatus=provisional means the hour can still grow: download it again later, using
overwrite=true deliberately if replacing the existing file. Do not archive it as a final hour.
Some deployments instead return 404 NOT_YET_AVAILABLE; respect Retry-After, as for
503 TEMPORARILY_UNAVAILABLE. 404 NO_DATA is a stored empty hour, not a pending one.
400 OUTSIDE_WINDOW suggests an HTTP dataset-import request; translate its source/range
into the import_dataset inputs and supply a name. Never execute a suggested recovery call
automatically. Treat unknown reasons like absent reasons, using HTTP status and message.
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.