Research digest: MCP sidecar-binary and gRPC control-plane bridge (2026-09-05)¶
Scope¶
The remaining items of epic #1240: bridging the four sidecar CLI binaries into the MCP tool surface, and the Priority-3 gRPC control-plane bridge. This digest records what was read to fix each tool's parameter surface, and the three findings that changed the design.
Source of truth for every bound¶
Nothing here was taken from the sidecars' --help text alone; every bound comes from the C parser, because several help strings understate them.
| Tool | Parser read | Bounds it enforces |
|---|---|---|
vmaf_per_shot | core/tools/vmaf_per_shot.c::per_shot_apply_opt, per_shot_validate, per_shot_settings_defaults | width/height 16..65535; bitdepth ∈ {8,10,12,16}; --target-vmaf 0..100; --crf-min/--crf-max 0..63 with crf_min <= crf_max; --diff-threshold 0..255 (default 12.0); --format csv|json (C default csv) |
vmaf_roi | core/tools/vmaf_roi.c::parse_args_num_opt, parse_args_str_opt, opts_set_defaults, and the VMAF_ROI_* defines | width/height 1..16384; --frame 0..1000000; --ctu-size 8..128; --strength 0..64 (a double); bitdepth restricted to {8,10,12,16} by an explicit second check after the 8..16 range check |
vmaf_bench | core/tools/vmaf_bench.c::parse_bench_opt, parse_resolution_arg, print_bench_help, main | --resolution is one of exactly five strings; --bpc ∈ {8,10,12,16}; --frames is clamped, not rejected (<2 → 2, >48 → 48) |
vmaf_vpl | core/tools/vmaf_vpl.c::print_usage, the argv loop, and the score/summary printfs | --frames 0 = all; --device index; --render-node is a device path handed straight to open(2) |
Finding 1 — three sidecar outputs are not JSON, and one is not text¶
vmaf_per_shot --format json writes a clean JSON document to stdout when --output -, so it parses. The other three do not:
vmaf_roiwith--encoder x265writes a qpfile: two#comment lines then one space-separated row of signed CTU offsets per grid row.vmaf_roiwith--encoder svt-av1writes a raw row-majorint8grid with no header, andemit_sidecaropens the file"wb"for that case. Routing it through stdout would corrupt it, so both servers always write to a temp file and choose the response encoding from the emitter:qpfile(text) orroi_map_base64.vmaf_benchwrites a fixed-width text table;vmaf_vplwritesFrames: N/Time: … s (… FPS)/VMAF: <score> (mean)lines. The VPL summary is regex-parsed intovmaf_score/frames_processedand the raw stdout is returned alongside.
Finding 2 — vmaf_bench --validate exits 1 as a result¶
run_bench_validation_mode returns total_fail > 0 ? 1 : 0 and prints ALL PASSED / FAILURES. Treating that exit code as a tool error (which is what run_benchmark does for bench_all.sh, ADR-0608 E-1) would report a successful GPU-vs-CPU comparison that found deltas as a broken tool. So vmaf_bench splits the two: in validate mode a non-zero exit becomes "validation_failed": true on a successful call; in benchmark mode it is an error, matching every other sidecar.
Finding 3 — Go and Python disagree on how to print a float¶
The parity contract is byte-identical argv. Go writes floats as strconv.FormatFloat(v, 'f', -1, 64): the shortest decimal that round-trips, never exponent notation. Python's repr is also shortest-round-trip but differs in two places that matter here:
| Value | Go 'f', -1 | Python repr |
|---|---|---|
90.0 | 90 | 90.0 |
1e-05 | 0.00001 | 1e-05 |
Both differences reach the argv (--target-vmaf 90 vs --target-vmaf 90.0). server.py::_fmt_float therefore strips a trailing .0 and re-renders an exponent form through decimal.Decimal(...), format(..., "f"). sidecar_parity_test.go carries a dedicated per_shot_integral_float_formatting case for it.
gRPC bridge: what the existing building blocks already provide¶
cmd/vmafx-controller/proto/controller.protohasSubmitJob,GetJob,CancelJobandStreamJobson the Client API;list_jobsmaps ontoStreamJobs, which per ADR-0962 streams the current SQLite snapshot and closes — draining it toio.EOFterminates, so no subscription bookkeeping is needed.backend: "auto"has no wire representation:ScoringParams.backend's documented "empty string means the scheduler may assign any available node", so the bridge normalisesauto → "".pkg/score.Client.Scorealready wraps the unaryVmafxScoring.ScoreRPC, including the OTel client stats handler, sovmaf_score_remoteis a thin call over it rather than a second dial path.- The controller's HTTP surface (
cmd/vmafx-controller/http_server.go) exposes only/healthz,/readyz,/metricsand/v1/score— no job endpoints. That rules out "let the Python server use HTTP instead of gRPC" without designing a second control-plane API, and is one of the inputs to ADR-1184.
Verification¶
Both servers were driven end-to-end against the host CPU build (core/build/tools/) and produced identical results for the same inputs — e.g. vmaf_roi on testdata/ref_576x324_48f.yuv frame 2: a 9×6 grid, 216 qpfile bytes / 54 int8 bytes, same offsets. The five gRPC tools were driven against a locally started vmafx-controller (VMAFX_AUTH_DISABLED=true, VMAFX_GRPC_LISTEN=127.0.0.1:59090): submit → get → list → cancel → get showed the job transitioning PENDING → CANCELLED, an unknown ID surfaced the controller's NotFound, and vmaf_score_remote reached the server's Score RPC and returned its server-side error.