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 mcpThe 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
| Method | Answer |
|---|---|
initialize | the block above |
notifications/initialized | nothing at all — it carries no id |
ping | {} |
tools/list | {"tools": [...]} — every tool this server's scope admits |
tools/call | a 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/get | the 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
idgets exactly one response line; a notification gets none. A message with noidis answered with silence even forinitialize. - A line that parses as JSON but carries no
methodis 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
-32700withid: 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 happened | Shape |
|---|---|
| The node accepted it | a normal result: sent: true, outcome: "accepted", the authoritative client_order_id |
| The node refused it | isError: true, text node rejected the command: <reason> |
| The connection dropped first | isError: true, text saying the outcome is UNKNOWN and it may have executed |
| No answer within 2s | a 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 0Credentials, 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 itA 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 stateBoth 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 --helpprints the usage to stdout and exits 0. Help is normal output, not a diagnostic —exit_for_parse_errorincrates/vike-cli/src/cmd/args.rsis 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 ask | The 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 |
Remote node
Reach a vike-tradehub node on another machine through an ssh tunnel — the daemon keeps binding loopback, -L brings the port to you, and the same tunnel serves vike-cli mcp, the trade REPL and the desktop app.
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.