Add adoption-focused examples, contract docs, and CLI polish

This commit is contained in:
Thales Maciel 2026-03-07 22:34:14 -03:00
parent 227983a877
commit 0aa5e25dc1
18 changed files with 560 additions and 2 deletions

1
.gitignore vendored
View file

@ -7,4 +7,5 @@ __pycache__/
htmlcov/ htmlcov/
dist/ dist/
build/ build/
.uv-cache/
*.egg-info/ *.egg-info/

View file

@ -12,6 +12,7 @@ This repository ships `pyro-mcp`, an MCP-compatible package for ephemeral VM lif
- Install Git LFS before cloning or materializing the packaged runtime bundle: `git lfs install`. - Install Git LFS before cloning or materializing the packaged runtime bundle: `git lfs install`.
- Run `make setup` after cloning. - Run `make setup` after cloning.
- Run `make check` before opening a PR. - Run `make check` before opening a PR.
- Run `make dist-check` when you change packaging, entrypoints, or install docs.
- Public user-facing CLI is `pyro`. - Public user-facing CLI is `pyro`.
- Public Python SDK entrypoint is `from pyro_mcp import Pyro`. - Public Python SDK entrypoint is `from pyro_mcp import Pyro`.
- The packaged runtime images under `src/pyro_mcp/runtime_bundle/` are stored in Git LFS. - The packaged runtime images under `src/pyro_mcp/runtime_bundle/` are stored in Git LFS.
@ -28,6 +29,7 @@ This repository ships `pyro-mcp`, an MCP-compatible package for ephemeral VM lif
- If you need full log payloads from the Ollama demo, use `make ollama-demo OLLAMA_DEMO_FLAGS=-v`. - If you need full log payloads from the Ollama demo, use `make ollama-demo OLLAMA_DEMO_FLAGS=-v`.
- After heavy runtime work, reclaim local space with `rm -rf build` and `git lfs prune`. - After heavy runtime work, reclaim local space with `rm -rf build` and `git lfs prune`.
- The pre-migration `pre-lfs-*` tag is local backup material only; do not push it or it will keep the old giant blobs reachable. - The pre-migration `pre-lfs-*` tag is local backup material only; do not push it or it will keep the old giant blobs reachable.
- Public contract documentation lives in `docs/public-contract.md`.
## Quality Gates ## Quality Gates

View file

@ -1,4 +1,5 @@
PYTHON ?= uv run python PYTHON ?= uv run python
UV_CACHE_DIR ?= .uv-cache
OLLAMA_BASE_URL ?= http://localhost:11434/v1 OLLAMA_BASE_URL ?= http://localhost:11434/v1
OLLAMA_MODEL ?= llama3.2:3b OLLAMA_MODEL ?= llama3.2:3b
OLLAMA_DEMO_FLAGS ?= OLLAMA_DEMO_FLAGS ?=
@ -8,7 +9,7 @@ RUNTIME_BUILD_DIR ?= build/runtime_bundle
RUNTIME_BUNDLE_DIR ?= src/pyro_mcp/runtime_bundle RUNTIME_BUNDLE_DIR ?= src/pyro_mcp/runtime_bundle
RUNTIME_MATERIALIZED_DIR ?= build/runtime_sources RUNTIME_MATERIALIZED_DIR ?= build/runtime_sources
.PHONY: help setup lint format typecheck test check demo network-demo doctor ollama ollama-demo run-server install-hooks runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-boot-check runtime-network-check .PHONY: help setup lint format typecheck test check dist-check demo network-demo doctor ollama ollama-demo run-server install-hooks runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-boot-check runtime-network-check
help: help:
@printf '%s\n' \ @printf '%s\n' \
@ -20,6 +21,7 @@ help:
' typecheck Run mypy' \ ' typecheck Run mypy' \
' test Run pytest' \ ' test Run pytest' \
' check Run lint, typecheck, and tests' \ ' check Run lint, typecheck, and tests' \
' dist-check Smoke-test the installed public pyro CLI entrypoint' \
' demo Run the deterministic VM demo' \ ' demo Run the deterministic VM demo' \
' network-demo Run the deterministic VM demo with guest networking enabled' \ ' network-demo Run the deterministic VM demo with guest networking enabled' \
' doctor Show runtime and host diagnostics' \ ' doctor Show runtime and host diagnostics' \
@ -59,6 +61,10 @@ test:
check: lint typecheck test check: lint typecheck test
dist-check:
.venv/bin/pyro --version
.venv/bin/pyro --help >/dev/null
demo: demo:
uv run pyro demo uv run pyro demo

View file

@ -9,6 +9,13 @@ It exposes the same runtime in two public forms:
It also ships an MCP server so LLM clients can use the same VM runtime through tools. It also ships an MCP server so LLM clients can use the same VM runtime through tools.
## Start Here
- Install: [docs/install.md](/home/thales/projects/personal/pyro/docs/install.md)
- Host requirements: [docs/host-requirements.md](/home/thales/projects/personal/pyro/docs/host-requirements.md)
- Public contract: [docs/public-contract.md](/home/thales/projects/personal/pyro/docs/public-contract.md)
- Troubleshooting: [docs/troubleshooting.md](/home/thales/projects/personal/pyro/docs/troubleshooting.md)
## Public UX ## Public UX
Primary install/run path: Primary install/run path:
@ -26,6 +33,12 @@ pyro mcp serve
The public user-facing interface is `pyro` and `Pyro`. The public user-facing interface is `pyro` and `Pyro`.
`Makefile` targets are contributor conveniences for this repository and are not the primary product UX. `Makefile` targets are contributor conveniences for this repository and are not the primary product UX.
Check the installed CLI version:
```bash
pyro --version
```
## Repository Storage ## Repository Storage
This repository uses Git LFS for the packaged runtime images under This repository uses Git LFS for the packaged runtime images under
@ -124,6 +137,13 @@ Verbose Ollama logs:
pyro demo ollama -v pyro demo ollama -v
``` ```
## Integration Examples
- Python one-shot SDK example: [examples/python_run.py](/home/thales/projects/personal/pyro/examples/python_run.py)
- Python lifecycle example: [examples/python_lifecycle.py](/home/thales/projects/personal/pyro/examples/python_lifecycle.py)
- MCP client config example: [examples/mcp_client_config.md](/home/thales/projects/personal/pyro/examples/mcp_client_config.md)
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](/home/thales/projects/personal/pyro/examples/agent_vm_run.py)
## Python SDK ## Python SDK
```python ```python
@ -160,6 +180,9 @@ result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
print(result["stdout"]) print(result["stdout"])
``` ```
The recommended agent-facing default is still one-shot execution through `run_in_vm(...)` / `vm_run`.
Use lifecycle methods only when the agent needs VM state to persist across multiple calls.
## MCP Tools ## MCP Tools
Primary agent-facing tool: Primary agent-facing tool:
@ -212,6 +235,7 @@ For work inside this repository:
make help make help
make setup make setup
make check make check
make dist-check
``` ```
Runtime build and validation helpers remain available through `make`, including: Runtime build and validation helpers remain available through `make`, including:

14
TASKS.tmp.md Normal file
View file

@ -0,0 +1,14 @@
# Temporary Task List
Execution order for the current adoption pass:
1. Add real integration examples for users.
2. Freeze and document the public contract with regression tests.
3. Improve distribution ergonomics and verify clean-install paths.
4. Add one high-level agent-ready example centered on `vm_run`.
5. Polish install, troubleshooting, and host-requirements docs.
Notes:
- `Makefile` remains contributor-only.
- Public user-facing surfaces are `pyro`, `Pyro`, and the MCP tool schemas.
- Keep guest networking explicit and documented where examples depend on it.

32
docs/host-requirements.md Normal file
View file

@ -0,0 +1,32 @@
# Host Requirements
`pyro-mcp` currently targets Linux x86_64 hosts.
## Required
- KVM available at `/dev/kvm`
- support for Firecracker microVMs
- sufficient disk for the bundled runtime images
## Required For Guest Networking
- `/dev/net/tun`
- `ip`
- `nft` or `iptables`
- host IP forwarding enabled
- privilege to create TAP devices and NAT rules
The current implementation uses `sudo -n` for host networking commands when a networked run is requested.
## Validate The Host
```bash
pyro doctor
```
Check these fields in the output:
- `runtime_ok`
- `kvm`
- `networking.tun_available`
- `networking.ip_forward_enabled`

46
docs/install.md Normal file
View file

@ -0,0 +1,46 @@
# Install
## Requirements
- Linux host
- Python 3.12+
- Git LFS
- `/dev/kvm`
If you want outbound guest networking:
- `ip`
- `nft` or `iptables`
- privilege to create TAP devices and configure NAT
## Fastest Start
Run the MCP server directly from the package without a manual install:
```bash
uvx --from pyro-mcp pyro mcp serve
```
Run one command in a sandbox:
```bash
uvx --from pyro-mcp pyro run --profile debian-git --vcpu-count 1 --mem-mib 1024 -- git --version
```
## Installed CLI
```bash
uv tool install .
pyro --version
pyro doctor
```
## Contributor Clone
```bash
git lfs install
git clone <repo>
cd pyro
git lfs pull
make setup
```

76
docs/public-contract.md Normal file
View file

@ -0,0 +1,76 @@
# Public Contract
This document defines the supported public interface for `pyro-mcp`.
## Package Identity
- Distribution name: `pyro-mcp`
- Public executable: `pyro`
- Public Python import: `from pyro_mcp import Pyro`
## CLI Contract
Top-level commands:
- `pyro mcp serve`
- `pyro run`
- `pyro doctor`
- `pyro demo`
- `pyro demo ollama`
Stable `pyro run` flags:
- `--profile`
- `--vcpu-count`
- `--mem-mib`
- `--timeout-seconds`
- `--ttl-seconds`
- `--network`
Behavioral guarantees:
- `pyro run -- <command>` returns structured JSON.
- `pyro doctor` returns structured JSON diagnostics.
- `pyro demo ollama` prints log lines plus a final summary line.
## Python SDK Contract
Primary facade:
- `Pyro`
Supported public methods:
- `create_server()`
- `list_profiles()`
- `create_vm(...)`
- `start_vm(vm_id)`
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
- `stop_vm(vm_id)`
- `delete_vm(vm_id)`
- `status_vm(vm_id)`
- `network_info_vm(vm_id)`
- `reap_expired()`
- `run_in_vm(...)`
## MCP Contract
Primary tool:
- `vm_run`
Advanced lifecycle tools:
- `vm_list_profiles`
- `vm_create`
- `vm_start`
- `vm_exec`
- `vm_stop`
- `vm_delete`
- `vm_status`
- `vm_network_info`
- `vm_reap_expired`
## Compatibility Rule
Changes to any command name, public flag, public method name, or MCP tool name are breaking changes and should be treated as a deliberate contract version change.

64
docs/troubleshooting.md Normal file
View file

@ -0,0 +1,64 @@
# Troubleshooting
## `pyro doctor` reports runtime checksum mismatch
Cause:
- the Git LFS pointer files are present, but the real runtime images have not been checked out
Fix:
```bash
git lfs pull
git lfs checkout
pyro doctor
```
## `pyro run --network` fails before the guest starts
Cause:
- the host cannot create TAP devices or NAT rules
Fix:
```bash
pyro doctor
```
Then verify:
- `ip`
- `nft` or `iptables`
- `/dev/net/tun`
- host privilege for `sudo -n`
## Ollama demo exits with tool-call failures
Cause:
- the model produced an invalid tool call or your Ollama model is not reliable enough for tool use
Fix:
```bash
pyro demo ollama -v
```
Inspect:
- model output
- requested tool calls
- tool results
## Repository clone is still huge after the LFS migration
Cause:
- old refs are still present locally
- `build/` or `.venv/` duplicates are consuming disk
Fix:
```bash
rm -rf build
git lfs prune
```
If needed, recreate `.venv/`.

62
examples/agent_vm_run.py Normal file
View file

@ -0,0 +1,62 @@
"""Provider-agnostic agent tool example centered on vm_run."""
from __future__ import annotations
import json
from typing import Any
from pyro_mcp import Pyro
VM_RUN_TOOL: dict[str, Any] = {
"name": "vm_run",
"description": "Run one command in an ephemeral Firecracker VM and clean it up.",
"input_schema": {
"type": "object",
"properties": {
"profile": {"type": "string"},
"command": {"type": "string"},
"vcpu_count": {"type": "integer"},
"mem_mib": {"type": "integer"},
"timeout_seconds": {"type": "integer", "default": 30},
"ttl_seconds": {"type": "integer", "default": 600},
"network": {"type": "boolean", "default": False},
},
"required": ["profile", "command", "vcpu_count", "mem_mib"],
},
}
def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]:
pyro = Pyro()
return pyro.run_in_vm(
profile=str(arguments["profile"]),
command=str(arguments["command"]),
vcpu_count=int(arguments["vcpu_count"]),
mem_mib=int(arguments["mem_mib"]),
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
network=bool(arguments.get("network", False)),
)
def main() -> None:
tool_arguments: dict[str, Any] = {
"profile": "debian-git",
"command": "git --version",
"vcpu_count": 1,
"mem_mib": 1024,
"timeout_seconds": 30,
"network": False,
}
tool_call: dict[str, Any] = {
"name": "vm_run",
"arguments": tool_arguments,
}
print(json.dumps({"tool": VM_RUN_TOOL, "tool_call": tool_call}, indent=2, sort_keys=True))
result = call_vm_run(tool_arguments)
print(json.dumps({"tool_result": result}, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,35 @@
# MCP Client Config Example
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
Generic stdio MCP configuration using `uvx`:
```json
{
"mcpServers": {
"pyro": {
"command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
}
}
}
```
If `pyro-mcp` is already installed locally, the same server can be configured with:
```json
{
"mcpServers": {
"pyro": {
"command": "pyro",
"args": ["mcp", "serve"]
}
}
}
```
Primary tool for most agents:
- `vm_run`
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.

View file

@ -0,0 +1,30 @@
"""Manage VM lifecycle directly through the public Python SDK."""
from __future__ import annotations
import json
from pyro_mcp import Pyro
def main() -> None:
pyro = Pyro()
created = pyro.create_vm(
profile="debian-git",
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=False,
)
vm_id = str(created["vm_id"])
try:
pyro.start_vm(vm_id)
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
print(json.dumps(result, indent=2, sort_keys=True))
finally:
pyro.delete_vm(vm_id)
if __name__ == "__main__":
main()

24
examples/python_run.py Normal file
View file

@ -0,0 +1,24 @@
"""Run one command in an ephemeral VM through the public Python SDK."""
from __future__ import annotations
import json
from pyro_mcp import Pyro
def main() -> None:
pyro = Pyro()
result = pyro.run_in_vm(
profile="debian-git",
command="git --version",
vcpu_count=1,
mem_mib=1024,
timeout_seconds=30,
network=False,
)
print(json.dumps(result, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

View file

@ -26,6 +26,7 @@ packages = ["src/pyro_mcp"]
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = [ include = [
"docs/**",
"src/pyro_mcp/runtime_bundle/**", "src/pyro_mcp/runtime_bundle/**",
"runtime_sources/**", "runtime_sources/**",
"src/pyro_mcp/**/*.py", "src/pyro_mcp/**/*.py",

View file

@ -1,7 +1,11 @@
"""Public package surface for pyro_mcp.""" """Public package surface for pyro_mcp."""
from importlib.metadata import version
from pyro_mcp.api import Pyro from pyro_mcp.api import Pyro
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
__all__ = ["Pyro", "VmManager", "create_server"] __version__ = version("pyro-mcp")
__all__ = ["Pyro", "VmManager", "__version__", "create_server"]

View file

@ -6,6 +6,7 @@ import argparse
import json import json
from typing import Any from typing import Any
from pyro_mcp import __version__
from pyro_mcp.api import Pyro from pyro_mcp.api import Pyro
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
@ -18,6 +19,7 @@ def _print_json(payload: dict[str, Any]) -> None:
def _build_parser() -> argparse.ArgumentParser: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="pyro CLI for ephemeral Firecracker VMs.") parser = argparse.ArgumentParser(description="pyro CLI for ephemeral Firecracker VMs.")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
mcp_parser = subparsers.add_parser("mcp", help="Run the MCP server.") mcp_parser = subparsers.add_parser("mcp", help="Run the MCP server.")

41
src/pyro_mcp/contract.py Normal file
View file

@ -0,0 +1,41 @@
"""Public contract constants for the CLI, SDK, and MCP server."""
from __future__ import annotations
PUBLIC_CLI_COMMANDS = ("mcp", "run", "doctor", "demo")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_RUN_FLAGS = (
"--profile",
"--vcpu-count",
"--mem-mib",
"--timeout-seconds",
"--ttl-seconds",
"--network",
)
PUBLIC_SDK_METHODS = (
"create_server",
"create_vm",
"delete_vm",
"exec_vm",
"list_profiles",
"network_info_vm",
"reap_expired",
"run_in_vm",
"start_vm",
"status_vm",
"stop_vm",
)
PUBLIC_MCP_TOOLS = (
"vm_run",
"vm_list_profiles",
"vm_create",
"vm_start",
"vm_exec",
"vm_stop",
"vm_delete",
"vm_status",
"vm_network_info",
"vm_reap_expired",
)

View file

@ -0,0 +1,94 @@
from __future__ import annotations
import argparse
import asyncio
import io
import tomllib
from contextlib import redirect_stdout
from pathlib import Path
from typing import Any, cast
import pytest
from pyro_mcp import Pyro, __version__
from pyro_mcp.cli import _build_parser
from pyro_mcp.contract import (
PUBLIC_CLI_COMMANDS,
PUBLIC_CLI_DEMO_SUBCOMMANDS,
PUBLIC_CLI_RUN_FLAGS,
PUBLIC_MCP_TOOLS,
PUBLIC_SDK_METHODS,
)
from pyro_mcp.vm_manager import VmManager
from pyro_mcp.vm_network import TapNetworkManager
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser:
subparsers = getattr(parser, "_subparsers", None)
if subparsers is None:
raise AssertionError("parser does not define subparsers")
group_actions = cast(list[Any], subparsers._group_actions) # noqa: SLF001
if not group_actions:
raise AssertionError("parser subparsers are empty")
choices = cast(dict[str, argparse.ArgumentParser], group_actions[0].choices)
return choices[name]
def test_public_sdk_methods_exist() -> None:
assert tuple(sorted(PUBLIC_SDK_METHODS)) == PUBLIC_SDK_METHODS
for method_name in PUBLIC_SDK_METHODS:
assert hasattr(Pyro, method_name), method_name
def test_public_cli_help_lists_commands_and_run_flags() -> None:
parser = _build_parser()
help_text = parser.format_help()
assert "--version" in help_text
for command_name in PUBLIC_CLI_COMMANDS:
assert command_name in help_text
run_parser = _build_parser()
run_help = run_parser.parse_args(
["run", "--profile", "debian-base", "--vcpu-count", "1", "--mem-mib", "512", "--", "true"]
)
assert run_help.command == "run"
run_help_text = _subparser_choice(parser, "run").format_help()
for flag in PUBLIC_CLI_RUN_FLAGS:
assert flag in run_help_text
demo_help_text = _subparser_choice(parser, "demo").format_help()
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:
assert subcommand_name in demo_help_text
def test_public_cli_version_matches_package_version() -> None:
parser = _build_parser()
stdout = io.StringIO()
with pytest.raises(SystemExit, match="0"), redirect_stdout(stdout):
parser.parse_args(["--version"])
assert stdout.getvalue().strip().endswith(f" {__version__}")
def test_public_mcp_tools_match_contract(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[str, ...]:
server = pyro.create_server()
tools = await server.list_tools()
return tuple(sorted(tool.name for tool in tools))
tool_names = asyncio.run(_run())
assert tool_names == tuple(sorted(PUBLIC_MCP_TOOLS))
def test_pyproject_exposes_single_public_cli_script() -> None:
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
scripts = pyproject["project"]["scripts"]
assert scripts == {"pyro": "pyro_mcp.cli:main"}