Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ something. `visionset ui` run outside one refuses with one sentence and exit 1;
one, because a command that silently made a workspace out of whatever directory you were standing
in is how data ends up somewhere nobody chose.

Or hand the workspace to an agent — the same cycle, over
[MCP](https://modelcontextprotocol.io), with the tools an agent needs to *look* at what it is
labelling:

```json
{ "mcpServers": { "visionset": {
"command": "visionset", "args": ["mcp"],
"env": { "VISIONSET_WORKSPACE": "/path/to/workspace" } } } }
```

Or drive the whole cycle from the terminal, without a server:

```bash
Expand Down Expand Up @@ -57,9 +67,10 @@ ffmpeg.
```
src/visionset/ Single Python distribution (one wheel, one import namespace)
kernel/ Hexagonal core: domain + ports + default adapters (framework-free)
wire/ The JSON shapes the CLI and MCP publish (gated against the REST models)
server/ FastAPI — exposes the SDK via REST; openapi.json is a committed contract
cli/ Typer CLI (`visionset` console script)
mcp/ MCP server (stdio) — thin mapping of tools to SDK calls
mcp/ MCP server (stdio) — 33 agent tools over the same SDK
formats/ Importer/exporter plugins (entry-point group `visionset.formats`)
_static/ Compiled UI bundle lands here at build time (ships in the wheel)
frontend/
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ contracts (kernel purity, headless annotator) are described there and enforced i
| [examples.md](examples.md) | The two runnable examples: the whole cycle in one pass, ingest on its own, and what each is built to demonstrate |
| [api.md](api.md) | The REST surface: the conventions every endpoint follows (paths, UUID ids, the list envelope, gates as query parameters), the one error body, why clients branch on `code` and not on the status, what decides 404 / 409 / 422, what a 5xx does and does not tell you, and which codes are worth retrying |
| [auth.md](auth.md) | Who may call it: per-workspace API tokens, why only a digest is stored, why every refusal is one identical 401, immediate revocation, the `visionset token` commands, and how a protected route is built |
| [mcp.md](mcp.md) | The agent surface: the thirty-three tools and what each is for, why fifty candidates became thirty-three, how a client is configured, the coordinate-frame rule that makes `get_asset_image` safe to annotate from, the error envelope and its `retry_with` field, the three gate words, and the stated limits (synchronous ingest and export, local paths, one workspace per server) |
| [cli.md](cli.md) | The command line: the whole cycle as a script, the three exit codes (and why one of them also means "no"), why stdout is data and stderr is prose, what `--json` promises and how it stays the API's shape, why `--workspace` follows the subcommand, and what `visionset init` and `visionset ui` each do |
5 changes: 4 additions & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,7 @@ that outlives the session. The secret is shown exactly once, and an agent's "onc
transcript: `confirm: true` guards accidental mutation, not exfiltration. Whoever launched
`visionset mcp` already had workspace access, so a second credential adds capability and subtracts
accountability. `list_tokens` is the only defensible candidate and is still operator surface
rather than dataset surface.
rather than dataset surface. That held when #35 shipped the surface: none of its thirty-three
tools touches a token, and none needs one — an agent reaching the MCP server is already inside the
sandbox the workspace defines, so there is nothing further to prove. Authentication is what an
*HTTP* client owes, because the network is what a token is for.
37 changes: 27 additions & 10 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ visionset token create --name NAME
visionset token list
visionset token revoke NAME [--yes]
visionset ui [--host] [--port] [--reload] # no --json
visionset mcp # not implemented yet
visionset mcp # stdio; no --json
```

## The cycle, as a script
Expand Down Expand Up @@ -358,21 +358,36 @@ digest is stored, and why revocation does not free the name — in [auth.md](aut

## `visionset mcp`

A stub. The MCP server is a fourth sibling client of the same SDK; the command that starts it names
its target by import string or subprocess for the same reason `ui` does — import-linter forbids
`visionset.cli` importing `visionset.mcp`.
Starts the MCP server on stdio, serving this workspace to an agent. Thirty-three tools covering
the whole cycle; [mcp.md](mcp.md) has the list, how to configure a client, and what a tool refusal
looks like.

```
visionset mcp [--workspace PATH]
```

Normally a client spawns it rather than a person running it. Like `ui`, it resolves the workspace
with the full precedence and then **states** the answer in `VISIONSET_WORKSPACE`, so the server it
starts cannot disagree with it, and it opens the workspace first so that `NotAWorkspace` is one
sentence at exit 1 rather than a refusal inside the agent's first tool call.

The target is named as a module for a subprocess rather than imported, for the reason `ui` names
uvicorn's app by import string — import-linter forbids `visionset.cli` importing `visionset.mcp`.
The subprocess inherits stdin and stdout, because those two streams *are* the transport, which is
also why this is the one command that prints **nothing at all** on stdout: a stray line would
corrupt the JSON-RPC stream before the first message.

## For contributors

Five private modules carry everything a command needs:
Four private modules and one shared package carry everything a command needs:

| | |
| --- | --- |
| `cli/_errors.py` | the exit codes and `domain_errors()` |
| `cli/_workspace.py` | `WorkspaceOption` and `opened_workspace()` |
| `cli/_output.py` | `JsonOption`, the column formatter, `document()`, `note()` |
| `cli/_json.py` | one hand-written projection per resource |
| `cli/_resolve.py` | `ProjectOption`, and turning a name or a tag into the thing it names |
| `visionset/wire/` | one hand-written projection per resource — **shared with the MCP surface**, which publishes the same shapes (see `docs/mcp.md`) |

A new command is a module beside them and one registration line in `cli/main.py` — groups by
`add_typer`, bare commands by `app.command("name")(fn)`, which is where they are registered rather
Expand All @@ -384,10 +399,12 @@ close and the refusal, and it closes in a `finally` so no `visionset.db-wal` is
**A command maps to exactly one service call, and says so in its docstring when it does not.**
`ingest` is the only one that does not, and its module explains why.

**Never `model_dump()` a domain model into `--json`.** Write the projection in `_json.py` and add
the pair to `tests/cli/test_json_contract.py`, which asserts key-for-key parity with the REST wire
model. That test may import both `visionset.cli` and `visionset.server` because `tests/` is outside
the package the independence contract governs — the packages themselves must not.
**Never `model_dump()` a domain model into `--json`.** Write the projection in `visionset/wire/`
and add the pair to `tests/cli/test_json_contract.py`, which asserts key-for-key parity with the
REST wire model. That test may import both `visionset.wire` and `visionset.server` because `tests/`
is outside the package the independence contract governs — the packages themselves must not. A
projection added there is published by the CLI **and** by MCP, which is why it is a package of its
own rather than a private module under `cli/`.

**A bound the domain enforces with a pydantic `Field` has to be mirrored in the Typer option**, or
the refusal arrives as a traceback: a pydantic `ValidationError` and a bare `ValueError` are not
Expand Down
Loading
Loading