MCP servers¶
VMAFx ships three Model Context Protocol (MCP) servers that expose scoring and tooling to LLM clients such as Claude Desktop and Cursor. Pick the one that fits your deployment from the table below, then follow its install and run section.
Model Context Protocol is a JSON-RPC protocol that lets an LLM call declared tools with typed arguments. All three servers are additive; running any combination at once is fine.
Pick a server¶
Python vmaf-mcp | Go vmafx-mcp | Embedded (in libvmaf) | |
|---|---|---|---|
| Install | pip install -e mcp-server/vmaf-mcp | go build ./cmd/vmafx-mcp/ | -Denable_mcp=true at build time |
| Transports | stdio (default), HTTP | stdio (default), streamable HTTP | stdio, Unix socket, loopback SSE |
| Tools | 19 | 24 | 2 (list_features, compute_vmaf) |
| Configuration | environment variables and --transport / --port flags | environment variables only | meson options and C API |
Needs a vmaf binary | yes (subprocess) | yes (subprocess; optional cgo for 2 tools) | no (in-process) |
| Use it when | you already run Python (deprecated, see below) | you want one static binary (recommended) | the host process needs an in-process control plane |
| Source | mcp-server/vmaf-mcp/ | cmd/vmafx-mcp/ | core/src/mcp/ |
The embedded server is documented in embedded.md. The rest of this page covers the Python and Go servers; the Go server section starts at Go implementation.
The Python server is deprecated
ADR-1229 makes the Go binary the MCP server and deprecates the Python package. The Go binary is installed at /usr/local/bin/vmafx-mcp in the container images; attach with docker exec -i vmaf-dev-mcp vmafx-mcp. The Python package is kept as a reference implementation and as the schema-parity baseline; new tools go into cmd/vmafx-mcp/ first.
Tool counts
The Go server serves 24 tools and the Python server 19. The 15 classic tools and the 4 sidecar tools (vmaf_per_shot, vmaf_roi, vmaf_bench, vmaf_vpl) have identical names and schemas in both servers. The 5 control-plane tools (submit_job, get_job, cancel_job, list_jobs, vmaf_score_remote) exist only in the Go server. The shared list is mcp-server/vmaf-mcp/tool-contract.json (see Tests).
Use an MCP server when you want an LLM to:
- score a
(reference, distorted)YUV pair and reason about the result, - enumerate which VMAF models shipped with the build,
- probe which runtime backends (CPU / CUDA / SYCL / HIP / Metal) the local binary can dispatch to,
- run the Netflix benchmark harness and summarise the output,
- evaluate a tiny-AI ONNX regressor against a parquet feature cache on a deterministic split and report PLCC / SROCC / RMSE,
- rank several candidate tiny-AI models on the same split.
The servers exec the repo's own built vmaf binary under argv, never a shell string, and refuse any file path that is not under an allowlisted root. See security.
Tool catalogue¶
The table lists all 24 tools. "Python" is yes for the 19 tools the Python server also serves; full schemas, examples and error codes are in tools.md.
| Tool | Python | Execution | Purpose |
|---|---|---|---|
vmaf_score | yes | Subprocess (CLI); Go can use direct cgo (VMAFX_MCP_DIRECT=1) | Score one (ref, dis) YUV pair; return the full JSON report |
vmaf_score_encoded | yes | Subprocess (ffmpeg + CLI) | Score a (ref, dis) encoded video pair via ffmpeg decode |
list_models | yes | Filesystem probe | Enumerate .json / .pkl / .onnx under model/ |
list_backends | yes | Subprocess (vmaf probe) | Report which backends the local vmaf binary was built with |
probe_backend | yes | Subprocess (vmaf probe) | Check whether a specific backend is runtime-healthy on this host |
vmaf_version | yes | Subprocess (vmaf -v) | Return the version string reported by the local vmaf binary |
run_benchmark | yes | Subprocess (bench_all.sh) | Run testdata/bench_all.sh on a pair |
run_compare | yes | Subprocess (vmaf-tune compare) | Compare codec adapters at target VMAF scores |
run_ladder | yes | Subprocess (vmaf-tune ladder) | Generate a quality-ladder bitrate report |
run_tune_per_shot | yes | Subprocess (vmaf-tune tune-per-shot) | Per-shot CRF/QP tuning |
eval_model_on_split | yes | Python: native; Go: native (libvmaf DNN API) | Evaluate a tiny-AI ONNX model on a parquet feature cache |
compare_models | yes | Python: native; Go: native (libvmaf DNN API) | Rank several ONNX models on the same split by descending PLCC |
list_extractors | yes | Subprocess (vmaf probe) | Enumerate all feature extractors registered in libvmaf |
describe_model | yes | Subprocess; Go can use direct cgo (VMAFX_MCP_DIRECT=1) | Return metadata for a VMAF model by name or path |
describe_worst_frames | yes | Subprocess (CLI + VLM) | Score a pair, extract the N worst-VMAF frames as PNGs, and describe visible artefacts via a local VLM |
vmaf_per_shot | yes | Subprocess (vmaf-perShot) | Per-shot scoring |
vmaf_roi | yes | Subprocess (vmaf_roi) | Region-of-interest scoring |
vmaf_bench | yes | Subprocess (vmaf_bench) | Micro-benchmark harness |
vmaf_vpl | yes | Subprocess (vmaf_vpl) | Intel VPL scoring tool |
submit_job | Go only | gRPC (controller) | Submit a job to the controller |
get_job | Go only | gRPC (controller) | Fetch one job |
cancel_job | Go only | gRPC (controller) | Cancel a job |
list_jobs | Go only | gRPC (controller) | Snapshot of jobs |
vmaf_score_remote | Go only | gRPC (vmafx-server) | Score through a remote scoring service |
All tools return a single TextContent message whose body is a JSON document. On error the body is {"error": "<message>"} with the same shape so the client can always json.loads() the response.
Execution mechanics¶
| Group | Execution | Tools |
|---|---|---|
| Process-based | the server spawns vmaf, vmaf-tune, ffmpeg or a sidecar binary | most tools |
| Direct cgo (Go only, opt-in) | in-process libvmaf call when VMAFX_MCP_DIRECT=1 | vmaf_score, describe_model |
| gRPC (Go only) | call to the controller or vmafx-server | submit_job, get_job, cancel_job, list_jobs, vmaf_score_remote |
Migrating the remaining process-based tools to direct calls is tracked in ADR-0704 and ADR-0931. The embedded C server (core/src/mcp/dispatcher.c, ADR-0209) runs entirely in-process, without child processes, and serves exactly two tools: list_features and compute_vmaf.
Install (Python server)¶
From a checkout of the repo:
-
Build
vmaffrom the repository root (Meson + Ninja; see building from source). -
Install the MCP server package.
-
Optional: pull in the ML dependencies for
eval_model_on_splitandcompare_models.
The server lands as vmaf-mcp on your PATH. It finds the vmaf CLI in this order: VMAF_BIN, /usr/local/bin/vmaf, <repo>/core/build/tools/vmaf, <repo>/build/tools/vmaf.
Testing¶
The test suite is in mcp-server/vmaf-mcp/tests/. The package pyproject.toml sets pythonpath = ["src"] under [tool.pytest.ini_options], so pytest run from the package directory finds the in-tree vmaf_mcp module without an editable installation:
Run (Python server)¶
No network ports are opened. The server reads JSON-RPC requests from stdin and writes responses to stdout; diagnostic logs go to stderr. For the HTTP mode see http-transport.md.
Claude Desktop configuration¶
Drop this into ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%/Claude/claude_desktop_config.json (Windows):
{
"mcpServers": {
"vmaf-local": {
"command": "vmaf-mcp",
"env": {
"VMAF_BIN": "/home/you/dev/vmaf/build/tools/vmaf",
"VMAF_MCP_ALLOW": "/home/you/yuv-corpus:/home/you/renders"
}
}
}
}
A complete example covering the Docker image variant lives in mcp-server/vmaf-mcp/claude-desktop-config-example.json.
Environment variables¶
The tool-handler variables below are read by both servers unless the last column says otherwise. The variables only the Go server reads (its transport, the control-plane tools, the direct scoring path) are in its environment table.
| Variable | Purpose | Default | Server |
|---|---|---|---|
VMAF_BIN | Absolute path to the vmaf CLI binary | search order above | both |
VMAF_MCP_ALLOW | Colon-separated extra roots under which file paths are accepted | empty (built-in roots only) | both |
VMAF_MCP_ASYNC | AnyIO backend (asyncio / trio) | asyncio | Python |
VMAF_MCP_MAX_CONCURRENT | Cap on concurrent vmaf subprocesses | 8 | Python |
VMAF_MCP_SUBPROCESS_TIMEOUT_S | Wall-clock timeout per subprocess call, in seconds | 600 | Python |
VMAF_TUNE_BIN | Path to the vmaf-tune binary | search order | both |
VMAF_ROOT | Data root holding the fixture YUVs that run_benchmark uses | repo root if it holds the fixtures, else /workspace | both |
VMAF_BENCH_OUTDIR | Output directory of testdata/bench_all.sh (see tools.md) | /tmp/vmaf-bench-<pid> | both |
VMAF_PER_SHOT_BIN, VMAF_ROI_BIN, VMAF_BENCH_BIN, VMAF_VPL_BIN | Override the path of each sidecar binary | next to vmaf, then /usr/local/bin, then the build trees | both |
The HTTP security variables (VMAFX_MCP_HTTP_TOKEN, ..._NO_AUTH, ..._BIND, ..._TLS_CERT, ..._TLS_KEY) are documented in http-transport.md.
Security model¶
The server is meant to run on the user's own machine, driven by a local LLM client. Because the LLM could craft input that tries to read arbitrary host paths, the server enforces a path allowlist.
Built-in roots, always allowed:
| Root | Note |
|---|---|
testdata/ | fork-added fixtures |
python/test/resource/ | includes python/test/resource/yuv/ |
model/ | shipped models |
/workspace/python/test/resource | the vmaf-dev-mcp container mount |
Extra roots can be added with VMAF_MCP_ALLOW=<abs-path>[:<abs-path>...].
Any tool argument that names a file (ref, dis, model, features, each member of models) is resolved with Path.resolve() and rejected unless it lands under one of the allowed roots and refers to an existing regular file. .. segments and symlinks that escape the allowlist are rejected by resolution.
The underlying CLI is exec'd with an argv list, never a shell string, so there is no pathway for shell-metacharacter injection.
Control-plane tools
The Go-only gRPC tools do not use this allowlist: their reference and distorted paths live on the worker node, so only the path shape is checked. See tools.md.
See also ai/security.md for the tiny-AI-specific hardening (ONNX operator allowlist, model size cap).
When not to use the MCP server¶
- Bulk scoring in a pipeline — use the
vmafCLI directly. MCP is request/response; the CLI streams pictures and does not pay JSON-RPC overhead per frame. - Integration into your own code — use the C API or the Python bindings for an in-process surface.
- CI checks — the Docker image is a better fit than stdio-attached MCP.
MCP shines when the caller is an LLM that benefits from a tool-calling interface with declared schemas and a JSON-shaped response.
Go implementation — vmafx-mcp¶
vmafx-mcp is a single static Go binary that serves the 19 tools of the Python server with byte-for-byte schema parity (ADR-0704) plus the 5 control-plane tools. It is the recommended implementation for deployments that cannot install a Python environment.
Build¶
The binary needs no runtime dependencies other than the vmaf CLI binary (resolved via VMAF_BIN or the standard search order). From the repository root, with Go 1.27 or newer:
Run¶
The Go binary runs on the golusoris fx framework (ADR-1119) and is configured entirely through environment variables; it has no CLI flags.
# Default stdio transport, a drop-in replacement for vmaf-mcp
vmafx-mcp
# Streamable-HTTP transport on the default address :3000
VMAFX_MCP_TRANSPORT=http vmafx-mcp
# Streamable-HTTP transport on a custom address
VMAFX_MCP_TRANSPORT=http VMAFX_MCP_HTTP_ADDR=:8080 vmafx-mcp
The HTTP transport serves the MCP streamable-HTTP protocol at the listen address. It does not serve the REST routes of the Python HTTP mode; see http-transport.md for the comparison and the security variables.
Migration (ADR-1119)
The pre-framework binary used vmafx-mcp --transport http --port 3000. Replace --transport <t> with VMAFX_MCP_TRANSPORT=<t> and --port <N> with VMAFX_MCP_HTTP_ADDR=:<N>. The value is a full listen address, not a bare port. The historical default port 3000 is kept as the default address :3000.
Environment (vmafx-mcp)¶
Every variable the Go binary reads, from the platform definition:
| Variable | Key | Type | Default | Chart value | Description |
|---|---|---|---|---|---|
VMAF_BIN | read directly | path | /usr/local/bin/vmaf, then the build trees | Path of the vmaf CLI behind the scoring tools, looked up on every tool call. | |
VMAFX_CONTROLLER_ADDR | read directly | host:port | localhost:9090 | gRPC address of vmafx-controller for the control-plane tools, read on every call. | |
VMAFX_CONTROLLER_TOKEN | read directly | string | (unset) | Secret. Bearer token sent with every controller call of the control-plane tools (plaintext transport). With auth on, get_job and list_jobs need vmafx:reader, submit_job and cancel_job vmafx:writer. | |
VMAFX_MCP_TRANSPORT | mcp.transport | string | stdio | stdio or http (streamable HTTP); anything else stops the server. | |
VMAFX_MCP_HTTP_ADDR | mcp.http.addr | host:port | :3000 | Listen address of the HTTP transport; an address without a host takes VMAFX_MCP_HTTP_BIND. | |
VMAFX_MCP_HTTP_BIND | read directly | host | 127.0.0.1 | Host of a listen address that names none; 0.0.0.0 listens on all interfaces (ADR-0967). | |
VMAFX_MCP_HTTP_TOKEN | read directly | string | (unset: every request is refused unless VMAFX_MCP_HTTP_NO_AUTH=1) | Secret. Bearer token the HTTP transport requires in Authorization: Bearer <token>. | |
VMAFX_MCP_HTTP_NO_AUTH | read directly | 1 | off | 1 turns the HTTP transport's authentication off; the server logs a warning. | |
VMAFX_MCP_DIRECT | read directly | 1 | off | 1 scores through libvmaf by cgo instead of a vmaf subprocess (ADR-0931); read on every tool call. | |
VMAFX_SERVER_ADDR | read directly | host:port | localhost:9090 | gRPC address of vmafx-server for vmaf_score_remote. | |
VMAFX_GRPC_TIMEOUT | read directly | seconds | 30 | Deadline of each control-plane RPC; a value that is not a positive integer keeps the default. | |
VMAF_MCP_ALLOW | read directly | path list | built-in roots | Additional roots, separated by the OS path-list separator, under which file paths are accepted. | |
VMAF_PER_SHOT_BIN | read directly | path | next to vmaf, then /usr/local/bin and the build trees | Binary of the vmaf-perShot tool. | |
VMAF_ROI_BIN | read directly | path | next to vmaf, then /usr/local/bin and the build trees | Binary of the vmaf_roi tool. | |
VMAF_BENCH_BIN | read directly | path | next to vmaf, then /usr/local/bin and the build trees | Binary of the vmaf_bench tool. | |
VMAF_VPL_BIN | read directly | path | next to vmaf, then /usr/local/bin and the build trees | Binary of the vmaf_vpl tool. | |
VMAF_ROOT | read directly | path | the repository root when it holds the fixtures, else /workspace | Data root of the fixture clips run_benchmark uses. | |
VMAF_TUNE_BIN | read directly | path | vmaf-tune on PATH, else the repository's | vmaf-tune binary of the tuning tools. | |
VMAFX_LOG_LEVEL | log.level | string | info | Log level: debug, info, warn or error, any case; an unknown value gives info. | |
VMAFX_LOG_FORMAT | log.format | string | auto | Log handler: auto (tint on a terminal, else JSON), tint or json; logs go to stderr. | |
VMAFX_OTEL_ENABLED | otel.enabled | bool | true | OpenTelemetry master switch; false installs no-op providers even with an endpoint. | |
VMAFX_OTEL_ENDPOINT | otel.endpoint | host:port | (unset) | OTLP/gRPC collector (otel-collector:4317); wins over OTEL_EXPORTER_OTLP_ENDPOINT. Neither set: no export (OpenTelemetry). | |
VMAFX_OTEL_INSECURE | otel.insecure | bool | true | Plaintext gRPC to the collector; false dials with TLS. | |
VMAFX_OTEL_SERVICE_NAME | otel.service.name | string | OTEL_SERVICE_NAME, else the binary name | service.name resource attribute. | |
VMAFX_OTEL_SERVICE_VERSION | otel.service.version | string | the build version | service.version resource attribute. | |
VMAFX_OTEL_SERVICE_NAMESPACE | otel.service.namespace | string | (unset) | service.namespace resource attribute. | |
VMAFX_OTEL_SAMPLE_RATIO | otel.sample.ratio | number | 1.0 | Parent-based trace sample ratio in [0, 1]; OTEL_TRACES_SAMPLER and its argument are not read. | |
VMAFX_OTEL_EXPORT_TRACES | otel.export.traces | bool | true | Export traces. | |
VMAFX_OTEL_EXPORT_METRICS | otel.export.metrics | bool | true | Export metrics. | |
VMAFX_OTEL_EXPORT_LOGS | otel.export.logs | bool | true | Export the logs signal; application logs are not bridged to it today. | |
OTEL_SERVICE_NAME | read directly | string | (unset) | service.name when VMAFX_OTEL_SERVICE_NAME is unset (OTel standard). | |
OTEL_SDK_DISABLED | read directly | string | (unset) | true (exactly) installs no-op providers (OTel standard). | |
OTEL_EXPORTER_OTLP_ENDPOINT | read directly | URL | (unset) | Collector as a URL (http://host:4317) when VMAFX_OTEL_ENDPOINT is unset; set, export is on (OTel standard). | |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | read directly | URL | (unset) | Per-signal collector URL for traces; set, export is on (OTel standard). | |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT | read directly | URL | (unset) | Per-signal collector URL for metrics; set, export is on (OTel standard). | |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | read directly | URL | (unset) | Per-signal collector URL for logs; set, export is on (OTel standard). | |
POD_NAME | read directly | string | (unset) | Pod name (Kubernetes downward API), added to log lines and OTel resources as k8s.pod.name. | |
POD_NAMESPACE | read directly | string | (unset) | Pod namespace, added as k8s.namespace.name. | |
POD_IP | read directly | string | (unset) | Pod IP, added as k8s.pod.ip. | |
NODE_NAME | read directly | string | (unset) | Kubernetes node name, added as k8s.node.name. | |
SERVICE_ACCOUNT | read directly | string | (unset) | Service account name, added as k8s.serviceaccount.name. |
Claude Desktop configuration (Go binary)¶
{
"mcpServers": {
"vmafx-local": {
"command": "/path/to/vmafx-mcp",
"env": {
"VMAF_BIN": "/home/you/dev/vmaf/build/tools/vmaf",
"VMAF_MCP_ALLOW": "/home/you/yuv-corpus"
}
}
}
}
Differences from the Python server¶
| Feature | Python (vmaf-mcp) | Go (vmafx-mcp) |
|---|---|---|
| Tool count | 19 | 24 (19 shared + 5 control-plane) |
| Shared tool names / schemas | Reference | Byte-for-byte parity |
| Transport | stdio (default), HTTP (PR #1583); --transport / --port flags | stdio (default), streamable HTTP; env vars VMAFX_MCP_TRANSPORT / VMAFX_MCP_HTTP_ADDR, no flags (ADR-1119) |
VLM descriptions (describe_worst_frames) | A local ONNX Runtime GenAI vision model ([vlm] extra, VMAF_MCP_VLM_MODEL) | Returns a placeholder |
eval_model_on_split, compare_models | Native Python (onnxruntime, pandas, scipy) | Native Go, no Python: parquet via parquet-go, statistics in pkg/modeleval, ONNX via libvmaf's vmaf_dnn_session_* API |
| Binary size | about 50 MB Python environment | about 10 MB static binary |
| Startup time | about 300 ms (Python import) | about 10 ms |
The size and startup figures are indicative, not a recorded measurement. The Go evaluation tools need a libvmaf built with -Denable_dnn; otherwise they return a clear "built without DNN support" error, mirroring Python's missing-[eval]-extra behaviour.
Go environment variables¶
Every variable vmafx-mcp reads, the framework's VMAFX_LOG_* and VMAFX_OTEL_* keys included, is in its environment table. All framework logging goes to stderr, so the stdio JSON-RPC stream on stdout stays uncorrupted.
Startup contract: all tools or none¶
vmafx-mcp serves the complete 24-tool surface or it does not start. If a tool's input schema fails to serialise at startup, the process writes the failing tool's name to stderr and exits non-zero.
Why the server refuses to start
The alternative would be to start anyway with that one tool's schema replaced by a permissive {"type": "object"}. That server would look identical over the wire to a healthy one, while the affected tool silently accepted every argument map, including ones it cannot run. A listed tool is therefore a tool whose declared arguments were validated as written.
The failure is a defect in the server's own schema literals, not something an operator can configure, so there is no flag to relax it. If vmafx-mcp exits at startup with registering the MCP tool surface: tool "<name>": ..., report the tool name; nothing in the environment can cause or fix it.
Tests¶
TestToolListMatchesPython and TestToolSchemasMatchPython compare the served tools with the Python server's tool list, read from mcp-server/vmaf-mcp/tool-contract.json: every Python tool must be served with the same property names, JSON types and required arguments, and every other tool must be one of the five declared Go-only tools. They need neither Python nor the test YUVs (the package still links libvmaf, like every go test here). TestVmafScoreTool and TestGoVsPythonOutputParity require the Netflix golden YUVs and the vmaf binary; they skip automatically when these are absent.
The contract file is written from the Python server's _list_tools(), never by hand. After adding or changing a tool in server.py, regenerate it and commit it with the change:
mcp-server/vmaf-mcp/tests/test_tool_contract.py fails while the file is stale (python3 -m vmaf_mcp.tool_contract --check gives the same answer), and a change to the file runs the Go checks in CI, so the Go tests then name the tool the Go server still lacks.
Related¶
- Tool reference — request/response schemas and error codes for every tool.
- Backend discovery and default allowlist — how
list_backendsprobes compiled-in GPU runtimes and which paths the server accepts withoutVMAF_MCP_ALLOW(ADR-0511). - Embedded server — the in-process server inside libvmaf.
- ADR-0100 — the per-surface doc bar this page satisfies (MCP tool: what / schema / allowed paths / example / error codes).
- ADR-0704 — decision record for the Go port.
- mcp-server/vmaf-mcp/README.md — short-form README kept alongside the Python code.