MCP server¶
Developer orientation for bxp-mcp, the Model Context Protocol server that exposes bxp's stateless surface (plus one full-run tool) as agent-callable tools over JSON-RPC 2.0 on stdio.
For the deepest reference — exact JSON shapes, every design rationale — read
bxp-mcp/CLAUDE.md. This page is the fast map.
Two MCP servers — don't confuse them. BXP ships two unrelated MCP servers. This page is
bxp-mcp: a standalone Zig binary, stateless tools over stdio, wrapping thebxp-core/inspectcore — an agent uses it to author and verify a config offline (no GUI). The other is gui-mcp (GuiMcpServer), embedded inside the running Flutter app: stateful tools over localhost HTTP, wrapping the liveTraceStore— an agent uses it to drive the live GUI. Different binary, transport, state model, and lifecycle (gui-mcp exists only while the GUI is running); they share only the MCP protocol itself. gui-mcp lives inbxp-gui— seegui/index.mdandbxp-gui/CLAUDE.md("Agent control").
What it is, and why¶
An AI agent (Claude Code, or any MCP host) spawns bxp-mcp as a child process
and pipes JSON-RPC requests to it. The server lets the agent author and verify
a bxp-cli config without driving the GUI: validate config text, evaluate and
trace expressions, read the language docs, and run a real conversion end-to-end
against sample CSV.
It is one of several thin adapters over a single shared stateless core,
bxp-core/src/inspect.zig:
flowchart LR
subgraph adapters [Thin adapters - transport only]
bridge[bxp-gui-bridge<br/>FFI in-process]
mcp[bxp-mcp<br/>JSON-RPC on stdio]
api[bxp-api<br/>HTTP - planned]
end
core[("inspect.zig<br/>stateless core")]
cli[[bxp-cli<br/>full pipeline]]
bridge --> core
mcp --> core
api -.-> core
mcp -- "bxp_simulate only<br/>(subprocess spawn)" --> cli
"One core, thin adapters": none of the adapters owns the stateless logic — it
lives in inspect. The transport follows from who calls and from where:
stdio = a local agent that spawns the server (private pipe, 1:1, zero config); a
port would be bxp-api's job (remote/shared/web). The boundary rule is the core
must not know who is calling it.
Two execution models¶
| Model | Tools | Cost | How |
|---|---|---|---|
| In-process | every tool except bxp_simulate |
microseconds | a tool call is a direct inspect function call — no spawn, no filesystem |
| Spawn | bxp_simulate |
a real run | spawns the co-located bxp-cli (sim.zig); a full conversion needs processBroker, the worker pool, and real file I/O |
The "no spawn" rule is about µs-latency stateless calls. A full run is inherently
heavyweight, so the spawn cost is noise. bxp-mcp locates bxp-cli next to
its own executable — it ships in the same console/desktop bundle as
bxp-cli, so the agent always invokes it where bxp-cli sits
alongside.
Tool catalog¶
All stateless tools share the bxp-core/inspect core with the GUI bridge (same
inspect core). The tools take config / expression text (not a file path) —
the agent passes the config it is authoring.
All nine, with the inspect entry point and the matching bridge call —
included from the generated catalog so a tenth tool cannot miss this page.
The per-tool descriptions and JSON schemas are in
the MCP tools reference.
| Tool | inspect call | bridge op |
|---|---|---|
bxp_validate |
annotateRaw(config, "<config>", 0) |
bridge_inspect {config} |
bxp_validate_expr |
validateExprJson(expr) |
bridge_eval_expr |
bxp_eval |
evalExpr(expr, headers?, fields?) |
— |
bxp_eval_batch |
evalBatch(request) |
bridge_inspect {eval_batch} |
bxp_eval_trace |
evalTrace(expr, ..., out) |
bridge_eval_expr_trace |
bxp_docs |
docsJson() |
bridge_inspect {docs} |
bxp_list_templates |
listTemplates(config) |
bridge_inspect {list_templates} |
bxp_fetch_template |
fetchTemplate(config, id) |
bridge_inspect {fetch_template} |
bxp_simulate |
— | — |
Row context: one shape across all three eval tools
bxp_eval, bxp_eval_trace and bxp_eval_batch all take headers /
fields as native JSON arrays of strings. The first two additionally
accept an array encoded into a string ("[\"Price\"]") — the shape they
declared until 2026-08-19 — so a caller written against the older schema
keeps working. Any other shape is refused by name. See
trace-protocol/inspect.md.
Agent workflow hint (also in the server's initialize instructions): call
bxp_docs first to learn the language, bxp_eval_trace to debug an expression,
bxp_simulate to verify a finished config for real.
Wire protocol¶
Newline-delimited JSON-RPC 2.0 over stdin/stdout — one JSON object per line. stderr is free for logs.
- request (has
id) → exactly one response line with the sameid. - notification (no
id, e.g.notifications/initialized) → no response. A request-only method (initialize/tools/list/ping/tools/call) arriving without anidis a stray notification and is silently dropped. - tool result →
{content:[{type:text,text}], isError}. isError:truemarks a tool failure — a missing required argument, an unexpected Zig error, a spawn/IO problem.- A domain
{"ok":false,…}answer (an expression error, a not-found template id, an orchestration report) keepsisError:false: it is a valid result the agent should read, not a transport failure. structuredContent(the parsed object) is added when the tool's declared output is a single JSON object (tools.allowsStructured).bxp_eval_traceis NDJSON by identity and stays text-only even when a function-free expression emits a single sentinel line.- server → client
notifications/progressare emitted mid-call when the request suppliedparams._meta.progressToken(call.reportProgress).
Methods handled: initialize, tools/list, tools/call, ping,
notifications/initialized, plus resources/list, resources/read,
resources/templates/list, prompts/list and prompts/get — the latter five
answered from empty catalogs, since bxp registers no resources or prompts.
Protocol version negotiation¶
The server advertises the latest MCP protocol revision it knows
(mcp.protocol_version) and, on initialize, echoes the client's requested
protocolVersion when it is one of the supported revisions
(mcp.supported_versions), otherwise answers with the latest. The tool surface
is identical across supported revisions.
Source layout¶
bxp-mcp/
src/
main.zig entry: base arena, --help, server identity + INSTRUCTIONS,
tools.register(), mcp.Server.serveStdio()
tools.zig tool catalog (the tools/list source) + handlers -> inspect
calls; register() pairs each row with its handler;
allowsStructured()
sim.zig bxp_simulate: stage config+CSV, spawn bxp-cli, read output,
diff, fold the BXTB sidecar trace into the report
build.zig path-deps ../bxp-core, imports `inspect` + `btrace` + `mcp`
build.zig.zon
The JSON-RPC layer is the zig-libs mcp module — framing, handshake,
tools/list, dispatch-by-name, structuredContent gating and progress. It is
taken through bxp-core's re-export, so bxp-mcp shares that package's single
zig-libs pin; the module itself is pure std with no dependencies of its own.
That module is this package's own former server.zig, extracted upstream on
2026-07-04 and hardened there — which is why the 2026-08-16 migration was
near-free: isSingleJsonObject, the tools/list serializer and every JSON-RPC
error string were already byte-identical. Upstream adds resources + prompts +
sampling/elicitation, a 16 MiB line cap, JSON-RPC §4 id validation, a
JSON-escaped progress message, and 89 unit tests the local transport never had.
Memory model¶
main.zig holds one base arena over the page allocator for startup (argv)
and the registered tool table. Every transient per-message allocation — the
incoming JSON parse, the handler's work, the response serialization temps —
routes through a per-message arena the mcp module creates and frees around
each response. A handler reaches it as call.arena: allocate freely, never
store past the call.
Aliasing lesson, kept because it is why the tool-output buffer is shaped the way it is. The local transport originally reused one retained-capacity output buffer across requests on an arena reset with
retain_capacity— which keeps pointers into arena memory the next request's JSON parse reuses, so a later call could alias its own output over the live request (observed corrupting a secondbxp_simulatereport). The fix was a fresh per-request buffer, and themcpmodule has that shape structurally:ToolCall.outis per-call and the whole arena is destroyed, not reset, between messages.
bxp_simulate in depth¶
sim.zig runs a full conversion the stateless tools cannot — it exercises
pre_pass / LOOKUP / row_rules against real input:
- Validate the template's input shape via
inspect.templateIo; reject xlsx/JSON-input templates (CSV input only). - Stage a stable, reused scratch workspace
<tmp>/bxp-mcp-sim/<uid>/(sanitized id; wiped fresh per run, left in place for inspection): the config verbatim asconfig.json, the CSV asdata/input<suffix>(suffix matched tofile_pattern_in). - Run the co-located
bxp-cliwith the existing flags only —--config/--template/--data <scratch>(so the config's owndata_diris untouched) plus--trace-file <ws>/trace.bxtb(a sidecar BXTB stream, independent of stdout, so the human summary stays clean). - Read back every produced output file, parse the BXTB sidecar with
btrace.Reader, and build the report.
Report (declares an outputSchema):
{
"ok": true, // the run happened; consult exit_code/status
"template": "…",
"exit_code": 0, // bxp-cli: 0 ok, 2 warnings, 1 error
"status": "ok",
"input": { "records": 12, "csv": "…" },
"output_records": 12,
"outputs": [ { "file": "…", "records": 12, "csv": "…" } ],
"summary": "…", // bxp-cli stdout
"diagnostics": "…", // bxp-cli stderr
"trace": { … }, // BXTB sidecar folded in (below)
"workspace": "/tmp/bxp-mcp-sim/…"
}
recordsare data rows — the header is excluded — soinput.records/output_recordsline up withtrace.source_rows/written_rows.- An output file that can't be read back (e.g. exceeds the 16 MB cap) appears as
{file, error}instead of{file, records, csv}— never silently dropped. ok:falseis reserved for orchestration failures that prevented a run (template not found, unsupported input, spawn/IO problem) and carries{ok:false, error, detail}.
The trace object folds the BXTB sidecar into JSON: aggregate
source_rows/written_rows/errors/warnings, then per-category exact counts
plus a capped (MAX_TRACE_SAMPLE = 200) sample for filtered (reason:
rule_skip / no_rule_match), row_errors, and output_rows — each row
carrying the 1-based input line resolved from its source_locator byte
offset. For the underlying BXTB frame format see
trace-protocol.
Build, run, test¶
cd bxp-mcp
zig build
zig build test # unit tests (sim.zig pure helpers)
./zig-out/bin/bxp-mcp --help
# Drive it by hand — one JSON-RPC object per line on stdin:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"bxp_eval","arguments":{"expr":"1 + 2"}}}' \
| ./zig-out/bin/bxp-mcp
The full smoke gate is scripts/test-02-mcp.sh: build + unit tests + a JSON-RPC
round-trip that drives one tool from each family and a complete bxp_simulate
run (verifying the co-located bxp-cli spawn and byte-identical output against a
dataset's .expected).
The binary takes no operational flags — it speaks MCP on stdin/stdout and
nothing else. --help prints its tool list and --version prints the build,
which is the quickest way to confirm a client is pointed at the right binary.
Register with an MCP client (e.g. Claude Code, ~/.claude.json):