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/
dist/
build/
.uv-cache/
*.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`.
- Run `make setup` after cloning.
- 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 Python SDK entrypoint is `from pyro_mcp import Pyro`.
- 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`.
- 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.
- Public contract documentation lives in `docs/public-contract.md`.
## Quality Gates

View file

@ -1,4 +1,5 @@
PYTHON ?= uv run python
UV_CACHE_DIR ?= .uv-cache
OLLAMA_BASE_URL ?= http://localhost:11434/v1
OLLAMA_MODEL ?= llama3.2:3b
OLLAMA_DEMO_FLAGS ?=
@ -8,7 +9,7 @@ RUNTIME_BUILD_DIR ?= build/runtime_bundle
RUNTIME_BUNDLE_DIR ?= src/pyro_mcp/runtime_bundle
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:
@printf '%s\n' \
@ -20,6 +21,7 @@ help:
' typecheck Run mypy' \
' test Run pytest' \
' check Run lint, typecheck, and tests' \
' dist-check Smoke-test the installed public pyro CLI entrypoint' \
' demo Run the deterministic VM demo' \
' network-demo Run the deterministic VM demo with guest networking enabled' \
' doctor Show runtime and host diagnostics' \
@ -59,6 +61,10 @@ test:
check: lint typecheck test
dist-check:
.venv/bin/pyro --version
.venv/bin/pyro --help >/dev/null
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.
## 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
Primary install/run path:
@ -26,6 +33,12 @@ pyro mcp serve
The public user-facing interface is `pyro` and `Pyro`.
`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
This repository uses Git LFS for the packaged runtime images under
@ -124,6 +137,13 @@ Verbose Ollama logs:
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
@ -160,6 +180,9 @@ result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
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
Primary agent-facing tool:
@ -212,6 +235,7 @@ For work inside this repository:
make help
make setup
make check
make dist-check
```
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]
include = [
"docs/**",
"src/pyro_mcp/runtime_bundle/**",
"runtime_sources/**",
"src/pyro_mcp/**/*.py",

View file

@ -1,7 +1,11 @@
"""Public package surface for pyro_mcp."""
from importlib.metadata import version
from pyro_mcp.api import Pyro
from pyro_mcp.server import create_server
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
from typing import Any
from pyro_mcp import __version__
from pyro_mcp.api import Pyro
from pyro_mcp.demo import run_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:
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)
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"}