MCP server: subprocess → direct cgo migration plan¶
Status: Phase 1 implemented (opt-in); Phases 2 to 4 not started Tracked by: ADR-0931
What it is¶
Set VMAFX_MCP_DIRECT=1 to make the Go MCP server score in process through libvmaf instead of forking the vmaf CLI. Operators who run vmafx-mcp and want lower per-call overhead need this page; everyone else can ignore it. The sections below cover the opt-in, the fall-back rules and the rollback.
The Go MCP server (cmd/vmafx-mcp/) historically delegated every scoring or introspection call to the vmaf CLI binary via exec.Command(...) and parsed its JSON stdout. Phase 1 of ADR-0931 introduced a direct cgo scoring path in pkg/libvmaf/direct.go (ScoreDirect, ValidateModel) and wired the two simplest tool handlers (vmaf_score, describe_model) to take that path when VMAFX_MCP_DIRECT=1 is set in the environment. The subprocess path remains the default.
The migration is intentionally staged:
| Phase | Scope | Default | Status |
|---|---|---|---|
| 1 | vmaf_score + describe_model, CPU + SVM only | subprocess | Implemented |
| 2 | vmaf_score_encoded, probe_backend, run_benchmark; GPU backend support; per-feature pooled scores | subprocess | Planned (not started) |
| 3 | Default-flip VMAFX_MCP_DIRECT=1 after parity sweep; ONNX/DNN cgo bridge | direct | Planned |
| 4 | Remove subprocess path + pkg/libvmaf::FindBinary heuristic | direct | Planned |
How the opt-in works¶
directPathEnabled() in cmd/vmafx-mcp/impl_direct.go reads VMAFX_MCP_DIRECT per call. Only the exact string "1" enables the direct path; any other value (including unset and "true") leaves the subprocess path in effect.
# Default: subprocess path
vmafx-mcp serve --transport stdio
# Phase 1 opt-in: direct cgo
VMAFX_MCP_DIRECT=1 vmafx-mcp serve --transport stdio
On the first call the direct path emits a one-shot marker to stderr so the operator can confirm the choice took effect:
Fall-back behaviour¶
The direct path falls back transparently to the subprocess path in any of these cases:
| Trigger | Reason |
|---|---|
Any extended scoring flag is set (scoreExtras.isZero() is false in impl.go) | The direct path serves plain full-reference scores only (ADR-1117) |
backend arg is not auto or cpu | Phase 1 is CPU only |
Model file extension is .onnx | DNN cgo bridge lands in Phase 3 |
resolveModelArgToPath cannot find the model on disk | vmaf.c has its own version-table resolver; defer to it |
The fall-back is silent (no marker) and the response payload is identical to the always-subprocess path, so the opt-in is safe to leave on. With the flag set, describe_model returns its normal payload and, for .json models, adds a libvmaf_validated field (describeModelDirect() in impl_direct.go).
Response shape¶
The direct path emits the same JSON the subprocess path emits, with one addition: backend_used reads "cpu (direct cgo)" instead of "cpu" so clients can confirm which path executed. The frame_count field is also populated; frames stays empty on the direct path.
{
"pooled_metrics": { "vmaf": { "mean": 76.668 } },
"frames": [],
"backend_requested": "cpu",
"backend_used": "cpu (direct cgo)",
"frame_count": 48
}
Phase 2 will populate frames with per-frame VMAF + per-feature pooled scores so the response is byte-identical to the subprocess JSON.
Error mapping¶
The direct path returns typed errors so MCP clients can branch programmatically:
| libvmaf return | Go sentinel | Wraps |
|---|---|---|
-EINVAL | libvmaf.ErrInvalidArgument | os.ErrInvalid |
-ENOMEM | libvmaf.ErrOutOfMemory | — |
-ENOENT | libvmaf.ErrModelNotFound | os.ErrNotExist |
-EIO | libvmaf.ErrPictureRead | — |
other < 0 | fmt.Errorf("libvmaf: %s returned %d (%s)", call, rc, errno) | — |
The mapping is defined in pkg/libvmaf/errors.go and tested in pkg/libvmaf/errors_test.go.
Direct scoring also treats teardown as fallible. It closes the context before destroying the registered model and makes one immediate close retry. A persistent failure is joined into the returned operation error while the C context and model remain allocated through process exit; freeing the model beneath a teardown-pending context would be a use-after-free.
Reproducer¶
# Build the CPU-only libvmaf and the MCP binary.
meson setup core/build-cpu core -Denable_cuda=false -Denable_sycl=false
ninja -C core/build-cpu
CGO_LDFLAGS="-L$(pwd)/core/build-cpu/src -lvmaf -lm" \
go build -o /tmp/vmafx-mcp ./cmd/vmafx-mcp/
# Run the smoke test against the 576x324 / 48-frame fixture.
CGO_LDFLAGS="-L$(pwd)/core/build-cpu/src -lvmaf -lm" \
LD_LIBRARY_PATH=$(pwd)/core/build-cpu/src \
VMAFX_MCP_DIRECT=1 \
VMAF_MCP_ALLOW=$(pwd)/testdata \
go test -v -run TestHandleVmafScore_RoutesToDirect ./cmd/vmafx-mcp/
Expected output: --- PASS: TestHandleVmafScore_RoutesToDirect, with the libvmaf: VMAFX_MCP_DIRECT=1 marker on stderr and a payload whose backend_used contains "direct cgo" and whose frame_count == 48.
Rollback¶
Unset VMAFX_MCP_DIRECT (or set to anything other than "1") and restart the MCP server. Every tool resumes the subprocess path immediately. No state is persisted across the toggle.