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. 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 ## 4.3.0
- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP - 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) - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - 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) - 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/) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
## Who It's For ## Who It's For
@ -76,7 +76,7 @@ What success looks like:
```bash ```bash
Platform: linux-x86_64 Platform: linux-x86_64
Runtime: PASS Runtime: PASS
Catalog version: 4.3.0 Catalog version: 4.4.0
... ...
[pull] phase=install environment=debian:12 [pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12 [pull] phase=ready environment=debian:12
@ -95,13 +95,15 @@ for the guest image.
## Chat Host Quickstart ## Chat Host Quickstart
After the quickstart works, the intended next step is to connect a chat host. After the quickstart works, the intended next step is to connect a chat host in
Use the helper flow first: one named mode. Use the helper flow first:
```bash ```bash
uvx --from pyro-mcp pyro host connect claude-code uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host print-config opencode 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: 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 uvx --from pyro-mcp pyro host repair opencode
``` ```
Those helpers wrap the same `pyro mcp serve` entrypoint. From a repo root, Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when
bare `pyro mcp serve` starts `workspace-core`, auto-detects the current Git one workflow already matches the job. Fall back to the generic no-mode path
checkout, and lets the first `workspace_create` omit `seed_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 ```bash
uvx --from pyro-mcp pyro mcp serve 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 ```bash
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo 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) - OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md) - Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
Claude Code: Claude Code cold-start or review-eval:
```bash ```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 ```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: OpenCode `opencode.json` snippet:
@ -162,7 +177,7 @@ OpenCode `opencode.json` snippet:
"pyro": { "pyro": {
"type": "local", "type": "local",
"enabled": true, "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 If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape. `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. snapshots, secrets, network policy, or disk tools.
## Zero To Hero ## Zero To Hero
1. Validate the host with `pyro doctor`. 1. Validate the host with `pyro doctor`.
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. 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 3. Connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host print-config opencode`, then fall back to raw `pyro mcp serve` `pyro host connect codex --mode repro-fix`, then fall back to raw
with `--project-path` / `--repo-url` when cwd is not the source of truth. `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). 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 5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
for the advertised workflows. for the advertised workflows.

View file

@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes
```bash ```bash
$ uvx --from pyro-mcp pyro env list $ 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 [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-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. 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 ## 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 auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`: `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: Use the helper flow first:
```bash ```bash
$ uvx --from pyro-mcp pyro host connect claude-code $ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
$ uvx --from pyro-mcp pyro host connect codex $ uvx --from pyro-mcp pyro host connect codex --mode inspect
$ uvx --from pyro-mcp pyro host print-config opencode $ 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: If setup drifts later:
@ -110,19 +122,19 @@ $ uvx --from pyro-mcp pyro host repair codex
$ uvx --from pyro-mcp pyro host repair opencode $ uvx --from pyro-mcp pyro host repair opencode
``` ```
Claude Code: Claude Code cold-start or review-eval:
```bash ```bash
$ uvx --from pyro-mcp pyro host connect claude-code $ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
$ 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 $ claude mcp list
``` ```
Codex: Codex repro-fix or inspect:
```bash ```bash
$ uvx --from pyro-mcp pyro host connect codex $ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
$ 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 $ 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 Once the host is connected, move to one of the five recipe docs in
[use-cases/README.md](use-cases/README.md). [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) - [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" $ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID"
``` ```
Move to `--profile workspace-full` only when the chat really needs shells, Move to the generic no-mode path when the named mode is too narrow. Move to
services, snapshots, secrets, network policy, or disk tools. `--profile workspace-full` only when the chat really needs shells, services,
snapshots, secrets, network policy, or disk tools.
## 8. Trust the smoke pack ## 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 If you are running from a repo checkout instead, replace `pyro` with
`uv run pyro`. `uv run pyro`.
After that one-shot proof works, the intended next step is `pyro host connect` After that one-shot proof works, the intended next step is a named chat mode
or `pyro host print-config`. through `pyro host connect` or `pyro host print-config`.
## 1. Check the host ## 1. Check the host
@ -93,7 +93,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```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 [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-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. 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: Use the helper flow first:
```bash ```bash
uvx --from pyro-mcp pyro host connect claude-code uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host print-config opencode 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: 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 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 auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`. `seed_path`.
@ -185,18 +197,18 @@ Copy-paste host-specific starts:
- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json) - OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md) - Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md)
Claude Code: Claude Code cold-start or review-eval:
```bash ```bash
pyro host connect claude-code pyro host connect claude-code --mode cold-start
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 ```bash
pyro host connect codex pyro host connect codex --mode repro-fix
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 uses the `mcp` / `type: "local"` config shape shown in 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 If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape. `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. snapshots, secrets, network policy, or disk tools.
## 6. Go from zero to hero ## 6. Go from zero to hero
@ -215,9 +228,9 @@ The intended user journey is:
1. validate the host with `pyro doctor` 1. validate the host with `pyro doctor`
2. pull `debian:12` 2. pull `debian:12`
3. prove guest execution with `pyro run debian:12 -- git --version` 3. prove guest execution with `pyro run debian:12 -- git --version`
4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or 4. connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host print-config opencode`, then use raw `pyro mcp serve` only when `pyro host connect codex --mode repro-fix`, then use raw
you need `--project-path` / `--repo-url` `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) 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` 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 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). 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 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 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: Use the helper flow before the raw host CLI commands:
```bash ```bash
pyro host connect claude-code pyro host connect codex --mode repro-fix
pyro host connect codex pyro host connect codex --mode inspect
pyro host print-config opencode 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 doctor
pyro host repair opencode pyro host repair opencode
``` ```
These helpers wrap the same `pyro mcp serve` entrypoint, preserve the current These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
`workspace-core` default, and make it obvious how to repair drift later. first user-facing story, and still leave the generic no-mode path available
when a mode is too narrow.
## Claude Code ## Claude Code
Preferred: Preferred:
```bash ```bash
pyro host connect claude-code pyro host connect claude-code --mode cold-start
``` ```
Repair: Repair:
@ -70,14 +95,14 @@ pyro host repair claude-code
Package without install: Package without install:
```bash ```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 claude mcp list
``` ```
If Claude Code launches the server from an unexpected cwd, use: If Claude Code launches the server from an unexpected cwd, use:
```bash ```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: Already installed:
@ -96,7 +121,7 @@ Reference:
Preferred: Preferred:
```bash ```bash
pyro host connect codex pyro host connect codex --mode repro-fix
``` ```
Repair: Repair:
@ -108,14 +133,14 @@ pyro host repair codex
Package without install: Package without install:
```bash ```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 codex mcp list
``` ```
If Codex launches the server from an unexpected cwd, use: If Codex launches the server from an unexpected cwd, use:
```bash ```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: Already installed:
@ -150,7 +175,7 @@ Minimal `opencode.json` snippet:
"pyro": { "pyro": {
"type": "local", "type": "local",
"enabled": true, "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 ## Generic MCP Fallback
Use this only when the host expects a plain `mcpServers` JSON config and does Use this only when the host expects a plain `mcpServers` JSON config, when the
not already have a dedicated example in the repo: 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) - [mcp_client_config.md](../examples/mcp_client_config.md)

View file

@ -75,10 +75,15 @@ pyro mcp serve
What to expect: 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 - from a repo root, bare `pyro mcp serve` also auto-detects the current Git
checkout so `workspace_create` can omit `seed_path` 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 - `pyro mcp serve --profile workspace-full` explicitly opts into the larger
tool surface tool surface
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only 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 These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
setup and repair path for supported hosts. 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: `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: 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) | | Cold-start repo validation | `cold-start` | [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) | | Repro plus fix loop | `repro-fix` | [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) | | Parallel isolated workspaces | `repro-fix` | [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) | | Unsafe or untrusted code inspection | `inspect` | [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) | | 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 Treat this smoke pack as the trustworthy guest-backed verification path for the
advertised product: advertised product:

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface. interface.
Current baseline is `4.3.0`: Current baseline is `4.4.0`:
- `pyro mcp serve` is now the default product entrypoint - `pyro mcp serve` is now the default product entrypoint
- `workspace-core` is now the default MCP profile - `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 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 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 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 16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned
Completed so far: 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 - `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 agent changed and ran since the last reset without reconstructing the
session from several lower-level views by hand. 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: 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) - [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md)
## Expected Outcome ## Expected Outcome

View file

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

View file

@ -12,13 +12,13 @@ make smoke-use-cases
Recipe matrix: 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) | | 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 | `workspace-core` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) | | Repro plus fix loop | `repro-fix` | `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) | | Parallel isolated workspaces | `repro-fix` | `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) | | Unsafe or untrusted code inspection | `inspect` | `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) | | 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: All five recipes use the same real Firecracker-backed smoke runner:

View file

@ -1,6 +1,12 @@
# Cold-Start Repo Validation # 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: Smoke target:
@ -21,6 +27,10 @@ Chat-host recipe:
5. Export the validation report back to the host. 5. Export the validation report back to the host.
6. Delete the workspace when the evaluation is done. 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, This recipe is intentionally guest-local and deterministic. It proves startup,
service readiness, validation, and host-out report capture without depending on service readiness, validation, and host-out report capture without depending on
external networks or private registries. external networks or private registries.

View file

@ -1,6 +1,12 @@
# Parallel Isolated Workspaces # Parallel Isolated Workspaces
Recommended profile: `workspace-core` Recommended mode: `repro-fix`
Recommended startup:
```bash
pyro host connect codex --mode repro-fix
```
Smoke target: Smoke target:
@ -22,4 +28,5 @@ Chat-host recipe:
The important proof here is operational, not syntactic: names, labels, list The important proof here is operational, not syntactic: names, labels, list
ordering, and file contents stay isolated even when multiple workspaces are 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 # Repro Plus Fix Loop
Recommended profile: `workspace-core` Recommended mode: `repro-fix`
Recommended startup:
```bash
pyro host connect codex --mode repro-fix
```
Smoke target: Smoke target:
@ -25,5 +31,8 @@ Chat-host recipe:
7. Diff and export the changed result. 7. Diff and export the changed result.
8. Reset to baseline and delete the workspace. 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. structured diff, explicit export, and reset-over-repair.

View file

@ -1,6 +1,12 @@
# Review And Evaluation Workflows # 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: Smoke target:

View file

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

View file

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

View file

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

View file

@ -1,6 +1,11 @@
# MCP Client Config Example # 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: 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: Preferred repair/bootstrap helpers:
- `pyro host connect claude-code` - `pyro host connect codex --mode repro-fix`
- `pyro host connect codex` - `pyro host connect codex --mode inspect`
- `pyro host print-config opencode` - `pyro host connect claude-code --mode cold-start`
- `pyro host doctor` - `pyro host connect claude-code --mode review-eval`
- `pyro host repair opencode` - `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 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. `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": { "mcpServers": {
"pyro": { "pyro": {
"command": "uvx", "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": { "mcpServers": {
"pyro": { "pyro": {
"command": "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 first `workspace_create` to start from a specific checkout, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list. `"--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 - `repro-fix`: patch, rerun, diff, export, reset
- `vm-run`: expose only `vm_run` - `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 - `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. Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.

View file

@ -3,7 +3,7 @@
"pyro": { "pyro": {
"type": "local", "type": "local",
"enabled": true, "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] [project]
name = "pyro-mcp" name = "pyro-mcp"
version = "4.3.0" version = "4.4.0"
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }

View file

@ -8,7 +8,12 @@ from typing import Any, Literal, cast
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pyro_mcp.contract import ( from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_MODES,
PUBLIC_MCP_PROFILES, PUBLIC_MCP_PROFILES,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_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"] McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"]
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = { _PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, "vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, "workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
"workspace-full": PUBLIC_MCP_WORKSPACE_FULL_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: def _validate_mcp_profile(profile: str) -> McpToolProfile:
@ -45,16 +115,42 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
return cast(McpToolProfile, profile) 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: if startup_source is None:
return "Create and start a persistent workspace." return prefix
described_source = describe_project_startup_source(startup_source) described_source = describe_project_startup_source(startup_source)
if described_source is None: if described_source is None:
return "Create and start a persistent workspace." return prefix
return ( return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}."
"Create and start a persistent workspace. If `seed_path` is omitted, "
f"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: class Pyro:
@ -487,6 +583,7 @@ class Pyro:
self, self,
*, *,
profile: McpToolProfile = "workspace-core", profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None, project_path: str | Path | None = None,
repo_url: str | None = None, repo_url: str | None = None,
repo_ref: str | None = None, repo_ref: str | None = None,
@ -502,13 +599,20 @@ class Pyro:
`repo_url`, and `no_project_source` override that behavior explicitly. `repo_url`, and `no_project_source` override that behavior explicitly.
""" """
normalized_profile = _validate_mcp_profile(profile) 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( startup_source = resolve_project_startup_source(
project_path=project_path, project_path=project_path,
repo_url=repo_url, repo_url=repo_url,
repo_ref=repo_ref, repo_ref=repo_ref,
no_project_source=no_project_source, 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") server = FastMCP(name="pyro_mcp")
def _enabled(tool_name: str) -> bool: def _enabled(tool_name: str) -> bool:
@ -621,7 +725,10 @@ class Pyro:
return self.reap_expired() return self.reap_expired()
if _enabled("workspace_create"): 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( def _create_workspace_from_server_defaults(
*, *,
@ -668,7 +775,7 @@ class Pyro:
_prepared_seed=prepared_seed, _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) @server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_core( async def workspace_create_core(
@ -749,15 +856,21 @@ class Pyro:
) )
if _enabled("workspace_exec"): 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( async def workspace_exec_core(
workspace_id: str, workspace_id: str,
command: str, command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace( return self.exec_workspace(
workspace_id, workspace_id,
command=command, command=command,
@ -767,14 +880,20 @@ class Pyro:
else: 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( async def workspace_exec_full(
workspace_id: str, workspace_id: str,
command: str, command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
secret_env: dict[str, str] | None = None, secret_env: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace( return self.exec_workspace(
workspace_id, workspace_id,
command=command, command=command,
@ -823,20 +942,30 @@ class Pyro:
if _enabled("workspace_summary"): 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]: async def workspace_summary(workspace_id: str) -> dict[str, Any]:
"""Summarize the current workspace session for human review."""
return self.summarize_workspace(workspace_id) return self.summarize_workspace(workspace_id)
if _enabled("workspace_export"): 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( async def workspace_export(
workspace_id: str, workspace_id: str,
path: str, path: str,
output_path: str, output_path: str,
) -> dict[str, Any]: ) -> 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) return self.export_workspace(workspace_id, path, output_path=output_path)
if _enabled("workspace_diff"): if _enabled("workspace_diff"):
@ -848,13 +977,21 @@ class Pyro:
if _enabled("workspace_file_list"): 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( async def workspace_file_list(
workspace_id: str, workspace_id: str,
path: str = "/workspace", path: str = "/workspace",
recursive: bool = False, recursive: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List metadata for files and directories under one live workspace path."""
return self.list_workspace_files( return self.list_workspace_files(
workspace_id, workspace_id,
path=path, path=path,
@ -863,13 +1000,18 @@ class Pyro:
if _enabled("workspace_file_read"): 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( async def workspace_file_read(
workspace_id: str, workspace_id: str,
path: str, path: str,
max_bytes: int = 65536, max_bytes: int = 65536,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Read one regular text file from a live workspace path."""
return self.read_workspace_file( return self.read_workspace_file(
workspace_id, workspace_id,
path, path,
@ -878,13 +1020,18 @@ class Pyro:
if _enabled("workspace_file_write"): 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( async def workspace_file_write(
workspace_id: str, workspace_id: str,
path: str, path: str,
text: str, text: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create or replace one regular text file under `/workspace`."""
return self.write_workspace_file( return self.write_workspace_file(
workspace_id, workspace_id,
path, path,
@ -893,12 +1040,17 @@ class Pyro:
if _enabled("workspace_patch_apply"): 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( async def workspace_patch_apply(
workspace_id: str, workspace_id: str,
patch: str, patch: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Apply a unified text patch inside one live workspace."""
return self.apply_workspace_patch( return self.apply_workspace_patch(
workspace_id, workspace_id,
patch=patch, patch=patch,
@ -976,27 +1128,62 @@ class Pyro:
return self.reset_workspace(workspace_id, snapshot=snapshot) return self.reset_workspace(workspace_id, snapshot=snapshot)
if _enabled("shell_open"): if _enabled("shell_open"):
if normalized_mode == "review-eval":
@server.tool() @server.tool(
async def shell_open( description=_tool_description(
workspace_id: str, "shell_open",
cwd: str = "/workspace", mode=normalized_mode,
cols: int = 120, fallback="Open a persistent interactive shell inside one workspace.",
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,
cols=cols,
rows=rows,
secret_env=secret_env,
) )
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",
cols: int = 120,
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=secret_env,
)
if _enabled("shell_read"): 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( async def shell_read(
workspace_id: str, workspace_id: str,
shell_id: str, shell_id: str,
@ -1005,7 +1192,6 @@ class Pyro:
plain: bool = False, plain: bool = False,
wait_for_idle_ms: int | None = None, wait_for_idle_ms: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Read merged PTY output from a workspace shell."""
return self.read_shell( return self.read_shell(
workspace_id, workspace_id,
shell_id, shell_id,
@ -1017,14 +1203,19 @@ class Pyro:
if _enabled("shell_write"): 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( async def shell_write(
workspace_id: str, workspace_id: str,
shell_id: str, shell_id: str,
input: str, input: str,
append_newline: bool = True, append_newline: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Write text input to a persistent workspace shell."""
return self.write_shell( return self.write_shell(
workspace_id, workspace_id,
shell_id, shell_id,
@ -1034,13 +1225,18 @@ class Pyro:
if _enabled("shell_signal"): 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( async def shell_signal(
workspace_id: str, workspace_id: str,
shell_id: str, shell_id: str,
signal_name: str = "INT", signal_name: str = "INT",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send a signal to the shell process group."""
return self.signal_shell( return self.signal_shell(
workspace_id, workspace_id,
shell_id, shell_id,
@ -1049,74 +1245,142 @@ class Pyro:
if _enabled("shell_close"): 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]: 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) return self.close_shell(workspace_id, shell_id)
if _enabled("service_start"): if _enabled("service_start"):
if normalized_mode == "cold-start":
@server.tool() @server.tool(
async def service_start( description=_tool_description(
workspace_id: str, "service_start",
service_name: str, mode=normalized_mode,
command: str, fallback="Start a named long-running service inside a workspace.",
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,
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}
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=secret_env,
published_ports=published_ports,
) )
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,
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,
secret_env: dict[str, str] | None = None,
published_ports: list[dict[str, int | None]] | None = None,
) -> 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=secret_env,
published_ports=published_ports,
)
if _enabled("service_list"): 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]: async def service_list(workspace_id: str) -> dict[str, Any]:
"""List named services in one workspace."""
return self.list_services(workspace_id) return self.list_services(workspace_id)
if _enabled("service_status"): 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]: 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) return self.status_service(workspace_id, service_name)
if _enabled("service_logs"): 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( async def service_logs(
workspace_id: str, workspace_id: str,
service_name: str, service_name: str,
tail_lines: int = 200, tail_lines: int = 200,
all: bool = False, all: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Read persisted stdout/stderr for one workspace service."""
return self.logs_service( return self.logs_service(
workspace_id, workspace_id,
service_name, service_name,
@ -1126,9 +1390,14 @@ class Pyro:
if _enabled("service_stop"): 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]: 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) return self.stop_service(workspace_id, service_name)
if _enabled("workspace_delete"): if _enabled("workspace_delete"):

View file

@ -11,8 +11,8 @@ from textwrap import dedent
from typing import Any, cast from typing import Any, cast
from pyro_mcp import __version__ from pyro_mcp import __version__
from pyro_mcp.api import McpToolProfile, Pyro from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.contract import PUBLIC_MCP_PROFILES from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
from pyro_mcp.demo import run_demo from pyro_mcp.demo import run_demo
from pyro_mcp.host_helpers import ( from pyro_mcp.host_helpers import (
HostDoctorEntry, HostDoctorEntry,
@ -181,6 +181,7 @@ def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
return HostServerConfig( return HostServerConfig(
installed_package=bool(getattr(args, "installed_package", False)), installed_package=bool(getattr(args, "installed_package", False)),
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))), profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)),
project_path=getattr(args, "project_path", None), project_path=getattr(args, "project_path", None),
repo_url=getattr(args, "repo_url", None), repo_url=getattr(args, "repo_url", None),
repo_ref=getattr(args, "repo_ref", 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", action="store_true",
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.", 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", "--profile",
choices=PUBLIC_MCP_PROFILES, choices=PUBLIC_MCP_PROFILES,
default="workspace-core", 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 = parser.add_mutually_exclusive_group()
source_group.add_argument( source_group.add_argument(
@ -1017,6 +1024,7 @@ def _build_parser() -> argparse.ArgumentParser:
""" """
Examples: Examples:
pyro host connect claude-code 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 connect codex --project-path /abs/path/to/repo
pyro host print-config opencode pyro host print-config opencode
pyro host repair opencode pyro host repair opencode
@ -1037,7 +1045,9 @@ def _build_parser() -> argparse.ArgumentParser:
""" """
Examples: Examples:
pyro host connect claude-code pyro host connect claude-code
pyro host connect claude-code --mode cold-start
pyro host connect codex --installed-package pyro host connect codex --installed-package
pyro host connect codex --mode repro-fix
pyro host connect codex --project-path /abs/path/to/repo pyro host connect codex --project-path /abs/path/to/repo
""" """
), ),
@ -1061,6 +1071,7 @@ def _build_parser() -> argparse.ArgumentParser:
""" """
Examples: Examples:
pyro host print-config opencode 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 --output ./opencode.json
pyro host print-config opencode --project-path /abs/path/to/repo pyro host print-config opencode --project-path /abs/path/to/repo
""" """
@ -1089,6 +1100,7 @@ def _build_parser() -> argparse.ArgumentParser:
""" """
Examples: Examples:
pyro host doctor pyro host doctor
pyro host doctor --mode inspect
pyro host doctor --project-path /abs/path/to/repo pyro host doctor --project-path /abs/path/to/repo
pyro host doctor --installed-package pyro host doctor --installed-package
""" """
@ -1112,6 +1124,7 @@ def _build_parser() -> argparse.ArgumentParser:
""" """
Examples: Examples:
pyro host repair claude-code 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 codex --project-path /abs/path/to/repo
pyro host repair opencode pyro host repair opencode
""" """
@ -1141,6 +1154,8 @@ def _build_parser() -> argparse.ArgumentParser:
""" """
Examples: Examples:
pyro mcp serve pyro mcp serve
pyro mcp serve --mode repro-fix
pyro mcp serve --mode inspect
pyro mcp serve --project-path . pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git pyro mcp serve --repo-url https://github.com/example/project.git
pyro mcp serve --profile vm-run pyro mcp serve --profile vm-run
@ -1155,19 +1170,27 @@ def _build_parser() -> argparse.ArgumentParser:
help="Run the MCP server over stdio.", help="Run the MCP server over stdio.",
description=( description=(
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp " "Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
"serve` now starts `workspace-core`, the recommended first profile " "serve` starts the generic `workspace-core` path. Use `--mode` to "
"for most chat hosts. When launched from inside a Git checkout, it " "start from an opinionated use-case flow, or `--profile` to choose "
"also seeds the first workspace from that repo by default." "a generic profile directly. When launched from inside a Git "
"checkout, it also seeds the first workspace from that repo by default."
), ),
epilog=dedent( epilog=dedent(
""" """
Default and recommended first start: Generic default path:
pyro mcp serve pyro mcp serve
pyro mcp serve --project-path . pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git 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: 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 vm-run: smallest one-shot-only surface
workspace-full: larger opt-in surface for shells, services, workspace-full: larger opt-in surface for shells, services,
snapshots, secrets, network policy, and disk tools 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 --project-path when the host does not preserve cwd
- use --repo-url for a clean-clone source outside a local checkout - use --repo-url for a clean-clone source outside a local checkout
Use --profile workspace-full only when the host truly needs those Use --mode when one named use case already matches the job. Fall
extra workspace capabilities. back to the generic no-mode path when the mode feels too narrow.
""" """
), ),
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
) )
mcp_serve_parser.add_argument( mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_profile_group.add_argument(
"--profile", "--profile",
choices=PUBLIC_MCP_PROFILES, choices=PUBLIC_MCP_PROFILES,
default="workspace-core", default="workspace-core",
help=( help=(
"Expose only one model-facing tool profile. `workspace-core` is " "Expose one generic model-facing tool profile instead of a named mode. "
"the default and recommended first profile for most chat hosts; " "`workspace-core` is the generic default and `workspace-full` is the "
"`workspace-full` is the larger opt-in profile." "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 = mcp_serve_parser.add_mutually_exclusive_group()
mcp_source_group.add_argument( mcp_source_group.add_argument(
"--project-path", "--project-path",
@ -2794,6 +2823,7 @@ def main() -> None:
if args.command == "mcp": if args.command == "mcp":
pyro.create_server( pyro.create_server(
profile=args.profile, profile=args.profile,
mode=getattr(args, "mode", None),
project_path=args.project_path, project_path=args.project_path,
repo_url=args.repo_url, repo_url=args.repo_url,
repo_ref=args.repo_ref, 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_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
PUBLIC_CLI_HOST_COMMON_FLAGS = ( PUBLIC_CLI_HOST_COMMON_FLAGS = (
"--installed-package", "--installed-package",
"--mode",
"--profile", "--profile",
"--project-path", "--project-path",
"--repo-url", "--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_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",) PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = ( PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--mode",
"--profile", "--profile",
"--project-path", "--project-path",
"--repo-url", "--repo-url",
@ -140,6 +142,7 @@ PUBLIC_CLI_RUN_FLAGS = (
"--json", "--json",
) )
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full") PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
PUBLIC_SDK_METHODS = ( PUBLIC_SDK_METHODS = (
"apply_workspace_patch", "apply_workspace_patch",
@ -258,4 +261,77 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
"workspace_sync_push", "workspace_sync_push",
"workspace_update", "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 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 pathlib import Path
from typing import Literal 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_CONNECT_TARGETS = ("claude-code", "codex")
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode") SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
@ -26,6 +26,7 @@ HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
class HostServerConfig: class HostServerConfig:
installed_package: bool = False installed_package: bool = False
profile: McpToolProfile = "workspace-core" profile: McpToolProfile = "workspace-core"
mode: WorkspaceUseCaseMode | None = None
project_path: str | None = None project_path: str | None = None
repo_url: str | None = None repo_url: str | None = None
repo_ref: 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]: 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: if config.project_path is not None and config.repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive") raise ValueError("--project-path and --repo-url are mutually exclusive")
if config.no_project_source and ( if config.no_project_source and (
@ -76,7 +79,9 @@ def _canonical_server_command(config: HostServerConfig) -> list[str]:
command = ["pyro", "mcp", "serve"] command = ["pyro", "mcp", "serve"]
if not config.installed_package: if not config.installed_package:
command = ["uvx", "--from", "pyro-mcp", *command] 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]) command.extend(["--profile", config.profile])
if config.project_path is not None: if config.project_path is not None:
command.extend(["--project-path", config.project_path]) 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] command = ["pyro", "host", "repair", host]
if config.installed_package: if config.installed_package:
command.append("--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]) command.extend(["--profile", config.profile])
if config.project_path is not None: if config.project_path is not None:
command.extend(["--project-path", config.project_path]) command.extend(["--project-path", config.project_path])

View file

@ -6,7 +6,7 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP 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 from pyro_mcp.vm_manager import VmManager
@ -14,6 +14,7 @@ def create_server(
manager: VmManager | None = None, manager: VmManager | None = None,
*, *,
profile: McpToolProfile = "workspace-core", profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None, project_path: str | Path | None = None,
repo_url: str | None = None, repo_url: str | None = None,
repo_ref: str | None = None, repo_ref: str | None = None,
@ -21,7 +22,8 @@ def create_server(
) -> FastMCP: ) -> FastMCP:
"""Create and return a configured MCP server instance. """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 `profile="workspace-full"` only when the host truly needs the full
advanced workspace surface. By default, the server auto-detects the advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory for nearest Git worktree root from its current working directory for
@ -29,6 +31,7 @@ def create_server(
""" """
return Pyro(manager=manager).create_server( return Pyro(manager=manager).create_server(
profile=profile, profile=profile,
mode=mode,
project_path=project_path, project_path=project_path,
repo_url=repo_url, repo_url=repo_url,
repo_ref=repo_ref, repo_ref=repo_ref,

View file

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

View file

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

View file

@ -6,8 +6,14 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
import pytest
from pyro_mcp.api import Pyro from pyro_mcp.api import Pyro
from pyro_mcp.contract import ( 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_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_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 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( def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:

View file

@ -3101,7 +3101,7 @@ def test_cli_workspace_shell_open_prints_id_only(
assert captured.err == "" 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") readme = Path("README.md").read_text(encoding="utf-8")
install = Path("docs/install.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") 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") claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
codex = Path("examples/codex_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")) opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8"))
claude_helper = "pyro host connect claude-code" claude_helper = "pyro host connect claude-code --mode cold-start"
codex_helper = "pyro host connect codex" codex_helper = "pyro host connect codex --mode repro-fix"
opencode_helper = "pyro host print-config opencode" inspect_helper = "pyro host connect codex --mode inspect"
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" review_helper = "pyro host connect claude-code --mode review-eval"
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" 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 "## Chat Host Quickstart" in readme
assert claude_helper in readme assert claude_helper in readme
assert codex_helper in readme assert codex_helper in readme
assert inspect_helper in readme
assert review_helper in readme
assert opencode_helper in readme assert opencode_helper in readme
assert "examples/opencode_mcp_config.json" in readme assert "examples/opencode_mcp_config.json" in readme
assert "pyro host doctor" 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 "auto-detects the current Git checkout" in readme.replace("\n", " ")
assert "--project-path /abs/path/to/repo" in readme assert "--project-path /abs/path/to/repo" in readme
assert "--repo-url https://github.com/example/project.git" 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 "## 5. Connect a chat host" in install
assert claude_helper in install assert claude_helper in install
assert codex_helper in install assert codex_helper in install
assert inspect_helper in install
assert review_helper in install
assert opencode_helper in install assert opencode_helper in install
assert "workspace-full" in install assert "workspace-full" in install
assert "--project-path /abs/path/to/repo" 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 claude_helper in first_run
assert codex_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 opencode_helper in first_run
assert "--project-path /abs/path/to/repo" 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 claude_helper in integrations
assert codex_helper in integrations assert codex_helper in integrations
assert inspect_helper in integrations
assert review_helper in integrations
assert opencode_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 "auto-detects the current Git checkout" in integrations
assert "examples/claude_code_mcp.md" in integrations assert "examples/claude_code_mcp.md" in integrations
assert "examples/codex_mcp.md" in integrations assert "examples/codex_mcp.md" in integrations
assert "examples/opencode_mcp_config.json" 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 "--project-path /abs/path/to/repo" in integrations
assert "--repo-url https://github.com/example/project.git" 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 "Use the host-specific examples first when they apply:" in mcp_config
assert "claude_code_mcp.md" in mcp_config assert "claude_code_mcp.md" in mcp_config
assert "codex_mcp.md" in mcp_config assert "codex_mcp.md" in mcp_config
assert "opencode_mcp_config.json" 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_helper in claude_code
assert claude_cmd in claude_code assert claude_cmd in claude_code
assert "claude mcp list" 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 "workspace-full" in claude_code
assert "--project-path /abs/path/to/repo" in claude_code assert "--project-path /abs/path/to/repo" in claude_code
assert codex_helper in codex assert codex_helper in codex
assert codex_cmd in codex assert codex_cmd in codex
assert "codex mcp list" 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 "workspace-full" in codex
assert "--project-path /abs/path/to/repo" 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", "pyro",
"mcp", "mcp",
"serve", "serve",
"--mode",
"repro-fix",
], ],
} }
} }
@ -4349,12 +4366,14 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
self, self,
*, *,
profile: str, profile: str,
mode: str | None,
project_path: str | None, project_path: str | None,
repo_url: str | None, repo_url: str | None,
repo_ref: str | None, repo_ref: str | None,
no_project_source: bool, no_project_source: bool,
) -> Any: ) -> Any:
observed["profile"] = profile observed["profile"] = profile
observed["mode"] = mode
observed["project_path"] = project_path observed["project_path"] = project_path
observed["repo_url"] = repo_url observed["repo_url"] = repo_url
observed["repo_ref"] = repo_ref observed["repo_ref"] = repo_ref
@ -4367,21 +4386,23 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
class StubParser: class StubParser:
def parse_args(self) -> argparse.Namespace: def parse_args(self) -> argparse.Namespace:
return argparse.Namespace( return argparse.Namespace(
command="mcp", command="mcp",
mcp_command="serve", mcp_command="serve",
profile="workspace-core", profile="workspace-core",
project_path="/repo", mode=None,
repo_url=None, project_path="/repo",
repo_ref=None, repo_url=None,
no_project_source=False, repo_ref=None,
) no_project_source=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro) monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main() cli.main()
assert observed == { assert observed == {
"profile": "workspace-core", "profile": "workspace-core",
"mode": None,
"project_path": "/repo", "project_path": "/repo",
"repo_url": None, "repo_url": None,
"repo_ref": None, "repo_ref": None,

View file

@ -131,6 +131,16 @@ def test_canonical_server_command_validates_and_renders_variants() -> None:
"--repo-ref", "--repo-ref",
"main", "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)) == [ assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
"uvx", "uvx",
"--from", "--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)) _canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
with pytest.raises(ValueError, match="requires --repo-url"): with pytest.raises(ValueError, match="requires --repo-url"):
_canonical_server_command(HostServerConfig(repo_ref="main")) _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: 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)) == ( assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
"pyro host repair codex --no-project-source" "pyro host repair codex --no-project-source"
) )
assert _repair_command("codex", HostServerConfig(mode="inspect")) == (
"pyro host repair codex --mode inspect"
)
assert _command_matches( assert _command_matches(
"pyro: uvx --from pyro-mcp pyro mcp serve", "pyro: uvx --from pyro-mcp pyro mcp serve",
["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_PUSH_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_MODES,
PUBLIC_MCP_PROFILES, PUBLIC_MCP_PROFILES,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_SDK_METHODS, 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 assert flag in mcp_serve_help_text
for profile_name in PUBLIC_MCP_PROFILES: for profile_name in PUBLIC_MCP_PROFILES:
assert profile_name in mcp_serve_help_text 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() workspace_help_text = _subparser_choice(parser, "workspace").format_help()
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS: 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)) 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: def test_pyproject_exposes_single_public_cli_script() -> None:
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
scripts = pyproject["project"]["scripts"] scripts = pyproject["project"]["scripts"]

View file

@ -9,6 +9,8 @@ import pytest
import pyro_mcp.server as server_module import pyro_mcp.server as server_module
from pyro_mcp.contract import ( 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_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_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)) 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: def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
manager = VmManager( manager = VmManager(
backend_name="mock", backend_name="mock",

View file

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

2
uv.lock generated
View file

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