Add opinionated MCP modes for workspace workflows

Introduce explicit repro-fix, inspect, cold-start, and review-eval modes across the MCP server, CLI, and host helpers, with canonical mode-to-tool mappings, narrowed schemas, and mode-specific tool descriptions on top of the existing workspace runtime.

Reposition the docs, host onramps, and use-case recipes so named modes are the primary user-facing startup story while the generic no-mode workspace-core path remains the escape hatch, and update the shared smoke runner to validate repro-fix and cold-start through mode-backed servers.

Validation: UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_api.py tests/test_server.py tests/test_host_helpers.py tests/test_public_contract.py tests/test_cli.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed make smoke-repro-fix-loop smoke-cold-start-validation outside the sandbox.
This commit is contained in:
Thales Maciel 2026-03-13 20:00:35 -03:00
parent dc86d84e96
commit d0cf6d8f21
33 changed files with 1034 additions and 274 deletions

View file

@ -2,6 +2,17 @@
All notable user-visible changes to `pyro-mcp` are documented here.
## 4.4.0
- Added explicit named MCP/server modes for the main workspace workflows:
`repro-fix`, `inspect`, `cold-start`, and `review-eval`.
- Kept the generic no-mode `workspace-core` path available as the escape hatch,
while making named modes the first user-facing story across help text, host
helpers, and the recipe docs.
- Aligned the shared use-case smoke runner with those modes so the repro/fix
and cold-start flows now prove a mode-backed happy path instead of only the
generic profile path.
## 4.3.0
- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP

View file

@ -30,7 +30,7 @@ SDK-first platform.
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- What's new in 4.3.0: [CHANGELOG.md#430](CHANGELOG.md#430)
- What's new in 4.4.0: [CHANGELOG.md#440](CHANGELOG.md#440)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
## Who It's For
@ -76,7 +76,7 @@ What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 4.3.0
Catalog version: 4.4.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -95,13 +95,15 @@ for the guest image.
## Chat Host Quickstart
After the quickstart works, the intended next step is to connect a chat host.
Use the helper flow first:
After the quickstart works, the intended next step is to connect a chat host in
one named mode. Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect claude-code
uvx --from pyro-mcp pyro host connect codex
uvx --from pyro-mcp pyro host print-config opencode
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts or you want to inspect it first:
@ -113,15 +115,28 @@ uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Those helpers wrap the same `pyro mcp serve` entrypoint. From a repo root,
bare `pyro mcp serve` starts `workspace-core`, auto-detects the current Git
checkout, and lets the first `workspace_create` omit `seed_path`.
Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when
one workflow already matches the job. Fall back to the generic no-mode path
when the mode feels too narrow.
Mode examples:
```bash
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
uvx --from pyro-mcp pyro mcp serve --mode inspect
uvx --from pyro-mcp pyro mcp serve --mode cold-start
uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Generic escape hatch:
```bash
uvx --from pyro-mcp pyro mcp serve
```
If the host does not preserve the server working directory, use:
From a repo root, the generic path auto-detects the current Git checkout and
lets the first `workspace_create` omit `seed_path`. If the host does not
preserve the server working directory, use:
```bash
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo
@ -142,16 +157,16 @@ Copy-paste host-specific starts:
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
Claude Code:
Claude Code cold-start or review-eval:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
```
Codex:
Codex repro-fix or inspect:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
```
OpenCode `opencode.json` snippet:
@ -162,7 +177,7 @@ OpenCode `opencode.json` snippet:
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
@ -176,18 +191,19 @@ array.
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape.
Use `--profile workspace-full` only when the chat truly needs shells, services,
Use the generic no-mode path when the named mode feels too narrow. Move to
`--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## Zero To Hero
1. Validate the host with `pyro doctor`.
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`.
3. Connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
`pyro host print-config opencode`, then fall back to raw `pyro mcp serve`
with `--project-path` / `--repo-url` when cwd is not the source of truth.
3. Connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host connect codex --mode repro-fix`, then fall back to raw
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
`repro-fix-loop` is the shortest chat-first story.
`repro-fix` is the shortest chat-first mode and story.
5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
for the advertised workflows.

View file

@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes
```bash
$ uvx --from pyro-mcp pyro env list
Catalog version: 4.3.0
Catalog version: 4.4.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -71,7 +71,17 @@ streams, so they may appear in either order in terminals or capture tools. Use
## 5. Start the MCP server
Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also
Use a named mode when one workflow already matches the job:
```bash
$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix
$ uvx --from pyro-mcp pyro mcp serve --mode inspect
$ uvx --from pyro-mcp pyro mcp serve --mode cold-start
$ uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Use the generic no-mode path when the mode feels too narrow. Bare
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`:
@ -96,9 +106,11 @@ $ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/proje
Use the helper flow first:
```bash
$ uvx --from pyro-mcp pyro host connect claude-code
$ uvx --from pyro-mcp pyro host connect codex
$ uvx --from pyro-mcp pyro host print-config opencode
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
$ uvx --from pyro-mcp pyro host connect codex --mode inspect
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts later:
@ -110,19 +122,19 @@ $ uvx --from pyro-mcp pyro host repair codex
$ uvx --from pyro-mcp pyro host repair opencode
```
Claude Code:
Claude Code cold-start or review-eval:
```bash
$ uvx --from pyro-mcp pyro host connect claude-code
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
$ claude mcp list
```
Codex:
Codex repro-fix or inspect:
```bash
$ uvx --from pyro-mcp pyro host connect codex
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
$ codex mcp list
```
@ -141,7 +153,7 @@ Other host-specific references:
Once the host is connected, move to one of the five recipe docs in
[use-cases/README.md](use-cases/README.md).
The shortest chat-first story is:
The shortest chat-first mode and story is:
- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md)
@ -162,8 +174,9 @@ $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./
$ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID"
```
Move to `--profile workspace-full` only when the chat really needs shells,
services, snapshots, secrets, network policy, or disk tools.
Move to the generic no-mode path when the named mode is too narrow. Move to
`--profile workspace-full` only when the chat really needs shells, services,
snapshots, secrets, network policy, or disk tools.
## 8. Trust the smoke pack

View file

@ -62,8 +62,8 @@ pyro run debian:12 -- git --version
If you are running from a repo checkout instead, replace `pyro` with
`uv run pyro`.
After that one-shot proof works, the intended next step is `pyro host connect`
or `pyro host print-config`.
After that one-shot proof works, the intended next step is a named chat mode
through `pyro host connect` or `pyro host print-config`.
## 1. Check the host
@ -93,7 +93,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 4.3.0
Catalog version: 4.4.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -144,9 +144,11 @@ deterministic structured result.
Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect claude-code
uvx --from pyro-mcp pyro host connect codex
uvx --from pyro-mcp pyro host print-config opencode
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts later, inspect and repair it with:
@ -158,7 +160,17 @@ uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also
Use a named mode when one workflow already matches the job:
```bash
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
uvx --from pyro-mcp pyro mcp serve --mode inspect
uvx --from pyro-mcp pyro mcp serve --mode cold-start
uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Use the generic no-mode path when the mode feels too narrow. Bare
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`.
@ -185,18 +197,18 @@ Copy-paste host-specific starts:
- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md)
Claude Code:
Claude Code cold-start or review-eval:
```bash
pyro host connect claude-code
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
pyro host connect claude-code --mode cold-start
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
```
Codex:
Codex repro-fix or inspect:
```bash
pyro host connect codex
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
pyro host connect codex --mode repro-fix
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
```
OpenCode uses the `mcp` / `type: "local"` config shape shown in
@ -205,7 +217,8 @@ OpenCode uses the `mcp` / `type: "local"` config shape shown in
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape.
Use `--profile workspace-full` only when the chat truly needs shells, services,
Use the generic no-mode path when the named mode is too narrow. Move to
`--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## 6. Go from zero to hero
@ -215,9 +228,9 @@ The intended user journey is:
1. validate the host with `pyro doctor`
2. pull `debian:12`
3. prove guest execution with `pyro run debian:12 -- git --version`
4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
`pyro host print-config opencode`, then use raw `pyro mcp serve` only when
you need `--project-path` / `--repo-url`
4. connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host connect codex --mode repro-fix`, then use raw
`pyro mcp serve --mode ...` or the generic no-mode path when needed
5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
6. trust but verify with `make smoke-use-cases`

View file

@ -13,7 +13,29 @@ path is still being shaped.
Use this page after you have already validated the host and guest execution
through [install.md](install.md) or [first-run.md](first-run.md).
## Recommended Default
## Recommended Modes
Use a named mode when one workflow already matches the job:
```bash
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
```
The mode-backed raw server forms are:
```bash
pyro mcp serve --mode repro-fix
pyro mcp serve --mode inspect
pyro mcp serve --mode cold-start
pyro mcp serve --mode review-eval
```
Use the generic no-mode path only when the named mode feels too narrow.
## Generic Default
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
@ -43,22 +65,25 @@ snapshots, secrets, network policy, or disk tools.
Use the helper flow before the raw host CLI commands:
```bash
pyro host connect claude-code
pyro host connect codex
pyro host print-config opencode
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
pyro host print-config opencode --mode repro-fix
pyro host doctor
pyro host repair opencode
```
These helpers wrap the same `pyro mcp serve` entrypoint, preserve the current
`workspace-core` default, and make it obvious how to repair drift later.
These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
first user-facing story, and still leave the generic no-mode path available
when a mode is too narrow.
## Claude Code
Preferred:
```bash
pyro host connect claude-code
pyro host connect claude-code --mode cold-start
```
Repair:
@ -70,14 +95,14 @@ pyro host repair claude-code
Package without install:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
claude mcp list
```
If Claude Code launches the server from an unexpected cwd, use:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
```
Already installed:
@ -96,7 +121,7 @@ Reference:
Preferred:
```bash
pyro host connect codex
pyro host connect codex --mode repro-fix
```
Repair:
@ -108,14 +133,14 @@ pyro host repair codex
Package without install:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
codex mcp list
```
If Codex launches the server from an unexpected cwd, use:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
```
Already installed:
@ -150,7 +175,7 @@ Minimal `opencode.json` snippet:
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
@ -165,8 +190,9 @@ array.
## Generic MCP Fallback
Use this only when the host expects a plain `mcpServers` JSON config and does
not already have a dedicated example in the repo:
Use this only when the host expects a plain `mcpServers` JSON config, when the
named modes are too narrow, and when it does not already have a dedicated
example in the repo:
- [mcp_client_config.md](../examples/mcp_client_config.md)

View file

@ -75,10 +75,15 @@ pyro mcp serve
What to expect:
- bare `pyro mcp serve` starts `workspace-core`
- named modes are now the first chat-host story:
- `pyro mcp serve --mode repro-fix`
- `pyro mcp serve --mode inspect`
- `pyro mcp serve --mode cold-start`
- `pyro mcp serve --mode review-eval`
- bare `pyro mcp serve` remains the generic no-mode path and starts
`workspace-core`
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
checkout so `workspace_create` can omit `seed_path`
- `workspace-core` is the default product path for chat hosts
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
tool surface
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
@ -105,7 +110,21 @@ The chat-host bootstrap helper surface is:
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
setup and repair path for supported hosts.
## Chat-Facing Workspace Contract
## Named Modes
The supported named modes are:
| Mode | Intended workflow | Key tools |
| --- | --- | --- |
| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary |
| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary |
| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools |
| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary |
Use the generic no-mode path when one of those named modes feels too narrow for
the job.
## Generic Workspace Contract
`workspace-core` is the normal chat path. It exposes:
@ -151,13 +170,13 @@ Move to `workspace-full` only when the chat truly needs:
The documented product workflows are:
| Workflow | Recommended profile | Doc |
| Workflow | Recommended mode | Doc |
| --- | --- | --- |
| Cold-start repo validation | `workspace-full` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
| Repro plus fix loop | `workspace-core` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
| Parallel isolated workspaces | `workspace-core` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `workspace-core` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
| Review and evaluation workflows | `workspace-full` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
Treat this smoke pack as the trustworthy guest-backed verification path for the
advertised product:

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface.
Current baseline is `4.3.0`:
Current baseline is `4.4.0`:
- `pyro mcp serve` is now the default product entrypoint
- `workspace-core` is now the default MCP profile
@ -82,7 +82,7 @@ capability gaps:
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done
16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned
Completed so far:
@ -123,10 +123,12 @@ Completed so far:
- `4.3.0` adds a concise workspace review surface so users can inspect what the
agent changed and ran since the last reset without reconstructing the
session from several lower-level views by hand.
- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`,
`inspect`, `cold-start`, or `review-eval` instead of choosing from the full
generic workspace surface first.
Planned next:
- [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md)
- [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md)
## Expected Outcome

View file

@ -1,6 +1,6 @@
# `4.4.0` Opinionated Use-Case Modes
Status: Planned
Status: Done
## Goal

View file

@ -12,13 +12,13 @@ make smoke-use-cases
Recipe matrix:
| Use case | Recommended profile | Smoke target | Recipe |
| Use case | Recommended mode | Smoke target | Recipe |
| --- | --- | --- | --- |
| Cold-start repo validation | `workspace-full` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
| Repro plus fix loop | `workspace-core` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
| Parallel isolated workspaces | `workspace-core` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `workspace-core` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
| Review and evaluation workflows | `workspace-full` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
| Repro plus fix loop | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
| Parallel isolated workspaces | `repro-fix` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `inspect` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
| Review and evaluation workflows | `review-eval` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
All five recipes use the same real Firecracker-backed smoke runner:

View file

@ -1,6 +1,12 @@
# Cold-Start Repo Validation
Recommended profile: `workspace-full`
Recommended mode: `cold-start`
Recommended startup:
```bash
pyro host connect claude-code --mode cold-start
```
Smoke target:
@ -21,6 +27,10 @@ Chat-host recipe:
5. Export the validation report back to the host.
6. Delete the workspace when the evaluation is done.
If the named mode feels too narrow, fall back to the generic no-mode path and
then opt into `--profile workspace-full` only when you truly need the larger
advanced surface.
This recipe is intentionally guest-local and deterministic. It proves startup,
service readiness, validation, and host-out report capture without depending on
external networks or private registries.

View file

@ -1,6 +1,12 @@
# Parallel Isolated Workspaces
Recommended profile: `workspace-core`
Recommended mode: `repro-fix`
Recommended startup:
```bash
pyro host connect codex --mode repro-fix
```
Smoke target:
@ -22,4 +28,5 @@ Chat-host recipe:
The important proof here is operational, not syntactic: names, labels, list
ordering, and file contents stay isolated even when multiple workspaces are
active at the same time.
active at the same time. Parallel work still means “open another workspace in
the same mode,” not “pick a special parallel-work mode.”

View file

@ -1,6 +1,12 @@
# Repro Plus Fix Loop
Recommended profile: `workspace-core`
Recommended mode: `repro-fix`
Recommended startup:
```bash
pyro host connect codex --mode repro-fix
```
Smoke target:
@ -25,5 +31,8 @@ Chat-host recipe:
7. Diff and export the changed result.
8. Reset to baseline and delete the workspace.
This is the main `workspace-core` story: model-native file ops, repeatable exec,
If the mode feels too narrow for the job, fall back to the generic bare
`pyro mcp serve` path.
This is the main `repro-fix` story: model-native file ops, repeatable exec,
structured diff, explicit export, and reset-over-repair.

View file

@ -1,6 +1,12 @@
# Review And Evaluation Workflows
Recommended profile: `workspace-full`
Recommended mode: `review-eval`
Recommended startup:
```bash
pyro host connect claude-code --mode review-eval
```
Smoke target:

View file

@ -1,6 +1,12 @@
# Unsafe Or Untrusted Code Inspection
Recommended profile: `workspace-core`
Recommended mode: `inspect`
Recommended startup:
```bash
pyro host connect codex --mode inspect
```
Smoke target:

View file

@ -1,18 +1,22 @@
# Claude Code MCP Setup
Recommended profile: `workspace-core`.
Recommended modes:
- `cold-start`
- `review-eval`
Preferred helper flow:
```bash
pyro host connect claude-code
pyro host doctor
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
pyro host doctor --mode cold-start
```
Package without install:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
claude mcp list
```
@ -22,7 +26,7 @@ from the current checkout automatically.
Already installed:
```bash
claude mcp add pyro -- pyro mcp serve
claude mcp add pyro -- pyro mcp serve --mode cold-start
claude mcp list
```
@ -30,14 +34,14 @@ If Claude Code launches the server from an unexpected cwd, pin the project
explicitly:
```bash
pyro host connect claude-code --project-path /abs/path/to/repo
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
```
If the local config drifts later:
```bash
pyro host repair claude-code
pyro host repair claude-code --mode cold-start
```
Move to `workspace-full` only when the chat truly needs shells, services,

View file

@ -1,18 +1,22 @@
# Codex MCP Setup
Recommended profile: `workspace-core`.
Recommended modes:
- `repro-fix`
- `inspect`
Preferred helper flow:
```bash
pyro host connect codex
pyro host doctor
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host doctor --mode repro-fix
```
Package without install:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
codex mcp list
```
@ -22,7 +26,7 @@ from the current checkout automatically.
Already installed:
```bash
codex mcp add pyro -- pyro mcp serve
codex mcp add pyro -- pyro mcp serve --mode repro-fix
codex mcp list
```
@ -30,14 +34,14 @@ If Codex launches the server from an unexpected cwd, pin the project
explicitly:
```bash
pyro host connect codex --project-path /abs/path/to/repo
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
```
If the local config drifts later:
```bash
pyro host repair codex
pyro host repair codex --mode repro-fix
```
Move to `workspace-full` only when the chat truly needs shells, services,

View file

@ -1,6 +1,11 @@
# MCP Client Config Example
Default for most chat hosts in `4.x`: `workspace-core`.
Recommended named modes for most chat hosts in `4.x`:
- `repro-fix`
- `inspect`
- `cold-start`
- `review-eval`
Use the host-specific examples first when they apply:
@ -10,14 +15,16 @@ Use the host-specific examples first when they apply:
Preferred repair/bootstrap helpers:
- `pyro host connect claude-code`
- `pyro host connect codex`
- `pyro host print-config opencode`
- `pyro host doctor`
- `pyro host repair opencode`
- `pyro host connect codex --mode repro-fix`
- `pyro host connect codex --mode inspect`
- `pyro host connect claude-code --mode cold-start`
- `pyro host connect claude-code --mode review-eval`
- `pyro host print-config opencode --mode repro-fix`
- `pyro host doctor --mode repro-fix`
- `pyro host repair opencode --mode repro-fix`
Use this generic config only when the host expects a plain `mcpServers` JSON
shape.
shape or when the named modes are too narrow for the workflow.
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
@ -28,7 +35,7 @@ Generic stdio MCP configuration using `uvx`:
"mcpServers": {
"pyro": {
"command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
@ -41,7 +48,7 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
"mcpServers": {
"pyro": {
"command": "pyro",
"args": ["mcp", "serve"]
"args": ["mcp", "serve", "--mode", "repro-fix"]
}
}
}
@ -51,15 +58,18 @@ If the host does not preserve the server working directory and you want the
first `workspace_create` to start from a specific checkout, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
Profile progression:
Mode progression:
- `workspace-core`: the default and recommended first persistent chat profile
- `vm-run`: expose only `vm_run`
- `repro-fix`: patch, rerun, diff, export, reset
- `inspect`: narrow offline-by-default inspection
- `cold-start`: validation plus service readiness
- `review-eval`: shell and snapshot-driven review
- generic no-mode path: the fallback when the named mode is too narrow
- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools
Primary profile for most agents:
Primary mode for most agents:
- `workspace-core`
- `repro-fix`
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.

View file

@ -3,7 +3,7 @@
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}

View file

@ -1,6 +1,6 @@
[project]
name = "pyro-mcp"
version = "4.3.0"
version = "4.4.0"
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
readme = "README.md"
license = { file = "LICENSE" }

View file

@ -8,7 +8,12 @@ from typing import Any, Literal, cast
from mcp.server.fastmcp import FastMCP
from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_MODES,
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
@ -30,12 +35,77 @@ from pyro_mcp.vm_manager import (
)
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"]
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
"workspace-full": PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
}
_MODE_TOOLS: dict[str, tuple[str, ...]] = {
"repro-fix": PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
"inspect": PUBLIC_MCP_INSPECT_MODE_TOOLS,
"cold-start": PUBLIC_MCP_COLD_START_MODE_TOOLS,
"review-eval": PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
}
_MODE_CREATE_INTENT: dict[str, str] = {
"repro-fix": "to reproduce a failure, patch files, rerun, diff, export, and reset",
"inspect": "to inspect suspicious or unfamiliar code with the smallest persistent surface",
"cold-start": (
"to validate a fresh repo, keep one service alive, and export a "
"validation report"
),
"review-eval": "to review interactively, checkpoint work, and export the final report",
}
_MODE_TOOL_DESCRIPTIONS: dict[str, dict[str, str]] = {
"repro-fix": {
"workspace_file_read": "Read one workspace file while investigating the broken state.",
"workspace_file_write": "Write one workspace file directly as part of the fix loop.",
"workspace_patch_apply": "Apply a structured text patch inside the workspace fix loop.",
"workspace_export": "Export the fixed result or patch-ready file back to the host.",
"workspace_summary": (
"Summarize the current repro/fix session for review before export "
"or reset."
),
},
"inspect": {
"workspace_file_list": "List suspicious files under the current workspace path.",
"workspace_file_read": "Read one suspicious or unfamiliar workspace file.",
"workspace_export": (
"Export only the inspection report or artifact you chose to "
"materialize."
),
"workspace_summary": "Summarize the current inspection session and its exported results.",
},
"cold-start": {
"workspace_create": "Create and start a persistent workspace for cold-start validation.",
"workspace_export": "Export the validation report or other final host-visible result.",
"workspace_summary": (
"Summarize the current validation session, service state, and "
"exports."
),
"service_start": "Start a named validation or app service and wait for typed readiness.",
"service_list": "List running and exited validation services in the current workspace.",
"service_status": "Inspect one validation service and its readiness outcome.",
"service_logs": "Read stdout and stderr from one validation service.",
"service_stop": "Stop one validation service in the workspace.",
},
"review-eval": {
"workspace_create": (
"Create and start a persistent workspace for interactive review "
"or evaluation."
),
"workspace_summary": "Summarize the current review session before exporting or resetting.",
"shell_open": "Open an interactive review shell inside the workspace.",
"shell_read": "Read chat-friendly PTY output from the current review shell.",
"shell_write": "Send one line of input to the current review shell.",
"shell_signal": "Interrupt or terminate the current review shell process group.",
"shell_close": "Close the current review shell.",
"snapshot_create": "Create a checkpoint before the review branch diverges.",
"snapshot_list": "List the baseline plus named review checkpoints.",
"snapshot_delete": "Delete one named review checkpoint.",
},
}
def _validate_mcp_profile(profile: str) -> McpToolProfile:
@ -45,16 +115,42 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
return cast(McpToolProfile, profile)
def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str:
def _validate_workspace_mode(mode: str) -> WorkspaceUseCaseMode:
if mode not in PUBLIC_MCP_MODES:
expected = ", ".join(PUBLIC_MCP_MODES)
raise ValueError(f"unknown workspace mode {mode!r}; expected one of: {expected}")
return cast(WorkspaceUseCaseMode, mode)
def _workspace_create_description(
startup_source: ProjectStartupSource | None,
*,
mode: WorkspaceUseCaseMode | None = None,
) -> str:
if mode is not None:
prefix = (
"Create and start a persistent workspace "
f"{_MODE_CREATE_INTENT[mode]}."
)
else:
prefix = "Create and start a persistent workspace."
if startup_source is None:
return "Create and start a persistent workspace."
return prefix
described_source = describe_project_startup_source(startup_source)
if described_source is None:
return "Create and start a persistent workspace."
return (
"Create and start a persistent workspace. If `seed_path` is omitted, "
f"the server seeds from {described_source}."
)
return prefix
return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}."
def _tool_description(
tool_name: str,
*,
mode: WorkspaceUseCaseMode | None,
fallback: str,
) -> str:
if mode is None:
return fallback
return _MODE_TOOL_DESCRIPTIONS.get(mode, {}).get(tool_name, fallback)
class Pyro:
@ -487,6 +583,7 @@ class Pyro:
self,
*,
profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
@ -502,13 +599,20 @@ class Pyro:
`repo_url`, and `no_project_source` override that behavior explicitly.
"""
normalized_profile = _validate_mcp_profile(profile)
normalized_mode = _validate_workspace_mode(mode) if mode is not None else None
if normalized_mode is not None and normalized_profile != "workspace-core":
raise ValueError("mode and profile are mutually exclusive")
startup_source = resolve_project_startup_source(
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
enabled_tools = set(
_MODE_TOOLS[normalized_mode]
if normalized_mode is not None
else _PROFILE_TOOLS[normalized_profile]
)
server = FastMCP(name="pyro_mcp")
def _enabled(tool_name: str) -> bool:
@ -621,7 +725,10 @@ class Pyro:
return self.reap_expired()
if _enabled("workspace_create"):
workspace_create_description = _workspace_create_description(startup_source)
workspace_create_description = _workspace_create_description(
startup_source,
mode=normalized_mode,
)
def _create_workspace_from_server_defaults(
*,
@ -668,7 +775,7 @@ class Pyro:
_prepared_seed=prepared_seed,
)
if normalized_profile == "workspace-core":
if normalized_mode is not None or normalized_profile == "workspace-core":
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_core(
@ -749,15 +856,21 @@ class Pyro:
)
if _enabled("workspace_exec"):
if normalized_profile == "workspace-core":
if normalized_mode is not None or normalized_profile == "workspace-core":
@server.tool(name="workspace_exec")
@server.tool(
name="workspace_exec",
description=_tool_description(
"workspace_exec",
mode=normalized_mode,
fallback="Run one command inside an existing persistent workspace.",
),
)
async def workspace_exec_core(
workspace_id: str,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace(
workspace_id,
command=command,
@ -767,14 +880,20 @@ class Pyro:
else:
@server.tool(name="workspace_exec")
@server.tool(
name="workspace_exec",
description=_tool_description(
"workspace_exec",
mode=normalized_mode,
fallback="Run one command inside an existing persistent workspace.",
),
)
async def workspace_exec_full(
workspace_id: str,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace(
workspace_id,
command=command,
@ -823,20 +942,30 @@ class Pyro:
if _enabled("workspace_summary"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_summary",
mode=normalized_mode,
fallback="Summarize the current workspace session for human review.",
)
)
async def workspace_summary(workspace_id: str) -> dict[str, Any]:
"""Summarize the current workspace session for human review."""
return self.summarize_workspace(workspace_id)
if _enabled("workspace_export"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_export",
mode=normalized_mode,
fallback="Export one file or directory from `/workspace` back to the host.",
)
)
async def workspace_export(
workspace_id: str,
path: str,
output_path: str,
) -> dict[str, Any]:
"""Export one file or directory from `/workspace` back to the host."""
return self.export_workspace(workspace_id, path, output_path=output_path)
if _enabled("workspace_diff"):
@ -848,13 +977,21 @@ class Pyro:
if _enabled("workspace_file_list"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_list",
mode=normalized_mode,
fallback=(
"List metadata for files and directories under one "
"live workspace path."
),
)
)
async def workspace_file_list(
workspace_id: str,
path: str = "/workspace",
recursive: bool = False,
) -> dict[str, Any]:
"""List metadata for files and directories under one live workspace path."""
return self.list_workspace_files(
workspace_id,
path=path,
@ -863,13 +1000,18 @@ class Pyro:
if _enabled("workspace_file_read"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_read",
mode=normalized_mode,
fallback="Read one regular text file from a live workspace path.",
)
)
async def workspace_file_read(
workspace_id: str,
path: str,
max_bytes: int = 65536,
) -> dict[str, Any]:
"""Read one regular text file from a live workspace path."""
return self.read_workspace_file(
workspace_id,
path,
@ -878,13 +1020,18 @@ class Pyro:
if _enabled("workspace_file_write"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_write",
mode=normalized_mode,
fallback="Create or replace one regular text file under `/workspace`.",
)
)
async def workspace_file_write(
workspace_id: str,
path: str,
text: str,
) -> dict[str, Any]:
"""Create or replace one regular text file under `/workspace`."""
return self.write_workspace_file(
workspace_id,
path,
@ -893,12 +1040,17 @@ class Pyro:
if _enabled("workspace_patch_apply"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_patch_apply",
mode=normalized_mode,
fallback="Apply a unified text patch inside one live workspace.",
)
)
async def workspace_patch_apply(
workspace_id: str,
patch: str,
) -> dict[str, Any]:
"""Apply a unified text patch inside one live workspace."""
return self.apply_workspace_patch(
workspace_id,
patch=patch,
@ -976,8 +1128,38 @@ class Pyro:
return self.reset_workspace(workspace_id, snapshot=snapshot)
if _enabled("shell_open"):
if normalized_mode == "review-eval":
@server.tool()
@server.tool(
description=_tool_description(
"shell_open",
mode=normalized_mode,
fallback="Open a persistent interactive shell inside one workspace.",
)
)
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
) -> dict[str, Any]:
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=None,
)
else:
@server.tool(
description=_tool_description(
"shell_open",
mode=normalized_mode,
fallback="Open a persistent interactive shell inside one workspace.",
)
)
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
@ -985,7 +1167,6 @@ class Pyro:
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Open a persistent interactive shell inside one workspace."""
return self.open_shell(
workspace_id,
cwd=cwd,
@ -996,7 +1177,13 @@ class Pyro:
if _enabled("shell_read"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_read",
mode=normalized_mode,
fallback="Read merged PTY output from a workspace shell.",
)
)
async def shell_read(
workspace_id: str,
shell_id: str,
@ -1005,7 +1192,6 @@ class Pyro:
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
"""Read merged PTY output from a workspace shell."""
return self.read_shell(
workspace_id,
shell_id,
@ -1017,14 +1203,19 @@ class Pyro:
if _enabled("shell_write"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_write",
mode=normalized_mode,
fallback="Write text input to a persistent workspace shell.",
)
)
async def shell_write(
workspace_id: str,
shell_id: str,
input: str,
append_newline: bool = True,
) -> dict[str, Any]:
"""Write text input to a persistent workspace shell."""
return self.write_shell(
workspace_id,
shell_id,
@ -1034,13 +1225,18 @@ class Pyro:
if _enabled("shell_signal"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_signal",
mode=normalized_mode,
fallback="Send a signal to the shell process group.",
)
)
async def shell_signal(
workspace_id: str,
shell_id: str,
signal_name: str = "INT",
) -> dict[str, Any]:
"""Send a signal to the shell process group."""
return self.signal_shell(
workspace_id,
shell_id,
@ -1049,14 +1245,68 @@ class Pyro:
if _enabled("shell_close"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_close",
mode=normalized_mode,
fallback="Close a persistent workspace shell.",
)
)
async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]:
"""Close a persistent workspace shell."""
return self.close_shell(workspace_id, shell_id)
if _enabled("service_start"):
if normalized_mode == "cold-start":
@server.tool()
@server.tool(
description=_tool_description(
"service_start",
mode=normalized_mode,
fallback="Start a named long-running service inside a workspace.",
)
)
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
) -> dict[str, Any]:
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=None,
published_ports=None,
)
else:
@server.tool(
description=_tool_description(
"service_start",
mode=normalized_mode,
fallback="Start a named long-running service inside a workspace.",
)
)
async def service_start(
workspace_id: str,
service_name: str,
@ -1071,7 +1321,6 @@ class Pyro:
secret_env: dict[str, str] | None = None,
published_ports: list[dict[str, int | None]] | None = None,
) -> dict[str, Any]:
"""Start a named long-running service inside a workspace."""
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
@ -1095,28 +1344,43 @@ class Pyro:
if _enabled("service_list"):
@server.tool()
@server.tool(
description=_tool_description(
"service_list",
mode=normalized_mode,
fallback="List named services in one workspace.",
)
)
async def service_list(workspace_id: str) -> dict[str, Any]:
"""List named services in one workspace."""
return self.list_services(workspace_id)
if _enabled("service_status"):
@server.tool()
@server.tool(
description=_tool_description(
"service_status",
mode=normalized_mode,
fallback="Inspect one named workspace service.",
)
)
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Inspect one named workspace service."""
return self.status_service(workspace_id, service_name)
if _enabled("service_logs"):
@server.tool()
@server.tool(
description=_tool_description(
"service_logs",
mode=normalized_mode,
fallback="Read persisted stdout/stderr for one workspace service.",
)
)
async def service_logs(
workspace_id: str,
service_name: str,
tail_lines: int = 200,
all: bool = False,
) -> dict[str, Any]:
"""Read persisted stdout/stderr for one workspace service."""
return self.logs_service(
workspace_id,
service_name,
@ -1126,9 +1390,14 @@ class Pyro:
if _enabled("service_stop"):
@server.tool()
@server.tool(
description=_tool_description(
"service_stop",
mode=normalized_mode,
fallback="Stop one running service in a workspace.",
)
)
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Stop one running service in a workspace."""
return self.stop_service(workspace_id, service_name)
if _enabled("workspace_delete"):

View file

@ -11,8 +11,8 @@ from textwrap import dedent
from typing import Any, cast
from pyro_mcp import __version__
from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
from pyro_mcp.demo import run_demo
from pyro_mcp.host_helpers import (
HostDoctorEntry,
@ -181,6 +181,7 @@ def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
return HostServerConfig(
installed_package=bool(getattr(args, "installed_package", False)),
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)),
project_path=getattr(args, "project_path", None),
repo_url=getattr(args, "repo_url", None),
repo_ref=getattr(args, "repo_ref", None),
@ -856,11 +857,17 @@ def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
)
parser.add_argument(
profile_group = parser.add_mutually_exclusive_group()
profile_group.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help="Server profile to configure for the host helper flow.",
help="Explicit profile for the host helper flow when not using a named mode.",
)
profile_group.add_argument(
"--mode",
choices=PUBLIC_MCP_MODES,
help="Opinionated use-case mode for the host helper flow.",
)
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument(
@ -1017,6 +1024,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host connect claude-code
pyro host connect claude-code --mode cold-start
pyro host connect codex --project-path /abs/path/to/repo
pyro host print-config opencode
pyro host repair opencode
@ -1037,7 +1045,9 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host connect claude-code
pyro host connect claude-code --mode cold-start
pyro host connect codex --installed-package
pyro host connect codex --mode repro-fix
pyro host connect codex --project-path /abs/path/to/repo
"""
),
@ -1061,6 +1071,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host print-config opencode
pyro host print-config opencode --mode repro-fix
pyro host print-config opencode --output ./opencode.json
pyro host print-config opencode --project-path /abs/path/to/repo
"""
@ -1089,6 +1100,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host doctor
pyro host doctor --mode inspect
pyro host doctor --project-path /abs/path/to/repo
pyro host doctor --installed-package
"""
@ -1112,6 +1124,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host repair claude-code
pyro host repair claude-code --mode review-eval
pyro host repair codex --project-path /abs/path/to/repo
pyro host repair opencode
"""
@ -1141,6 +1154,8 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro mcp serve
pyro mcp serve --mode repro-fix
pyro mcp serve --mode inspect
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
pyro mcp serve --profile vm-run
@ -1155,19 +1170,27 @@ def _build_parser() -> argparse.ArgumentParser:
help="Run the MCP server over stdio.",
description=(
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
"serve` now starts `workspace-core`, the recommended first profile "
"for most chat hosts. When launched from inside a Git checkout, it "
"also seeds the first workspace from that repo by default."
"serve` starts the generic `workspace-core` path. Use `--mode` to "
"start from an opinionated use-case flow, or `--profile` to choose "
"a generic profile directly. When launched from inside a Git "
"checkout, it also seeds the first workspace from that repo by default."
),
epilog=dedent(
"""
Default and recommended first start:
Generic default path:
pyro mcp serve
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
Named modes:
repro-fix: structured edit / diff / export / reset loop
inspect: smallest persistent inspection surface
cold-start: validation plus service readiness
review-eval: shell plus snapshots for review workflows
Profiles:
workspace-core: default for normal persistent chat editing
workspace-core: default for normal persistent chat editing and the
recommended first profile for most chat hosts
vm-run: smallest one-shot-only surface
workspace-full: larger opt-in surface for shells, services,
snapshots, secrets, network policy, and disk tools
@ -1178,22 +1201,28 @@ def _build_parser() -> argparse.ArgumentParser:
- use --project-path when the host does not preserve cwd
- use --repo-url for a clean-clone source outside a local checkout
Use --profile workspace-full only when the host truly needs those
extra workspace capabilities.
Use --mode when one named use case already matches the job. Fall
back to the generic no-mode path when the mode feels too narrow.
"""
),
formatter_class=_HelpFormatter,
)
mcp_serve_parser.add_argument(
mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_profile_group.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help=(
"Expose only one model-facing tool profile. `workspace-core` is "
"the default and recommended first profile for most chat hosts; "
"`workspace-full` is the larger opt-in profile."
"Expose one generic model-facing tool profile instead of a named mode. "
"`workspace-core` is the generic default and `workspace-full` is the "
"larger opt-in profile."
),
)
mcp_profile_group.add_argument(
"--mode",
choices=PUBLIC_MCP_MODES,
help="Expose one opinionated use-case mode instead of the generic profile path.",
)
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_source_group.add_argument(
"--project-path",
@ -2794,6 +2823,7 @@ def main() -> None:
if args.command == "mcp":
pyro.create_server(
profile=args.profile,
mode=getattr(args, "mode", None),
project_path=args.project_path,
repo_url=args.repo_url,
repo_ref=args.repo_ref,

View file

@ -8,6 +8,7 @@ PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
PUBLIC_CLI_HOST_COMMON_FLAGS = (
"--installed-package",
"--mode",
"--profile",
"--project-path",
"--repo-url",
@ -20,6 +21,7 @@ PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--mode",
"--profile",
"--project-path",
"--repo-url",
@ -140,6 +142,7 @@ PUBLIC_CLI_RUN_FLAGS = (
"--json",
)
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
PUBLIC_SDK_METHODS = (
"apply_workspace_patch",
@ -258,4 +261,77 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_file_write",
"workspace_list",
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_INSPECT_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_COLD_START_MODE_TOOLS = (
"service_list",
"service_logs",
"service_start",
"service_status",
"service_stop",
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = (
"shell_close",
"shell_open",
"shell_read",
"shell_signal",
"shell_write",
"snapshot_create",
"snapshot_delete",
"snapshot_list",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS

View file

@ -11,7 +11,7 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Literal
from pyro_mcp.api import McpToolProfile
from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
@ -26,6 +26,7 @@ HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
class HostServerConfig:
installed_package: bool = False
profile: McpToolProfile = "workspace-core"
mode: WorkspaceUseCaseMode | None = None
project_path: str | None = None
repo_url: str | None = None
repo_ref: str | None = None
@ -60,6 +61,8 @@ def _host_binary(host: str) -> str:
def _canonical_server_command(config: HostServerConfig) -> list[str]:
if config.mode is not None and config.profile != "workspace-core":
raise ValueError("--mode and --profile are mutually exclusive")
if config.project_path is not None and config.repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if config.no_project_source and (
@ -76,7 +79,9 @@ def _canonical_server_command(config: HostServerConfig) -> list[str]:
command = ["pyro", "mcp", "serve"]
if not config.installed_package:
command = ["uvx", "--from", "pyro-mcp", *command]
if config.profile != "workspace-core":
if config.mode is not None:
command.extend(["--mode", config.mode])
elif config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
@ -97,7 +102,9 @@ def _repair_command(host: str, config: HostServerConfig, *, config_path: Path |
command = ["pyro", "host", "repair", host]
if config.installed_package:
command.append("--installed-package")
if config.profile != "workspace-core":
if config.mode is not None:
command.extend(["--mode", config.mode])
elif config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])

View file

@ -6,7 +6,7 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP
from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.vm_manager import VmManager
@ -14,6 +14,7 @@ def create_server(
manager: VmManager | None = None,
*,
profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
@ -21,7 +22,8 @@ def create_server(
) -> FastMCP:
"""Create and return a configured MCP server instance.
`workspace-core` is the default stable chat-host profile in 4.x. Use
Bare server creation uses the generic `workspace-core` path in 4.x. Use
`mode=...` for one of the named use-case surfaces, or
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory for
@ -29,6 +31,7 @@ def create_server(
"""
return Pyro(manager=manager).create_server(
profile=profile,
mode=mode,
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "4.3.0"
DEFAULT_CATALOG_VERSION = "4.4.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None
oci_reference: str | None = None
source_digest: str | None = None
compatibility: str = ">=4.3.0,<5.0.0"
compatibility: str = ">=4.4.0,<5.0.0"
@dataclass(frozen=True)

View file

@ -29,7 +29,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC
class WorkspaceUseCaseRecipe:
scenario: str
title: str
profile: Literal["workspace-core", "workspace-full"]
mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"]
smoke_target: str
doc_path: str
summary: str
@ -39,7 +39,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="cold-start-validation",
title="Cold-Start Repo Validation",
profile="workspace-full",
mode="cold-start",
smoke_target="smoke-cold-start-validation",
doc_path="docs/use-cases/cold-start-repo-validation.md",
summary=(
@ -50,7 +50,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="repro-fix-loop",
title="Repro Plus Fix Loop",
profile="workspace-core",
mode="repro-fix",
smoke_target="smoke-repro-fix-loop",
doc_path="docs/use-cases/repro-fix-loop.md",
summary=(
@ -61,7 +61,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="parallel-workspaces",
title="Parallel Isolated Workspaces",
profile="workspace-core",
mode="repro-fix",
smoke_target="smoke-parallel-workspaces",
doc_path="docs/use-cases/parallel-workspaces.md",
summary=(
@ -72,7 +72,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="untrusted-inspection",
title="Unsafe Or Untrusted Code Inspection",
profile="workspace-core",
mode="inspect",
smoke_target="smoke-untrusted-inspection",
doc_path="docs/use-cases/untrusted-inspection.md",
summary=(
@ -83,7 +83,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="review-eval",
title="Review And Evaluation Workflows",
profile="workspace-full",
mode="review-eval",
smoke_target="smoke-review-eval",
doc_path="docs/use-cases/review-eval-workflows.md",
summary=(
@ -141,11 +141,12 @@ def _create_project_aware_workspace(
*,
environment: str,
project_path: Path,
mode: Literal["repro-fix", "cold-start"],
name: str,
labels: dict[str, str],
) -> dict[str, object]:
async def _run() -> dict[str, object]:
server = pyro.create_server(profile="workspace-core", project_path=project_path)
server = pyro.create_server(mode=mode, project_path=project_path)
return _extract_structured_tool_result(
await server.call_tool(
"workspace_create",
@ -194,14 +195,19 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
)
workspace_id: str | None = None
try:
workspace_id = _create_workspace(
created = _create_project_aware_workspace(
pyro,
environment=environment,
seed_path=seed_dir,
project_path=seed_dir,
mode="cold-start",
name="cold-start-validation",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"},
)
workspace_id = str(created["workspace_id"])
_log(f"cold-start-validation workspace_id={workspace_id}")
workspace_seed = created["workspace_seed"]
assert isinstance(workspace_seed, dict), created
assert workspace_seed["origin_kind"] == "project_path", created
validation = pyro.exec_workspace(workspace_id, command="sh validate.sh")
assert int(validation["exit_code"]) == 0, validation
assert str(validation["stdout"]) == "validated\n", validation
@ -259,6 +265,7 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
pyro,
environment=environment,
project_path=seed_dir,
mode="repro-fix",
name="repro-fix-loop",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
)
@ -495,7 +502,7 @@ def run_workspace_use_case_scenario(
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
for scenario_name in scenario_names:
recipe = _RECIPE_BY_SCENARIO[scenario_name]
_log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}")
_log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}")
scenario_root = root / scenario_name
scenario_root.mkdir(parents=True, exist_ok=True)
runner = _SCENARIO_RUNNERS[scenario_name]

View file

@ -6,8 +6,14 @@ import time
from pathlib import Path
from typing import Any, cast
import pytest
from pyro_mcp.api import Pyro
from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
@ -157,6 +163,120 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
assert "workspace_disk_export" not in tool_map
def test_pyro_create_server_repro_fix_mode_registers_expected_tools_and_schemas(
tmp_path: Path,
) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server(mode="repro-fix")
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return sorted(tool_map), tool_map
tool_names, tool_map = asyncio.run(_run())
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
assert "network_policy" not in create_properties
assert "secrets" not in create_properties
exec_properties = tool_map["workspace_exec"]["inputSchema"]["properties"]
assert "secret_env" not in exec_properties
assert "service_start" not in tool_map
assert "shell_open" not in tool_map
assert "snapshot_create" not in tool_map
assert "reproduce a failure" in str(tool_map["workspace_create"]["description"])
def test_pyro_create_server_cold_start_mode_registers_expected_tools_and_schemas(
tmp_path: Path,
) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server(mode="cold-start")
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return sorted(tool_map), tool_map
tool_names, tool_map = asyncio.run(_run())
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
assert "shell_open" not in tool_map
assert "snapshot_create" not in tool_map
service_start_properties = tool_map["service_start"]["inputSchema"]["properties"]
assert "secret_env" not in service_start_properties
assert "published_ports" not in service_start_properties
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
assert "network_policy" not in create_properties
def test_pyro_create_server_review_eval_mode_registers_expected_tools_and_schemas(
tmp_path: Path,
) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server(mode="review-eval")
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return sorted(tool_map), tool_map
tool_names, tool_map = asyncio.run(_run())
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS))
assert "service_start" not in tool_map
assert "shell_open" in tool_map
assert "snapshot_create" in tool_map
shell_open_properties = tool_map["shell_open"]["inputSchema"]["properties"]
assert "secret_env" not in shell_open_properties
def test_pyro_create_server_inspect_mode_registers_expected_tools(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> list[str]:
server = pyro.create_server(mode="inspect")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_INSPECT_MODE_TOOLS))
def test_pyro_create_server_rejects_mode_and_non_default_profile(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
with pytest.raises(ValueError, match="mutually exclusive"):
pyro.create_server(profile="workspace-full", mode="repro-fix")
def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
tmp_path: Path,
) -> None:

View file

@ -3101,7 +3101,7 @@ def test_cli_workspace_shell_open_prints_id_only(
assert captured.err == ""
def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
readme = Path("README.md").read_text(encoding="utf-8")
install = Path("docs/install.md").read_text(encoding="utf-8")
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
@ -3110,19 +3110,24 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8")
opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8"))
claude_helper = "pyro host connect claude-code"
codex_helper = "pyro host connect codex"
opencode_helper = "pyro host print-config opencode"
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
claude_helper = "pyro host connect claude-code --mode cold-start"
codex_helper = "pyro host connect codex --mode repro-fix"
inspect_helper = "pyro host connect codex --mode inspect"
review_helper = "pyro host connect claude-code --mode review-eval"
opencode_helper = "pyro host print-config opencode --mode repro-fix"
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start"
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix"
assert "## Chat Host Quickstart" in readme
assert claude_helper in readme
assert codex_helper in readme
assert inspect_helper in readme
assert review_helper in readme
assert opencode_helper in readme
assert "examples/opencode_mcp_config.json" in readme
assert "pyro host doctor" in readme
assert "bare `pyro mcp serve` starts `workspace-core`" in readme
assert "pyro mcp serve --mode repro-fix" in readme
assert "generic no-mode path" in readme
assert "auto-detects the current Git checkout" in readme.replace("\n", " ")
assert "--project-path /abs/path/to/repo" in readme
assert "--repo-url https://github.com/example/project.git" in readme
@ -3130,44 +3135,54 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
assert "## 5. Connect a chat host" in install
assert claude_helper in install
assert codex_helper in install
assert inspect_helper in install
assert review_helper in install
assert opencode_helper in install
assert "workspace-full" in install
assert "--project-path /abs/path/to/repo" in install
assert "pyro mcp serve --mode cold-start" in install
assert claude_helper in first_run
assert codex_helper in first_run
assert inspect_helper in first_run
assert review_helper in first_run
assert opencode_helper in first_run
assert "--project-path /abs/path/to/repo" in first_run
assert "pyro mcp serve --mode review-eval" in first_run
assert claude_helper in integrations
assert codex_helper in integrations
assert inspect_helper in integrations
assert review_helper in integrations
assert opencode_helper in integrations
assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations
assert "## Recommended Modes" in integrations
assert "pyro mcp serve --mode inspect" in integrations
assert "auto-detects the current Git checkout" in integrations
assert "examples/claude_code_mcp.md" in integrations
assert "examples/codex_mcp.md" in integrations
assert "examples/opencode_mcp_config.json" in integrations
assert "That is the product path." in integrations
assert "generic no-mode path" in integrations
assert "--project-path /abs/path/to/repo" in integrations
assert "--repo-url https://github.com/example/project.git" in integrations
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config
assert "Use the host-specific examples first when they apply:" in mcp_config
assert "claude_code_mcp.md" in mcp_config
assert "codex_mcp.md" in mcp_config
assert "opencode_mcp_config.json" in mcp_config
assert '"serve", "--mode", "repro-fix"' in mcp_config
assert claude_helper in claude_code
assert claude_cmd in claude_code
assert "claude mcp list" in claude_code
assert "pyro host repair claude-code" in claude_code
assert "pyro host repair claude-code --mode cold-start" in claude_code
assert "workspace-full" in claude_code
assert "--project-path /abs/path/to/repo" in claude_code
assert codex_helper in codex
assert codex_cmd in codex
assert "codex mcp list" in codex
assert "pyro host repair codex" in codex
assert "pyro host repair codex --mode repro-fix" in codex
assert "workspace-full" in codex
assert "--project-path /abs/path/to/repo" in codex
@ -3183,6 +3198,8 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
"pyro",
"mcp",
"serve",
"--mode",
"repro-fix",
],
}
}
@ -4349,12 +4366,14 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
self,
*,
profile: str,
mode: str | None,
project_path: str | None,
repo_url: str | None,
repo_ref: str | None,
no_project_source: bool,
) -> Any:
observed["profile"] = profile
observed["mode"] = mode
observed["project_path"] = project_path
observed["repo_url"] = repo_url
observed["repo_ref"] = repo_ref
@ -4371,6 +4390,7 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
command="mcp",
mcp_command="serve",
profile="workspace-core",
mode=None,
project_path="/repo",
repo_url=None,
repo_ref=None,
@ -4382,6 +4402,7 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
cli.main()
assert observed == {
"profile": "workspace-core",
"mode": None,
"project_path": "/repo",
"repo_url": None,
"repo_ref": None,

View file

@ -131,6 +131,16 @@ def test_canonical_server_command_validates_and_renders_variants() -> None:
"--repo-ref",
"main",
]
assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--mode",
"repro-fix",
]
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
"uvx",
"--from",
@ -149,6 +159,10 @@ def test_canonical_server_command_validates_and_renders_variants() -> None:
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
with pytest.raises(ValueError, match="requires --repo-url"):
_canonical_server_command(HostServerConfig(repo_ref="main"))
with pytest.raises(ValueError, match="mutually exclusive"):
_canonical_server_command(
HostServerConfig(profile="workspace-full", mode="repro-fix")
)
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
@ -167,6 +181,9 @@ def test_repair_command_and_command_matches_cover_edge_cases() -> None:
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
"pyro host repair codex --no-project-source"
)
assert _repair_command("codex", HostServerConfig(mode="inspect")) == (
"pyro host repair codex --mode inspect"
)
assert _command_matches(
"pyro: uvx --from pyro-mcp pyro mcp serve",
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],

View file

@ -62,7 +62,12 @@ from pyro_mcp.contract import (
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_MODES,
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_SDK_METHODS,
)
@ -139,6 +144,8 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
assert flag in mcp_serve_help_text
for profile_name in PUBLIC_MCP_PROFILES:
assert profile_name in mcp_serve_help_text
for mode_name in PUBLIC_MCP_MODES:
assert mode_name in mcp_serve_help_text
workspace_help_text = _subparser_choice(parser, "workspace").format_help()
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:
@ -372,6 +379,14 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None:
assert tool_names == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
def test_public_mcp_modes_are_declared_and_non_empty() -> None:
assert PUBLIC_MCP_MODES == ("repro-fix", "inspect", "cold-start", "review-eval")
assert PUBLIC_MCP_REPRO_FIX_MODE_TOOLS
assert PUBLIC_MCP_INSPECT_MODE_TOOLS
assert PUBLIC_MCP_COLD_START_MODE_TOOLS
assert PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS
def test_pyproject_exposes_single_public_cli_script() -> None:
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
scripts = pyproject["project"]["scripts"]

View file

@ -9,6 +9,8 @@ import pytest
import pyro_mcp.server as server_module
from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
)
@ -85,6 +87,36 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path:
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
def test_create_server_repro_fix_mode_registers_expected_tools(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
async def _run() -> list[str]:
server = create_server(manager=manager, mode="repro-fix")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
def test_create_server_cold_start_mode_registers_expected_tools(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
async def _run() -> list[str]:
server = create_server(manager=manager, mode="cold-start")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",

View file

@ -499,8 +499,15 @@ class _FakePyro:
workspace.shells.pop(shell_id, None)
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
def create_server(self, *, profile: str, project_path: Path) -> Any:
def create_server(
self,
*,
profile: str = "workspace-core",
mode: str | None = None,
project_path: Path,
) -> Any:
assert profile == "workspace-core"
assert mode in {"repro-fix", "cold-start"}
seed_path = Path(project_path)
outer = self
@ -554,7 +561,7 @@ def test_use_case_docs_and_targets_stay_aligned() -> None:
recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8")
assert recipe.smoke_target in index_text
assert recipe.doc_path.rsplit("/", 1)[-1] in index_text
assert recipe.profile in recipe_text
assert recipe.mode in recipe_text
assert recipe.smoke_target in recipe_text
assert f"{recipe.smoke_target}:" in makefile_text

2
uv.lock generated
View file

@ -715,7 +715,7 @@ crypto = [
[[package]]
name = "pyro-mcp"
version = "4.3.0"
version = "4.4.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },