Shinro MCP Servers¶
Three Model Context Protocol (MCP) servers expose the shinro control library, split by
phase of work. Both run over stdio — compatible with any MCP client (Claude Desktop,
Cline, custom hosts).
| Server | Console script | Tools | For |
|---|---|---|---|
| component | shinro-mcp |
34 | Designing and running controllers, estimators, trajectories, LTI analysis |
| codegen | shinro-mcp-codegen |
16 | Tracing, lowering to Zig, verification gates, building the kernel |
| notebook | shinro-mcp-notebook |
4 | Recording experimental runs to an append-only JSONL file |
They are separate servers because an MCP client puts every tool schema of a
connected server into the model's context on each turn. A session that is only tuning
a controller should not carry 14 tracing/lowering/verification schemas (~48% of the
combined tool payload), and a compile session should not carry the component tools.
Tool-selection accuracy degrades as the visible tool set grows (see arXiv
2505.10570, 2505.03275, 2606.15508), so the split is a per-query-relevance
measure, not just a token one.
Scope of the codegen server¶
The codegen server ends at a verified, stamped lib<name>.so plus its
deployment record. It deliberately does not:
- deploy the kernel to a target or board,
- drive it closed-loop against a plant,
- measure it on target (timing, jitter, memory, protocol latency).
Those belong to the consuming project — for shinro's own harness, shinro-bench.
Keeping the boundary here is what lets the server stay free of any robot, transport,
or hardware knowledge: it needs only your configs, the ONNX weights if any, and
zig on PATH. If you want closed-loop behaviour, drive the built .so yourself
(see the u_prev note under the external-repository gotchas) or use the simulation
runner in your own code.
Quick Start¶
Both ship as console scripts (installed with the package; entry points in
pyproject.toml):
shinro-mcp # component server
shinro-mcp-codegen # codegen server
shinro-mcp-notebook # experiment notebook
# or, if the console script is not on PATH:
python -m shinro.mcp.server
python -m shinro.mcp.codegen_server
Each listens on stdio and registers only its own tools. Connect via an MCP client
that supports the stdio transport — either server alone, or both when a session spans
design and compile.
Use from your own repository¶
Everything below was verified from a clean virtualenv against a package install (not a source checkout) and an unrelated working directory.
1. Install¶
There is no PyPI release yet (pip install shinro fails). Install from a wheel or
the git remote:
pip install /path/to/shinro-0.3.1.dev20-py3-none-any.whl
# or, with repo access:
pip install "shinro[onnx-rl] @ git+ssh://git@github.com/Shinro-xyz/shinro-control.git"
Extras: [onnx-rl] for ONNX policies (adds onnx; compile-time only), [torch],
[mujoco], [media]. zig must be on PATH for build_kernel — tracing and
lowering do not need it.
The wheel ships the Zig runtime (runtime/build.zig, lower.zig, the baked emosqp
solver) and all four console scripts, so a build works from an installed package.
2. Write your own configs¶
shinro ships no config files. The samples/ directory in the repository is
documentation; it is not installed. Your project owns its TOMLs — copy the sample you
need if you have the repo, otherwise write them from docs/components.md:
your-repo/
configs/kf.toml # estimator (type = "KalmanFilter", ...)
configs/lqr.toml # controller (type = "LQR", ...)
scenario.toml # [controller] [estimator] [compile]
# scenario.toml
[controller]
type = "LQR"
config = "configs/lqr.toml"
[estimator]
type = "KalmanFilter"
config = "configs/kf.toml"
[compile]
optimize = "debug" # "debug" | "release" (ReleaseFast)
artifact_name = "libdemo" # kernel installs as lib/libdemo.so
n_x = 3
n_u = 3
Paths inside a TOML resolve relative to the server process's working directory
(there is no package fallback, by design). So set your client's cwd to your repo root.
3. Point your MCP client at the server¶
{
"mcpServers": {
"shinro": { "command": "shinro-mcp", "cwd": "/path/to/your-repo" },
"shinro-codegen": { "command": "shinro-mcp-codegen", "cwd": "/path/to/your-repo" }
}
}
Add only the codegen server if the session is compile-only (that is the split's purpose); add both when a session spans design and compile. Clients that gate project-scoped server definitions need your approval for each new repository.
4. The workflow, and what a healthy run looks like¶
| Tool | Expected |
|---|---|
generate_scenario |
nodes_traced: 47, nodes_lowered: 39, has_solve_qp: false |
build_kernel |
exit_code: 0, artifact_name: "libdemo", artifact_bytes > 0, oracle.status: "passed", a master_hash |
verify_deployment |
ok: true, failures: [] |
The oracle tolerance is 1e-12 for non-QP graphs (1e-3 with a .solve_qp node); a
KF+LQR graph typically lands around 5e-16. Artifacts appear in
build/graph/lib/libdemo.{so,manifest.json,deployment.json}.
5. Gotchas specific to a foreign repository¶
- CWD is the contract.
config,model_path,scenario_pathandout_dirare all resolved against the server's working directory. A wrongcwdis a loudFileNotFoundError, never a silent fallback. - Graph names are process-local. A graph registered by
generate_scenariolives in that server process; a new session must re-generate it (deterministic) or passscenario_pathto the verification tools. - Build artifacts land where you point them.
build_kerneldefaultsprefix = graph_dir; passprefixto separate the lowered graph from the deployable tree, and passartifact_nameif it is notlibbase(auto-detected only when exactly one.sois present). - The lowering tools refuse the packaged runtime dir. They will not write into
shinro/runtime/, because that is the shipped graph. - Third-party components need their module imported before the server starts —
shinro-compile --import your_moduledoes this for the CLI; for the MCP server, import them from a.pth/sitecustomize or a thin wrapper script that imports your module and then callsshinro.mcp.codegen_server.main(). Registering a custom plant needs a frozenConfigdataclass plusphysics_engine,get_state,get_model,dynamicsandstep. - Driving the compiled
.soyourself:state_inputsincludesu_prev. In a composed graphu_previs declared with the recurrent ports but has nostate_*slice in the state buffer. Feed it the previous control; treating it as a slice shortens the packed input buffer byn_uand the kernel silently reads shifted garbage.cg.state_outputs/state_slicescover only the realstate_*buffers. - PID requires
n_x == n_u(element-wise gains). A non-square plant needs LQR/MPC or a reduced model — seedocs/components.md.
Module layout¶
Two entry points over one set of domain modules under src/shinro/mcp/:
| Module | Contents |
|---|---|
_common.py |
The two FastMCP instances (server, codegen_server), the instance stores, and the conversion/analyzer helpers |
_controllers.py |
Controller tools (create, compute, reset, MPC constraints, PID limits, listing) |
_estimators.py |
Estimator tools (create, estimate, reset, listing) |
_trajectories.py |
Trajectory tools (create, generate, position_at, listing) |
_plants.py |
Plant tools (create, attach, list, model) — the model seam for nonlinear components |
_simulation.py |
Design-time simulation: run one scenario (simulate_scenario), rank many (sweep_scenarios) |
_lifecycle.py |
Instance lifecycle (list every stored object, delete one) |
_notebook.py |
Run-record tools (append one record, list summaries, fetch a record) |
notebook_server.py |
Notebook entry point: imports _notebook, provides main() |
_analysis.py |
LTI analysis tools (rank tests, Gramians, summary, balanced truncation) |
_codegen.py |
Tracing and lowering tools (trace, compose, interpret, lower, gen scenario, measure) |
_verification.py |
Verification tools (gate A, zig build, oracle B, stamp, verify) |
server.py |
Component entry point: imports its tool modules, re-exports every tool, provides main() |
codegen_server.py |
Codegen entry point: same for the compile-pipeline tools |
Each tool module decorates its functions with @server.tool() against one of the two
instances in _common.py, so a tool registers on exactly one server by importing the
matching entry module. Adding a tool means adding it to the relevant domain module —
no registry to update. Import from shinro.mcp.server or shinro.mcp.codegen_server
(not the private modules) to keep that contract.
Client Configuration¶
The project ships with .mcp.json at the root — the standard file for distributing MCP config with a project. Most clients (Claude Code, OpenCode, Cline) auto-discover it.
Claude Code / OpenCode / Cline¶
No setup needed — .mcp.json is picked up automatically. Claude Code prompts for approval on first use.
.mcp.json ships only the component server (shinro), because the default working
set is controller/estimator design. Add the codegen server to the same file when a
session will trace, lower, or verify:
{
"mcpServers": {
"shinro": { "command": "shinro-mcp" },
"shinro-codegen": { "command": "shinro-mcp-codegen" }
}
}
Connecting both is fine — they are separate processes with independent state, and each advertises only its own tools. Enable the second one when you need it rather than by default: that is the whole point of the split.
Claude Desktop¶
Add to claude_desktop_config.json (add a second entry from the block above for the
codegen server):
OpenCode (explicit config)¶
Add to opencode.json:
Any stdio MCP client¶
If the shinro-mcp script isn't on PATH (e.g. an active virtualenv), point the
command at the module form instead:
Tool Reference¶
Controllers¶
| Tool | Description |
|---|---|
create_controller |
Create a PID, LQR, MPC_LTI, or MPC_DeltaU controller |
controller_compute |
Compute a control action from a stored controller |
controller_reset |
Reset a controller's internal state |
set_mpc_constraints |
Set input constraints on any controller exposing constraints() — MPC_LTI, MPC_DeltaU, or your own QP controller |
get_mpc_constraints |
Read current constraints from such a controller |
set_pid_output_limits |
Set output clamping limits on a PID controller (every PID variant shares this interface) |
list_controller_types |
List all registered controller types |
list_controllers |
List all created controller instances |
list_instances |
List every stored controller, estimator, trajectory and plant, with kind, type, and attachments |
delete_instance |
Delete a stored instance; refuses to delete a plant that components are attached to |
Controller types: every registered controller — PID, LQR, MPC_LTI,
MPC_DeltaU, MPPI, SMC, onnx_rl, plus any you register with
@register_controller. create_controller / controller_compute dispatch through the
registry and by input role, so a third-party controller needs no tool changes.
The MPC constraint tools are a capability test, not a name allowlist: a controller
gains them by implementing constraints(constraint_matrix, upper_bounds, lower_bounds)
plus the N / m / bk attributes the default-bound helper needs. set_pid_output_limits
stays PID-specific, since output clamping is part of the PID contract every variant
shares.
Example — PID:
{
"name": "create_controller",
"arguments": {
"name": "my_pid",
"type": "PID",
"params": {
"dt": 0.02,
"kp": [2.0, 2.0],
"ki": [0.1, 0.1],
"kd": [0.5, 0.5]
}
}
}
Example — MPC with constraints:
{
"name": "create_controller",
"arguments": {
"name": "my_mpc",
"type": "MPC_LTI",
"params": {
"dt": 0.02,
"horizon": 10,
"state_cost": [1.0, 1.0],
"control_cost": [0.1, 0.1]
}
}
}
Then set constraints:
{
"name": "set_mpc_constraints",
"arguments": {
"name": "my_mpc",
"upper": [0.5, 0.5],
"lower": [-0.5, -0.5]
}
}
Example — Compute:
{
"name": "controller_compute",
"arguments": {
"name": "my_pid",
"state": [1.0, 0.5],
"reference": [0.0, 0.0]
}
}
Returns: {"action": [-2.0, -1.0]}
Estimators¶
| Tool | Description |
|---|---|
create_estimator |
Create a KalmanFilter or LuenbergerObserver |
estimator_estimate |
Run one predict-update cycle |
estimator_reset |
Reset estimator internal state |
list_estimator_types |
List all registered estimator types |
list_estimators |
List all created estimator instances |
Estimator types: KalmanFilter, LuenbergerObserver
Example — Kalman filter:
{
"name": "create_estimator",
"arguments": {
"name": "my_kf",
"type": "KalmanFilter",
"params": {
"dt": 0.02,
"process_noise": [0.01, 0.01],
"measurement_noise": [0.1, 0.1]
}
}
}
Example — Estimate:
{
"name": "estimator_estimate",
"arguments": {
"name": "my_kf",
"measurement": [0.0, 0.0],
"control_input": [0.0, 0.0]
}
}
Returns: {"state_estimate": [0.0, 0.0]}
Trajectories¶
| Tool | Description |
|---|---|
create_trajectory |
Create a cubic, quintic, waypoint, or phase-list trajectory |
trajectory_generate |
Compute polynomial coefficients for cubic/quintic generators |
trajectory_position_at |
Evaluate position, velocity, acceleration at time t |
list_trajectory_types |
List all registered trajectory types |
list_trajectories |
List all created trajectory instances |
Trajectory types: cubic_segments, quintic_segments, waypoints, phase_list
Example — Cubic trajectory:
{
"name": "create_trajectory",
"arguments": {
"name": "my_traj",
"type": "cubic_segments",
"params": {"dt": 0.02}
}
}
{
"name": "trajectory_generate",
"arguments": {
"name": "my_traj",
"start_position": [0.0, 0.0, 0.0],
"end_position": [0.0, 0.24, 0.15],
"duration": 3.0,
"start_vel": [0.0, 0.0, 0.0],
"end_vel": [0.0, 0.0, 0.0]
}
}
Returns: {"position": [...], "velocity": [...], "acceleration": [...]}
Plants¶
A plant supplies what non-linear components cannot carry in their own config: SMC's
f_x/g_x, MPPI's dynamics and cost, and the EKF/UKF process model. Linear
components (LQR, PID, MPC, KF, Luenberger) need none of this.
| Tool | Description |
|---|---|
create_plant |
Create a plant from a TOML config or inline params |
attach_plant |
Attach a plant to a controller or estimator |
plant_model |
Discrete-time (A, B) + dims; pass state for the true nonlinear f(x,u) and (f_x, g_x) |
list_plant_types |
Registered plant types |
list_plants |
Created plants, their dims, and what they are attached to |
controller_compute maps its inputs to the controller's compute() parameters by
role (state / reference / u_prev / model / free host input) using the same
dispatcher as the simulation runner and gate A. So once a plant is attached:
- SMC receives
f_x/g_xderived from the plant — no extra arguments from you. - MPPI rolls out the plant's dynamics;
epsilonis leftNoneso it samples. - EKF/UKF get their process model;
estimate()is unchanged.
A non-linear controller with no plant attached reports a clear error naming the
plant rather than a TypeError about g_x.
Example — SMC on an inverted pendulum:
{"name": "create_plant", "arguments": {"name": "pend", "config_path": "configs/pendulum.toml"}}
{"name": "create_controller", "arguments": {"name": "smc", "type": "SMC", "params": {"dt": 0.02, "c": [1.0, 2.0], "k1": 1.0, "phi": 0.1}}}
{"name": "attach_plant", "arguments": {"component_name": "smc", "plant_name": "pend"}}
{"name": "controller_compute", "arguments": {"name": "smc", "state": [0.1, 0.0], "reference": [0.0, 0.0]}}
attach_plant on a self-contained component (e.g. LQR) is refused, because its model
is already in its config.
Four model hooks, and what each is for. There is no get_dynamics; a plant
exposes these instead:
| Hook | Meaning | Consumed by |
|---|---|---|
get_model() |
discrete (A, B) — linearized at the operating point, Euler-discretized at dt |
LQR, MPC, plant_model |
dynamics(x, u) |
continuous-time vector field f(x, u) — the exact nonlinear model |
EKF/UKF process model, MPPI rollouts |
model_terms(x, n_u) |
control-affine pair (f_x, g_x) at x; f = f_x + g_x·u |
SMC |
control_matrix(x, u) |
g(x, u) = ∂f/∂u |
model_terms |
plant_model(name) returns one stable shape with both views and the exact
evaluation point it used — no mode-switching and no hidden default:
{
"n_x": 2, "n_u": 1, "state": [0.0, 0.0],
"linearization": { "A": [[1.0, 0.01], [0.1962, 1.0]], "B": [[0.0], [0.4]] },
"evaluation": {
"x": [0.1, 0.0], "u": [0.5], "u_source": "caller",
"dynamics": [0.0, 21.9587],
"model_terms": { "f_x": [0.0, 1.9587], "g_x": [[0.0], [40.0]] },
"affine_exact": true,
"affine_residual": 2e-11
}
}
linearizationis the plant's default operating point (get_model()).evaluationis atx(default: the plant's current state) andu(default:operating_control(), reported asu_source:caller/operating_control/zero). A rotor plant's default is therefore hover, not zero —control_matrix(x, 0)is identically zero there, which would report ag_xthat looks broken.affine_exactanswers the question the prose used to answer: it probes whethergdepends onuat thisx.truemeansf = f_x + g_x·uholds for allu(force/torque plants);falsemeans it is the tangent at the operating point (rotor thrust ~ ω²), exact only there —affine_residualis‖f − (f_x + g_x·u)‖∞.
Simulation¶
Runs a scenario closed-loop in numpy and reports how well it tracked — what tuning
needs. Change a gain or weight, re-run, compare tracking.final / tracking.max.
| Tool | Description |
|---|---|
simulate_scenario |
Run one scenario: final state, tracking error, action magnitude, downsampled trajectory |
sweep_scenarios |
Run several trials and rank them by one metric (the compare step of a design loop) |
It wraps the library's own run_scenario, so a number here means the same thing as a
number anywhere else in the project. It is numpy simulation: it neither builds nor
loads a kernel — the compiled path stays on the codegen server, and the oracle is what
proves the two agree.
The scenario must declare a [plant] (or [sim]) — compile-only scenarios, which
declare their dims in [compile], cannot be simulated.
{
"name": "simulate_scenario",
"arguments": {"scenario_path": "scenarios/pendulum.toml", "steps": 800, "seed": 0}
}
{"steps": 800, "dt": 0.01,
"final": {"plant_state": [0.003, -0.0056], "estimated": [...], "reference": [0.0, 0.0]},
"tracking": {"final": 0.00557, "max": 0.20052, "mean": 0.05589},
"max_abs_action": 0.1086, "all_finite": true,
"check": {"ok": null},
"trajectory": {"t": [0.0, 2.66, 5.32, 7.99], "plant_state": [...], ...}}
tracking is the L∞ distance from the reference; check applies the scenario's own
[scenario.tolerance] block when it declares one (otherwise ok is null — the tool
does not invent a threshold). Pass samples: 0 for summary metrics only.
Sweeping — the compare step. A trial is a config file: each candidate is a
scenario path, or an object naming replacement component configs
(controller_config / estimator_config / plant_config). Those paths are applied in
memory — nothing on disk is rewritten — so every trial stays a reviewable diff and the
winner is a file you can point at, not a value only that call knows.
{
"name": "sweep_scenarios",
"arguments": {
"scenario_path": "scenarios/pendulum.toml",
"candidates": [
{"label": "soft", "controller_config": "configs/smc_k1_0.5.toml"},
{"label": "stiff", "controller_config": "configs/smc_k1_8.toml"}
],
"steps": 800, "seed": 0,
"objective": "tracking.final", "direction": "min"
}
}
objective is any dotted numeric path in the per-trial summary (tracking.final,
tracking.max, max_abs_action, duration). A candidate that fails to build lands
in failed instead of aborting the sweep, and max_trials (default 50) caps a runaway
search. best reports the winning files to keep.
System Analysis¶
| Tool | Description |
|---|---|
analyze_controllability |
Kalman rank test for controllability |
analyze_observability |
Kalman rank test for observability |
gramian_continuous |
Infinite-horizon continuous-time Gramians + Hankel SVs |
gramian_discrete |
Infinite-horizon discrete-time Gramians + Hankel SVs |
gramian_finite |
Finite-horizon Gramians via ODE integration |
system_summary |
Human-readable system properties report |
balanced_truncation |
Order-r reduced model via balanced truncation |
Example — Controllability:
Returns: {"controllable": true, "rank": 2, "n": 2, "condition": 4.0}
Example — Balanced truncation:
{
"name": "balanced_truncation",
"arguments": {
"A": [[-1, 0], [0, -2]],
"B": [[1], [1]],
"C": [[1, 1]],
"r": 1
}
}
Returns: {"Ar": [[-1.0]], "Br": [[1.414]], "Cr": [[1.414]], "Dr": [[0]], "reduced_order": 1, "error_bound": 0.0}
Codegen — trace and lower¶
The zig-free half of the compile pipeline (src/shinro/codegen/ +
shinro.scenarios.gen). Graphs are traced and stored by name in their own
registry, so a graph and a controller may share a name.
| Tool | Description |
|---|---|
list_codegen_ops |
Registered graph ops + scenario graph recipes |
check_component |
Construct a config and report its trace contract (no trace) |
trace_component |
Trace one component into a graph; reports discovered state_attrs + shapes |
build_composed_graph |
Trace an estimator + controller into one closed-loop step graph |
graph_summary |
Ports, op histogram, and lowerability of a stored graph |
interpret_graph |
Run a stored graph on numpy inputs (the Python oracle) |
measure_graph |
Static size metrics: host buffers, VM stack buffer, baked blobs |
lower_graph |
Serialize a stored graph to graph_data.zig + manifest (no compiler) |
generate_scenario |
The gen stage: trace + compose + lower an isolated scenario graph |
list_graphs |
List the graphs held in this session (name, trace size, ports) |
delete_graph |
Drop a stored graph — already-built artifacts are untouched |
Two-stage workflow: generate_scenario (or build_composed_graph +
lower_graph) produces the graph; build_kernel compiles it.
Node counts. nodes_traced is the captured graph before lowering folds
constants/canonicalizes — this is what interpret_graph (and gate A) run on.
nodes_lowered is the table the Zig VM actually compiles, and can be smaller.
Stored graphs are process-local — and now evictable. A graph registered by
generate_scenario lives in that server process's registry, and holds a full copy of
the traced graph. A new client session must re-generate it (cheap, deterministic) or
pass scenario_path to the verification tools; a long session should list_graphs
and delete_graph what it no longer needs.
Example — trace a Kalman filter:
{
"name": "trace_component",
"arguments": {
"name": "kf",
"config_path": "samples/estimators/kalman_base.toml",
"input_shapes": {"measurement": [3], "control_input": [3]}
}
}
Returns the ports, the op histogram, and state_attrs / state_shapes — pass the
latter back as state_shapes to re-trace with state_* ports instead of
freezing the recurrence at trace-time values.
Safety: the lowering tools refuse any out_dir inside the packaged
shinro/runtime/ directory, because runtime/graph_data.zig is the shipped
graph that make zig-gen regenerates.
Verification — gates and the zig stage¶
src/shinro/verification/ plus the build stage. Gate A and oracle B are
complementary: gate A proves the graph reproduces the components, oracle B
proves the Zig VM reproduces the graph. Only both make a stamp's "verified"
claim true.
| Tool | Description |
|---|---|
run_gate_a |
interpret(graph) vs the live estimator + controller (no zig) |
build_kernel |
Integrity + gate A + zig build + oracle B + stamp + verify |
run_oracle |
.so vs interpret() on random inputs (oracle B) |
stamp_deployment |
Write the deployment record (master hash over the four slots) |
verify_deployment |
Re-hash artifacts against the record (drift gate) |
build_kernel requires zig on PATH and returns an exit_code / status
(ok, usage_error, oracle_mismatch, build_or_verify_failed,
zig_missing). Omitting scenario_path builds and stamps unverified — the
record then carries oracle.status = "not_run". The artifact stem is resolved
from lib/*.so after the build, so a scenario whose [compile].artifact_name
is not libbase still reports its oracle and master hash.
Known gap — policy-only graphs have no gate A. With [estimator] absent
there is no live component pair to drive, so oracle B is the only equivalence
check. Oracle B compares the .so against interpret() of the same graph, so
it cannot catch a mis-wired state port or a wrong observation encoder — those
would have to be caught against the eager onnx_rl adapter. For policies, also
read the port layout from graph_summary and confirm the recurrence yourself.
The pipeline narrates on stdout, which on a stdio server is the JSON-RPC
channel; these tools capture it and return it as a log field.
Example — build and verify the base kernel:
{
"name": "build_kernel",
"arguments": {
"graph_dir": "build/mcp/base",
"scenario_path": "tests/integration/scenarios/base_tracking.toml"
}
}
Returns exit_code, status, artifact, artifact_bytes, the recorded
oracle, the master_hash, and the captured log.
Notebook¶
The notebook server (shinro-mcp-notebook) records experimental runs so trials
outlive the session that produced them. Records go to an append-only JSONL file you
name — one JSON object per line, nothing rewritten, so a crashed session cannot
corrupt earlier entries.
| Tool | Description |
|---|---|
record_run |
Append one record: path, name, components, metrics, notes, tags |
list_runs |
Compact summaries (id, name, timestamp, tags, metric names), oldest first |
get_run |
One full record, by id first, then by name (most recent) |
compare_runs |
Rank the notebook by one metric — the "which should I keep?" call |
The server fills provenance for you — created_at, shinro_version, python,
platform, cwd, and (inside a git repo) git_sha / git_dirty. That is the part an
agent could forget or get wrong, and it is what makes a record traceable to the code
that produced it.
Record a run:
{
"name": "record_run",
"arguments": {
"path": "runs/experiments.jsonl",
"name": "pid-motor-ref2",
"components": {
"plant": {"type": "DCMotor", "config": "configs/plant.toml"},
"controller": {"type": "PID", "config": "configs/pid.toml"}
},
"metrics": {"final_error": 0.0097, "steps": 600, "artifact_bytes": 11706317},
"tags": ["motor", "baseline"]
}
}
Comparing runs. compare_runs(path, metric="metrics.final_error", direction="min",
tags=[...]) ranks the history by any dotted numeric path (nested ones too, e.g.
metrics.check.steady_state.error). Records that lack the metric are counted in
missing_metric rather than ranked, so a notebook of mixed experiments still compares
like with like; if nothing has the metric it returns the available ones.
Notes: the path is resolved against the server's working directory (parent directories
are created); a missing notebook reads as an empty list rather than an error; and a
malformed line is skipped and counted in skipped_lines so one bad append does not make
the rest unreadable.
Test Coverage Summary¶
The test suites (tests/test_mcp_server.py, tests/test_mcp_plants.py,
tests/test_mcp_lifecycle.py, tests/test_mcp_notebook.py,
tests/test_mcp_simulation.py, tests/test_mcp_codegen.py,
tests/test_mcp_verification.py) cover:
| Category | Tests | What's tested |
|---|---|---|
| Controller creation | 8 | Inline params, config files, duplicate names, unknown types, missing params, nonexistent config, missing type field, incomplete params |
| Controller compute | 10 | PID/LQR/MPC action dimensions, MPC_DeltaU with/without u_prev, unknown names, wrong dimensions, empty state, default reference, integral accumulation, broadcasting |
| Controller reset | 3 | PID clears integral, unknown name, LQR no-op |
| Controller listing | 3 | Types include all, empty store, name-to-type mapping |
| Malicious edge cases | 16 | NaN/Inf in state, very large values/gains, negative/zero gains, empty/long/unicode/special names, 100 controllers, string/None in state, negative/zero horizon MPC, negative dt, zero dt, negative state/control cost LQR, large horizon MPC timeout, large state dimension, MPC constraints |
| Estimator creation | 5 | Inline Kalman/Luenberger, config file, unknown type, no params, nonexistent config |
| Estimator estimate | 5 | Kalman/Luenberger returns estimate, convergence with noiseless measurements, unknown name, wrong dimension |
| Estimator reset | 3 | Kalman clears state, Luenberger clears state, unknown name |
| Estimator listing | 3 | Types include all, empty store, name-to-type mapping |
| Trajectory creation | 6 | Config file, cubic/quintic inline, unknown type, no params, nonexistent config |
| Trajectory generate | 4 | Cubic/quintic success, unknown name, config-based error |
| Trajectory position_at | 6 | Returns pos/vel/acc, start/end match, unknown name, before generate, time clamping |
| Trajectory listing | 3 | Types include all, empty store, name-to-type mapping |
| System analysis | 20 | Controllability (5), observability (4), continuous Gramians (5), discrete Gramians (3), finite Gramians (3), system summary (2), balanced truncation (4) |
| MPC constraints | 8 | Set/get on MPC_LTI/DeltaU, custom matrix, non-MPC error, unknown name, bounds respected in compute |
| PID output limits | 5 | Set success, clamping, anti-windup, non-PID error, unknown name, config-based limits |
Total: 260 tests across the seven suites (261 collected; make test deselects
the large-horizon MPC timeout test)
Codegen (tests/test_mcp_codegen.py, 31) and verification
(tests/test_mcp_verification.py, 21) add:
| Category | Tests | What's tested |
|---|---|---|
| Introspection | 3 | Op/recipe inventory, contract probe, missing config |
| Tracing | 6 | KF state discovery + shapes, op capture, re-trace with state ports, wrong-rank error, string/int shape specs, unknown type |
| Compose + interpret | 9 | Port layout, half-specified limits, zero-error control, flat-value reshaping, missing/wrong-size ports, unknown names, summary |
| Lowering | 6 | Writes graph + manifest, manifest matches disk, provenance, refuses runtime dir + subdirs, leaves shipped graph byte-identical, unknown name |
| Gen stage | 4 | Isolated graph, interpretable output, missing scenario, runtime-dir guard |
| Measure | 2 | Buffer/blob metrics, unknown name |
| Store isolation | 1 | A graph and a controller may share a name |
| Gate A | 4 | Passes on base, reports graph size, rejects policy-only, missing scenario |
| Zig build | 6 | Exit 0, passed oracle, deployment record, captured log, zig-missing, no-graph usage error |
| Oracle B | 4 | Passes on built kernel, stored-graph name, missing binary, missing graph source |
| Stamp + verify | 7 | Roundtrip hash, supplied oracle block, missing binary, verify ok, binary drift, graph drift, missing record |
Edge cases covered¶
- Numerical: NaN, Inf, float max, negative values, zero values
- Dimensional: Wrong state/measurement dimensions, empty vectors, broadcasting
- Naming: Empty string, 10000-char names, unicode, special characters
- Resource: 100 concurrent controllers, 1000-dimensional state, large MPC horizon rejection
- Stateful: Integral accumulation, anti-windup, reset clears state, convergence over time
- Error handling: Unknown types, missing params, nonexistent files, type mismatches, solver failures