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.
## 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
- 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)
- 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/)
- 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)
- Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -59,7 +59,7 @@ What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 3.3.0
Catalog version: 3.4.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -189,7 +189,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```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-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.
@ -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
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
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.
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
@ -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
- `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:
@ -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:
```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:
```bash
@ -520,6 +526,12 @@ Persistent workspace tools:
- `workspace_logs(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
- 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)
- 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 `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)
- 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
$ 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-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.
@ -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 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 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.
@ -252,7 +252,7 @@ State: started
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
`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
`/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

View file

@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```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-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.
@ -224,7 +224,7 @@ After the CLI path works, you can move on to:
- 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'`
- 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`
- 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`
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
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
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

View file

@ -7,8 +7,10 @@ CLI path in [install.md](install.md) or [first-run.md](first-run.md).
## Recommended Default
Use `vm_run` first for one-shot commands, then move to the stable workspace surface when the
agent needs to inhabit one sandbox across multiple calls.
Use `vm_run` first for one-shot commands, then move to `workspace-core` when the
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:
@ -17,8 +19,11 @@ That keeps the model-facing contract small:
- one ephemeral VM
- automatic cleanup
Move to `workspace_*` when the agent needs repeated commands, shells, services, snapshots, reset,
diff, or export in one stable workspace across multiple calls.
Profile progression:
- `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
@ -30,20 +35,14 @@ Best when:
Recommended surface:
- `vm_run`
- `workspace_create(name=..., labels=...)` + `workspace_list` + `workspace_update` when the agent needs to rediscover or retag long-lived workspaces between turns
- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state
- `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
- `vm_run` for one-shot loops
- the `workspace-core` tool set for the normal persistent chat loop
- the `workspace-full` tool set only when the host explicitly needs advanced workspace capabilities
Canonical example:
- [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
@ -55,7 +54,13 @@ Best when:
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:

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 --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 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 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.
@ -125,8 +126,8 @@ Primary facade:
Supported public entrypoints:
- `create_server()`
- `Pyro.create_server()`
- `create_server(profile="workspace-full")`
- `Pyro.create_server(profile="workspace-full")`
- `Pyro.list_environments()`
- `Pyro.pull_environment(environment)`
- `Pyro.inspect_environment(environment)`
@ -176,7 +177,7 @@ Supported public entrypoints:
Stable public method names:
- `create_server()`
- `create_server(profile="workspace-full")`
- `list_environments()`
- `pull_environment(environment)`
- `inspect_environment(environment)`
@ -265,6 +266,18 @@ Behavioral defaults:
## 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:
- `vm_run`

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface.
Current baseline is `3.3.0`:
Current baseline is `3.4.0`:
- the stable workspace contract exists across CLI, SDK, and MCP
- 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
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)
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
`last_activity_at` tracking so humans and chat-driven agents can rediscover and resume the right
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

View file

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

View file

@ -2,7 +2,7 @@
"mcpServers": {
"pyro": {
"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": {
"pyro": {
"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": {
"pyro": {
"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": {
"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.

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]
name = "pyro-mcp"
version = "3.3.0"
version = "3.4.0"
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
readme = "README.md"
license = { file = "LICENSE" }

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ from typing import Any
from pyro_mcp import __version__
from pyro_mcp.api import Pyro
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
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.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 "
"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,
)
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",
help="Run the MCP server over stdio.",
description="Expose pyro tools over stdio for an MCP client.",
epilog=dedent(
"""
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.
"""
),
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",
@ -2175,7 +2197,7 @@ def main() -> None:
_print_prune_human(prune_payload)
return
if args.command == "mcp":
pyro.create_server().run(transport="stdio")
pyro.create_server(profile=args.profile).run(transport="stdio")
return
if args.command == "run":
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_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"create",
"delete",
@ -108,6 +110,7 @@ PUBLIC_CLI_RUN_FLAGS = (
"--allow-host-compat",
"--json",
)
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
PUBLIC_SDK_METHODS = (
"apply_workspace_patch",
@ -204,3 +207,23 @@ PUBLIC_MCP_TOOLS = (
"workspace_sync_push",
"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 pyro_mcp.api import Pyro
from pyro_mcp.api import McpToolProfile, Pyro
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."""
return Pyro(manager=manager).create_server()
return Pyro(manager=manager).create_server(profile=profile)
def main() -> None:

View file

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

View file

@ -6,6 +6,11 @@ from pathlib import Path
from typing import Any, cast
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_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)
tool_names = asyncio.run(_run())
assert "vm_run" in tool_names
assert "vm_create" in tool_names
assert "workspace_create" in tool_names
assert "workspace_list" in tool_names
assert "workspace_update" in tool_names
assert "workspace_start" in tool_names
assert "workspace_stop" in tool_names
assert "workspace_diff" in tool_names
assert "workspace_sync_push" in tool_names
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
assert "workspace_patch_apply" in tool_names
assert "workspace_disk_export" in tool_names
assert "workspace_disk_list" in tool_names
assert "workspace_disk_read" in tool_names
assert "snapshot_create" in tool_names
assert "snapshot_list" in tool_names
assert "snapshot_delete" in tool_names
assert "workspace_reset" in tool_names
assert "shell_open" in tool_names
assert "shell_read" in tool_names
assert "shell_write" in tool_names
assert "shell_signal" in tool_names
assert "shell_close" in tool_names
assert "service_start" in tool_names
assert "service_list" in tool_names
assert "service_status" in tool_names
assert "service_logs" in tool_names
assert "service_stop" in tool_names
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS))
def test_pyro_create_server_vm_run_profile_registers_only_vm_run(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(profile="vm-run")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS
def test_pyro_create_server_workspace_core_profile_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(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:

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()
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
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] = {}
class StubPyro:
def create_server(self) -> Any:
def create_server(self, *, profile: str) -> Any:
observed["profile"] = profile
return type(
"StubServer",
(),
@ -3444,12 +3449,12 @@ 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")
return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
assert observed == {"transport": "stdio"}
assert observed == {"profile": "workspace-core", "transport": "stdio"}
def test_cli_demo_default_prints_json(

View file

@ -16,6 +16,8 @@ from pyro_mcp.contract import (
PUBLIC_CLI_COMMANDS,
PUBLIC_CLI_DEMO_SUBCOMMANDS,
PUBLIC_CLI_ENV_SUBCOMMANDS,
PUBLIC_CLI_MCP_SERVE_FLAGS,
PUBLIC_CLI_MCP_SUBCOMMANDS,
PUBLIC_CLI_RUN_FLAGS,
PUBLIC_CLI_WORKSPACE_CREATE_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_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_TOOLS,
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()
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
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()
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:

View file

@ -7,6 +7,11 @@ from typing import Any, cast
import pytest
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.vm_manager import VmManager
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)
tool_names = asyncio.run(_run())
assert "vm_create" in tool_names
assert "vm_exec" in tool_names
assert "vm_list_environments" in tool_names
assert "vm_network_info" in tool_names
assert "vm_run" in tool_names
assert "vm_status" in tool_names
assert "workspace_create" in tool_names
assert "workspace_list" in tool_names
assert "workspace_update" in tool_names
assert "workspace_start" in tool_names
assert "workspace_stop" in tool_names
assert "workspace_diff" in tool_names
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
assert "workspace_patch_apply" in tool_names
assert "workspace_disk_export" in tool_names
assert "workspace_disk_list" in tool_names
assert "workspace_disk_read" in tool_names
assert "workspace_logs" in tool_names
assert "workspace_sync_push" in tool_names
assert "shell_open" in tool_names
assert "shell_read" in tool_names
assert "shell_write" in tool_names
assert "shell_signal" in tool_names
assert "shell_close" in tool_names
assert "service_start" in tool_names
assert "service_list" in tool_names
assert "service_status" in tool_names
assert "service_logs" in tool_names
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
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS))
def test_create_server_vm_run_profile_registers_only_vm_run(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, profile="vm-run")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS
def test_create_server_workspace_core_profile_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, profile="workspace-core")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
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"}
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:
manager = VmManager(
backend_name="mock",

2
uv.lock generated
View file

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