vike
Client setup

Raw JSON-RPC

There is no URL to curl — the trader server is a pipe. The stdio wire protocol, the result envelope, every error shape, and the two that mean "it may have executed".

⚠ The trader server is not HTTP. It has no URL, no port and no headers: vike-cli mcp is MCP's stdio transport — newline-delimited JSON-RPC 2.0 on stdin and stdout, one message per line with no embedded newlines. Anything a client would send, a shell can send. (The hosted data server is HTTP; its curl walkthrough is on Data client setup → Raw HTTP.)

An MCP client handles all of this for you. You need it anyway when you are scripting against the server, or when a client will not connect and you want to know whether the fault is yours or its.

The handshake

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-client","version":"1.0.0"}}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  | vike-cli mcp

The server echoes the protocolVersion you asked for — falling back to 2024-11-05 when a client declares none (DEFAULT_PROTOCOL in crates/vike-cli/src/cmd/mcp.rs) — and names itself:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": { "tools": {}, "resources": {}, "prompts": {} },
    "serverInfo": { "name": "vike-cli", "version": "..." },
    "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."
  }
}

serverInfo.version is the version of the vike-cli binary you launched, so it tells you which build you are talking to. capabilities advertises tools, resources and prompts; there is no sampling to negotiate.

instructions is free text a client shows the model as guidance about the server — elided above because it is a paragraph, not a field you parse. It exists because a tool description can only describe its tool: "there is no tool for this, and the command that does it is vike-recorder" is a fact about the absence of a tool, so there is nowhere in a roster to write it. So the text says this server is one surface of a larger system and names the operator-side commands for the rest — recording a tape, getting bars into the history store, a local backtest, and which credential keys this box holds — plus two rules: invent no tool name, and never report a command a human would have to type as something you ran. It is assembled by instructions in crates/vike-cli/src/cmd/mcp.rs from named constants, so the string is the same on every run of a given build and profile.

⚠ It is scoped by the same ToolAccess the roster is, and it grants nothing. A read-only session's text drops the preview-gate sentence and an offline one drops list_series too, because prose naming a tool tools/list withholds is advertising a withheld tool (the_instructions_are_scoped_by_the_same_access_the_roster_is). What the agent may call is unchanged either way — this field is advice, and every gate on this page still applies to the call that follows it. The operator-side commands are in every profile's text and must be: they are things a human types, and no scope of this server changes what the operator's terminal can run.

initialize is not a gate. Each method is dispatched on its own, so the one-line version of "is this thing working" is a bare tools/list:

printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  | vike-cli mcp | jq '.result.tools | length'

17, under the default --profile full. tools/list takes no cursor and returns the registry (tools_spec()) filtered by whatever scope the server was launched with, every time — so a count of 10 or 4 means the operator passed --profile read-only or --profile offline, and a count one short of either means a --deny-tool.

The nine methods

MethodAnswer
initializethe block above
notifications/initializednothing at all — it carries no id
ping{}
tools/list{"tools": [...]} — every tool this server's scope admits
tools/calla tool result — see below
resources/list{"resources": [...]} — vike://node/snapshot and vike://backtest/last
resources/read{"contents": [{uri, mimeType, text}]}, an array even for one document
prompts/list{"prompts": [...]} — three flows, each with its arguments
prompts/getthe rendered sheet as one user message

Anything else is a -32601 method not found: <name>.

A resource is a read the agent does not have to choose a tool for, addressable by URI so a client can pin it into a conversation: each one routes into the matching tool's own implementation, so it can never answer differently from the tool beside it, and a URI whose tool the scope withholds is refused rather than served. resources/read on a resource that has nothing to serve — no backtest has run in this session — is a -32002, never an empty document, because {} is what an agent would summarise as "the backtest produced nothing". A prompt executes nothing, so the scope never withholds one; what changes is the text, which names only the write tools this session actually admits.

Four framing rules follow from how the loop reads stdin and how each message is dispatched — serve and Server::handle in crates/vike-cli/src/cmd/mcp.rs — and a script author will hit all of them:

  • A request carrying an id gets exactly one response line; a notification gets none. A message with no id is answered with silence even for initialize.
  • A line that parses as JSON but carries no method is ignored silently. So echoing a response back into the pipe produces nothing, rather than an error you could catch.
  • A line that is not JSON is a -32700 with id: null, and the loop keeps going. A malformed message does not kill the session.
  • Blank lines are skipped, and EOF exits 0. Closing stdin is the clean shutdown.

Each response is written and flushed immediately, so a script that writes a request and then blocks on a read will not deadlock waiting for a buffer.

The payload is pretty-printed, and the line is still one line

The tool's JSON is pretty-printed — but into a JSON string, where the newlines become \n escapes. The transport framing is unaffected: one message, one line. Read by lines, then decode.

The result envelope

A successful tool call carries the payload twice: pretty-printed inside the usual text block, and again as structuredContent for a client that would rather not re-parse a string.

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{ "type": "text", "text": "{\n  \"templates\": [ ... ]\n}" }],
    "structuredContent": { "templates": [] },
    "isError": false
  }
}

A failure is a flagged result with no structuredContent:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [{ "type": "text", "text": "discover_params requires a `script` string argument" }],
    "isError": true
  }
}

An unknown tool name is an isError result, not a -32601

An unrecognised tool name comes back as unknown tool: <name> in an ordinary result carrying isError: true. A script that only inspects the JSON-RPC error member will read a typo'd tool name as a successful call and then parse a missing payload. So will a tool that panics — a panic is deliberately caught and returned as tool panicked: <name> with isError: true, rather than being allowed to take the session down.

Test result.isError on every call, not just the presence of error.

Calling a tool

{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
  "name": "validate_strategy",
  "arguments": { "script": "let x = param(\"len\", 20);" }}}

Note validate_strategy and discover_params take script, not code. The two share one compile path and disagree about a bad script: validate_strategy returns it as an ok: false answer — the agent reads the compiler message and fixes the script — while discover_params returns it as a tool error, rhai compile error: …. A missing script argument is a tool error for both. The four offline tools, and the argument each takes, are on Authoring; the datahub-backed ones are on Backtesting.

An order-write tool: the two calls, on the wire

Every write tool is preview-gated, and the gate has two conditions rather than one: it executes only when a call carries confirm: true and a preview_token this server issued for that exact command. A first call is therefore always a preview, and with no --node configured it opens no connection either — which is what makes the request below safe to paste anywhere.

Call one — the preview.

{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {
  "name": "submit_order",
  "arguments": { "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.001 }}}

The result's structuredContent is the preview. ⚠ The values below are illustrative — the minted id and the token counter differ on every run, and node_verdict is whatever your node says — but the field set is exactly what preview_of emits:

{
  "will_execute": false,
  "tool": "submit_order",
  "wire_command": {
    "Submit": {
      "client_order_id": "a1b2c3d47",
      "venue": "binance", "symbol": "BTCUSDT",
      "side": 1, "qty": 0.001,
      "order_type": "market",
      "price": null, "trigger_price": null, "reduce_only": false
    }
  },
  "guardrail": { "qty": 0.001, "notional": null, "max_qty": null, "max_notional": null, "within_limits": true },
  "node_verdict": null,
  "verified_by_node": false,
  "preview_token": "pv-1",
  "reason": null,
  "note": "PREVIEW ONLY — nothing was sent. ..."
}

wire_command is WireCommand in crates/vike-tradehub-client/src/wire.rs, serialized as-is — externally tagged, so the variant name is the key and WireOrderRequest's fields are the object. notional is null because a market order carries no price to size against, which is the default order type and the reason the client-side guardrail is not something to lean on.

node_verdict is null here because this example has no --node and no control key, so there was nobody to ask; with a node configured it carries the node's own dry-run and verified_by_node becomes true. The note says the same thing in prose, and on this unverified path it says explicitly that the guardrail is a client-side estimate and not an approval.

Call two — the execution. The same arguments, plus both fields:

{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {
  "name": "submit_order",
  "arguments": { "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.001,
                 "confirm": true, "preview_token": "pv-1" }}}

Read preview_token out of call one's response and put it in call two — that is the whole protocol. What reaches the venue is the command the preview resolved, id included, so there is nothing to copy back by hand.

A script that only checks isError will read a failed confirm as a send

Every way of failing this gate returns a preview, not an error: no preview_token, or a confirm that is the string "true" rather than the JSON boolean (or 1, or "yes"), or both. isError is false, structuredContent is a fresh preview carrying a new token, and nothing went out. Branch on will_execute, never on isError alone.

The tokens that do raise a tool error are the ones you cannot use anyway: unknown or already used (a token fires at most once), expired (the window is 60 seconds), or bound to a different command (the binding compares the whole wire command). Live node has the four rules and the reason the token is deliberately not a secret.

The four outcomes of a confirmed write

The server waits for the acknowledgement of the exact command it sent, so the answer always describes this command. It waits up to 2 seconds (ACK_WAIT), and there are exactly four shapes:

What happenedShape
The node accepted ita normal result: sent: true, outcome: "accepted", the authoritative client_order_id
The node refused itisError: true, text node rejected the command: <reason>
The connection dropped firstisError: true, text saying the outcome is UNKNOWN and it may have executed
No answer within 2sa normal result: sent: true, outcome: "unknown"

⚠ The last two both mean "it may have executed", and only one of them is flagged. A timeout comes back as a success-shaped result whose outcome is the string "unknown"; a dropped connection comes back as isError. A script that branches on isError alone will treat a timed-out submit as a completed one. In both cases the obligation is the same and the note says so: call node_snapshot before retrying. The full contract — what the node's ControlLimits (crates/vike-tradehub/src/server.rs) and RiskGate (crates/vike-exec/src/risk.rs) actually enforce, and why the client-side guardrail is advisory — is on Live node.

Keeping one process alive

The node connections are opened lazily and kept for the life of the process: a control command is fire-and-forget over a background worker, and the observe connection has to stay subscribed to keep receiving snapshot pushes (the Server struct's own doc says why a per-call connect would be wrong). So a printf | vike-cli mcp one-shot reconnects every time, which is fine for a health check and wasteful for a session that will issue several node calls.

For a real scripted session, hold the pipe open:

mkfifo /tmp/vike-mcp-in
vike-cli mcp --node 127.0.0.1:7879 < /tmp/vike-mcp-in &
exec 3> /tmp/vike-mcp-in

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' >&3
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"node_snapshot","arguments":{}}}' >&3

exec 3>&-          # EOF — the server exits 0

Credentials, and their absence

There is no credential in any of the offline calls above: they need none, and nothing here is metered. The node tools need VIKE_TRADEHUB_OBSERVE_KEY (read) or VIKE_TRADEHUB_CONTROL_KEY (write), read from the process environment and then from <project>/settings/secrets.env, and say so by name and by location when neither has one:

VIKE_TRADEHUB_CONTROL_KEY is set neither in the process environment nor in the credential
store — run `vike-cli secrets path` to see which store this project resolves to, and
`vike-cli secrets list` to see whether the key is in it

A missing --node is a different message, and worth telling apart from the one above:

no vike-tradehub node configured — pass `--node <host:port>` to read node state

Both arrive before any socket is opened — Server::ensure_observe and Server::ensure_control check the address and the key before they connect — so neither costs a connection attempt.

When the command itself is the problem

Two exit codes are worth knowing, because set -e sees them:

  • vike-cli mcp --help prints the usage to stdout and exits 0. Help is normal output, not a diagnostic — exit_for_parse_error in crates/vike-cli/src/cmd/args.rs is where that call is made, for every non-interactive subcommand. (It exited 1 once, and that broke every wrapper checking a status.)
  • An unrecognised flag prints vike-cli mcp: unknown argument: <flag> plus the usage to stderr and exits 1.

Everything vike-cli has to say goes to stderr, deliberately: under mcp, stdout is a protocol.

In practice, you ask in English

Once a client is connected you write none of the above. The mapping is what the tool descriptions are for:

You askThe client calls
"Show me the strategy templates"list_templates
"Does this strategy compile?"validate_strategy
"What knobs does it have?"discover_params
"Which indicators can it call?"list_indicators
"Backtest this on BTC through last quarter"run_backtest
"Sweep the fast/slow lengths"run_sweep
"Validate it walk-forward"run_walk_forward
"What data do we have stored?"list_series
"What is the node holding right now?"node_snapshot
"Flatten the BTC position"flatten, previewed first

On this page