feat(mcp): real tools over the SDK — thirty-three of them, and an agent that can see (#35) - #107
Merged
Conversation
`cli/_json.py` becomes `visionset/wire/`, a surface-agnostic package the CLI and (next) MCP both import. A second hand-written spelling of the same twenty shapes is what "promoted, not copied" exists to prevent. The direction stays one-way and machine-enforced: `visionset.wire` joins the kernel-purity contract's forbidden list beside the three delivery packages. The server keeps its pydantic models, because `openapi.json` is generated from them. Six projections the CLI never needed and MCP does: `schema_change`, `schema_diff`, `batch_asset`, `geometry`, `annotation`, `class_count` and `dataset_stats`. Eleven new pairs in the parity gate, plus `dataset` and `asset_progress`, which had projections but no row. `schema_diff` has no route behind it, so it joins the two encoding-only shapes at the bottom. No behaviour change: 1698 tests, up from 1670 by exactly the new parity cases.
…nt that can see Replaces the thirteen stubs with the MCP tool sweep #35 asks for: fifty parity candidates evaluated one by one, thirty ship, twenty fold or drop, and three are new or merged. `visionset mcp` starts it. `get_asset_image` is the tool the milestone was for. Without it an agent can drive the whole workflow and never see what it is annotating. It returns the cached preview plus four numbers, because a preview is capped at 256 on its long edge while geometry is always in the asset's native pixels: `width`/`height` are the frame to write in, `image_width`/`image_height` are what was sent, and `scale` is the factor between them. An agent that skipped the multiplication would produce annotations that are individually plausible and uniformly wrong, and nothing downstream could detect it. `preview_schema_change` gives `SchemaService.preview` its first caller since #6. Conventions: the workspace is opened per call and named only by the environment; domain models go straight into tool signatures, so their docstrings reach the agent as `$defs` and their validators refuse malformed input; refusals are one envelope carrying the kernel's own sentence plus `retry_with`, which answers the only question a machine-readable code was needed for — `allow_destructive` retries a narrowing schema change and nothing retries an orphaning one. Registration is one table in `main.py`, which is also where `guarded`, `inspect.cleandoc` and the read/write hints are applied, so none of the three can be forgotten by the next tool. No migration: FORMAT_VERSION stays 11. `openapi.json` and the generated client are both byte-identical — MCP touches no route. 1896 tests, up from 1670.
The agent surface written down: the thirty-three tools grouped by cycle stage, how a client is configured, the coordinate-frame rule that makes `get_asset_image` safe to annotate from, the error envelope and why it carries `retry_with` instead of a code, the three gate words, the stated limits, and the ship-vs-fold argument for all twenty candidates that did not make it. `cli.md` gets a real `visionset mcp` section in place of the stub note; `workspaces.md` gains the per-call open and why it differs from the server's; `schemas.md` records that `preview` finally has a caller; `auth.md` confirms the no-token-tools decision held when the surface actually shipped.
This was referenced Jul 29, 2026
JArmandoAnaya
added a commit
that referenced
this pull request
Aug 21, 2026
…nt that can see (#35) (#107) * refactor(wire): promote the JSON projections out of the CLI `cli/_json.py` becomes `visionset/wire/`, a surface-agnostic package the CLI and (next) MCP both import. A second hand-written spelling of the same twenty shapes is what "promoted, not copied" exists to prevent. The direction stays one-way and machine-enforced: `visionset.wire` joins the kernel-purity contract's forbidden list beside the three delivery packages. The server keeps its pydantic models, because `openapi.json` is generated from them. Six projections the CLI never needed and MCP does: `schema_change`, `schema_diff`, `batch_asset`, `geometry`, `annotation`, `class_count` and `dataset_stats`. Eleven new pairs in the parity gate, plus `dataset` and `asset_progress`, which had projections but no row. `schema_diff` has no route behind it, so it joins the two encoding-only shapes at the bottom. No behaviour change: 1698 tests, up from 1670 by exactly the new parity cases. * feat(mcp): real tools over the SDK — thirty-three of them, and an agent that can see Replaces the thirteen stubs with the MCP tool sweep #35 asks for: fifty parity candidates evaluated one by one, thirty ship, twenty fold or drop, and three are new or merged. `visionset mcp` starts it. `get_asset_image` is the tool the milestone was for. Without it an agent can drive the whole workflow and never see what it is annotating. It returns the cached preview plus four numbers, because a preview is capped at 256 on its long edge while geometry is always in the asset's native pixels: `width`/`height` are the frame to write in, `image_width`/`image_height` are what was sent, and `scale` is the factor between them. An agent that skipped the multiplication would produce annotations that are individually plausible and uniformly wrong, and nothing downstream could detect it. `preview_schema_change` gives `SchemaService.preview` its first caller since #6. Conventions: the workspace is opened per call and named only by the environment; domain models go straight into tool signatures, so their docstrings reach the agent as `$defs` and their validators refuse malformed input; refusals are one envelope carrying the kernel's own sentence plus `retry_with`, which answers the only question a machine-readable code was needed for — `allow_destructive` retries a narrowing schema change and nothing retries an orphaning one. Registration is one table in `main.py`, which is also where `guarded`, `inspect.cleandoc` and the read/write hints are applied, so none of the three can be forgotten by the next tool. No migration: FORMAT_VERSION stays 11. `openapi.json` and the generated client are both byte-identical — MCP touches no route. 1896 tests, up from 1670. * docs(mcp): docs/mcp.md, and the sibling docs that now point at it The agent surface written down: the thirty-three tools grouped by cycle stage, how a client is configured, the coordinate-frame rule that makes `get_asset_image` safe to annotate from, the error envelope and why it carries `retry_with` instead of a code, the three gate words, the stated limits, and the ship-vs-fold argument for all twenty candidates that did not make it. `cli.md` gets a real `visionset mcp` section in place of the stub note; `workspaces.md` gains the per-call open and why it differs from the server's; `schemas.md` records that `preview` finally has a caller; `auth.md` confirms the no-token-tools decision held when the surface actually shipped.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #35.
The MCP server was the one surface that had never called the kernel: 140 lines of thirteen stubs
with the pre-#27 names, every one returning
not_implemented, andvisionset mcpprinting"server would start here". It now ships thirty-three tools and an agent can run the entirecycle over stdio — create a project, declare a schema, ingest, look at the pixels, write model
annotations, promote, publish, verify and export.
Ship-vs-fold, tool by tool (acceptance criterion 1)
Fifty candidates were recorded across PRs #99–#102. Thirty ship, twenty fold or drop, and three
are new or merged, for 33. The parity rule means evaluated, not implemented: tool-selection
accuracy degrades with count, so a tool ships only when an agent has a reason to reach for it that
no neighbour covers.
Ship (33)
create_projectlist_projectsget_projectdelete_projectget_schemapreview_schema_changecreate_schema_versioningestlist_sourcesbackfill_thumbnailslist_batchesget_batchapprove_batchstart_batchlist_batch_assetscomplete_batchpromote_batchget_jobstart_jobnext_pending_assetsget_asset_imagelist_asset_annotationsadd_annotationsupdate_annotationsdelete_annotationsset_asset_progresscomplete_jobdataset_statspublish_releaselist_releasesverify_releaselist_formatsexport_releaseFold or drop (20), with the argument
Folded into a parent — the parent already reads it, so a second tool is a round trip for a
field:
get_project_dataset,get_dataset(→get_project; the dataset is 1:1 and its id is thehandle every release tool needs),
list_schema_versions(→get_schema),get_source(→list_sources),list_batch_jobs(→get_batch),get_job_progress(→get_job),get_asset(→
get_asset_image, whose structured half carries every published field anyway),get_release(→
list_releases).Folded into
ingest:register_image_source,register_video_source,start_ingest. Thekernel splits registration because a clip needs a rate and a probe while a folder needs neither;
by the time ingest runs the source already carries the kind, the path and the rate. Dispatch is
path.is_dir()—visionset ingest's shape exactly.Dropped, nothing to poll:
get_ingest_job,list_ingest_jobs,resume_ingest. Ingest issynchronous (below), so the finished job comes back in the answer. An interrupted run is re-run,
not resumed: registration is idempotent on
(kind, path, extraction_fps)and content addressingmakes the re-run free — the same argument that gave the CLI no
--resume.Dropped, no agent caller:
list_dataset_assets(the annotation loop iterates batches, notthe trunk),
list_dataset_changes(an audit record a person reads),remove_dataset_asset(curation — a judgement about what a dataset should contain, not a step in producing one),
get_release_manifest(the whole frozen document is a token bill an agent cannot afford;verify_releaseanswers "is it intact" andexport_releasewrites the contents somewhere usable),get_release_assignment(export_releaseputs the folds on disk in the form anything downstreamconsumes),
rename_project.Never offered: token administration, per docs/auth.md and #25. That decision held when the
surface actually shipped — none of the 33 touches a token, and none needs one.
Three not on the parity list
ingest(one tool standing for three candidates);preview_schema_change, which givesSchemaService.previewits first caller since #6 — plan-before-apply matters most for thesurface that cannot see a change's consequences until it has made one; and
backfill_thumbnails,because it is the remedy
get_asset_image's refusal names, and a refusal naming an unreachableremedy is worse than no refusal.
get_asset_image— the finding worth the entryThe pixels an agent sees are not the frame its coordinates live in. A preview is capped at
DEFAULT_THUMBNAIL_MAX_EDGE(256) on its long edge; annotation geometry is always in theasset's native pixels and is never normalized. An agent that measures a box on a 256-pixel preview
and submits it unscaled produces annotations that are individually plausible and uniformly
wrong — wrong in a way nothing downstream can detect, because every number is in range and every
shape is well formed.
So the structured half publishes both frames and the factor between them:
{ "asset_id": "…", "width": 4032, "height": 3024, "format": "jpeg", "image_width": 256, "image_height": 192, "resolution": "thumbnail", "scale": 15.75 }The preview's dimensions are measured, never derived — the port caps an edge, so the size is
a function of aspect ratio and of whether the asset was smaller to begin with, and a computed
scalewould be a guess. It reads them throughImageProcessor.probe, which keeps a decoder outof a delivery module.
tests/mcp/test_agent_walk.pydoes the multiplication for real.Verified against mcp 1.28.1, not assumed
[Image(...), {...}]silently yields nostructuredContent. The trap that looks right. Thesupported shape is constructing a
CallToolResultyourself.CallToolResult, notAnnotated[CallToolResult, Model]. The annotated form declares anoutput schema and validates
structuredContentagainst it — which would reject the errorenvelope, since a refusal cannot also be a valid image result. Bare passes it through untouched.
dict[str, Any]populatesstructuredContent; one returningCallToolResultdoes not, for a returned dict. So
guardedwraps the envelope in aCallToolResultforexactly the tool that declares one — otherwise
get_asset_imagewould be the only tool in thesurface whose refusals had to be parsed out of a text block.
registration.
test_registration.pycounts the listing againstTOOLSto catch it.inspect.cleandocis mandatory. FastMCP ships__doc__raw into the tool listing,indentation and all.
Annotated[..., Field(description=...)]—there is no docstring-argument parser. A test asserts every parameter of every tool has one.
from __future__ import annotationscannot coexist with a module namedannotations—server: batch/job endpoints — approve, partition, "next N pending assets", annotation submission, progress (the third-party-app contract) #29's trap, hit again.
mcp/main.pydrops the future import and says why.Conventions
Domain models go straight into tool signatures —
list[LabelClass],Geometry,SplitRecipe,AssetProgress. The opposite call from REST, and for a reason: FastAPI copies a docstring intoopenapi.jsonand turns a PEP 695 alias into a named component, soserver/models.pykeeps its ownspellings; FastMCP puts the same docstrings into
$defson the input schema, where they are thebest guidance an agent gets. The domain's validators then refuse malformed input with the field
path, and nothing is restated. The one exception is
mcp/annotations.py's two input models, whichomit
schema_versionbecause the service overwrites it — a required input whose value is discardedis a lie in the schema.
The inherited wart is stated rather than hidden: a discriminated union's tag carries a default in
the domain, so
typeshows as optional while pydantic needs it to pick a variant.test_a_geometry_with_no_type_cannot_pick_a_variantpins it and the tool descriptions say to spellit out.
Two failure shapes, deliberately. A malformed request is
isErrorwith the validator'smessage naming the field (the API's 422); a domain refusal is an ordinary result carrying one
envelope (the API's 404/409). The envelope has four keys, always present:
{ "error": { "message": "…", "retry_with": "allow_destructive", "hint": null, "index": null } }No
codefield, deliberately. The codes live inserver/errors.py, which this package may notimport, and #31 forbids deriving one from a class name. What a code was needed for is one question
— "may I retry this, and with what?" — and
retry_withanswers it directly:DESTRUCTIVE_SCHEMA_CHANGEgets"allow_destructive"andSCHEMA_CHANGE_WOULD_ORPHANgetsnull, which is exactly the distinction #31 warned would put a client in a retry loop. The three
gate words stay three and a test holds them to it.
Registration is one table in
main.py— the CLI's rule, for the CLI's reason (a decorator inprojects.pywould make it importmain.py, which imports it). It is also the single placeguarded,cleandocand the read/writeToolAnnotationsare applied, so none can be forgotten.Hints are hints and
confirmis what enforces, so a test asserts the two agree in both directions.The workspace is opened per tool call and named only by the environment. No tool takes a
workspace parameter — threading one through 33 tools puts a path an agent cannot know into every
call. Per-call rather than held: no module-level state, every tool testable with
monkeypatch.setenvalone, and — since SQLite has one writer — a stdio server holding the file would keep
visionset uiand a second agent out of a workspace nobody is using.
visionset/wire/— promoted, not copiedcli/_json.pybecomesvisionset/wire/, a surface-agnostic package the CLI and MCP both import.A second hand-written spelling of the same twenty shapes is what that rule exists to prevent. The
direction stays one-way and machine-enforced:
visionset.wirejoins the kernel-purity contract'sforbidden list beside the three delivery packages. The server keeps its pydantic models, because
openapi.jsonis generated from them.Seven projections the CLI never needed:
schema_change,schema_diff,batch_asset,geometry,annotation,class_count,dataset_stats. Eleven new pairs in the parity gate, plusdatasetand
asset_progress, which had projections but no row. Still two spellings gated, not three.visionset mcpcli/ui.py's shape with a subprocess where that one has uvicorn: import-linter forbidsvisionset.cliimportingvisionset.mcp, so the target is named as a module and the child inheritsstdin and stdout — those two streams are the transport. The full four-branch precedence is applied
and then stated in
VISIONSET_WORKSPACE, so the child cannot disagree. The pre-flight open isreal, not a check: it runs the migration, so
NotAWorkspaceis one sentence at exit 1 rather than aJSON envelope inside the agent's first tool call. It is the one command that prints nothing on
stdout.
Acceptance criteria
create_connected_server_and_client_session, neverFastMCP.call_tool(which skipsvalidation, returns an undocumented 2-tuple and raises instead of returning
isError)delete_projectwithoutconfirmrefuses and theproject is still there; plus
allow_destructive, the un-retryable orphan case, andallow_lossyagainst an injected lossy exportervisionset mcplists all tools over stdio —test_registration.pyAssetrow —test_asset_tools.py, plus the scale factor and a Pillow decode of the returned bytesLedger
FORMAT_VERSIONstays 11VERSION0.0.1.dev0openapi.jsonERROR_RULESmcp>=1.2was already declared; 1.28.1 installed)anyio>=4.5— transitive today, and relying on that is the mistakepython-multipart's comment already calls outAlso worth knowing
IngestFailure.namefor a directory ingest is the full path, not the basename — it is whateverthe run's own loop was holding. Unlike
Source.pathandAsset.uri, which are deliberatelyunpublished, it already travels on the wire through
IngestFailureOutand is the same string theREST API and the CLI report. Pre-existing and out of scope here; noted because it is an
inconsistency somebody will eventually want to settle.
Left for #36: the published transcript.
tests/mcp/test_agent_walk.pyis the walk it will narrate.