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:
parent
dc86d84e96
commit
d0cf6d8f21
33 changed files with 1034 additions and 274 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,6 +2,17 @@
|
|||
|
||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||
|
||||
## 4.4.0
|
||||
|
||||
- Added explicit named MCP/server modes for the main workspace workflows:
|
||||
`repro-fix`, `inspect`, `cold-start`, and `review-eval`.
|
||||
- Kept the generic no-mode `workspace-core` path available as the escape hatch,
|
||||
while making named modes the first user-facing story across help text, host
|
||||
helpers, and the recipe docs.
|
||||
- Aligned the shared use-case smoke runner with those modes so the repro/fix
|
||||
and cold-start flows now prove a mode-backed happy path instead of only the
|
||||
generic profile path.
|
||||
|
||||
## 4.3.0
|
||||
|
||||
- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -30,7 +30,7 @@ SDK-first platform.
|
|||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
||||
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||
- What's new in 4.3.0: [CHANGELOG.md#430](CHANGELOG.md#430)
|
||||
- What's new in 4.4.0: [CHANGELOG.md#440](CHANGELOG.md#440)
|
||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||
|
||||
## Who It's For
|
||||
|
|
@ -76,7 +76,7 @@ What success looks like:
|
|||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
Catalog version: 4.3.0
|
||||
Catalog version: 4.4.0
|
||||
...
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
|
|
@ -95,13 +95,15 @@ for the guest image.
|
|||
|
||||
## Chat Host Quickstart
|
||||
|
||||
After the quickstart works, the intended next step is to connect a chat host.
|
||||
Use the helper flow first:
|
||||
After the quickstart works, the intended next step is to connect a chat host in
|
||||
one named mode. Use the helper flow first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect claude-code
|
||||
uvx --from pyro-mcp pyro host connect codex
|
||||
uvx --from pyro-mcp pyro host print-config opencode
|
||||
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts or you want to inspect it first:
|
||||
|
|
@ -113,15 +115,28 @@ uvx --from pyro-mcp pyro host repair codex
|
|||
uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Those helpers wrap the same `pyro mcp serve` entrypoint. From a repo root,
|
||||
bare `pyro mcp serve` starts `workspace-core`, auto-detects the current Git
|
||||
checkout, and lets the first `workspace_create` omit `seed_path`.
|
||||
Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when
|
||||
one workflow already matches the job. Fall back to the generic no-mode path
|
||||
when the mode feels too narrow.
|
||||
|
||||
Mode examples:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Generic escape hatch:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
If the host does not preserve the server working directory, use:
|
||||
From a repo root, the generic path auto-detects the current Git checkout and
|
||||
lets the first `workspace_create` omit `seed_path`. If the host does not
|
||||
preserve the server working directory, use:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo
|
||||
|
|
@ -142,16 +157,16 @@ Copy-paste host-specific starts:
|
|||
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
|
||||
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
||||
|
||||
Claude Code:
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
```
|
||||
|
||||
Codex:
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
```
|
||||
|
||||
OpenCode `opencode.json` snippet:
|
||||
|
|
@ -162,7 +177,7 @@ OpenCode `opencode.json` snippet:
|
|||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -176,18 +191,19 @@ array.
|
|||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same command or config shape.
|
||||
|
||||
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
||||
Use the generic no-mode path when the named mode feels too narrow. Move to
|
||||
`--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## Zero To Hero
|
||||
|
||||
1. Validate the host with `pyro doctor`.
|
||||
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`.
|
||||
3. Connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
|
||||
`pyro host print-config opencode`, then fall back to raw `pyro mcp serve`
|
||||
with `--project-path` / `--repo-url` when cwd is not the source of truth.
|
||||
3. Connect Claude Code, Codex, or OpenCode with one named mode such as
|
||||
`pyro host connect codex --mode repro-fix`, then fall back to raw
|
||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
|
||||
4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
|
||||
`repro-fix-loop` is the shortest chat-first story.
|
||||
`repro-fix` is the shortest chat-first mode and story.
|
||||
5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
|
||||
for the advertised workflows.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes
|
|||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env list
|
||||
Catalog version: 4.3.0
|
||||
Catalog version: 4.4.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -71,7 +71,17 @@ streams, so they may appear in either order in terminals or capture tools. Use
|
|||
|
||||
## 5. Start the MCP server
|
||||
|
||||
Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Use the generic no-mode path when the mode feels too narrow. Bare
|
||||
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`:
|
||||
|
||||
|
|
@ -96,9 +106,11 @@ $ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/proje
|
|||
Use the helper flow first:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code
|
||||
$ uvx --from pyro-mcp pyro host connect codex
|
||||
$ uvx --from pyro-mcp pyro host print-config opencode
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts later:
|
||||
|
|
@ -110,19 +122,19 @@ $ uvx --from pyro-mcp pyro host repair codex
|
|||
$ uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Claude Code:
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code
|
||||
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
$ claude mcp list
|
||||
```
|
||||
|
||||
Codex:
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect codex
|
||||
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
$ codex mcp list
|
||||
```
|
||||
|
||||
|
|
@ -141,7 +153,7 @@ Other host-specific references:
|
|||
Once the host is connected, move to one of the five recipe docs in
|
||||
[use-cases/README.md](use-cases/README.md).
|
||||
|
||||
The shortest chat-first story is:
|
||||
The shortest chat-first mode and story is:
|
||||
|
||||
- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md)
|
||||
|
||||
|
|
@ -162,8 +174,9 @@ $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./
|
|||
$ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||
Move to `--profile workspace-full` only when the chat really needs shells,
|
||||
services, snapshots, secrets, network policy, or disk tools.
|
||||
Move to the generic no-mode path when the named mode is too narrow. Move to
|
||||
`--profile workspace-full` only when the chat really needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## 8. Trust the smoke pack
|
||||
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ pyro run debian:12 -- git --version
|
|||
If you are running from a repo checkout instead, replace `pyro` with
|
||||
`uv run pyro`.
|
||||
|
||||
After that one-shot proof works, the intended next step is `pyro host connect`
|
||||
or `pyro host print-config`.
|
||||
After that one-shot proof works, the intended next step is a named chat mode
|
||||
through `pyro host connect` or `pyro host print-config`.
|
||||
|
||||
## 1. Check the host
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 4.3.0
|
||||
Catalog version: 4.4.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -144,9 +144,11 @@ deterministic structured result.
|
|||
Use the helper flow first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect claude-code
|
||||
uvx --from pyro-mcp pyro host connect codex
|
||||
uvx --from pyro-mcp pyro host print-config opencode
|
||||
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts later, inspect and repair it with:
|
||||
|
|
@ -158,7 +160,17 @@ uvx --from pyro-mcp pyro host repair codex
|
|||
uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Use the generic no-mode path when the mode feels too narrow. Bare
|
||||
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`.
|
||||
|
||||
|
|
@ -185,18 +197,18 @@ Copy-paste host-specific starts:
|
|||
- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
Claude Code:
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
pyro host connect claude-code --mode cold-start
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
```
|
||||
|
||||
Codex:
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
pyro host connect codex
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
pyro host connect codex --mode repro-fix
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
```
|
||||
|
||||
OpenCode uses the `mcp` / `type: "local"` config shape shown in
|
||||
|
|
@ -205,7 +217,8 @@ OpenCode uses the `mcp` / `type: "local"` config shape shown in
|
|||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same command or config shape.
|
||||
|
||||
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
||||
Use the generic no-mode path when the named mode is too narrow. Move to
|
||||
`--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## 6. Go from zero to hero
|
||||
|
|
@ -215,9 +228,9 @@ The intended user journey is:
|
|||
1. validate the host with `pyro doctor`
|
||||
2. pull `debian:12`
|
||||
3. prove guest execution with `pyro run debian:12 -- git --version`
|
||||
4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
|
||||
`pyro host print-config opencode`, then use raw `pyro mcp serve` only when
|
||||
you need `--project-path` / `--repo-url`
|
||||
4. connect Claude Code, Codex, or OpenCode with one named mode such as
|
||||
`pyro host connect codex --mode repro-fix`, then use raw
|
||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed
|
||||
5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
|
||||
6. trust but verify with `make smoke-use-cases`
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,29 @@ path is still being shaped.
|
|||
Use this page after you have already validated the host and guest execution
|
||||
through [install.md](install.md) or [first-run.md](first-run.md).
|
||||
|
||||
## Recommended Default
|
||||
## Recommended Modes
|
||||
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
```
|
||||
|
||||
The mode-backed raw server forms are:
|
||||
|
||||
```bash
|
||||
pyro mcp serve --mode repro-fix
|
||||
pyro mcp serve --mode inspect
|
||||
pyro mcp serve --mode cold-start
|
||||
pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Use the generic no-mode path only when the named mode feels too narrow.
|
||||
|
||||
## Generic Default
|
||||
|
||||
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
|
|
@ -43,22 +65,25 @@ snapshots, secrets, network policy, or disk tools.
|
|||
Use the helper flow before the raw host CLI commands:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code
|
||||
pyro host connect codex
|
||||
pyro host print-config opencode
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
pyro host print-config opencode --mode repro-fix
|
||||
pyro host doctor
|
||||
pyro host repair opencode
|
||||
```
|
||||
|
||||
These helpers wrap the same `pyro mcp serve` entrypoint, preserve the current
|
||||
`workspace-core` default, and make it obvious how to repair drift later.
|
||||
These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
|
||||
first user-facing story, and still leave the generic no-mode path available
|
||||
when a mode is too narrow.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Preferred:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code
|
||||
pyro host connect claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Repair:
|
||||
|
|
@ -70,14 +95,14 @@ pyro host repair claude-code
|
|||
Package without install:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
If Claude Code launches the server from an unexpected cwd, use:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
Already installed:
|
||||
|
|
@ -96,7 +121,7 @@ Reference:
|
|||
Preferred:
|
||||
|
||||
```bash
|
||||
pyro host connect codex
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Repair:
|
||||
|
|
@ -108,14 +133,14 @@ pyro host repair codex
|
|||
Package without install:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
If Codex launches the server from an unexpected cwd, use:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
Already installed:
|
||||
|
|
@ -150,7 +175,7 @@ Minimal `opencode.json` snippet:
|
|||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,8 +190,9 @@ array.
|
|||
|
||||
## Generic MCP Fallback
|
||||
|
||||
Use this only when the host expects a plain `mcpServers` JSON config and does
|
||||
not already have a dedicated example in the repo:
|
||||
Use this only when the host expects a plain `mcpServers` JSON config, when the
|
||||
named modes are too narrow, and when it does not already have a dedicated
|
||||
example in the repo:
|
||||
|
||||
- [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
|
|
|
|||
|
|
@ -75,10 +75,15 @@ pyro mcp serve
|
|||
|
||||
What to expect:
|
||||
|
||||
- bare `pyro mcp serve` starts `workspace-core`
|
||||
- named modes are now the first chat-host story:
|
||||
- `pyro mcp serve --mode repro-fix`
|
||||
- `pyro mcp serve --mode inspect`
|
||||
- `pyro mcp serve --mode cold-start`
|
||||
- `pyro mcp serve --mode review-eval`
|
||||
- bare `pyro mcp serve` remains the generic no-mode path and starts
|
||||
`workspace-core`
|
||||
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
|
||||
checkout so `workspace_create` can omit `seed_path`
|
||||
- `workspace-core` is the default product path for chat hosts
|
||||
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
|
||||
tool surface
|
||||
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
|
||||
|
|
@ -105,7 +110,21 @@ The chat-host bootstrap helper surface is:
|
|||
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
|
||||
setup and repair path for supported hosts.
|
||||
|
||||
## Chat-Facing Workspace Contract
|
||||
## Named Modes
|
||||
|
||||
The supported named modes are:
|
||||
|
||||
| Mode | Intended workflow | Key tools |
|
||||
| --- | --- | --- |
|
||||
| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary |
|
||||
| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary |
|
||||
| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools |
|
||||
| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary |
|
||||
|
||||
Use the generic no-mode path when one of those named modes feels too narrow for
|
||||
the job.
|
||||
|
||||
## Generic Workspace Contract
|
||||
|
||||
`workspace-core` is the normal chat path. It exposes:
|
||||
|
||||
|
|
@ -151,13 +170,13 @@ Move to `workspace-full` only when the chat truly needs:
|
|||
|
||||
The documented product workflows are:
|
||||
|
||||
| Workflow | Recommended profile | Doc |
|
||||
| Workflow | Recommended mode | Doc |
|
||||
| --- | --- | --- |
|
||||
| Cold-start repo validation | `workspace-full` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `workspace-core` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `workspace-core` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `workspace-core` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `workspace-full` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
|
||||
| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
|
||||
|
||||
Treat this smoke pack as the trustworthy guest-backed verification path for the
|
||||
advertised product:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ goal:
|
|||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||
interface.
|
||||
|
||||
Current baseline is `4.3.0`:
|
||||
Current baseline is `4.4.0`:
|
||||
|
||||
- `pyro mcp serve` is now the default product entrypoint
|
||||
- `workspace-core` is now the default MCP profile
|
||||
|
|
@ -82,7 +82,7 @@ capability gaps:
|
|||
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done
|
||||
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done
|
||||
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done
|
||||
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned
|
||||
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done
|
||||
16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned
|
||||
|
||||
Completed so far:
|
||||
|
|
@ -123,10 +123,12 @@ Completed so far:
|
|||
- `4.3.0` adds a concise workspace review surface so users can inspect what the
|
||||
agent changed and ran since the last reset without reconstructing the
|
||||
session from several lower-level views by hand.
|
||||
- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`,
|
||||
`inspect`, `cold-start`, or `review-eval` instead of choosing from the full
|
||||
generic workspace surface first.
|
||||
|
||||
Planned next:
|
||||
|
||||
- [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md)
|
||||
- [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md)
|
||||
|
||||
## Expected Outcome
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# `4.4.0` Opinionated Use-Case Modes
|
||||
|
||||
Status: Planned
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ make smoke-use-cases
|
|||
|
||||
Recipe matrix:
|
||||
|
||||
| Use case | Recommended profile | Smoke target | Recipe |
|
||||
| Use case | Recommended mode | Smoke target | Recipe |
|
||||
| --- | --- | --- | --- |
|
||||
| Cold-start repo validation | `workspace-full` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `workspace-core` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `workspace-core` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `workspace-core` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `workspace-full` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
|
||||
| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `repro-fix` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `inspect` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `review-eval` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
|
||||
|
||||
All five recipes use the same real Firecracker-backed smoke runner:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Cold-Start Repo Validation
|
||||
|
||||
Recommended profile: `workspace-full`
|
||||
Recommended mode: `cold-start`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -21,6 +27,10 @@ Chat-host recipe:
|
|||
5. Export the validation report back to the host.
|
||||
6. Delete the workspace when the evaluation is done.
|
||||
|
||||
If the named mode feels too narrow, fall back to the generic no-mode path and
|
||||
then opt into `--profile workspace-full` only when you truly need the larger
|
||||
advanced surface.
|
||||
|
||||
This recipe is intentionally guest-local and deterministic. It proves startup,
|
||||
service readiness, validation, and host-out report capture without depending on
|
||||
external networks or private registries.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Parallel Isolated Workspaces
|
||||
|
||||
Recommended profile: `workspace-core`
|
||||
Recommended mode: `repro-fix`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -22,4 +28,5 @@ Chat-host recipe:
|
|||
|
||||
The important proof here is operational, not syntactic: names, labels, list
|
||||
ordering, and file contents stay isolated even when multiple workspaces are
|
||||
active at the same time.
|
||||
active at the same time. Parallel work still means “open another workspace in
|
||||
the same mode,” not “pick a special parallel-work mode.”
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Repro Plus Fix Loop
|
||||
|
||||
Recommended profile: `workspace-core`
|
||||
Recommended mode: `repro-fix`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
@ -25,5 +31,8 @@ Chat-host recipe:
|
|||
7. Diff and export the changed result.
|
||||
8. Reset to baseline and delete the workspace.
|
||||
|
||||
This is the main `workspace-core` story: model-native file ops, repeatable exec,
|
||||
If the mode feels too narrow for the job, fall back to the generic bare
|
||||
`pyro mcp serve` path.
|
||||
|
||||
This is the main `repro-fix` story: model-native file ops, repeatable exec,
|
||||
structured diff, explicit export, and reset-over-repair.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Review And Evaluation Workflows
|
||||
|
||||
Recommended profile: `workspace-full`
|
||||
Recommended mode: `review-eval`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode review-eval
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
# Unsafe Or Untrusted Code Inspection
|
||||
|
||||
Recommended profile: `workspace-core`
|
||||
Recommended mode: `inspect`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode inspect
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
# Claude Code MCP Setup
|
||||
|
||||
Recommended profile: `workspace-core`.
|
||||
Recommended modes:
|
||||
|
||||
- `cold-start`
|
||||
- `review-eval`
|
||||
|
||||
Preferred helper flow:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code
|
||||
pyro host doctor
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
pyro host doctor --mode cold-start
|
||||
```
|
||||
|
||||
Package without install:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
|
|
@ -22,7 +26,7 @@ from the current checkout automatically.
|
|||
Already installed:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- pyro mcp serve
|
||||
claude mcp add pyro -- pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
|
|
@ -30,14 +34,14 @@ If Claude Code launches the server from an unexpected cwd, pin the project
|
|||
explicitly:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --project-path /abs/path/to/repo
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If the local config drifts later:
|
||||
|
||||
```bash
|
||||
pyro host repair claude-code
|
||||
pyro host repair claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
# Codex MCP Setup
|
||||
|
||||
Recommended profile: `workspace-core`.
|
||||
Recommended modes:
|
||||
|
||||
- `repro-fix`
|
||||
- `inspect`
|
||||
|
||||
Preferred helper flow:
|
||||
|
||||
```bash
|
||||
pyro host connect codex
|
||||
pyro host doctor
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host doctor --mode repro-fix
|
||||
```
|
||||
|
||||
Package without install:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
|
|
@ -22,7 +26,7 @@ from the current checkout automatically.
|
|||
Already installed:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- pyro mcp serve
|
||||
codex mcp add pyro -- pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
|
|
@ -30,14 +34,14 @@ If Codex launches the server from an unexpected cwd, pin the project
|
|||
explicitly:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --project-path /abs/path/to/repo
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If the local config drifts later:
|
||||
|
||||
```bash
|
||||
pyro host repair codex
|
||||
pyro host repair codex --mode repro-fix
|
||||
```
|
||||
|
||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
# MCP Client Config Example
|
||||
|
||||
Default for most chat hosts in `4.x`: `workspace-core`.
|
||||
Recommended named modes for most chat hosts in `4.x`:
|
||||
|
||||
- `repro-fix`
|
||||
- `inspect`
|
||||
- `cold-start`
|
||||
- `review-eval`
|
||||
|
||||
Use the host-specific examples first when they apply:
|
||||
|
||||
|
|
@ -10,14 +15,16 @@ Use the host-specific examples first when they apply:
|
|||
|
||||
Preferred repair/bootstrap helpers:
|
||||
|
||||
- `pyro host connect claude-code`
|
||||
- `pyro host connect codex`
|
||||
- `pyro host print-config opencode`
|
||||
- `pyro host doctor`
|
||||
- `pyro host repair opencode`
|
||||
- `pyro host connect codex --mode repro-fix`
|
||||
- `pyro host connect codex --mode inspect`
|
||||
- `pyro host connect claude-code --mode cold-start`
|
||||
- `pyro host connect claude-code --mode review-eval`
|
||||
- `pyro host print-config opencode --mode repro-fix`
|
||||
- `pyro host doctor --mode repro-fix`
|
||||
- `pyro host repair opencode --mode repro-fix`
|
||||
|
||||
Use this generic config only when the host expects a plain `mcpServers` JSON
|
||||
shape.
|
||||
shape or when the named modes are too narrow for the workflow.
|
||||
|
||||
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
|
||||
|
||||
|
|
@ -28,7 +35,7 @@ Generic stdio MCP configuration using `uvx`:
|
|||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "uvx",
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +48,7 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
|
|||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "pyro",
|
||||
"args": ["mcp", "serve"]
|
||||
"args": ["mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -51,15 +58,18 @@ If the host does not preserve the server working directory and you want the
|
|||
first `workspace_create` to start from a specific checkout, add
|
||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
|
||||
|
||||
Profile progression:
|
||||
Mode progression:
|
||||
|
||||
- `workspace-core`: the default and recommended first persistent chat profile
|
||||
- `vm-run`: expose only `vm_run`
|
||||
- `repro-fix`: patch, rerun, diff, export, reset
|
||||
- `inspect`: narrow offline-by-default inspection
|
||||
- `cold-start`: validation plus service readiness
|
||||
- `review-eval`: shell and snapshot-driven review
|
||||
- generic no-mode path: the fallback when the named mode is too narrow
|
||||
- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools
|
||||
|
||||
Primary profile for most agents:
|
||||
Primary mode for most agents:
|
||||
|
||||
- `workspace-core`
|
||||
- `repro-fix`
|
||||
|
||||
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "pyro-mcp"
|
||||
version = "4.3.0"
|
||||
version = "4.4.0"
|
||||
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ from typing import Any, Literal, cast
|
|||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from pyro_mcp.contract import (
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
PUBLIC_MCP_MODES,
|
||||
PUBLIC_MCP_PROFILES,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||
|
|
@ -30,12 +35,77 @@ from pyro_mcp.vm_manager import (
|
|||
)
|
||||
|
||||
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
|
||||
WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"]
|
||||
|
||||
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
|
||||
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
"workspace-full": PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||
}
|
||||
_MODE_TOOLS: dict[str, tuple[str, ...]] = {
|
||||
"repro-fix": PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
"inspect": PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
"cold-start": PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
"review-eval": PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
}
|
||||
_MODE_CREATE_INTENT: dict[str, str] = {
|
||||
"repro-fix": "to reproduce a failure, patch files, rerun, diff, export, and reset",
|
||||
"inspect": "to inspect suspicious or unfamiliar code with the smallest persistent surface",
|
||||
"cold-start": (
|
||||
"to validate a fresh repo, keep one service alive, and export a "
|
||||
"validation report"
|
||||
),
|
||||
"review-eval": "to review interactively, checkpoint work, and export the final report",
|
||||
}
|
||||
_MODE_TOOL_DESCRIPTIONS: dict[str, dict[str, str]] = {
|
||||
"repro-fix": {
|
||||
"workspace_file_read": "Read one workspace file while investigating the broken state.",
|
||||
"workspace_file_write": "Write one workspace file directly as part of the fix loop.",
|
||||
"workspace_patch_apply": "Apply a structured text patch inside the workspace fix loop.",
|
||||
"workspace_export": "Export the fixed result or patch-ready file back to the host.",
|
||||
"workspace_summary": (
|
||||
"Summarize the current repro/fix session for review before export "
|
||||
"or reset."
|
||||
),
|
||||
},
|
||||
"inspect": {
|
||||
"workspace_file_list": "List suspicious files under the current workspace path.",
|
||||
"workspace_file_read": "Read one suspicious or unfamiliar workspace file.",
|
||||
"workspace_export": (
|
||||
"Export only the inspection report or artifact you chose to "
|
||||
"materialize."
|
||||
),
|
||||
"workspace_summary": "Summarize the current inspection session and its exported results.",
|
||||
},
|
||||
"cold-start": {
|
||||
"workspace_create": "Create and start a persistent workspace for cold-start validation.",
|
||||
"workspace_export": "Export the validation report or other final host-visible result.",
|
||||
"workspace_summary": (
|
||||
"Summarize the current validation session, service state, and "
|
||||
"exports."
|
||||
),
|
||||
"service_start": "Start a named validation or app service and wait for typed readiness.",
|
||||
"service_list": "List running and exited validation services in the current workspace.",
|
||||
"service_status": "Inspect one validation service and its readiness outcome.",
|
||||
"service_logs": "Read stdout and stderr from one validation service.",
|
||||
"service_stop": "Stop one validation service in the workspace.",
|
||||
},
|
||||
"review-eval": {
|
||||
"workspace_create": (
|
||||
"Create and start a persistent workspace for interactive review "
|
||||
"or evaluation."
|
||||
),
|
||||
"workspace_summary": "Summarize the current review session before exporting or resetting.",
|
||||
"shell_open": "Open an interactive review shell inside the workspace.",
|
||||
"shell_read": "Read chat-friendly PTY output from the current review shell.",
|
||||
"shell_write": "Send one line of input to the current review shell.",
|
||||
"shell_signal": "Interrupt or terminate the current review shell process group.",
|
||||
"shell_close": "Close the current review shell.",
|
||||
"snapshot_create": "Create a checkpoint before the review branch diverges.",
|
||||
"snapshot_list": "List the baseline plus named review checkpoints.",
|
||||
"snapshot_delete": "Delete one named review checkpoint.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _validate_mcp_profile(profile: str) -> McpToolProfile:
|
||||
|
|
@ -45,16 +115,42 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
|
|||
return cast(McpToolProfile, profile)
|
||||
|
||||
|
||||
def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str:
|
||||
def _validate_workspace_mode(mode: str) -> WorkspaceUseCaseMode:
|
||||
if mode not in PUBLIC_MCP_MODES:
|
||||
expected = ", ".join(PUBLIC_MCP_MODES)
|
||||
raise ValueError(f"unknown workspace mode {mode!r}; expected one of: {expected}")
|
||||
return cast(WorkspaceUseCaseMode, mode)
|
||||
|
||||
|
||||
def _workspace_create_description(
|
||||
startup_source: ProjectStartupSource | None,
|
||||
*,
|
||||
mode: WorkspaceUseCaseMode | None = None,
|
||||
) -> str:
|
||||
if mode is not None:
|
||||
prefix = (
|
||||
"Create and start a persistent workspace "
|
||||
f"{_MODE_CREATE_INTENT[mode]}."
|
||||
)
|
||||
else:
|
||||
prefix = "Create and start a persistent workspace."
|
||||
if startup_source is None:
|
||||
return "Create and start a persistent workspace."
|
||||
return prefix
|
||||
described_source = describe_project_startup_source(startup_source)
|
||||
if described_source is None:
|
||||
return "Create and start a persistent workspace."
|
||||
return (
|
||||
"Create and start a persistent workspace. If `seed_path` is omitted, "
|
||||
f"the server seeds from {described_source}."
|
||||
)
|
||||
return prefix
|
||||
return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}."
|
||||
|
||||
|
||||
def _tool_description(
|
||||
tool_name: str,
|
||||
*,
|
||||
mode: WorkspaceUseCaseMode | None,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
if mode is None:
|
||||
return fallback
|
||||
return _MODE_TOOL_DESCRIPTIONS.get(mode, {}).get(tool_name, fallback)
|
||||
|
||||
|
||||
class Pyro:
|
||||
|
|
@ -487,6 +583,7 @@ class Pyro:
|
|||
self,
|
||||
*,
|
||||
profile: McpToolProfile = "workspace-core",
|
||||
mode: WorkspaceUseCaseMode | None = None,
|
||||
project_path: str | Path | None = None,
|
||||
repo_url: str | None = None,
|
||||
repo_ref: str | None = None,
|
||||
|
|
@ -502,13 +599,20 @@ class Pyro:
|
|||
`repo_url`, and `no_project_source` override that behavior explicitly.
|
||||
"""
|
||||
normalized_profile = _validate_mcp_profile(profile)
|
||||
normalized_mode = _validate_workspace_mode(mode) if mode is not None else None
|
||||
if normalized_mode is not None and normalized_profile != "workspace-core":
|
||||
raise ValueError("mode and profile are mutually exclusive")
|
||||
startup_source = resolve_project_startup_source(
|
||||
project_path=project_path,
|
||||
repo_url=repo_url,
|
||||
repo_ref=repo_ref,
|
||||
no_project_source=no_project_source,
|
||||
)
|
||||
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
|
||||
enabled_tools = set(
|
||||
_MODE_TOOLS[normalized_mode]
|
||||
if normalized_mode is not None
|
||||
else _PROFILE_TOOLS[normalized_profile]
|
||||
)
|
||||
server = FastMCP(name="pyro_mcp")
|
||||
|
||||
def _enabled(tool_name: str) -> bool:
|
||||
|
|
@ -621,7 +725,10 @@ class Pyro:
|
|||
return self.reap_expired()
|
||||
|
||||
if _enabled("workspace_create"):
|
||||
workspace_create_description = _workspace_create_description(startup_source)
|
||||
workspace_create_description = _workspace_create_description(
|
||||
startup_source,
|
||||
mode=normalized_mode,
|
||||
)
|
||||
|
||||
def _create_workspace_from_server_defaults(
|
||||
*,
|
||||
|
|
@ -668,7 +775,7 @@ class Pyro:
|
|||
_prepared_seed=prepared_seed,
|
||||
)
|
||||
|
||||
if normalized_profile == "workspace-core":
|
||||
if normalized_mode is not None or normalized_profile == "workspace-core":
|
||||
|
||||
@server.tool(name="workspace_create", description=workspace_create_description)
|
||||
async def workspace_create_core(
|
||||
|
|
@ -749,15 +856,21 @@ class Pyro:
|
|||
)
|
||||
|
||||
if _enabled("workspace_exec"):
|
||||
if normalized_profile == "workspace-core":
|
||||
if normalized_mode is not None or normalized_profile == "workspace-core":
|
||||
|
||||
@server.tool(name="workspace_exec")
|
||||
@server.tool(
|
||||
name="workspace_exec",
|
||||
description=_tool_description(
|
||||
"workspace_exec",
|
||||
mode=normalized_mode,
|
||||
fallback="Run one command inside an existing persistent workspace.",
|
||||
),
|
||||
)
|
||||
async def workspace_exec_core(
|
||||
workspace_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> dict[str, Any]:
|
||||
"""Run one command inside an existing persistent workspace."""
|
||||
return self.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
|
|
@ -767,14 +880,20 @@ class Pyro:
|
|||
|
||||
else:
|
||||
|
||||
@server.tool(name="workspace_exec")
|
||||
@server.tool(
|
||||
name="workspace_exec",
|
||||
description=_tool_description(
|
||||
"workspace_exec",
|
||||
mode=normalized_mode,
|
||||
fallback="Run one command inside an existing persistent workspace.",
|
||||
),
|
||||
)
|
||||
async def workspace_exec_full(
|
||||
workspace_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run one command inside an existing persistent workspace."""
|
||||
return self.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
|
|
@ -823,20 +942,30 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_summary"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_summary",
|
||||
mode=normalized_mode,
|
||||
fallback="Summarize the current workspace session for human review.",
|
||||
)
|
||||
)
|
||||
async def workspace_summary(workspace_id: str) -> dict[str, Any]:
|
||||
"""Summarize the current workspace session for human review."""
|
||||
return self.summarize_workspace(workspace_id)
|
||||
|
||||
if _enabled("workspace_export"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_export",
|
||||
mode=normalized_mode,
|
||||
fallback="Export one file or directory from `/workspace` back to the host.",
|
||||
)
|
||||
)
|
||||
async def workspace_export(
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
output_path: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Export one file or directory from `/workspace` back to the host."""
|
||||
return self.export_workspace(workspace_id, path, output_path=output_path)
|
||||
|
||||
if _enabled("workspace_diff"):
|
||||
|
|
@ -848,13 +977,21 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_file_list"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_file_list",
|
||||
mode=normalized_mode,
|
||||
fallback=(
|
||||
"List metadata for files and directories under one "
|
||||
"live workspace path."
|
||||
),
|
||||
)
|
||||
)
|
||||
async def workspace_file_list(
|
||||
workspace_id: str,
|
||||
path: str = "/workspace",
|
||||
recursive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""List metadata for files and directories under one live workspace path."""
|
||||
return self.list_workspace_files(
|
||||
workspace_id,
|
||||
path=path,
|
||||
|
|
@ -863,13 +1000,18 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_file_read"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_file_read",
|
||||
mode=normalized_mode,
|
||||
fallback="Read one regular text file from a live workspace path.",
|
||||
)
|
||||
)
|
||||
async def workspace_file_read(
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
max_bytes: int = 65536,
|
||||
) -> dict[str, Any]:
|
||||
"""Read one regular text file from a live workspace path."""
|
||||
return self.read_workspace_file(
|
||||
workspace_id,
|
||||
path,
|
||||
|
|
@ -878,13 +1020,18 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_file_write"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_file_write",
|
||||
mode=normalized_mode,
|
||||
fallback="Create or replace one regular text file under `/workspace`.",
|
||||
)
|
||||
)
|
||||
async def workspace_file_write(
|
||||
workspace_id: str,
|
||||
path: str,
|
||||
text: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create or replace one regular text file under `/workspace`."""
|
||||
return self.write_workspace_file(
|
||||
workspace_id,
|
||||
path,
|
||||
|
|
@ -893,12 +1040,17 @@ class Pyro:
|
|||
|
||||
if _enabled("workspace_patch_apply"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"workspace_patch_apply",
|
||||
mode=normalized_mode,
|
||||
fallback="Apply a unified text patch inside one live workspace.",
|
||||
)
|
||||
)
|
||||
async def workspace_patch_apply(
|
||||
workspace_id: str,
|
||||
patch: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a unified text patch inside one live workspace."""
|
||||
return self.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch=patch,
|
||||
|
|
@ -976,8 +1128,38 @@ class Pyro:
|
|||
return self.reset_workspace(workspace_id, snapshot=snapshot)
|
||||
|
||||
if _enabled("shell_open"):
|
||||
if normalized_mode == "review-eval":
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_open",
|
||||
mode=normalized_mode,
|
||||
fallback="Open a persistent interactive shell inside one workspace.",
|
||||
)
|
||||
)
|
||||
async def shell_open(
|
||||
workspace_id: str,
|
||||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
return self.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=None,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_open",
|
||||
mode=normalized_mode,
|
||||
fallback="Open a persistent interactive shell inside one workspace.",
|
||||
)
|
||||
)
|
||||
async def shell_open(
|
||||
workspace_id: str,
|
||||
cwd: str = "/workspace",
|
||||
|
|
@ -985,7 +1167,6 @@ class Pyro:
|
|||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Open a persistent interactive shell inside one workspace."""
|
||||
return self.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
|
|
@ -996,7 +1177,13 @@ class Pyro:
|
|||
|
||||
if _enabled("shell_read"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_read",
|
||||
mode=normalized_mode,
|
||||
fallback="Read merged PTY output from a workspace shell.",
|
||||
)
|
||||
)
|
||||
async def shell_read(
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
|
|
@ -1005,7 +1192,6 @@ class Pyro:
|
|||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read merged PTY output from a workspace shell."""
|
||||
return self.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
|
|
@ -1017,14 +1203,19 @@ class Pyro:
|
|||
|
||||
if _enabled("shell_write"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_write",
|
||||
mode=normalized_mode,
|
||||
fallback="Write text input to a persistent workspace shell.",
|
||||
)
|
||||
)
|
||||
async def shell_write(
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
input: str,
|
||||
append_newline: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Write text input to a persistent workspace shell."""
|
||||
return self.write_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
|
|
@ -1034,13 +1225,18 @@ class Pyro:
|
|||
|
||||
if _enabled("shell_signal"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_signal",
|
||||
mode=normalized_mode,
|
||||
fallback="Send a signal to the shell process group.",
|
||||
)
|
||||
)
|
||||
async def shell_signal(
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
signal_name: str = "INT",
|
||||
) -> dict[str, Any]:
|
||||
"""Send a signal to the shell process group."""
|
||||
return self.signal_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
|
|
@ -1049,14 +1245,68 @@ class Pyro:
|
|||
|
||||
if _enabled("shell_close"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"shell_close",
|
||||
mode=normalized_mode,
|
||||
fallback="Close a persistent workspace shell.",
|
||||
)
|
||||
)
|
||||
async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]:
|
||||
"""Close a persistent workspace shell."""
|
||||
return self.close_shell(workspace_id, shell_id)
|
||||
|
||||
if _enabled("service_start"):
|
||||
if normalized_mode == "cold-start":
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_start",
|
||||
mode=normalized_mode,
|
||||
fallback="Start a named long-running service inside a workspace.",
|
||||
)
|
||||
)
|
||||
async def service_start(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd: str = "/workspace",
|
||||
ready_file: str | None = None,
|
||||
ready_tcp: str | None = None,
|
||||
ready_http: str | None = None,
|
||||
ready_command: str | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
) -> dict[str, Any]:
|
||||
readiness: dict[str, Any] | None = None
|
||||
if ready_file is not None:
|
||||
readiness = {"type": "file", "path": ready_file}
|
||||
elif ready_tcp is not None:
|
||||
readiness = {"type": "tcp", "address": ready_tcp}
|
||||
elif ready_http is not None:
|
||||
readiness = {"type": "http", "url": ready_http}
|
||||
elif ready_command is not None:
|
||||
readiness = {"type": "command", "command": ready_command}
|
||||
return self.start_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=None,
|
||||
published_ports=None,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_start",
|
||||
mode=normalized_mode,
|
||||
fallback="Start a named long-running service inside a workspace.",
|
||||
)
|
||||
)
|
||||
async def service_start(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
|
|
@ -1071,7 +1321,6 @@ class Pyro:
|
|||
secret_env: dict[str, str] | None = None,
|
||||
published_ports: list[dict[str, int | None]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a named long-running service inside a workspace."""
|
||||
readiness: dict[str, Any] | None = None
|
||||
if ready_file is not None:
|
||||
readiness = {"type": "file", "path": ready_file}
|
||||
|
|
@ -1095,28 +1344,43 @@ class Pyro:
|
|||
|
||||
if _enabled("service_list"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_list",
|
||||
mode=normalized_mode,
|
||||
fallback="List named services in one workspace.",
|
||||
)
|
||||
)
|
||||
async def service_list(workspace_id: str) -> dict[str, Any]:
|
||||
"""List named services in one workspace."""
|
||||
return self.list_services(workspace_id)
|
||||
|
||||
if _enabled("service_status"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_status",
|
||||
mode=normalized_mode,
|
||||
fallback="Inspect one named workspace service.",
|
||||
)
|
||||
)
|
||||
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
"""Inspect one named workspace service."""
|
||||
return self.status_service(workspace_id, service_name)
|
||||
|
||||
if _enabled("service_logs"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_logs",
|
||||
mode=normalized_mode,
|
||||
fallback="Read persisted stdout/stderr for one workspace service.",
|
||||
)
|
||||
)
|
||||
async def service_logs(
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
tail_lines: int = 200,
|
||||
all: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Read persisted stdout/stderr for one workspace service."""
|
||||
return self.logs_service(
|
||||
workspace_id,
|
||||
service_name,
|
||||
|
|
@ -1126,9 +1390,14 @@ class Pyro:
|
|||
|
||||
if _enabled("service_stop"):
|
||||
|
||||
@server.tool()
|
||||
@server.tool(
|
||||
description=_tool_description(
|
||||
"service_stop",
|
||||
mode=normalized_mode,
|
||||
fallback="Stop one running service in a workspace.",
|
||||
)
|
||||
)
|
||||
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
|
||||
"""Stop one running service in a workspace."""
|
||||
return self.stop_service(workspace_id, service_name)
|
||||
|
||||
if _enabled("workspace_delete"):
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ from textwrap import dedent
|
|||
from typing import Any, cast
|
||||
|
||||
from pyro_mcp import __version__
|
||||
from pyro_mcp.api import McpToolProfile, Pyro
|
||||
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
|
||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
||||
from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
|
||||
from pyro_mcp.demo import run_demo
|
||||
from pyro_mcp.host_helpers import (
|
||||
HostDoctorEntry,
|
||||
|
|
@ -181,6 +181,7 @@ def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
|
|||
return HostServerConfig(
|
||||
installed_package=bool(getattr(args, "installed_package", False)),
|
||||
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
|
||||
mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)),
|
||||
project_path=getattr(args, "project_path", None),
|
||||
repo_url=getattr(args, "repo_url", None),
|
||||
repo_ref=getattr(args, "repo_ref", None),
|
||||
|
|
@ -856,11 +857,17 @@ def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
|
|||
action="store_true",
|
||||
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
|
||||
)
|
||||
parser.add_argument(
|
||||
profile_group = parser.add_mutually_exclusive_group()
|
||||
profile_group.add_argument(
|
||||
"--profile",
|
||||
choices=PUBLIC_MCP_PROFILES,
|
||||
default="workspace-core",
|
||||
help="Server profile to configure for the host helper flow.",
|
||||
help="Explicit profile for the host helper flow when not using a named mode.",
|
||||
)
|
||||
profile_group.add_argument(
|
||||
"--mode",
|
||||
choices=PUBLIC_MCP_MODES,
|
||||
help="Opinionated use-case mode for the host helper flow.",
|
||||
)
|
||||
source_group = parser.add_mutually_exclusive_group()
|
||||
source_group.add_argument(
|
||||
|
|
@ -1017,6 +1024,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"""
|
||||
Examples:
|
||||
pyro host connect claude-code
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect codex --project-path /abs/path/to/repo
|
||||
pyro host print-config opencode
|
||||
pyro host repair opencode
|
||||
|
|
@ -1037,7 +1045,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"""
|
||||
Examples:
|
||||
pyro host connect claude-code
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect codex --installed-package
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --project-path /abs/path/to/repo
|
||||
"""
|
||||
),
|
||||
|
|
@ -1061,6 +1071,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"""
|
||||
Examples:
|
||||
pyro host print-config opencode
|
||||
pyro host print-config opencode --mode repro-fix
|
||||
pyro host print-config opencode --output ./opencode.json
|
||||
pyro host print-config opencode --project-path /abs/path/to/repo
|
||||
"""
|
||||
|
|
@ -1089,6 +1100,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"""
|
||||
Examples:
|
||||
pyro host doctor
|
||||
pyro host doctor --mode inspect
|
||||
pyro host doctor --project-path /abs/path/to/repo
|
||||
pyro host doctor --installed-package
|
||||
"""
|
||||
|
|
@ -1112,6 +1124,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"""
|
||||
Examples:
|
||||
pyro host repair claude-code
|
||||
pyro host repair claude-code --mode review-eval
|
||||
pyro host repair codex --project-path /abs/path/to/repo
|
||||
pyro host repair opencode
|
||||
"""
|
||||
|
|
@ -1141,6 +1154,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"""
|
||||
Examples:
|
||||
pyro mcp serve
|
||||
pyro mcp serve --mode repro-fix
|
||||
pyro mcp serve --mode inspect
|
||||
pyro mcp serve --project-path .
|
||||
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
pyro mcp serve --profile vm-run
|
||||
|
|
@ -1155,19 +1170,27 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
help="Run the MCP server over stdio.",
|
||||
description=(
|
||||
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
|
||||
"serve` now starts `workspace-core`, the recommended first profile "
|
||||
"for most chat hosts. When launched from inside a Git checkout, it "
|
||||
"also seeds the first workspace from that repo by default."
|
||||
"serve` starts the generic `workspace-core` path. Use `--mode` to "
|
||||
"start from an opinionated use-case flow, or `--profile` to choose "
|
||||
"a generic profile directly. When launched from inside a Git "
|
||||
"checkout, it also seeds the first workspace from that repo by default."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Default and recommended first start:
|
||||
Generic default path:
|
||||
pyro mcp serve
|
||||
pyro mcp serve --project-path .
|
||||
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
|
||||
Named modes:
|
||||
repro-fix: structured edit / diff / export / reset loop
|
||||
inspect: smallest persistent inspection surface
|
||||
cold-start: validation plus service readiness
|
||||
review-eval: shell plus snapshots for review workflows
|
||||
|
||||
Profiles:
|
||||
workspace-core: default for normal persistent chat editing
|
||||
workspace-core: default for normal persistent chat editing and the
|
||||
recommended first profile for most chat hosts
|
||||
vm-run: smallest one-shot-only surface
|
||||
workspace-full: larger opt-in surface for shells, services,
|
||||
snapshots, secrets, network policy, and disk tools
|
||||
|
|
@ -1178,22 +1201,28 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
- use --project-path when the host does not preserve cwd
|
||||
- use --repo-url for a clean-clone source outside a local checkout
|
||||
|
||||
Use --profile workspace-full only when the host truly needs those
|
||||
extra workspace capabilities.
|
||||
Use --mode when one named use case already matches the job. Fall
|
||||
back to the generic no-mode path when the mode feels too narrow.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
mcp_serve_parser.add_argument(
|
||||
mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group()
|
||||
mcp_profile_group.add_argument(
|
||||
"--profile",
|
||||
choices=PUBLIC_MCP_PROFILES,
|
||||
default="workspace-core",
|
||||
help=(
|
||||
"Expose only one model-facing tool profile. `workspace-core` is "
|
||||
"the default and recommended first profile for most chat hosts; "
|
||||
"`workspace-full` is the larger opt-in profile."
|
||||
"Expose one generic model-facing tool profile instead of a named mode. "
|
||||
"`workspace-core` is the generic default and `workspace-full` is the "
|
||||
"larger opt-in profile."
|
||||
),
|
||||
)
|
||||
mcp_profile_group.add_argument(
|
||||
"--mode",
|
||||
choices=PUBLIC_MCP_MODES,
|
||||
help="Expose one opinionated use-case mode instead of the generic profile path.",
|
||||
)
|
||||
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
|
||||
mcp_source_group.add_argument(
|
||||
"--project-path",
|
||||
|
|
@ -2794,6 +2823,7 @@ def main() -> None:
|
|||
if args.command == "mcp":
|
||||
pyro.create_server(
|
||||
profile=args.profile,
|
||||
mode=getattr(args, "mode", None),
|
||||
project_path=args.project_path,
|
||||
repo_url=args.repo_url,
|
||||
repo_ref=args.repo_ref,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
|||
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
|
||||
PUBLIC_CLI_HOST_COMMON_FLAGS = (
|
||||
"--installed-package",
|
||||
"--mode",
|
||||
"--profile",
|
||||
"--project-path",
|
||||
"--repo-url",
|
||||
|
|
@ -20,6 +21,7 @@ PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",
|
|||
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
|
||||
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
|
||||
PUBLIC_CLI_MCP_SERVE_FLAGS = (
|
||||
"--mode",
|
||||
"--profile",
|
||||
"--project-path",
|
||||
"--repo-url",
|
||||
|
|
@ -140,6 +142,7 @@ PUBLIC_CLI_RUN_FLAGS = (
|
|||
"--json",
|
||||
)
|
||||
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
|
||||
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
|
||||
|
||||
PUBLIC_SDK_METHODS = (
|
||||
"apply_workspace_patch",
|
||||
|
|
@ -258,4 +261,77 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
|
|||
"workspace_sync_push",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = (
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_file_write",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_patch_apply",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_sync_push",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS = (
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS = (
|
||||
"service_list",
|
||||
"service_logs",
|
||||
"service_start",
|
||||
"service_status",
|
||||
"service_stop",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = (
|
||||
"shell_close",
|
||||
"shell_open",
|
||||
"shell_read",
|
||||
"shell_signal",
|
||||
"shell_write",
|
||||
"snapshot_create",
|
||||
"snapshot_delete",
|
||||
"snapshot_list",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from datetime import UTC, datetime
|
|||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pyro_mcp.api import McpToolProfile
|
||||
from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode
|
||||
|
||||
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
|
||||
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
|
||||
|
|
@ -26,6 +26,7 @@ HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
|
|||
class HostServerConfig:
|
||||
installed_package: bool = False
|
||||
profile: McpToolProfile = "workspace-core"
|
||||
mode: WorkspaceUseCaseMode | None = None
|
||||
project_path: str | None = None
|
||||
repo_url: str | None = None
|
||||
repo_ref: str | None = None
|
||||
|
|
@ -60,6 +61,8 @@ def _host_binary(host: str) -> str:
|
|||
|
||||
|
||||
def _canonical_server_command(config: HostServerConfig) -> list[str]:
|
||||
if config.mode is not None and config.profile != "workspace-core":
|
||||
raise ValueError("--mode and --profile are mutually exclusive")
|
||||
if config.project_path is not None and config.repo_url is not None:
|
||||
raise ValueError("--project-path and --repo-url are mutually exclusive")
|
||||
if config.no_project_source and (
|
||||
|
|
@ -76,7 +79,9 @@ def _canonical_server_command(config: HostServerConfig) -> list[str]:
|
|||
command = ["pyro", "mcp", "serve"]
|
||||
if not config.installed_package:
|
||||
command = ["uvx", "--from", "pyro-mcp", *command]
|
||||
if config.profile != "workspace-core":
|
||||
if config.mode is not None:
|
||||
command.extend(["--mode", config.mode])
|
||||
elif config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
|
|
@ -97,7 +102,9 @@ def _repair_command(host: str, config: HostServerConfig, *, config_path: Path |
|
|||
command = ["pyro", "host", "repair", host]
|
||||
if config.installed_package:
|
||||
command.append("--installed-package")
|
||||
if config.profile != "workspace-core":
|
||||
if config.mode is not None:
|
||||
command.extend(["--mode", config.mode])
|
||||
elif config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from pathlib import Path
|
|||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from pyro_mcp.api import McpToolProfile, Pyro
|
||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ def create_server(
|
|||
manager: VmManager | None = None,
|
||||
*,
|
||||
profile: McpToolProfile = "workspace-core",
|
||||
mode: WorkspaceUseCaseMode | None = None,
|
||||
project_path: str | Path | None = None,
|
||||
repo_url: str | None = None,
|
||||
repo_ref: str | None = None,
|
||||
|
|
@ -21,7 +22,8 @@ def create_server(
|
|||
) -> FastMCP:
|
||||
"""Create and return a configured MCP server instance.
|
||||
|
||||
`workspace-core` is the default stable chat-host profile in 4.x. Use
|
||||
Bare server creation uses the generic `workspace-core` path in 4.x. Use
|
||||
`mode=...` for one of the named use-case surfaces, or
|
||||
`profile="workspace-full"` only when the host truly needs the full
|
||||
advanced workspace surface. By default, the server auto-detects the
|
||||
nearest Git worktree root from its current working directory for
|
||||
|
|
@ -29,6 +31,7 @@ def create_server(
|
|||
"""
|
||||
return Pyro(manager=manager).create_server(
|
||||
profile=profile,
|
||||
mode=mode,
|
||||
project_path=project_path,
|
||||
repo_url=repo_url,
|
||||
repo_ref=repo_ref,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "4.3.0"
|
||||
DEFAULT_CATALOG_VERSION = "4.4.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
|||
oci_repository: str | None = None
|
||||
oci_reference: str | None = None
|
||||
source_digest: str | None = None
|
||||
compatibility: str = ">=4.3.0,<5.0.0"
|
||||
compatibility: str = ">=4.4.0,<5.0.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC
|
|||
class WorkspaceUseCaseRecipe:
|
||||
scenario: str
|
||||
title: str
|
||||
profile: Literal["workspace-core", "workspace-full"]
|
||||
mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"]
|
||||
smoke_target: str
|
||||
doc_path: str
|
||||
summary: str
|
||||
|
|
@ -39,7 +39,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="cold-start-validation",
|
||||
title="Cold-Start Repo Validation",
|
||||
profile="workspace-full",
|
||||
mode="cold-start",
|
||||
smoke_target="smoke-cold-start-validation",
|
||||
doc_path="docs/use-cases/cold-start-repo-validation.md",
|
||||
summary=(
|
||||
|
|
@ -50,7 +50,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="repro-fix-loop",
|
||||
title="Repro Plus Fix Loop",
|
||||
profile="workspace-core",
|
||||
mode="repro-fix",
|
||||
smoke_target="smoke-repro-fix-loop",
|
||||
doc_path="docs/use-cases/repro-fix-loop.md",
|
||||
summary=(
|
||||
|
|
@ -61,7 +61,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="parallel-workspaces",
|
||||
title="Parallel Isolated Workspaces",
|
||||
profile="workspace-core",
|
||||
mode="repro-fix",
|
||||
smoke_target="smoke-parallel-workspaces",
|
||||
doc_path="docs/use-cases/parallel-workspaces.md",
|
||||
summary=(
|
||||
|
|
@ -72,7 +72,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="untrusted-inspection",
|
||||
title="Unsafe Or Untrusted Code Inspection",
|
||||
profile="workspace-core",
|
||||
mode="inspect",
|
||||
smoke_target="smoke-untrusted-inspection",
|
||||
doc_path="docs/use-cases/untrusted-inspection.md",
|
||||
summary=(
|
||||
|
|
@ -83,7 +83,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
|||
WorkspaceUseCaseRecipe(
|
||||
scenario="review-eval",
|
||||
title="Review And Evaluation Workflows",
|
||||
profile="workspace-full",
|
||||
mode="review-eval",
|
||||
smoke_target="smoke-review-eval",
|
||||
doc_path="docs/use-cases/review-eval-workflows.md",
|
||||
summary=(
|
||||
|
|
@ -141,11 +141,12 @@ def _create_project_aware_workspace(
|
|||
*,
|
||||
environment: str,
|
||||
project_path: Path,
|
||||
mode: Literal["repro-fix", "cold-start"],
|
||||
name: str,
|
||||
labels: dict[str, str],
|
||||
) -> dict[str, object]:
|
||||
async def _run() -> dict[str, object]:
|
||||
server = pyro.create_server(profile="workspace-core", project_path=project_path)
|
||||
server = pyro.create_server(mode=mode, project_path=project_path)
|
||||
return _extract_structured_tool_result(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
|
|
@ -194,14 +195,19 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
|
|||
)
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
workspace_id = _create_workspace(
|
||||
created = _create_project_aware_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
project_path=seed_dir,
|
||||
mode="cold-start",
|
||||
name="cold-start-validation",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
_log(f"cold-start-validation workspace_id={workspace_id}")
|
||||
workspace_seed = created["workspace_seed"]
|
||||
assert isinstance(workspace_seed, dict), created
|
||||
assert workspace_seed["origin_kind"] == "project_path", created
|
||||
validation = pyro.exec_workspace(workspace_id, command="sh validate.sh")
|
||||
assert int(validation["exit_code"]) == 0, validation
|
||||
assert str(validation["stdout"]) == "validated\n", validation
|
||||
|
|
@ -259,6 +265,7 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
|
|||
pyro,
|
||||
environment=environment,
|
||||
project_path=seed_dir,
|
||||
mode="repro-fix",
|
||||
name="repro-fix-loop",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
|
||||
)
|
||||
|
|
@ -495,7 +502,7 @@ def run_workspace_use_case_scenario(
|
|||
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
|
||||
for scenario_name in scenario_names:
|
||||
recipe = _RECIPE_BY_SCENARIO[scenario_name]
|
||||
_log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}")
|
||||
_log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}")
|
||||
scenario_root = root / scenario_name
|
||||
scenario_root.mkdir(parents=True, exist_ok=True)
|
||||
runner = _SCENARIO_RUNNERS[scenario_name]
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ import time
|
|||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.contract import (
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
|
||||
|
|
@ -157,6 +163,120 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
|
|||
assert "workspace_disk_export" not in tool_map
|
||||
|
||||
|
||||
def test_pyro_create_server_repro_fix_mode_registers_expected_tools_and_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server(mode="repro-fix")
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
|
||||
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
|
||||
assert "network_policy" not in create_properties
|
||||
assert "secrets" not in create_properties
|
||||
exec_properties = tool_map["workspace_exec"]["inputSchema"]["properties"]
|
||||
assert "secret_env" not in exec_properties
|
||||
assert "service_start" not in tool_map
|
||||
assert "shell_open" not in tool_map
|
||||
assert "snapshot_create" not in tool_map
|
||||
assert "reproduce a failure" in str(tool_map["workspace_create"]["description"])
|
||||
|
||||
|
||||
def test_pyro_create_server_cold_start_mode_registers_expected_tools_and_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server(mode="cold-start")
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
|
||||
assert "shell_open" not in tool_map
|
||||
assert "snapshot_create" not in tool_map
|
||||
service_start_properties = tool_map["service_start"]["inputSchema"]["properties"]
|
||||
assert "secret_env" not in service_start_properties
|
||||
assert "published_ports" not in service_start_properties
|
||||
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
|
||||
assert "network_policy" not in create_properties
|
||||
|
||||
|
||||
def test_pyro_create_server_review_eval_mode_registers_expected_tools_and_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server(mode="review-eval")
|
||||
tools = await server.list_tools()
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS))
|
||||
assert "service_start" not in tool_map
|
||||
assert "shell_open" in tool_map
|
||||
assert "snapshot_create" in tool_map
|
||||
shell_open_properties = tool_map["shell_open"]["inputSchema"]["properties"]
|
||||
assert "secret_env" not in shell_open_properties
|
||||
|
||||
|
||||
def test_pyro_create_server_inspect_mode_registers_expected_tools(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
server = pyro.create_server(mode="inspect")
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
|
||||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_INSPECT_MODE_TOOLS))
|
||||
|
||||
|
||||
def test_pyro_create_server_rejects_mode_and_non_default_profile(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
pyro.create_server(profile="workspace-full", mode="repro-fix")
|
||||
|
||||
|
||||
def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -3101,7 +3101,7 @@ def test_cli_workspace_shell_open_prints_id_only(
|
|||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
||||
def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
|
||||
readme = Path("README.md").read_text(encoding="utf-8")
|
||||
install = Path("docs/install.md").read_text(encoding="utf-8")
|
||||
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
|
||||
|
|
@ -3110,19 +3110,24 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
|||
claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
|
||||
codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8")
|
||||
opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8"))
|
||||
claude_helper = "pyro host connect claude-code"
|
||||
codex_helper = "pyro host connect codex"
|
||||
opencode_helper = "pyro host print-config opencode"
|
||||
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
|
||||
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
|
||||
claude_helper = "pyro host connect claude-code --mode cold-start"
|
||||
codex_helper = "pyro host connect codex --mode repro-fix"
|
||||
inspect_helper = "pyro host connect codex --mode inspect"
|
||||
review_helper = "pyro host connect claude-code --mode review-eval"
|
||||
opencode_helper = "pyro host print-config opencode --mode repro-fix"
|
||||
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start"
|
||||
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix"
|
||||
|
||||
assert "## Chat Host Quickstart" in readme
|
||||
assert claude_helper in readme
|
||||
assert codex_helper in readme
|
||||
assert inspect_helper in readme
|
||||
assert review_helper in readme
|
||||
assert opencode_helper in readme
|
||||
assert "examples/opencode_mcp_config.json" in readme
|
||||
assert "pyro host doctor" in readme
|
||||
assert "bare `pyro mcp serve` starts `workspace-core`" in readme
|
||||
assert "pyro mcp serve --mode repro-fix" in readme
|
||||
assert "generic no-mode path" in readme
|
||||
assert "auto-detects the current Git checkout" in readme.replace("\n", " ")
|
||||
assert "--project-path /abs/path/to/repo" in readme
|
||||
assert "--repo-url https://github.com/example/project.git" in readme
|
||||
|
|
@ -3130,44 +3135,54 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
|||
assert "## 5. Connect a chat host" in install
|
||||
assert claude_helper in install
|
||||
assert codex_helper in install
|
||||
assert inspect_helper in install
|
||||
assert review_helper in install
|
||||
assert opencode_helper in install
|
||||
assert "workspace-full" in install
|
||||
assert "--project-path /abs/path/to/repo" in install
|
||||
assert "pyro mcp serve --mode cold-start" in install
|
||||
|
||||
assert claude_helper in first_run
|
||||
assert codex_helper in first_run
|
||||
assert inspect_helper in first_run
|
||||
assert review_helper in first_run
|
||||
assert opencode_helper in first_run
|
||||
assert "--project-path /abs/path/to/repo" in first_run
|
||||
assert "pyro mcp serve --mode review-eval" in first_run
|
||||
|
||||
assert claude_helper in integrations
|
||||
assert codex_helper in integrations
|
||||
assert inspect_helper in integrations
|
||||
assert review_helper in integrations
|
||||
assert opencode_helper in integrations
|
||||
assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations
|
||||
assert "## Recommended Modes" in integrations
|
||||
assert "pyro mcp serve --mode inspect" in integrations
|
||||
assert "auto-detects the current Git checkout" in integrations
|
||||
assert "examples/claude_code_mcp.md" in integrations
|
||||
assert "examples/codex_mcp.md" in integrations
|
||||
assert "examples/opencode_mcp_config.json" in integrations
|
||||
assert "That is the product path." in integrations
|
||||
assert "generic no-mode path" in integrations
|
||||
assert "--project-path /abs/path/to/repo" in integrations
|
||||
assert "--repo-url https://github.com/example/project.git" in integrations
|
||||
|
||||
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
|
||||
assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config
|
||||
assert "Use the host-specific examples first when they apply:" in mcp_config
|
||||
assert "claude_code_mcp.md" in mcp_config
|
||||
assert "codex_mcp.md" in mcp_config
|
||||
assert "opencode_mcp_config.json" in mcp_config
|
||||
assert '"serve", "--mode", "repro-fix"' in mcp_config
|
||||
|
||||
assert claude_helper in claude_code
|
||||
assert claude_cmd in claude_code
|
||||
assert "claude mcp list" in claude_code
|
||||
assert "pyro host repair claude-code" in claude_code
|
||||
assert "pyro host repair claude-code --mode cold-start" in claude_code
|
||||
assert "workspace-full" in claude_code
|
||||
assert "--project-path /abs/path/to/repo" in claude_code
|
||||
|
||||
assert codex_helper in codex
|
||||
assert codex_cmd in codex
|
||||
assert "codex mcp list" in codex
|
||||
assert "pyro host repair codex" in codex
|
||||
assert "pyro host repair codex --mode repro-fix" in codex
|
||||
assert "workspace-full" in codex
|
||||
assert "--project-path /abs/path/to/repo" in codex
|
||||
|
||||
|
|
@ -3183,6 +3198,8 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
|
|||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--mode",
|
||||
"repro-fix",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -4349,12 +4366,14 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
self,
|
||||
*,
|
||||
profile: str,
|
||||
mode: str | None,
|
||||
project_path: str | None,
|
||||
repo_url: str | None,
|
||||
repo_ref: str | None,
|
||||
no_project_source: bool,
|
||||
) -> Any:
|
||||
observed["profile"] = profile
|
||||
observed["mode"] = mode
|
||||
observed["project_path"] = project_path
|
||||
observed["repo_url"] = repo_url
|
||||
observed["repo_ref"] = repo_ref
|
||||
|
|
@ -4371,6 +4390,7 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
command="mcp",
|
||||
mcp_command="serve",
|
||||
profile="workspace-core",
|
||||
mode=None,
|
||||
project_path="/repo",
|
||||
repo_url=None,
|
||||
repo_ref=None,
|
||||
|
|
@ -4382,6 +4402,7 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
cli.main()
|
||||
assert observed == {
|
||||
"profile": "workspace-core",
|
||||
"mode": None,
|
||||
"project_path": "/repo",
|
||||
"repo_url": None,
|
||||
"repo_ref": None,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,16 @@ def test_canonical_server_command_validates_and_renders_variants() -> None:
|
|||
"--repo-ref",
|
||||
"main",
|
||||
]
|
||||
assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [
|
||||
"uvx",
|
||||
"--from",
|
||||
"pyro-mcp",
|
||||
"pyro",
|
||||
"mcp",
|
||||
"serve",
|
||||
"--mode",
|
||||
"repro-fix",
|
||||
]
|
||||
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
|
||||
"uvx",
|
||||
"--from",
|
||||
|
|
@ -149,6 +159,10 @@ def test_canonical_server_command_validates_and_renders_variants() -> None:
|
|||
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
|
||||
with pytest.raises(ValueError, match="requires --repo-url"):
|
||||
_canonical_server_command(HostServerConfig(repo_ref="main"))
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
_canonical_server_command(
|
||||
HostServerConfig(profile="workspace-full", mode="repro-fix")
|
||||
)
|
||||
|
||||
|
||||
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
|
||||
|
|
@ -167,6 +181,9 @@ def test_repair_command_and_command_matches_cover_edge_cases() -> None:
|
|||
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
|
||||
"pyro host repair codex --no-project-source"
|
||||
)
|
||||
assert _repair_command("codex", HostServerConfig(mode="inspect")) == (
|
||||
"pyro host repair codex --mode inspect"
|
||||
)
|
||||
assert _command_matches(
|
||||
"pyro: uvx --from pyro-mcp pyro mcp serve",
|
||||
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
|
||||
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS,
|
||||
PUBLIC_MCP_MODES,
|
||||
PUBLIC_MCP_PROFILES,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
PUBLIC_SDK_METHODS,
|
||||
)
|
||||
|
|
@ -139,6 +144,8 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
assert flag in mcp_serve_help_text
|
||||
for profile_name in PUBLIC_MCP_PROFILES:
|
||||
assert profile_name in mcp_serve_help_text
|
||||
for mode_name in PUBLIC_MCP_MODES:
|
||||
assert mode_name in mcp_serve_help_text
|
||||
|
||||
workspace_help_text = _subparser_choice(parser, "workspace").format_help()
|
||||
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:
|
||||
|
|
@ -372,6 +379,14 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None:
|
|||
assert tool_names == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
||||
|
||||
|
||||
def test_public_mcp_modes_are_declared_and_non_empty() -> None:
|
||||
assert PUBLIC_MCP_MODES == ("repro-fix", "inspect", "cold-start", "review-eval")
|
||||
assert PUBLIC_MCP_REPRO_FIX_MODE_TOOLS
|
||||
assert PUBLIC_MCP_INSPECT_MODE_TOOLS
|
||||
assert PUBLIC_MCP_COLD_START_MODE_TOOLS
|
||||
assert PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS
|
||||
|
||||
|
||||
def test_pyproject_exposes_single_public_cli_script() -> None:
|
||||
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
scripts = pyproject["project"]["scripts"]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import pytest
|
|||
|
||||
import pyro_mcp.server as server_module
|
||||
from pyro_mcp.contract import (
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS,
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
|
||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
|
||||
)
|
||||
|
|
@ -85,6 +87,36 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path:
|
|||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
|
||||
|
||||
|
||||
def test_create_server_repro_fix_mode_registers_expected_tools(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
server = create_server(manager=manager, mode="repro-fix")
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
|
||||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
|
||||
|
||||
|
||||
def test_create_server_cold_start_mode_registers_expected_tools(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
server = create_server(manager=manager, mode="cold-start")
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
|
||||
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
|
||||
|
||||
|
||||
def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
|
|
|
|||
|
|
@ -499,8 +499,15 @@ class _FakePyro:
|
|||
workspace.shells.pop(shell_id, None)
|
||||
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
|
||||
|
||||
def create_server(self, *, profile: str, project_path: Path) -> Any:
|
||||
def create_server(
|
||||
self,
|
||||
*,
|
||||
profile: str = "workspace-core",
|
||||
mode: str | None = None,
|
||||
project_path: Path,
|
||||
) -> Any:
|
||||
assert profile == "workspace-core"
|
||||
assert mode in {"repro-fix", "cold-start"}
|
||||
seed_path = Path(project_path)
|
||||
outer = self
|
||||
|
||||
|
|
@ -554,7 +561,7 @@ def test_use_case_docs_and_targets_stay_aligned() -> None:
|
|||
recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8")
|
||||
assert recipe.smoke_target in index_text
|
||||
assert recipe.doc_path.rsplit("/", 1)[-1] in index_text
|
||||
assert recipe.profile in recipe_text
|
||||
assert recipe.mode in recipe_text
|
||||
assert recipe.smoke_target in recipe_text
|
||||
assert f"{recipe.smoke_target}:" in makefile_text
|
||||
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -715,7 +715,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "4.3.0"
|
||||
version = "4.4.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue