vike-cli mcp
A local stdio MCP server exposing the create, backtest and live-trade tools to an agent — with a mandatory preview in front of every order, a profile that can withhold the write tools outright, an initialize string telling the model what lies outside this surface, and an optional record of what the agent attempted.
vike-cli mcp serves the vike agent surface as MCP tools over stdio, so an agent can run the full
loop: author a Rhai strategy, discover its knobs, see the host-bound indicator set, run a backtest —
and, against a running vike-tradehub node, read live
state and place orders through a mandatory preview gate (crates/vike-cli/src/cmd/mcp.rs).
usage: vike-cli mcp [--addr 127.0.0.1:7878] [--node <host:port>] \
[--profile full|read-only|offline] [--deny-tool NAME]... \
[--trace | --trace-dir <dir>]| flag | meaning |
|---|---|
--addr <host:port> | the data service the run tools ship profiles to; default 127.0.0.1:7878 |
--node <host:port> | a running vike-tradehub node, for the live read and write tools. Omit it and the create-and-backtest tools still work |
--profile <ring> | which tools this server serves at all: full (default, every tool), read-only (no order-write tools) or offline (only the tools that open no socket) |
--deny-tool <name> | withhold one tool by name, repeatable; subtracts from whatever the profile allows |
--trace | append one JSONL record per tool call under <project>/settings/state/agent. Off unless asked for |
--trace-dir <dir> | the same record, written exactly there |
-h, --help | print the usage to stdout and exit 0 |
An unknown --profile value or an unknown --deny-tool name is a usage error naming the valid set,
never a silent fallback — Profile::parse in crates/vike-cli/src/cmd/mcp.rs has no _ => Full
arm, and parse_config validates a deny name against the served roster before it accepts it. Both
refusals are the same rule: a typo in an MCP client's launch config must not serve more than the
operator asked for, and must not leave them believing a tool was withheld when nothing was.
Client setup — the config block an agent needs, and where it goes — is on connect an agent and connect Claude to the Studio. Each tool's schema, a runnable call and its captured response are under the MCP tools.
Transport
MCP's stdio transport is JSON-RPC 2.0 framed as newline-delimited JSON — one message per line, no
embedded newlines. It is hand-rolled with serde_json, the same "no async, no new transport stack,
stdio-JSON" convention the workspace's other sidecars use; a full MCP SDK would drag in a second
async stack the workspace rejects. Requests carrying an id get exactly one response; notifications
get none. initialize echoes the client's requested protocol version when it declares one and
otherwise answers 2024-11-05, declares three capabilities — tools, resources and prompts —
and carries an instructions string beside them (below). ping, tools/list, resources/list
and prompts/list are answered directly;
tools/call, resources/read and prompts/get route into a handler; an unrecognised method
returns -32601, and a malformed line is answered with a -32700 parse error rather than killing
the connection.
stdout is a protocol
Diagnostics, warnings and the settings loader's resolutions all go to stderr. One stray
println! corrupts the JSON-RPC stream. This is also why the dispatcher's warning surfacing is
stderr-only, and why the settings library returns its resolutions as data rather than logging them.
What initialize tells the model
MCP's instructions field is free text the client puts in front of the model as guidance about the
server, and this server sends one (instructions in crates/vike-cli/src/cmd/mcp.rs, assembled
from named constants — so it is fixed text for a given build and profile, not something a session
accumulates).
It exists because of a fact no tool description can carry. A description describes its tool;
"there is no tool for this, and the binary that does it is vike-recorder" is a fact about the
absence of a tool, and there is no tool to hang it on. A model-in-the-loop evaluation on
2026-09-06 measured the consequence four times over: the agent refused correctly, said the
capability was a separate step, and could not name the step — so the operator was told no and
nothing else. So the text says three things unconditionally:
- This server is one surface of a larger system, which is the fact that makes the rest worth its place in every session's context window.
- One line per capability that lives outside it, each naming the command an operator types:
vike-recorderfor a live tape,vike-cli data fetchorvike-cli data seed-demofor bars in the history store,vike-cli backtest --localfor a backtest with no data service running, andvike-cli secrets list/vike-cli secrets pathfor which venue keys this box holds and where they are read from. None is a tool, and the text says so outright. - Two rules about answering: invent no tool name, and never report an operator-side command as something you ran.
Two further sentences ride on the scope, and each is present only because some profile drops it:
one names list_series as how you confirm a tape or a fetch arrived, and one says an order write
without the preview_token this server issued for that exact command returns a preview and sends
nothing. Those two identifiers are the whole of what the text shares with tools/list — it
restates no schema, no argument table and no procedure, because the roster carries the first at
length and pages like this one carry the rest.
It is advice, not capability, and it is scoped like the roster
Nothing an agent may call changes because of this text. What changes is what it can say when it cannot call anything, which is the whole point.
And it is scoped by the same ToolAccess as tools/list: a read-only session's text carries no
preview-gate sentence, and an offline one also drops the sentence naming list_series, because
prose pointing at a withheld tool is advertising a withheld tool
(the_instructions_are_scoped_by_the_same_access_the_roster_is). The operator-side commands survive
every profile and must: a profile scopes this server's tools, not the operator's terminal.
The credential line names secrets list and secrets path and no writer — a deliberate fence
rather than an omission, held by the_mcp_surface_advertises_no_credential_writer, which now reads
this text as well as the tool roster. The same test requires the text to state outright that no
tool here reads, writes or arms a credential: the store's path with no such sentence beside it
reads as an invitation, and an omission is what a model fills in with a guess.
The tools
Offline, read-only — pure script work, no server: validate_strategy (compile is validation),
discover_params, list_templates, list_indicators.
Remote data service, read-only — a backtest mutates nothing: run_backtest, run_sweep,
run_walk_forward, list_strategies, list_series.
Live node, read-only: node_snapshot — orders, positions, per-venue equity, recent events.
Needs --node and VIKE_TRADEHUB_OBSERVE_KEY.
Live order-write, annotated destructiveHint: submit_order, cancel_order, modify,
flatten, market_exit, mass_cancel, set_trading_state. Needs --node and
VIKE_TRADEHUB_CONTROL_KEY.
list_indicators deliberately names no roster in its description: that text ships in every session's
tools/list whether the tool is called or not, and it used to splice the bound-name list in — 13
characters when the host bound three names, kilobytes now that it binds the catalog. Pass category
for one family or name for one indicator — the host-bound BUILT-IN set, which the human
indicators verb prints too; an indicator installed from
user_data/indicators shows up in that verb's listing and in no answer this tool gives.
Serving fewer of them: --profile and --deny-tool
The preview gate below makes a write take two calls. --profile is the other question — whether
this server offers the write tools at all — and it is answered at startup, by the operator, in the
MCP client's launch command. Under read-only the seven order-write tools are absent from
tools/list as well as refused by tools/call; under offline every network-touching tool goes
too, leaving the four authoring tools.
| profile | serves | what it is for |
|---|---|---|
full | all 17 | the default — an absent flag is the surface that shipped before the flag existed |
read-only | 10 | an agent that may read the node, run backtests and author scripts, and cannot place an order |
offline | 4 | script authoring with no socket at all: validate_strategy, discover_params, list_templates, list_indicators |
Neither ring is a roster somebody typed, which is what stops it drifting from the gate it mirrors.
ToolAccess::new in crates/vike-cli/src/cmd/mcp.rs makes one pass over the tool registry and
produces two complements — tools/list renders one and Server::call_tool refuses the other — so
an advertised tool cannot be refused and a withheld one cannot be routed. read-only is the
negation of is_write_tool, the same WRITE_TOOLS roster the preview gate itself routes on, so an
eighth write verb joins the ring the moment it joins that array. offline is the tool's own
openWorldHint annotation read back off the spec it advertises, and a tool that declares nothing is
withheld rather than admitted — the safe direction for a ring whose whole promise is negative.
--deny-tool NAME subtracts one tool from whatever the profile allows, and feeds the same set; it
is repeatable. --profile read-only --deny-tool run_sweep is an agent that may backtest but not
spend the data service's time on a grid.
Three consequences worth knowing before you scope a session:
- The resources are scoped by the same predicate. A resource is a second way in to a tool's
implementation —
vike://node/snapshotroutes intonode_snapshot— so serving one while its tool is withheld would hand back exactly the answer the profile refused, through a URI. Neither resource is a write, soread-onlywithholds neither;offlinewithholds both, because both reach a tool that opens a socket. - The prompts are re-worded, not withheld. A prompt executes nothing, and a sheet that vanishes teaches an agent less than one saying the tools are not served here. So a session with no admitted write opens every prompt with a banner saying so, and the two-call instructions are rendered from the admitted write set rather than from the roster — when none is admitted there is no gate to teach and none is printed.
- The
initializeinstructions lose clauses under the same predicate — not re-worded, dropped: a session with no write tool is sent no preview-gate sentence, and one with nolist_seriesis sent no sentence naming it (see above). Free text that reaches the model is a place a withheld tool can be advertised just as surely as a roster entry is.
A refusal names the profile, lists the tools that are served, and says that only the operator can widen it by restarting the server — an agent told merely "not available" spends its next turns hunting for the switch that turns it on.
The preview gate
A write executes only when the call carries both confirm: true and a preview_token
this server issued for that exact command. A call missing either half previews instead: it returns
the resolved command, a client-side guardrail check, the node's own dry-run verdict when there is a
node to ask, a note, and a token. Server::call_tool is the one gate, and it fails closed in every
direction — but not equally loudly. A missing confirm, a confirm that is not the JSON boolean
(the string "true" is not), or a missing token all come back as an ordinary preview; an unknown,
expired or mismatched token comes back as a tool error saying which. Nothing sends.
The token is minted by PendingPreviews::issue, fires once (PendingPreviews::take removes it
before the command runs), expires after 60 seconds (PREVIEW_WINDOW), is bound to the command
it previewed (same_intent, which ignores only the minted client order id) and lives in process
memory, so it dies with the stdio session. It is not a secret and does not need to be — on a stdio
server the only party who can send a request already holds both ends of the pipe. What it proves is
that a preview happened for this command: a sequence, not an authorization. The full rules, field
by field, are on the live-node tools page.
confirm: true on its own is not an error — it is a preview
A confirming call with no preview_token does not fail. It returns a fresh preview with
will_execute: false and a new token, and sends nothing. An agent that reads isError: false as
"the order went in", without reading will_execute, believes it placed an order that does not
exist. The node's own control limits and the core risk gate are the enforcing backstop
regardless: the client preview is UX, the node gate is truth. See
the control boundary.
Every submit_order carries a client order id. It stays an optional argument, but the node refuses
a remote submit with an empty one, so when the agent omits it this server mints one — the same
generator and wire form the live core uses — before the preview renders. The agent does not have to
carry that id back: what a confirming call executes is the stored command, not the one it
rebuilt, so the id that reaches the venue is the one the preview displayed and the node dry-ran.
client_order_id in the accepted response is always the authoritative one.
Each write tool reports its own command's outcome, by awaiting the ticket the send returned under a two-second deadline. It used to poll a handle-wide error field that latches, so every tool call after the first refusal returned that same refusal — including commands the node had executed. An agent reading that would reasonably retry and place the order twice.
Every write tool also takes an optional reason: why this command is being issued. It rides
beside the command, is echoed in the preview, and lands in the node's audit trail, sanitized
server-side — control characters stripped, capped at 512 characters. It never reaches the order, the
core fold, the journal, or a venue.
The agent transcript: --trace
The node records one audit line per accepted control command. Nothing recorded what the agent
attempted — the tools it called, the arguments it called them with, and above all what was
refused, which never reaches the node's trail at all because it was refused before a byte left
this process. --trace is that record: one appended JSON Lines entry per tools/call, written by
crates/vike-cli/src/cmd/mcp_trace.rs.
Each entry carries the tool, whether it is a write, the profile the server is running under, the
arguments, the verdict, and — for a gated write — the preview token's identity. The verdict is one
of four words, and refused is the one the file exists for: ok (the tool ran and answered),
preview (a write tool returned its mandatory preview and sent nothing), refused (a gate said no
— the profile, or the token binding — and nothing was attempted) and error (the tool was admitted
and failed). preview is kept apart from ok deliberately: "the agent called submit_order" and
"an order left this process" are different facts.
It is a sibling of the change journal rather than a fifth record kind in it — a per-event stream
does not belong in a per-change ledger — so --trace writes monthly files under
<project>/settings/state/agent/, beside .../state/changes/, pruned to the newest 24 at startup.
--trace-dir <dir> names a directory outright instead. A --trace that can resolve no project
refuses to start rather than serving silently with no record: an operator who typed the flag must
not find out after the incident that there was nothing to read.
Off by default, and no secret and no token bytes are written
Absent flags mean nothing is written and no directory is created. When it is on, argument values are
redacted through the workspace's one credential-name authority (vike_config::is_secret_key)
applied in two positions: every object key, so an _API_KEY/_SECRET/_TOKEN-suffixed name never
has its value recorded, and every credential-shaped SHOUTING_CASE identifier appearing inside a string value, so a
reason mentioning BINANCE_LIVE_API_SECRET=… has the whole value replaced. The shouting-case
precondition is what keeps that a rule rather than a nuisance: the shapes match the bare word
TOKEN, so applied to prose they would erase every reason that mentions one. The preview_token
argument is redacted by that first rule; what the record carries instead is a field of its own
holding the token's identity — pv-3, the per-session counter — and whether this call minted it
or presented it. That is a sequence rather than an authorization, which is why there is nothing
there to withhold. The residual is stated rather than implied: a high-entropy string
with no name anywhere near it is not detectable, and is recorded (capped). This module reads no
credential store and no environment — the arguments are its only input.
An oversized record degrades rather than being refused: the arguments are dropped, a marker says so, and the row survives. That is the deliberate inversion of the change journal's rule, and it follows from what each record is for — for a ledger of changes a truncated line is worse than a missing one, while for a record of attempts the missing line is the failure. A trace that cannot be written is reported on stderr, never stdout, for the reason the callout at the top of this page gives.
Node keys
The two HMAC keys are resolved once by the dispatcher: process environment first, then the
credential store the daemon itself reads (<project>/settings/secrets.env). Values are trimmed,
because the server trims — a client that did not would sign with a trailing newline and get an
opaque auth denial rather than a useful message. Absent from both, the trade tools return a clean
error naming both places and the create-and-backtest tools keep working. Which keys exist on this
box is answered by vike-cli secrets; which venue may arm at all is
policy.toml.
One vocabulary, two surfaces
The write verbs resolve through the same construction site, the same client-order-id mint and the
same guardrail as the human trade REPL. Both used to build wire
commands independently and had already drifted — the REPL supported modify and mass-cancel while
the agent surface did not, and each carried its own copy of the guardrail. A new write verb is now
visibly missing from whichever surface forgets to expose it, rather than silently absent from one.
Global flags
The one flag grammar every vike-cli verb shares — both spellings, the rule that a token beginning with -- is never a value, and where help becomes exit 0.
Set up
Scaffold a project, read its settings, hold its secrets, and attach a box to a running node — the four verbs you run before any of the others.