vike

Drive Vike Trader from Claude

Two captured MCP sessions, annotated — the handshake, the templates, a compile error, a tool call that failed because the agent guessed, and the backtest report that came back.

Every response on this page is copied verbatim from two captured sessions against vike-cli mcp, driven over stdio with newline-delimited JSON-RPC 2.0. A local vike-datahub at 127.0.0.1:7878 served the backtest. Nothing is illustrative: where a number looks unflattering, it is the number the run produced. The one exception is the handshake below, which is re-rendered whenever the server's initialize result gains a field — that result is fixed text assembled from constants rather than anything a run produces, so a stale capture of it is simply a wrong payload. It is pretty-printed here and arrives on one line; the key order is not a presentation choice — the server builds its objects with serde_json's default map, so the block below is in the order the wire has it (sorted) rather than the order the code writes the fields.

Client setup has the launch entry for your client. This page is what happens after it launches.

The handshake

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"docs-capture","version":"1"}}}
{
  "capabilities": {
    "prompts": {},
    "resources": {},
    "tools": {}
  },
  "instructions": "This is `vike-cli`'s agent surface: ... \n\n ... Never invent a tool name, and never report an operator-side command as something you ran.",
  "protocolVersion": "2024-11-05",
  "serverInfo": {
    "name": "vike-cli",
    "version": "0.1.0"
  }
}

Four things the server just told the client:

  • serverInfo.name is vike-cli. The MCP server is not a separate binary — it is a subcommand of the same CLI you use at a prompt, and the version is that crate's own.
  • capabilities advertises tools, resources and prompts. No sampling to negotiate: the server asks the client for nothing.
  • The protocolVersion came back echoed, because the client declared one.
  • instructions is a paragraph the client puts in front of the model — elided here, and the only part of the handshake aimed at the model rather than at the client. It says this server is one surface of a larger system and names the commands an operator runs for the rest: record a tape with vike-recorder, get bars in with vike-cli data fetch, backtest with no server at all with vike-cli backtest --local, and see which venue keys this box holds with vike-cli secrets list. None of those is a tool, and the text says so — the point is that an agent asked for one of them can now name the command instead of refusing blind.

⚠ The instructions grant nothing. Nothing in that paragraph is callable, and the roster below is unchanged by it; it is also scoped by the same ToolAccess that decides the roster, so a session launched --profile read-only is told less than this one — no preview gate, because it has no write tool to gate. vike-cli mcp has the scoping rule and what each profile's text drops.

A notifications/initialized follows, carries no id, and gets no response.

What the server offers

tools/list returned the whole registry in one message — 9,859 characters, because every tool ships its full inputSchema alongside its description. The names, in the order they arrived:

validate_strategy   discover_params    list_templates     list_indicators
run_backtest        run_sweep          run_walk_forward   list_strategies
list_series         node_snapshot      submit_order       cancel_order
modify              flatten            market_exit        mass_cancel
set_trading_state

Ten read-only, seven that write to a node. Authoring, backtesting and trading are the reference pages for the three groups.

Those 9,859 characters are a real cost: they land in the model's context whether a tool is called or not. It is why the descriptions are terse, and why list_indicators — whose bound set is most of the indicator catalog — names none of that set in its own description and makes you ask.

Terseness has a price of its own, though, and the number above is a floor rather than a budget: two descriptions have been deliberately lengthened since this capture, each paid for by a measured mistake. discover_params read as a tool for inspecting somebody else's script, so an agent that had just written one answered "which parameters can I tune" from authorship instead of calling it; set_trading_state said only "the kill switch", and an agent reached for it to put a venue live. Both now say what the tool is not — the change is in tools_spec (crates/vike-cli/src/cmd/mcp.rs), and it is a straight trade of context bytes for a failure that had already happened.

Starting from a template

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_templates","arguments":{}}}

Three starters came back, each a row of two fields — a name and its source:

  • SMA cross
  • RSI reversion
  • Donchian breakout

The first, verbatim:

let fast = param("fast", 5.0);
let slow = param("slow", 20.0);
fn on_bar() {
    let f = sma(fast.to_int()); let s = sma(slow.to_int());
    if s.is_nan() { return; }
    let target = if f > s { 1.0 } else { -1.0 };
    let delta = target - position();
    if abs(delta) > 1e-12 { market(if delta > 0.0 { 1 } else { -1 }, abs(delta)); }
}

The third template ships a comment worth more than the strategy it belongs to. It uses an SMA as a symmetric proxy, and says why:

donchian is NOT bound: it is multi-output (upper/mid/lower) and the bridge binds single-output indicators only, so donchian(len) is a function-not-found at the first call rather than a silent upper-band read.

That is the failure mode the offline tools exist to catch. Rhai resolves a function name when the line runs, so a script naming an unbound indicator compiles perfectly and then fails on every single bar. See list_templates.

The script this session wrote

// quiet_breakout — buy a breakout, but only out of a quiet bar.
//
// Two user indicators do the work: breakout_level for the level the bar has to clear, and
// range_pct to insist the bar that clears it is not already a wide, over-extended one.
let lookback = param("lookback", 48.0);
let calm = param("calm", 3.0);
let qty = param("qty", 0.05);

fn on_bar() {
    // Read both every bar, before any branch — an indicator only advances when it is called.
    let level = breakout_level(lookback.to_int());
    let rng = range_pct();
    if level.is_nan() || rng.is_nan() { return; }

    if close() > level && rng < calm && position() <= 0.0 { market(1, qty); }
    if close() < level * 0.97 && position() > 0.0 { market(-1, position()); }
}

Passed to validate_strategy — the wire form carries it as a JSON string with escaped newlines, one message per line — the answer was two words long:

{
  "ok": true
}

ok: true is a narrower claim than it looks. The check runs through vike_script::discover_params, the broker-free compile path: it compiles the script and runs its top level exactly once, the same error surface as mounting the strategy. So the three param() lines ran. The body of fn on_bar() did not.

breakout_level and range_pct are not built-ins. They are the author's own files, loaded from user_data/indicators/ and installed process-wide before any strategy compiles (install_user_indicators in crates/vike-script/src/engine.rs). No compile check can vouch for them; the backtest further down is what actually proved those two calls resolve.

One thing to carry into that backtest: the install that binds a name is the install on the box that compiles the script. validate_strategy compiles in the CLI, so the CLI's set answers. A run_backtest compiles on the datahub, so the server's set answers — install_user_indicators in crates/vike-datahub/src/datahub_cli.rs reads that box's own user_data/indicators/, and its doc says a client script binds to whatever the server holds there. Here the two agreed because the datahub was the same machine. Writing an indicator and loading indicators cover that half.

The knobs

discover_params on the same script:

{
  "params": [
    {
      "default": 48.0,
      "name": "lookback"
    },
    {
      "default": 3.0,
      "name": "calm"
    },
    {
      "default": 0.05,
      "name": "qty"
    }
  ]
}

Those three names are exactly what a profile's [strategy.params] or a [sweep] table may override. Same compile path as the tool above, opposite disposition on failure: a broken script is an ok: false answer from validate_strategy and a tool error from discover_params, because there are no parameters left to report.

What a compile error looks like

A second session opened by handing over a deliberately broken script:

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"validate_strategy","arguments":{"script":"fn on_bar( { market(1 0.5) }"}}}
{
  "error": "Expecting ')' to close the parameters list of function 'on_bar' (line 1, position 12)",
  "ok": false
}

That is the Rhai compiler's own message, passed through with the position it reported. It arrives as an ordinary successful result — not a JSON-RPC error, not an isError result — so an agent reads it, fixes the script and calls again. That loop is what validate_strategy is for.

Asking about one indicator

{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_indicators","arguments":{"name":"rsi"}}}
{
  "count": 1,
  "indicators": [
    {
      "accessors": [],
      "bare_call": true,
      "category": "Momentum",
      "name": "rsi",
      "outputs": [
        "rsi"
      ],
      "params": [
        {
          "default": 14.0,
          "max": 1000.0,
          "min": 1.0,
          "name": "length"
        }
      ],
      "pretty": "RSI"
    }
  ]
}

Called with no arguments the tool returns the compact roster — name and category only — and detail is pulled one name or one category at a time. The tiering is deliberate: the bound set was three names when the tool was written and is nearly the whole catalog now.

The reason the tool exists at all is that the host-bound set is not the registry. crates/vike-cli/src/cmd/indicators.rs's find_bound separates the two ways a name can miss — a typo, which gets "no indicator named x", and a real registry entry the host holds back, which gets the host's own reason for holding it back. Different mistakes, different repairs.

The call that failed

Now the honest part of the session. The agent had a script, had its knobs, and asked for a backtest by assembling arguments that looked like a backtest. The wire form was one line; the script argument is elided here for length:

{"name":"run_backtest","arguments":{
  "script":   "// quiet_breakout …",
  "venue":    "binance",
  "symbol":   "BTCUSDT",
  "interval": "1h",
  "from":     "2025-01-01T00",
  "to":       "2026-01-01T00",
  "cash":     10000,
  "fee_rate": 0.001,
  "params":   {"lookback":48,"calm":3,"qty":0.05}
}}

Every one of those keys is plausible. Only script is real: the other eight are ignored, and the call failed on the one argument the agent never sent. The answer:

run_backtest requires a `profile` string argument

The real inputSchema, which had been sitting in the tools/list response the whole time:

{
  "properties": {
    "profile": {
      "type": "string"
    },
    "script": {
      "type": "string"
    }
  },
  "required": [
    "profile"
  ],
  "type": "object"
}

Two strings. Venue, symbol, interval, range, cash, fees and params all live inside the profile TOML, because the server's own BacktestProfile::from_toml_str is what reads them. With no script argument the profile crosses the wire byte for byte. With one, the client parses the TOML once to inject the script and re-serializes it — but it still reads none of those fields, and the server remains the only thing that interprets the profile.

Leave this failure in your transcript

Guessing an argument list is what any agent does when it pattern-matches a tool name instead of reading its schema. What makes it cheap here is the answer: one line, naming the missing argument, so the retry is obvious and costs one round trip. A tool error worth shipping is one an agent can act on without a human.

The corrected call

Same tool, same script, arguments rebuilt around the profile:

name = "quiet_breakout — BTCUSDT 1h"

[data]
kind = "bar"
interval = "1h"
venue = "binance"
symbols = ["BTCUSDT"]
from = "2025-01-01T00"
to = "2026-01-01T00"

[engine]
cash = 10000.0
fee_rate = 0.001
slippage = 0.0

[strategy]
name = "rhai"

[strategy.params]
lookback = 48.0
calm = 3.0
qty = 0.05

The script rides in the optional second argument and is injected as [strategy.params].src. Note strategy.name = "rhai" — that is the one arm which reads an injected src, and getting it wrong runs a built-in strategy while carrying the script along as an unread parameter. Run profiles documents the file.

The report, verbatim:

{
  "report": {
    "final_equity": 8334.568427500006,
    "funding_paid": 0.0,
    "max_drawdown": 0.1839635835571801,
    "n_trades": 48,
    "name": "quiet_breakout — BTCUSDT 1h",
    "per_symbol_pnl": [
      [
        "BTCUSDT",
        -1180.6035000000015
      ]
    ],
    "profit_factor": 0.687335413608655,
    "sharpe": -1.0475904521415624,
    "total_return": -0.16654315724999946,
    "win_rate": 0.3125
  }
}

Forty-eight trades, a win rate of 0.3125, a profit factor below one, a Sharpe of −1.05 and a total return of −0.167. A losing run, reported plainly. The profile requested a year of hourly bars; the report carries no bar count or coverage field, so how much of that year the store actually held is not something this capture states. Read a report walks the numbers; the report defines each field.

Why the agent's report is checkable

The report above is not an MCP-flavoured summary of a run. It is the run.

  • One function does the script injection — inject_script_src in crates/vike-cli/src/cmd/backtest.rs — and the backtest subcommand and this tool both reach it. So do run_sweep and run_walk_forward, through crates/vike-cli/src/cmd/mcp.rs's shared profile_from_args.
  • Both then ship the resulting TOML text verbatim to DatahubClient::run_backtest.
  • The server parses it once with BacktestProfile::from_toml_str and returns the report JSON, which the tool wraps under report and hands back untouched.

So vike-cli backtest with the same profile and script, against the same vike-datahub, returns the same numbers. The agent surface performs no arithmetic of its own, and that is the property which makes it trustworthy: anything an agent quotes back to you can be reproduced at a prompt, by you, without the agent.

The half this session does not show

Seven of the seventeen tools write to a node. Six of them place, change or cancel orders — submit_order, cancel_order, modify, flatten, market_exit and mass_cancel — and the seventh, set_trading_state, places no order at all: it moves the account's trading state, the kill switch. This session called none of them. No vike-tradehub node was running.

That absence does not, on its own, stop a call. Called the ordinary way — without confirm: true — a write tool returns its preview, and with no node configured it opens no socket; the connection is attempted only once a call has cleared the write gate, which is where the error naming the missing --node arrives.

There is therefore no captured paper submit_order into flatten pair to show here, and fabricating one would defeat the purpose of the page. What holds for those tools regardless:

  • A write tool executes nothing until a call carries both confirm: true and the preview_token that tool's own preview returned. Anything less returns a preview — the resolved wire command, a client-side guardrail check, the node's own dry-run when there is a node to ask, a note, and a fresh token — which the agent and the human watching it can both read before anything is sent. The token fires once, expires after 60 seconds and is bound to the command it previewed, so the two calls cannot be about two different orders. ⚠ Failing the gate is not an error: a confirm: true with no token comes back as another preview, so will_execute is the field to read, not the absence of isError.
  • To execute, they need a running node named by --node, plus VIKE_TRADEHUB_CONTROL_KEY. The read-only node_snapshot needs that same --node, with VIKE_TRADEHUB_OBSERVE_KEY in place of the control key — and unlike a preview, it has nothing to answer with until the node is there.
  • Nothing an MCP tool does can arm a venue. A venue with no credentials mounts as paper, and the per-venue ceiling policy.venues.<venue> defaults to paper and can only ever refuse.

Read submit_order and flatten for the argument shapes, and trading for the preview gate and how outcomes are reported.

Next

On this page