Skip to content

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:

libvmaf: VMAFX_MCP_DIRECT=1 — using in-process cgo scoring path (ADR-0931)

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.