Add MCP tool profiles for workspace chat flows

Expose stable MCP/server tool profiles so chat hosts can start narrow and widen only when needed. This adds vm-run, workspace-core, and workspace-full across the CLI serve path, Pyro.create_server(), and the package-level create_server() factory while keeping workspace-full as the default.

Register profile-specific tool sets from one shared contract mapping, and narrow the workspace-core schemas so secrets, network policy, shells, services, snapshots, and disk tools do not leak into the default persistent chat profile. The full surface remains available unchanged under workspace-full.

Refresh the public docs and examples around the profile progression, add a canonical OpenAI Responses workspace-core example, mark the 3.4.0 roadmap milestone done, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed workspace-core smoke for create, file write, exec, diff, export, reset, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 23:52:13 -03:00
parent 446f7fce04
commit eecfd7a7d7
23 changed files with 984 additions and 511 deletions

View file

@ -2,6 +2,17 @@
All notable user-visible changes to `pyro-mcp` are documented here. All notable user-visible changes to `pyro-mcp` are documented here.
## 3.4.0
- Added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and
`workspace-full` so chat hosts can expose only the right model-facing surface.
- Added `--profile` to `pyro mcp serve` plus matching `profile=` support on
`Pyro.create_server()` and the package-level `create_server()` factory.
- Added canonical `workspace-core` integration examples for OpenAI Responses
and MCP client configuration, and narrowed the `workspace-core` schemas so
secrets, network policy, shells, services, snapshots, and disk tools stay out
of the default persistent chat profile.
## 3.3.0 ## 3.3.0
- Added first-class workspace naming and discovery across the CLI, Python SDK, and MCP server - Added first-class workspace naming and discovery across the CLI, Python SDK, and MCP server

View file

@ -22,7 +22,7 @@ It exposes the same runtime in three public forms:
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
- What's new in 3.3.0: [CHANGELOG.md#330](CHANGELOG.md#330) - What's new in 3.4.0: [CHANGELOG.md#340](CHANGELOG.md#340)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md) - Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md) - Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -59,7 +59,7 @@ What success looks like:
```bash ```bash
Platform: linux-x86_64 Platform: linux-x86_64
Runtime: PASS Runtime: PASS
Catalog version: 3.3.0 Catalog version: 3.4.0
... ...
[pull] phase=install environment=debian:12 [pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12 [pull] phase=ready environment=debian:12
@ -189,7 +189,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```bash
Catalog version: 3.3.0 Catalog version: 3.4.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -305,7 +305,7 @@ Persistent workspaces start in `/workspace` and keep command history until you d
machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when
you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import
later host-side changes into a started workspace. Sync is non-atomic in `3.3.0`; if it fails later host-side changes into a started workspace. Sync is non-atomic in `3.4.0`; if it fails
partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot.
Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time
baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use
@ -333,7 +333,7 @@ The public user-facing interface is `pyro` and `Pyro`. After the CLI validation
- `pyro` for direct CLI usage, including one-shot `run` and persistent `workspace` workflows - `pyro` for direct CLI usage, including one-shot `run` and persistent `workspace` workflows
- `from pyro_mcp import Pyro` for Python orchestration - `from pyro_mcp import Pyro` for Python orchestration
- `pyro mcp serve` for MCP clients - `pyro mcp serve --profile workspace-core` for MCP clients
Command forms: Command forms:
@ -399,9 +399,15 @@ Use `--allow-host-compat` only if you explicitly want host execution.
Run the MCP server after the CLI path above works: Run the MCP server after the CLI path above works:
```bash ```bash
pyro mcp serve pyro mcp serve --profile workspace-core
``` ```
Profile progression for chat hosts:
- `vm-run`: expose only `vm_run`
- `workspace-core`: expose the practical persistent chat loop
- `workspace-full`: expose shells, services, snapshots, secrets, network policy, and disk tools
Run the deterministic demo: Run the deterministic demo:
```bash ```bash
@ -520,6 +526,12 @@ Persistent workspace tools:
- `workspace_logs(workspace_id)` - `workspace_logs(workspace_id)`
- `workspace_delete(workspace_id)` - `workspace_delete(workspace_id)`
Recommended MCP tool profiles:
- `vm-run`: `vm_run` only
- `workspace-core`: `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, `workspace_delete`
- `workspace-full`: the complete stable MCP surface above
## Integration Examples ## Integration Examples
- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py) - Python one-shot SDK example: [examples/python_run.py](examples/python_run.py)
@ -529,6 +541,7 @@ Persistent workspace tools:
- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json) - Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json)
- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json) - Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json)
- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py) - OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py)
- OpenAI Responses `workspace-core` example: [examples/openai_responses_workspace_core.py](examples/openai_responses_workspace_core.py)
- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py) - LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py)
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py) - Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py)

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash ```bash
$ uvx --from pyro-mcp pyro env list $ uvx --from pyro-mcp pyro env list
Catalog version: 3.3.0 Catalog version: 3.4.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -116,7 +116,7 @@ $ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TO
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' $ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'
$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports $ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app $ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
$ uvx --from pyro-mcp pyro mcp serve $ uvx --from pyro-mcp pyro mcp serve --profile workspace-core
``` ```
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
@ -252,7 +252,7 @@ State: started
Use `--seed-path` when the workspace should start from a host directory or a local Use `--seed-path` when the workspace should start from a host directory or a local
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use `.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
`pyro workspace sync push` when you need to import later host-side changes into a started `pyro workspace sync push` when you need to import later host-side changes into a started
workspace. Sync is non-atomic in `3.3.0`; if it fails partway through, prefer `pyro workspace reset` workspace. Sync is non-atomic in `3.4.0`; if it fails partway through, prefer `pyro workspace reset`
to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current
`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create `/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create
named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the

View file

@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```bash
Catalog version: 3.3.0 Catalog version: 3.4.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -224,7 +224,7 @@ After the CLI path works, you can move on to:
- interactive shells: `pyro workspace shell open WORKSPACE_ID` - interactive shells: `pyro workspace shell open WORKSPACE_ID`
- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` - long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
- localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` - localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app`
- MCP: `pyro mcp serve` - MCP: `pyro mcp serve --profile workspace-core`
- Python SDK: `from pyro_mcp import Pyro` - Python SDK: `from pyro_mcp import Pyro`
- Demos: `pyro demo` or `pyro demo --network` - Demos: `pyro demo` or `pyro demo --network`
@ -274,7 +274,7 @@ Workspace commands default to the persistent `/workspace` directory inside the g
the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path` the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path`
when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
is non-atomic in `3.3.0`; if it fails partway through, prefer `pyro workspace reset` to recover is non-atomic in `3.4.0`; if it fails partway through, prefer `pyro workspace reset` to recover
from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace
tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named
checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use

View file

@ -7,8 +7,10 @@ CLI path in [install.md](install.md) or [first-run.md](first-run.md).
## Recommended Default ## Recommended Default
Use `vm_run` first for one-shot commands, then move to the stable workspace surface when the Use `vm_run` first for one-shot commands, then move to `workspace-core` when the
agent needs to inhabit one sandbox across multiple calls. agent needs to inhabit one sandbox across multiple calls. Only promote the chat
surface to `workspace-full` when it truly needs shells, services, snapshots,
secrets, network policy, or disk tools.
That keeps the model-facing contract small: That keeps the model-facing contract small:
@ -17,8 +19,11 @@ That keeps the model-facing contract small:
- one ephemeral VM - one ephemeral VM
- automatic cleanup - automatic cleanup
Move to `workspace_*` when the agent needs repeated commands, shells, services, snapshots, reset, Profile progression:
diff, or export in one stable workspace across multiple calls.
- `vm-run`: one-shot only
- `workspace-core`: persistent workspace create/list/update/status/sync/exec/logs/file ops/diff/export/reset/delete
- `workspace-full`: the full stable workspace surface, including shells, services, snapshots, secrets, network policy, and disk tools
## OpenAI Responses API ## OpenAI Responses API
@ -30,20 +35,14 @@ Best when:
Recommended surface: Recommended surface:
- `vm_run` - `vm_run` for one-shot loops
- `workspace_create(name=..., labels=...)` + `workspace_list` + `workspace_update` when the agent needs to rediscover or retag long-lived workspaces between turns - the `workspace-core` tool set for the normal persistent chat loop
- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state - the `workspace-full` tool set only when the host explicitly needs advanced workspace capabilities
- `workspace_file_list` / `workspace_file_read` / `workspace_file_write` / `workspace_patch_apply` when the agent needs model-native file inspection and text edits inside one live workspace
- `workspace_create(..., secrets=...)` + `workspace_exec(..., secret_env=...)` when the workspace needs private tokens or authenticated setup
- `workspace_create(..., network_policy="egress+published-ports")` + `start_service(..., published_ports=[...])` when the host must probe one workspace service
- `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer
- `stop_workspace(...)` + `list_workspace_disk(...)` / `read_workspace_disk(...)` / `export_workspace_disk(...)` when one stopped guest-backed workspace needs offline inspection or a raw ext4 copy
- `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace
- `open_shell(..., secret_env=...)` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace
Canonical example: Canonical example:
- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py) - [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py)
- [examples/openai_responses_workspace_core.py](../examples/openai_responses_workspace_core.py)
## MCP Clients ## MCP Clients
@ -55,7 +54,13 @@ Best when:
Recommended entrypoint: Recommended entrypoint:
- `pyro mcp serve` - `pyro mcp serve --profile workspace-core`
Profile progression:
- `pyro mcp serve --profile vm-run` for the smallest one-shot surface
- `pyro mcp serve --profile workspace-core` for the normal persistent chat loop
- `pyro mcp serve --profile workspace-full` only when the model truly needs advanced workspace tools
Starter config: Starter config:

View file

@ -82,6 +82,7 @@ Behavioral guarantees:
- `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. - `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
- `pyro workspace create --name NAME --label KEY=VALUE` attaches human-oriented discovery metadata without changing the stable `workspace_id`. - `pyro workspace create --name NAME --label KEY=VALUE` attaches human-oriented discovery metadata without changing the stable `workspace_id`.
- `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports. - `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports.
- `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}` narrows the model-facing MCP surface without changing runtime behavior.
- `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`. - `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`.
- `pyro workspace list` returns persisted workspaces sorted by most recent `last_activity_at`. - `pyro workspace list` returns persisted workspaces sorted by most recent `last_activity_at`.
- `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace. - `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace.
@ -125,8 +126,8 @@ Primary facade:
Supported public entrypoints: Supported public entrypoints:
- `create_server()` - `create_server(profile="workspace-full")`
- `Pyro.create_server()` - `Pyro.create_server(profile="workspace-full")`
- `Pyro.list_environments()` - `Pyro.list_environments()`
- `Pyro.pull_environment(environment)` - `Pyro.pull_environment(environment)`
- `Pyro.inspect_environment(environment)` - `Pyro.inspect_environment(environment)`
@ -176,7 +177,7 @@ Supported public entrypoints:
Stable public method names: Stable public method names:
- `create_server()` - `create_server(profile="workspace-full")`
- `list_environments()` - `list_environments()`
- `pull_environment(environment)` - `pull_environment(environment)`
- `inspect_environment(environment)` - `inspect_environment(environment)`
@ -265,6 +266,18 @@ Behavioral defaults:
## MCP Contract ## MCP Contract
Stable MCP profiles:
- `vm-run`: exposes only `vm_run`
- `workspace-core`: exposes `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, and `workspace_delete`
- `workspace-full`: exposes the complete stable MCP surface below
Behavioral defaults:
- `pyro mcp serve` and `create_server()` default to `workspace-full`.
- `workspace-core` narrows `workspace_create` by omitting `network_policy` and `secrets`.
- `workspace-core` narrows `workspace_exec` by omitting `secret_env`.
Primary tool: Primary tool:
- `vm_run` - `vm_run`

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface. interface.
Current baseline is `3.3.0`: Current baseline is `3.4.0`:
- the stable workspace contract exists across CLI, SDK, and MCP - the stable workspace contract exists across CLI, SDK, and MCP
- one-shot `pyro run` still exists as the narrow entrypoint - one-shot `pyro run` still exists as the narrow entrypoint
@ -47,7 +47,7 @@ More concretely, the model should not need to:
1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done 1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done
2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done 2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done
3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) 3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done
4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) 4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md)
5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md) 5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md)
@ -58,6 +58,9 @@ Completed so far:
- `3.3.0` added workspace names, key/value labels, `workspace list`, `workspace update`, and - `3.3.0` added workspace names, key/value labels, `workspace list`, `workspace update`, and
`last_activity_at` tracking so humans and chat-driven agents can rediscover and resume the right `last_activity_at` tracking so humans and chat-driven agents can rediscover and resume the right
workspace without external notes. workspace without external notes.
- `3.4.0` added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and
`workspace-full`, plus canonical profile-based OpenAI and MCP examples so chat hosts can start
narrow and widen only when needed.
## Expected Outcome ## Expected Outcome

View file

@ -1,6 +1,6 @@
# `3.4.0` Tool Profiles And Canonical Chat Flows # `3.4.0` Tool Profiles And Canonical Chat Flows
Status: Planned Status: Done
## Goal ## Goal

View file

@ -2,7 +2,7 @@
"mcpServers": { "mcpServers": {
"pyro": { "pyro": {
"command": "uvx", "command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-core"]
} }
} }
} }

View file

@ -2,7 +2,7 @@
"mcpServers": { "mcpServers": {
"pyro": { "pyro": {
"command": "uvx", "command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-core"]
} }
} }
} }

View file

@ -9,7 +9,7 @@ Generic stdio MCP configuration using `uvx`:
"mcpServers": { "mcpServers": {
"pyro": { "pyro": {
"command": "uvx", "command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-core"]
} }
} }
} }
@ -22,15 +22,21 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
"mcpServers": { "mcpServers": {
"pyro": { "pyro": {
"command": "pyro", "command": "pyro",
"args": ["mcp", "serve"] "args": ["mcp", "serve", "--profile", "workspace-core"]
} }
} }
} }
``` ```
Primary tool for most agents: Profile progression:
- `vm_run` - `vm-run`: expose only `vm_run`
- `workspace-core`: the default persistent chat profile
- `workspace-full`: shells, services, snapshots, secrets, network policy, and disk tools
Primary profile for most agents:
- `workspace-core`
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls. Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.

View file

@ -0,0 +1,89 @@
"""Canonical OpenAI Responses API integration centered on workspace-core.
Requirements:
- `pip install openai` or `uv add openai`
- `OPENAI_API_KEY`
This example mirrors the `workspace-core` MCP profile by deriving tool schemas
from `Pyro.create_server(profile="workspace-core")` and dispatching tool calls
back through that same profiled server.
"""
from __future__ import annotations
import asyncio
import json
import os
from typing import Any, cast
from pyro_mcp import Pyro
DEFAULT_MODEL = "gpt-5"
def _tool_to_openai(tool: Any) -> dict[str, Any]:
return {
"type": "function",
"name": str(tool.name),
"description": str(getattr(tool, "description", "") or ""),
"strict": True,
"parameters": dict(tool.inputSchema),
}
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def run_openai_workspace_core_example(*, prompt: str, model: str = DEFAULT_MODEL) -> str:
from openai import OpenAI # type: ignore[import-not-found]
pyro = Pyro()
server = pyro.create_server(profile="workspace-core")
tools = [_tool_to_openai(tool) for tool in await server.list_tools()]
client = OpenAI()
input_items: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
while True:
response = client.responses.create(
model=model,
input=input_items,
tools=tools,
)
input_items.extend(response.output)
tool_calls = [item for item in response.output if item.type == "function_call"]
if not tool_calls:
return str(response.output_text)
for tool_call in tool_calls:
result = _extract_structured(
await server.call_tool(tool_call.name, json.loads(tool_call.arguments))
)
input_items.append(
{
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": json.dumps(result, sort_keys=True),
}
)
def main() -> None:
model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL)
prompt = (
"Use the workspace-core tools to create a Debian 12 workspace named "
"`chat-fix`, write `app.py` with `print(\"fixed\")`, run it with "
"`python3 app.py`, export the file to `./app.py`, then delete the workspace. "
"Do not use one-shot vm_run for this request."
)
print(asyncio.run(run_openai_workspace_core_example(prompt=prompt, model=model)))
if __name__ == "__main__":
main()

View file

@ -1,6 +1,6 @@
[project] [project]
name = "pyro-mcp" name = "pyro-mcp"
version = "3.3.0" version = "3.4.0"
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents." description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }

View file

@ -3,10 +3,16 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal, cast
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pyro_mcp.contract import (
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
)
from pyro_mcp.vm_manager import ( from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT, DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB, DEFAULT_MEM_MIB,
@ -17,6 +23,21 @@ from pyro_mcp.vm_manager import (
VmManager, VmManager,
) )
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
_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,
}
def _validate_mcp_profile(profile: str) -> McpToolProfile:
if profile not in PUBLIC_MCP_PROFILES:
expected = ", ".join(PUBLIC_MCP_PROFILES)
raise ValueError(f"unknown MCP profile {profile!r}; expected one of: {expected}")
return cast(McpToolProfile, profile)
class Pyro: class Pyro:
"""High-level facade over the ephemeral VM runtime.""" """High-level facade over the ephemeral VM runtime."""
@ -437,9 +458,16 @@ class Pyro:
allow_host_compat=allow_host_compat, allow_host_compat=allow_host_compat,
) )
def create_server(self) -> FastMCP: def create_server(self, *, profile: McpToolProfile = "workspace-full") -> FastMCP:
normalized_profile = _validate_mcp_profile(profile)
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
server = FastMCP(name="pyro_mcp") server = FastMCP(name="pyro_mcp")
def _enabled(tool_name: str) -> bool:
return tool_name in enabled_tools
if _enabled("vm_run"):
@server.tool() @server.tool()
async def vm_run( async def vm_run(
environment: str, environment: str,
@ -463,11 +491,15 @@ class Pyro:
allow_host_compat=allow_host_compat, allow_host_compat=allow_host_compat,
) )
if _enabled("vm_list_environments"):
@server.tool() @server.tool()
async def vm_list_environments() -> list[dict[str, object]]: async def vm_list_environments() -> list[dict[str, object]]:
"""List curated Linux environments and installation status.""" """List curated Linux environments and installation status."""
return self.list_environments() return self.list_environments()
if _enabled("vm_create"):
@server.tool() @server.tool()
async def vm_create( async def vm_create(
environment: str, environment: str,
@ -487,43 +519,91 @@ class Pyro:
allow_host_compat=allow_host_compat, allow_host_compat=allow_host_compat,
) )
if _enabled("vm_start"):
@server.tool() @server.tool()
async def vm_start(vm_id: str) -> dict[str, Any]: async def vm_start(vm_id: str) -> dict[str, Any]:
"""Start a created VM and transition it into a command-ready state.""" """Start a created VM and transition it into a command-ready state."""
return self.start_vm(vm_id) return self.start_vm(vm_id)
if _enabled("vm_exec"):
@server.tool() @server.tool()
async def vm_exec(vm_id: str, command: str, timeout_seconds: int = 30) -> dict[str, Any]: async def vm_exec(
vm_id: str,
command: str,
timeout_seconds: int = 30,
) -> dict[str, Any]:
"""Run one non-interactive command and auto-clean the VM.""" """Run one non-interactive command and auto-clean the VM."""
return self.exec_vm(vm_id, command=command, timeout_seconds=timeout_seconds) return self.exec_vm(vm_id, command=command, timeout_seconds=timeout_seconds)
if _enabled("vm_stop"):
@server.tool() @server.tool()
async def vm_stop(vm_id: str) -> dict[str, Any]: async def vm_stop(vm_id: str) -> dict[str, Any]:
"""Stop a running VM.""" """Stop a running VM."""
return self.stop_vm(vm_id) return self.stop_vm(vm_id)
if _enabled("vm_delete"):
@server.tool() @server.tool()
async def vm_delete(vm_id: str) -> dict[str, Any]: async def vm_delete(vm_id: str) -> dict[str, Any]:
"""Delete a VM and its runtime artifacts.""" """Delete a VM and its runtime artifacts."""
return self.delete_vm(vm_id) return self.delete_vm(vm_id)
if _enabled("vm_status"):
@server.tool() @server.tool()
async def vm_status(vm_id: str) -> dict[str, Any]: async def vm_status(vm_id: str) -> dict[str, Any]:
"""Get the current state and metadata for a VM.""" """Get the current state and metadata for a VM."""
return self.status_vm(vm_id) return self.status_vm(vm_id)
if _enabled("vm_network_info"):
@server.tool() @server.tool()
async def vm_network_info(vm_id: str) -> dict[str, Any]: async def vm_network_info(vm_id: str) -> dict[str, Any]:
"""Get the current network configuration assigned to a VM.""" """Get the current network configuration assigned to a VM."""
return self.network_info_vm(vm_id) return self.network_info_vm(vm_id)
if _enabled("vm_reap_expired"):
@server.tool() @server.tool()
async def vm_reap_expired() -> dict[str, Any]: async def vm_reap_expired() -> dict[str, Any]:
"""Delete VMs whose TTL has expired.""" """Delete VMs whose TTL has expired."""
return self.reap_expired() return self.reap_expired()
@server.tool() if _enabled("workspace_create"):
async def workspace_create( if normalized_profile == "workspace-core":
@server.tool(name="workspace_create")
async def workspace_create_core(
environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT,
mem_mib: int = DEFAULT_MEM_MIB,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
seed_path: str | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
ttl_seconds=ttl_seconds,
network_policy=DEFAULT_WORKSPACE_NETWORK_POLICY,
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=None,
name=name,
labels=labels,
)
else:
@server.tool(name="workspace_create")
async def workspace_create_full(
environment: str, environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT, vcpu_count: int = DEFAULT_VCPU_COUNT,
mem_mib: int = DEFAULT_MEM_MIB, mem_mib: int = DEFAULT_MEM_MIB,
@ -549,11 +629,15 @@ class Pyro:
labels=labels, labels=labels,
) )
if _enabled("workspace_list"):
@server.tool() @server.tool()
async def workspace_list() -> dict[str, Any]: async def workspace_list() -> dict[str, Any]:
"""List persisted workspaces with summary metadata.""" """List persisted workspaces with summary metadata."""
return self.list_workspaces() return self.list_workspaces()
if _enabled("workspace_update"):
@server.tool() @server.tool()
async def workspace_update( async def workspace_update(
workspace_id: str, workspace_id: str,
@ -571,8 +655,27 @@ class Pyro:
clear_labels=clear_labels, clear_labels=clear_labels,
) )
@server.tool() if _enabled("workspace_exec"):
async def workspace_exec( if normalized_profile == "workspace-core":
@server.tool(name="workspace_exec")
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,
timeout_seconds=timeout_seconds,
secret_env=None,
)
else:
@server.tool(name="workspace_exec")
async def workspace_exec_full(
workspace_id: str, workspace_id: str,
command: str, command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
@ -586,6 +689,8 @@ class Pyro:
secret_env=secret_env, secret_env=secret_env,
) )
if _enabled("workspace_sync_push"):
@server.tool() @server.tool()
async def workspace_sync_push( async def workspace_sync_push(
workspace_id: str, workspace_id: str,
@ -595,26 +700,36 @@ class Pyro:
"""Push host content into the persistent `/workspace` of a started workspace.""" """Push host content into the persistent `/workspace` of a started workspace."""
return self.push_workspace_sync(workspace_id, source_path=source_path, dest=dest) return self.push_workspace_sync(workspace_id, source_path=source_path, dest=dest)
if _enabled("workspace_status"):
@server.tool() @server.tool()
async def workspace_status(workspace_id: str) -> dict[str, Any]: async def workspace_status(workspace_id: str) -> dict[str, Any]:
"""Inspect workspace state and latest command metadata.""" """Inspect workspace state and latest command metadata."""
return self.status_workspace(workspace_id) return self.status_workspace(workspace_id)
if _enabled("workspace_stop"):
@server.tool() @server.tool()
async def workspace_stop(workspace_id: str) -> dict[str, Any]: async def workspace_stop(workspace_id: str) -> dict[str, Any]:
"""Stop one persistent workspace without resetting `/workspace`.""" """Stop one persistent workspace without resetting `/workspace`."""
return self.stop_workspace(workspace_id) return self.stop_workspace(workspace_id)
if _enabled("workspace_start"):
@server.tool() @server.tool()
async def workspace_start(workspace_id: str) -> dict[str, Any]: async def workspace_start(workspace_id: str) -> dict[str, Any]:
"""Start one stopped persistent workspace without resetting `/workspace`.""" """Start one stopped persistent workspace without resetting `/workspace`."""
return self.start_workspace(workspace_id) return self.start_workspace(workspace_id)
if _enabled("workspace_logs"):
@server.tool() @server.tool()
async def workspace_logs(workspace_id: str) -> dict[str, Any]: async def workspace_logs(workspace_id: str) -> dict[str, Any]:
"""Return persisted command history for one workspace.""" """Return persisted command history for one workspace."""
return self.logs_workspace(workspace_id) return self.logs_workspace(workspace_id)
if _enabled("workspace_export"):
@server.tool() @server.tool()
async def workspace_export( async def workspace_export(
workspace_id: str, workspace_id: str,
@ -624,11 +739,15 @@ class Pyro:
"""Export one file or directory from `/workspace` back to the host.""" """Export one file or directory from `/workspace` back to the host."""
return self.export_workspace(workspace_id, path, output_path=output_path) return self.export_workspace(workspace_id, path, output_path=output_path)
if _enabled("workspace_diff"):
@server.tool() @server.tool()
async def workspace_diff(workspace_id: str) -> dict[str, Any]: async def workspace_diff(workspace_id: str) -> dict[str, Any]:
"""Compare `/workspace` to the immutable create-time baseline.""" """Compare `/workspace` to the immutable create-time baseline."""
return self.diff_workspace(workspace_id) return self.diff_workspace(workspace_id)
if _enabled("workspace_file_list"):
@server.tool() @server.tool()
async def workspace_file_list( async def workspace_file_list(
workspace_id: str, workspace_id: str,
@ -642,6 +761,8 @@ class Pyro:
recursive=recursive, recursive=recursive,
) )
if _enabled("workspace_file_read"):
@server.tool() @server.tool()
async def workspace_file_read( async def workspace_file_read(
workspace_id: str, workspace_id: str,
@ -655,6 +776,8 @@ class Pyro:
max_bytes=max_bytes, max_bytes=max_bytes,
) )
if _enabled("workspace_file_write"):
@server.tool() @server.tool()
async def workspace_file_write( async def workspace_file_write(
workspace_id: str, workspace_id: str,
@ -668,6 +791,8 @@ class Pyro:
text=text, text=text,
) )
if _enabled("workspace_patch_apply"):
@server.tool() @server.tool()
async def workspace_patch_apply( async def workspace_patch_apply(
workspace_id: str, workspace_id: str,
@ -679,6 +804,8 @@ class Pyro:
patch=patch, patch=patch,
) )
if _enabled("workspace_disk_export"):
@server.tool() @server.tool()
async def workspace_disk_export( async def workspace_disk_export(
workspace_id: str, workspace_id: str,
@ -687,6 +814,8 @@ class Pyro:
"""Export the raw stopped workspace rootfs image to one host path.""" """Export the raw stopped workspace rootfs image to one host path."""
return self.export_workspace_disk(workspace_id, output_path=output_path) return self.export_workspace_disk(workspace_id, output_path=output_path)
if _enabled("workspace_disk_list"):
@server.tool() @server.tool()
async def workspace_disk_list( async def workspace_disk_list(
workspace_id: str, workspace_id: str,
@ -700,42 +829,54 @@ class Pyro:
recursive=recursive, recursive=recursive,
) )
if _enabled("workspace_disk_read"):
@server.tool() @server.tool()
async def workspace_disk_read( async def workspace_disk_read(
workspace_id: str, workspace_id: str,
path: str, path: str,
max_bytes: int = 65536, max_bytes: int = 65536,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Read one regular file from a stopped workspace rootfs without booting the guest.""" """Read one regular file from a stopped workspace rootfs offline."""
return self.read_workspace_disk( return self.read_workspace_disk(
workspace_id, workspace_id,
path, path,
max_bytes=max_bytes, max_bytes=max_bytes,
) )
if _enabled("snapshot_create"):
@server.tool() @server.tool()
async def snapshot_create(workspace_id: str, snapshot_name: str) -> dict[str, Any]: async def snapshot_create(workspace_id: str, snapshot_name: str) -> dict[str, Any]:
"""Create one named workspace snapshot from the current `/workspace` tree.""" """Create one named workspace snapshot from the current `/workspace` tree."""
return self.create_snapshot(workspace_id, snapshot_name) return self.create_snapshot(workspace_id, snapshot_name)
if _enabled("snapshot_list"):
@server.tool() @server.tool()
async def snapshot_list(workspace_id: str) -> dict[str, Any]: async def snapshot_list(workspace_id: str) -> dict[str, Any]:
"""List the baseline plus named snapshots for one workspace.""" """List the baseline plus named snapshots for one workspace."""
return self.list_snapshots(workspace_id) return self.list_snapshots(workspace_id)
if _enabled("snapshot_delete"):
@server.tool() @server.tool()
async def snapshot_delete(workspace_id: str, snapshot_name: str) -> dict[str, Any]: async def snapshot_delete(workspace_id: str, snapshot_name: str) -> dict[str, Any]:
"""Delete one named snapshot from a workspace.""" """Delete one named snapshot from a workspace."""
return self.delete_snapshot(workspace_id, snapshot_name) return self.delete_snapshot(workspace_id, snapshot_name)
if _enabled("workspace_reset"):
@server.tool() @server.tool()
async def workspace_reset( async def workspace_reset(
workspace_id: str, workspace_id: str,
snapshot: str = "baseline", snapshot: str = "baseline",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Recreate a workspace and restore `/workspace` from baseline or one named snapshot.""" """Recreate a workspace and restore `/workspace` from one snapshot."""
return self.reset_workspace(workspace_id, snapshot=snapshot) return self.reset_workspace(workspace_id, snapshot=snapshot)
if _enabled("shell_open"):
@server.tool() @server.tool()
async def shell_open( async def shell_open(
workspace_id: str, workspace_id: str,
@ -753,6 +894,8 @@ class Pyro:
secret_env=secret_env, secret_env=secret_env,
) )
if _enabled("shell_read"):
@server.tool() @server.tool()
async def shell_read( async def shell_read(
workspace_id: str, workspace_id: str,
@ -768,6 +911,8 @@ class Pyro:
max_chars=max_chars, max_chars=max_chars,
) )
if _enabled("shell_write"):
@server.tool() @server.tool()
async def shell_write( async def shell_write(
workspace_id: str, workspace_id: str,
@ -783,6 +928,8 @@ class Pyro:
append_newline=append_newline, append_newline=append_newline,
) )
if _enabled("shell_signal"):
@server.tool() @server.tool()
async def shell_signal( async def shell_signal(
workspace_id: str, workspace_id: str,
@ -796,11 +943,15 @@ class Pyro:
signal_name=signal_name, signal_name=signal_name,
) )
if _enabled("shell_close"):
@server.tool() @server.tool()
async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]: async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]:
"""Close a persistent workspace shell.""" """Close a persistent workspace shell."""
return self.close_shell(workspace_id, shell_id) return self.close_shell(workspace_id, shell_id)
if _enabled("service_start"):
@server.tool() @server.tool()
async def service_start( async def service_start(
workspace_id: str, workspace_id: str,
@ -838,16 +989,22 @@ class Pyro:
published_ports=published_ports, published_ports=published_ports,
) )
if _enabled("service_list"):
@server.tool() @server.tool()
async def service_list(workspace_id: str) -> dict[str, Any]: async def service_list(workspace_id: str) -> dict[str, Any]:
"""List named services in one workspace.""" """List named services in one workspace."""
return self.list_services(workspace_id) return self.list_services(workspace_id)
if _enabled("service_status"):
@server.tool() @server.tool()
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]: async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Inspect one named workspace service.""" """Inspect one named workspace service."""
return self.status_service(workspace_id, service_name) return self.status_service(workspace_id, service_name)
if _enabled("service_logs"):
@server.tool() @server.tool()
async def service_logs( async def service_logs(
workspace_id: str, workspace_id: str,
@ -863,11 +1020,15 @@ class Pyro:
all=all, all=all,
) )
if _enabled("service_stop"):
@server.tool() @server.tool()
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]: async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Stop one running service in a workspace.""" """Stop one running service in a workspace."""
return self.stop_service(workspace_id, service_name) return self.stop_service(workspace_id, service_name)
if _enabled("workspace_delete"):
@server.tool() @server.tool()
async def workspace_delete(workspace_id: str) -> dict[str, Any]: async def workspace_delete(workspace_id: str) -> dict[str, Any]:
"""Delete a persistent workspace and its backing sandbox.""" """Delete a persistent workspace and its backing sandbox."""

View file

@ -11,6 +11,7 @@ from typing import Any
from pyro_mcp import __version__ from pyro_mcp import __version__
from pyro_mcp.api import Pyro from pyro_mcp.api import Pyro
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
from pyro_mcp.demo import run_demo from pyro_mcp.demo import run_demo
from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
@ -740,24 +741,45 @@ def _build_parser() -> argparse.ArgumentParser:
"Run the MCP server after you have already validated the host and " "Run the MCP server after you have already validated the host and "
"guest execution with `pyro doctor` and `pyro run`." "guest execution with `pyro doctor` and `pyro run`."
), ),
epilog="Example:\n pyro mcp serve", epilog=dedent(
"""
Examples:
pyro mcp serve --profile vm-run
pyro mcp serve --profile workspace-core
pyro mcp serve --profile workspace-full
"""
),
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
) )
mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True, metavar="MCP") mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True, metavar="MCP")
mcp_subparsers.add_parser( mcp_serve_parser = mcp_subparsers.add_parser(
"serve", "serve",
help="Run the MCP server over stdio.", help="Run the MCP server over stdio.",
description="Expose pyro tools over stdio for an MCP client.", description="Expose pyro tools over stdio for an MCP client.",
epilog=dedent( epilog=dedent(
""" """
Example: Example:
pyro mcp serve pyro mcp serve --profile workspace-core
Profiles:
vm-run: only the vm_run tool
workspace-core: vm_run plus the practical workspace chat loop
workspace-full: the full stable workspace surface
Use this from an MCP client config after the CLI evaluation path works. Use this from an MCP client config after the CLI evaluation path works.
""" """
), ),
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
) )
mcp_serve_parser.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-full",
help=(
"Expose only one model-facing tool profile. "
"`workspace-full` preserves the current full MCP surface."
),
)
run_parser = subparsers.add_parser( run_parser = subparsers.add_parser(
"run", "run",
@ -2175,7 +2197,7 @@ def main() -> None:
_print_prune_human(prune_payload) _print_prune_human(prune_payload)
return return
if args.command == "mcp": if args.command == "mcp":
pyro.create_server().run(transport="stdio") pyro.create_server(profile=args.profile).run(transport="stdio")
return return
if args.command == "run": if args.command == "run":
command = _require_command(args.command_args) command = _require_command(args.command_args)

View file

@ -5,6 +5,8 @@ from __future__ import annotations
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace") PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"create", "create",
"delete", "delete",
@ -108,6 +110,7 @@ PUBLIC_CLI_RUN_FLAGS = (
"--allow-host-compat", "--allow-host-compat",
"--json", "--json",
) )
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
PUBLIC_SDK_METHODS = ( PUBLIC_SDK_METHODS = (
"apply_workspace_patch", "apply_workspace_patch",
@ -204,3 +207,23 @@ PUBLIC_MCP_TOOLS = (
"workspace_sync_push", "workspace_sync_push",
"workspace_update", "workspace_update",
) )
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS = ("vm_run",)
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
"vm_run",
"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_status",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS

View file

@ -4,13 +4,17 @@ from __future__ import annotations
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from pyro_mcp.api import Pyro from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_manager import VmManager
def create_server(manager: VmManager | None = None) -> FastMCP: def create_server(
manager: VmManager | None = None,
*,
profile: McpToolProfile = "workspace-full",
) -> FastMCP:
"""Create and return a configured MCP server instance.""" """Create and return a configured MCP server instance."""
return Pyro(manager=manager).create_server() return Pyro(manager=manager).create_server(profile=profile)
def main() -> None: def main() -> None:

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0" DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "3.3.0" DEFAULT_CATALOG_VERSION = "3.4.0"
OCI_MANIFEST_ACCEPT = ", ".join( OCI_MANIFEST_ACCEPT = ", ".join(
( (
"application/vnd.oci.image.index.v1+json", "application/vnd.oci.image.index.v1+json",

View file

@ -6,6 +6,11 @@ from pathlib import Path
from typing import Any, cast from typing import Any, cast
from pyro_mcp.api import Pyro from pyro_mcp.api import Pyro
from pyro_mcp.contract import (
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
)
from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_manager import VmManager
from pyro_mcp.vm_network import TapNetworkManager from pyro_mcp.vm_network import TapNetworkManager
@ -47,37 +52,54 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
return sorted(tool.name for tool in tools) return sorted(tool.name for tool in tools)
tool_names = asyncio.run(_run()) tool_names = asyncio.run(_run())
assert "vm_run" in tool_names assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS))
assert "vm_create" in tool_names
assert "workspace_create" in tool_names
assert "workspace_list" in tool_names def test_pyro_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None:
assert "workspace_update" in tool_names pyro = Pyro(
assert "workspace_start" in tool_names manager=VmManager(
assert "workspace_stop" in tool_names backend_name="mock",
assert "workspace_diff" in tool_names base_dir=tmp_path / "vms",
assert "workspace_sync_push" in tool_names network_manager=TapNetworkManager(enabled=False),
assert "workspace_export" in tool_names )
assert "workspace_file_list" in tool_names )
assert "workspace_file_read" in tool_names
assert "workspace_file_write" in tool_names async def _run() -> list[str]:
assert "workspace_patch_apply" in tool_names server = pyro.create_server(profile="vm-run")
assert "workspace_disk_export" in tool_names tools = await server.list_tools()
assert "workspace_disk_list" in tool_names return sorted(tool.name for tool in tools)
assert "workspace_disk_read" in tool_names
assert "snapshot_create" in tool_names assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS
assert "snapshot_list" in tool_names
assert "snapshot_delete" in tool_names
assert "workspace_reset" in tool_names def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_schemas(
assert "shell_open" in tool_names tmp_path: Path,
assert "shell_read" in tool_names ) -> None:
assert "shell_write" in tool_names pyro = Pyro(
assert "shell_signal" in tool_names manager=VmManager(
assert "shell_close" in tool_names backend_name="mock",
assert "service_start" in tool_names base_dir=tmp_path / "vms",
assert "service_list" in tool_names network_manager=TapNetworkManager(enabled=False),
assert "service_status" in tool_names )
assert "service_logs" in tool_names )
assert "service_stop" in tool_names
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server(profile="workspace-core")
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_WORKSPACE_CORE_PROFILE_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 "shell_open" not in tool_map
assert "service_start" not in tool_map
assert "snapshot_create" not in tool_map
assert "workspace_disk_export" not in tool_map
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None: def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:

View file

@ -63,6 +63,10 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
mcp_help = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help() mcp_help = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help()
assert "Expose pyro tools over stdio for an MCP client." in mcp_help assert "Expose pyro tools over stdio for an MCP client." in mcp_help
assert "--profile" in mcp_help
assert "workspace-core" in mcp_help
assert "workspace-full" in mcp_help
assert "vm-run" in mcp_help
assert "Use this from an MCP client config after the CLI evaluation path works." in mcp_help assert "Use this from an MCP client config after the CLI evaluation path works." in mcp_help
workspace_help = _subparser_choice(parser, "workspace").format_help() workspace_help = _subparser_choice(parser, "workspace").format_help()
@ -3435,7 +3439,8 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
observed: dict[str, str] = {} observed: dict[str, str] = {}
class StubPyro: class StubPyro:
def create_server(self) -> Any: def create_server(self, *, profile: str) -> Any:
observed["profile"] = profile
return type( return type(
"StubServer", "StubServer",
(), (),
@ -3444,12 +3449,12 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
class StubParser: class StubParser:
def parse_args(self) -> argparse.Namespace: def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(command="mcp", mcp_command="serve") return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro) monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main() cli.main()
assert observed == {"transport": "stdio"} assert observed == {"profile": "workspace-core", "transport": "stdio"}
def test_cli_demo_default_prints_json( def test_cli_demo_default_prints_json(

View file

@ -16,6 +16,8 @@ from pyro_mcp.contract import (
PUBLIC_CLI_COMMANDS, PUBLIC_CLI_COMMANDS,
PUBLIC_CLI_DEMO_SUBCOMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS,
PUBLIC_CLI_ENV_SUBCOMMANDS, PUBLIC_CLI_ENV_SUBCOMMANDS,
PUBLIC_CLI_MCP_SERVE_FLAGS,
PUBLIC_CLI_MCP_SUBCOMMANDS,
PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_RUN_FLAGS,
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
@ -54,6 +56,7 @@ from pyro_mcp.contract import (
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_TOOLS, PUBLIC_MCP_TOOLS,
PUBLIC_SDK_METHODS, PUBLIC_SDK_METHODS,
) )
@ -99,6 +102,14 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
env_help_text = _subparser_choice(parser, "env").format_help() env_help_text = _subparser_choice(parser, "env").format_help()
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
assert subcommand_name in env_help_text assert subcommand_name in env_help_text
mcp_help_text = _subparser_choice(parser, "mcp").format_help()
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
assert subcommand_name in mcp_help_text
mcp_serve_help_text = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help()
for flag in PUBLIC_CLI_MCP_SERVE_FLAGS:
assert flag in mcp_serve_help_text
for profile_name in PUBLIC_MCP_PROFILES:
assert profile_name in mcp_serve_help_text
workspace_help_text = _subparser_choice(parser, "workspace").format_help() workspace_help_text = _subparser_choice(parser, "workspace").format_help()
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS: for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:

View file

@ -7,6 +7,11 @@ from typing import Any, cast
import pytest import pytest
import pyro_mcp.server as server_module import pyro_mcp.server as server_module
from pyro_mcp.contract import (
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
)
from pyro_mcp.server import create_server from pyro_mcp.server import create_server
from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_manager import VmManager
from pyro_mcp.vm_network import TapNetworkManager from pyro_mcp.vm_network import TapNetworkManager
@ -25,42 +30,37 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
return sorted(tool.name for tool in tools) return sorted(tool.name for tool in tools)
tool_names = asyncio.run(_run()) tool_names = asyncio.run(_run())
assert "vm_create" in tool_names assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS))
assert "vm_exec" in tool_names
assert "vm_list_environments" in tool_names
assert "vm_network_info" in tool_names def test_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None:
assert "vm_run" in tool_names manager = VmManager(
assert "vm_status" in tool_names backend_name="mock",
assert "workspace_create" in tool_names base_dir=tmp_path / "vms",
assert "workspace_list" in tool_names network_manager=TapNetworkManager(enabled=False),
assert "workspace_update" in tool_names )
assert "workspace_start" in tool_names
assert "workspace_stop" in tool_names async def _run() -> list[str]:
assert "workspace_diff" in tool_names server = create_server(manager=manager, profile="vm-run")
assert "workspace_export" in tool_names tools = await server.list_tools()
assert "workspace_file_list" in tool_names return sorted(tool.name for tool in tools)
assert "workspace_file_read" in tool_names
assert "workspace_file_write" in tool_names assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS
assert "workspace_patch_apply" in tool_names
assert "workspace_disk_export" in tool_names
assert "workspace_disk_list" in tool_names def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path: Path) -> None:
assert "workspace_disk_read" in tool_names manager = VmManager(
assert "workspace_logs" in tool_names backend_name="mock",
assert "workspace_sync_push" in tool_names base_dir=tmp_path / "vms",
assert "shell_open" in tool_names network_manager=TapNetworkManager(enabled=False),
assert "shell_read" in tool_names )
assert "shell_write" in tool_names
assert "shell_signal" in tool_names async def _run() -> list[str]:
assert "shell_close" in tool_names server = create_server(manager=manager, profile="workspace-core")
assert "service_start" in tool_names tools = await server.list_tools()
assert "service_list" in tool_names return sorted(tool.name for tool in tools)
assert "service_status" in tool_names
assert "service_logs" in tool_names assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
assert "service_stop" in tool_names
assert "snapshot_create" in tool_names
assert "snapshot_delete" in tool_names
assert "snapshot_list" in tool_names
assert "workspace_reset" in tool_names
def test_vm_run_round_trip(tmp_path: Path) -> None: def test_vm_run_round_trip(tmp_path: Path) -> None:
@ -193,6 +193,91 @@ def test_server_main_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> No
assert called == {"transport": "stdio"} assert called == {"transport": "stdio"}
def test_workspace_core_profile_round_trip(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("old\n", encoding="utf-8")
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], ...]:
server = create_server(manager=manager, profile="workspace-core")
created = _extract_structured(
await server.call_tool(
"workspace_create",
{
"environment": "debian:12-base",
"allow_host_compat": True,
"seed_path": str(source_dir),
"name": "chat-loop",
"labels": {"issue": "123"},
},
)
)
workspace_id = str(created["workspace_id"])
written = _extract_structured(
await server.call_tool(
"workspace_file_write",
{
"workspace_id": workspace_id,
"path": "note.txt",
"text": "fixed\n",
},
)
)
executed = _extract_structured(
await server.call_tool(
"workspace_exec",
{
"workspace_id": workspace_id,
"command": "cat note.txt",
},
)
)
diffed = _extract_structured(
await server.call_tool("workspace_diff", {"workspace_id": workspace_id})
)
export_path = tmp_path / "exported-note.txt"
exported = _extract_structured(
await server.call_tool(
"workspace_export",
{
"workspace_id": workspace_id,
"path": "note.txt",
"output_path": str(export_path),
},
)
)
reset = _extract_structured(
await server.call_tool("workspace_reset", {"workspace_id": workspace_id})
)
deleted = _extract_structured(
await server.call_tool("workspace_delete", {"workspace_id": workspace_id})
)
return created, written, executed, diffed, exported, reset, deleted
created, written, executed, diffed, exported, reset, deleted = asyncio.run(_run())
assert created["name"] == "chat-loop"
assert created["labels"] == {"issue": "123"}
assert written["bytes_written"] == len("fixed\n".encode("utf-8"))
assert executed["stdout"] == "fixed\n"
assert diffed["changed"] is True
assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "fixed\n"
assert reset["command_count"] == 0
assert deleted["deleted"] is True
def test_workspace_tools_round_trip(tmp_path: Path) -> None: def test_workspace_tools_round_trip(tmp_path: Path) -> None:
manager = VmManager( manager = VmManager(
backend_name="mock", backend_name="mock",

2
uv.lock generated
View file

@ -706,7 +706,7 @@ crypto = [
[[package]] [[package]]
name = "pyro-mcp" name = "pyro-mcp"
version = "3.3.0" version = "3.4.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },