From d0cf6d8f2182e5a8f7d3fb9c3d9516e0fed1d802 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 20:00:35 -0300 Subject: [PATCH] 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. --- CHANGELOG.md | 11 + README.md | 58 ++- docs/first-run.md | 41 +- docs/install.md | 47 +- docs/integrations.md | 56 ++- docs/public-contract.md | 37 +- docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.4.0-opinionated-use-case-modes.md | 2 +- docs/use-cases/README.md | 12 +- docs/use-cases/cold-start-repo-validation.md | 12 +- docs/use-cases/parallel-workspaces.md | 11 +- docs/use-cases/repro-fix-loop.md | 13 +- docs/use-cases/review-eval-workflows.md | 8 +- docs/use-cases/untrusted-inspection.md | 8 +- examples/claude_code_mcp.md | 20 +- examples/codex_mcp.md | 20 +- examples/mcp_client_config.md | 38 +- examples/opencode_mcp_config.json | 2 +- pyproject.toml | 2 +- src/pyro_mcp/api.py | 455 ++++++++++++++---- src/pyro_mcp/cli.py | 60 ++- src/pyro_mcp/contract.py | 76 +++ src/pyro_mcp/host_helpers.py | 13 +- src/pyro_mcp/server.py | 7 +- src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/workspace_use_case_smokes.py | 27 +- tests/test_api.py | 120 +++++ tests/test_cli.py | 63 ++- tests/test_host_helpers.py | 17 + tests/test_public_contract.py | 15 + tests/test_server.py | 32 ++ tests/test_workspace_use_case_smokes.py | 11 +- uv.lock | 2 +- 33 files changed, 1034 insertions(+), 274 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa1330..982d57d 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 74f0965..f433117 100644 --- a/README.md +++ b/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. diff --git a/docs/first-run.md b/docs/first-run.md index 9e26999..894067c 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -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 diff --git a/docs/install.md b/docs/install.md index 4a57178..ed03f26 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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` diff --git a/docs/integrations.md b/docs/integrations.md index 933a5ea..1abf5a9 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -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) diff --git a/docs/public-contract.md b/docs/public-contract.md index e1e0856..3d2265b 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.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: diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 60b85b6..be346fa 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md index c57f951..52d2d22 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md +++ b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md @@ -1,6 +1,6 @@ # `4.4.0` Opinionated Use-Case Modes -Status: Planned +Status: Done ## Goal diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md index ec249b7..e471332 100644 --- a/docs/use-cases/README.md +++ b/docs/use-cases/README.md @@ -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: diff --git a/docs/use-cases/cold-start-repo-validation.md b/docs/use-cases/cold-start-repo-validation.md index 3b303be..763210b 100644 --- a/docs/use-cases/cold-start-repo-validation.md +++ b/docs/use-cases/cold-start-repo-validation.md @@ -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. diff --git a/docs/use-cases/parallel-workspaces.md b/docs/use-cases/parallel-workspaces.md index ccabd7a..685f6a4 100644 --- a/docs/use-cases/parallel-workspaces.md +++ b/docs/use-cases/parallel-workspaces.md @@ -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.” diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md index fac4090..ad920c5 100644 --- a/docs/use-cases/repro-fix-loop.md +++ b/docs/use-cases/repro-fix-loop.md @@ -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. diff --git a/docs/use-cases/review-eval-workflows.md b/docs/use-cases/review-eval-workflows.md index 3ab9204..4012c34 100644 --- a/docs/use-cases/review-eval-workflows.md +++ b/docs/use-cases/review-eval-workflows.md @@ -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: diff --git a/docs/use-cases/untrusted-inspection.md b/docs/use-cases/untrusted-inspection.md index 6a7b85b..aab7ada 100644 --- a/docs/use-cases/untrusted-inspection.md +++ b/docs/use-cases/untrusted-inspection.md @@ -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: diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index 0beb63c..de9931c 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -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, diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index 330f38e..53f3c7b 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -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, diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index d0b2046..f4de5f0 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -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. diff --git a/examples/opencode_mcp_config.json b/examples/opencode_mcp_config.json index c060946..518dc7d 100644 --- a/examples/opencode_mcp_config.json +++ b/examples/opencode_mcp_config.json @@ -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"] } } } diff --git a/pyproject.toml b/pyproject.toml index 963ecdd..796ba47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 935c619..967b05c 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -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,27 +1128,62 @@ class Pyro: return self.reset_workspace(workspace_id, snapshot=snapshot) if _enabled("shell_open"): + if normalized_mode == "review-eval": - @server.tool() - async def shell_open( - workspace_id: str, - cwd: str = "/workspace", - cols: int = 120, - rows: int = 30, - secret_env: dict[str, str] | None = None, - ) -> dict[str, Any]: - """Open a persistent interactive shell inside one workspace.""" - return self.open_shell( - workspace_id, - cwd=cwd, - cols=cols, - rows=rows, - secret_env=secret_env, + @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", + cols: int = 120, + rows: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + return self.open_shell( + workspace_id, + cwd=cwd, + cols=cols, + rows=rows, + secret_env=secret_env, + ) if _enabled("shell_read"): - @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,74 +1245,142 @@ 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() - async def service_start( - workspace_id: str, - service_name: str, - command: str, - cwd: str = "/workspace", - ready_file: str | None = None, - ready_tcp: str | None = None, - ready_http: str | None = None, - ready_command: str | None = None, - ready_timeout_seconds: int = 30, - ready_interval_ms: int = 500, - secret_env: dict[str, str] | None = None, - published_ports: list[dict[str, int | None]] | None = None, - ) -> dict[str, Any]: - """Start a named long-running service inside a workspace.""" - readiness: dict[str, Any] | None = None - if ready_file is not None: - readiness = {"type": "file", "path": ready_file} - elif ready_tcp is not None: - readiness = {"type": "tcp", "address": ready_tcp} - elif ready_http is not None: - readiness = {"type": "http", "url": ready_http} - elif ready_command is not None: - readiness = {"type": "command", "command": ready_command} - return self.start_service( - workspace_id, - service_name, - command=command, - cwd=cwd, - readiness=readiness, - ready_timeout_seconds=ready_timeout_seconds, - ready_interval_ms=ready_interval_ms, - secret_env=secret_env, - published_ports=published_ports, + @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, + command: str, + cwd: str = "/workspace", + ready_file: str | None = None, + ready_tcp: str | None = None, + ready_http: str | None = None, + ready_command: str | None = None, + ready_timeout_seconds: int = 30, + ready_interval_ms: int = 500, + secret_env: dict[str, str] | None = None, + published_ports: list[dict[str, int | None]] | None = None, + ) -> dict[str, Any]: + readiness: dict[str, Any] | None = None + if ready_file is not None: + readiness = {"type": "file", "path": ready_file} + elif ready_tcp is not None: + readiness = {"type": "tcp", "address": ready_tcp} + elif ready_http is not None: + readiness = {"type": "http", "url": ready_http} + elif ready_command is not None: + readiness = {"type": "command", "command": ready_command} + return self.start_service( + workspace_id, + service_name, + command=command, + cwd=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + secret_env=secret_env, + published_ports=published_ports, + ) if _enabled("service_list"): - @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"): diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index cfb6c86..5b5293b 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -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, diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index cddbbff..bebae39 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -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 diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py index 42420bb..dc06654 100644 --- a/src/pyro_mcp/host_helpers.py +++ b/src/pyro_mcp/host_helpers.py @@ -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]) diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index 7206ba9..455e9d2 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -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, diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 82375e1..74b1fe6 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -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) diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 26635d9..b69a90d 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -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] diff --git a/tests/test_api.py b/tests/test_api.py index 2fb7d10..56b461f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index 2d15c18..3b48e23 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 @@ -4367,21 +4386,23 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace( - command="mcp", - mcp_command="serve", - profile="workspace-core", - project_path="/repo", - repo_url=None, - repo_ref=None, - no_project_source=False, - ) + return argparse.Namespace( + command="mcp", + mcp_command="serve", + profile="workspace-core", + mode=None, + project_path="/repo", + repo_url=None, + repo_ref=None, + no_project_source=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() assert observed == { "profile": "workspace-core", + "mode": None, "project_path": "/repo", "repo_url": None, "repo_ref": None, diff --git a/tests/test_host_helpers.py b/tests/test_host_helpers.py index 0f84ae1..2255208 100644 --- a/tests/test_host_helpers.py +++ b/tests/test_host_helpers.py @@ -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"], diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 527c68b..31fbdf1 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -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"] diff --git a/tests/test_server.py b/tests/test_server.py index 1017d77..1ad39b9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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", diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index 8297c22..f369587 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -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 diff --git a/uv.lock b/uv.lock index 5944d05..b554f16 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.3.0" +version = "4.4.0" source = { editable = "." } dependencies = [ { name = "mcp" },