Repository navigation
REST/CLI/MCP alignment: coverage gaps and cross-surface inconsistencies #884
Description
Scope
REST (visionset.server) is the most complete surface. This reviews how far CLI
(visionset.cli) and MCP (visionset.mcp) have drifted from it, and whether the
kernel-purity / sibling-independence boundaries still hold. Findings below are
ranked by severity; each has a file:line pointer and a repro.
Architecture boundary: holding. uv run lint-imports passes all 4 contracts
(kernel purity, sibling independence, jobs below surfaces, inference below
surfaces) across 243 files / 1600 dependencies, uv run pytest tests/architecture
passes (59 tests), and no delivery package imports another — CLI starts the server
by import string (cli/server.py:50) and MCP as a subprocess module
(cli/mcp.py:51,107), never by importing its internals. openapi.json and
docs/content/mcp-tools.md are both current against the live routes/tools.
Findings
-
Dataset change-log
actoris inconsistent per surface. REST's
promote(batch_id)(server/routes/batches.py:727) and its dataset-asset
removal (routes/datasets.py:162) writeactor: null; CLI writes"cli"
(cli/batches.py:105,417,443); MCP writes"mcp"
(mcp/batches.py:80,661,779). REST has an authenticated identity available
and still writes null. Repro: promote one batch through each surface, then
GET /datasets/{id}/changes— three differentactorshapes for the same
kind of event. Direction: pick the actor value in one place (kernel default,
or REST supplying the token identity) so the log is comparable across
surfaces. -
An MCP tool description points at a tool that doesn't exist.
create_inference_connection'sprovider_idfield says "aslist_providers
names them" (mcp/inference.py:81), butlist_providersis only a REST
handler's Python function name (server/routes/providers.py:30) — neither
CLI nor MCP can list installed providers. Direction: add a providers listing
to CLI/MCP, or reword the description. -
No way for MCP to list a project's assets — a removed asset becomes
unreachable.create_batch/add_batch_assetstake asset ids, but the only
discovery paths are through a batch or job. Repro:remove_batch_assetson a
draft batch, then no MCP tool lists that asset again (GET /projects/{id}/assetsstill shows it on REST). Re-ingesting the same source
path is the only recovery, and only while that path still exists. Direction:
add a project-level asset-listing tool, or document the gap in
docs/content/mcp.md's exclusion list. -
Refusals carry materially less information on CLI/MCP than on REST. The
same schema-orphan refusal: REST returns409with
code: SCHEMA_CHANGE_WOULD_ORPHANand ablockerslist (class, annotation
count, asset count); MCP returns{message, retry_with: null, hint: null, index: null}with none of that; CLI--jsonexits 1 with empty stdout
and the message only on stderr. Root cause: the code table and detail
builder live in the server layer (server/errors.py:217,614), which
sibling surfaces cannot import, so each surface keeps its own partial copy
(mcp/_errors.py:64,79,cli/_errors.py:62). Direction: move the error-code
/ detail mapping into the kernel so CLI/MCP can publish the samecodeand
detailREST does. (CLI's single exit code and MCP's four-key envelope are
deliberate — only the missingcode/detailis the gap.) -
The orphan-remedy path is REST-only. The refusal's suggested fix —
"delete or relabel those annotations first" — requires knowing which
assets block it, which isPOST /projects/{id}/schema/blocking-assets
(server/routes/schemas.py:167). Not exposed on CLI or MCP, and not listed
as a deliberate exclusion.preview_schema_changeonly gives per-class
counts, not asset ids. Direction: expose blocking-assets on MCP/CLI, or add
ids to the preview. -
allowed_actionsadvertises actions a surface can't perform. MCP's
get_jobon a pending job returnsallowed_actions: ["start", "pre_label"],
but there is nostart_jobMCP tool (jobs auto-start on first write
instead) — calling it returns "Unknown tool". Similarly, CLIbatch list --jsonon a draft batch returnsedit_membershipamong
allowed_actions, but there is no CLI command for batch membership edits.
tests/architecture/test_capability_reachability.py:28-37checks this only
for REST/MCP and only forBatchAction, with the job-start case already
named as an exemption. Direction: extend the reachability gate to CLI, or
document per-surface which declared actions have no matching command. -
Race in MCP's auto-start: a legal write gets refused.
mcp/_autostart.py:57-61reads the job's state and callsstartin two
separate units of work. Repro: start the job through REST between those two
steps, then an MCPadd_annotationscall fails with "job … is
'in_progress' and cannot become 'in_progress'", and the annotation is never
written. Direction: guard the start with the job's still-pending state in
one transaction, or treat "already started" as success for this path. -
MCP can't set
provider_idonupdate_inference_connection. REST's
ConnectionUpdateand CLI'sinference update --provider
(cli/inference.py:178,213) both have it; the MCP tool signature
(mcp/inference.py:121) doesn't. Direction: add the parameter or document
why it's intentionally absent. -
REST-only endpoints not marked as deliberate exclusions anywhere:
PUT /jobs/{id}/assignee(server/routes/jobs.py:233),
GET /projects/{id}/stats(routes/projects.py:62),
GET /assets/{id}/batches(routes/assets.py:155),
dataset asset-annotations (routes/datasets.py:123), preprocessing preview,
and the providers listing (finding 2). CLI additionally lacks batch-get,
dataset stats, schema compare/preview, repin, correction batches, and the
pre-label plan (all present on MCP). Direction: rule on each — implement, or
add to the exclusion lists indocs/content/mcp.md/docs/content/cli.md. -
Shape/name drift between surfaces (cosmetic but worth tracking):
MCP'screate_projectreturns{project, dataset}while REST/CLI return a
flat project (mcp/projects.py:54); the same query parameter is spelled
n/job/connection_idon REST andcount/job_id/connection
on MCP. -
CLI silently drops
--seedwithout--split.
_split_of(cli/releases.py:52) returns before it looks atseedwhen
no split is requested, so--seedis accepted and ignored with no
warning. Direction: reject--seedwhen--splitis absent. -
Duplicated cross-surface logic, and thin spots in the import
contracts._promotedis copied five times (cli/batches.py:118,
mcp/batches.py:84,routes/assets.py:62,routes/batches.py:86,
routes/video_imports.py:79); name-or-id resolution is written twice
(cli/_resolve.py,mcp/_resolve.py); the project pre-label loop exists
in three places (cli/projects.py:103,mcp/batches.py:515,
jobs/prelabel.py). Separately,visionset.wire,visionset.formatsand
visionset.preprocessinghave no import-linter contract forbidding a
framework import, and the jobs/inference contracts don't forbid importing
the externalmcppackage — current imports are clean, but nothing
enforces it going forward. Direction: move the shared reads into the
kernel, and extend the import-linter contracts to cover these packages.
Reverse gaps (CLI/MCP capability REST lacks)
Machine-local operations (init, token create/list/revoke, ingest,
export, backfill-thumbnails) are CLI/MCP-only by design — REST has no
filesystem to point at. MCP's get_project also surfaces
JobService.project_progress, which no REST route exposes. Worth a decision:
either add the matching REST route, or record these explicitly as
surface-specific by design.
Activity
fix(mcp): close the autostart race between a job's pending check and …
JArmandoAnaya commentedon Oct 10, 2026
Every finding in this review now has a merged fix:
The machine-local commands (
init,token,ingest,export) take a path on the caller's machine, so they are deliberately not on REST; this is recorded in the docs.Closing.