From 0aa5e25dc16c92b3bd85004e7c7f491989e895ff Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 7 Mar 2026 22:34:14 -0300 Subject: [PATCH] Add adoption-focused examples, contract docs, and CLI polish --- .gitignore | 1 + AGENTS.md | 2 + Makefile | 8 ++- README.md | 24 +++++++++ TASKS.tmp.md | 14 ++++++ docs/host-requirements.md | 32 ++++++++++++ docs/install.md | 46 +++++++++++++++++ docs/public-contract.md | 76 ++++++++++++++++++++++++++++ docs/troubleshooting.md | 64 ++++++++++++++++++++++++ examples/agent_vm_run.py | 62 +++++++++++++++++++++++ examples/mcp_client_config.md | 35 +++++++++++++ examples/python_lifecycle.py | 30 +++++++++++ examples/python_run.py | 24 +++++++++ pyproject.toml | 1 + src/pyro_mcp/__init__.py | 6 ++- src/pyro_mcp/cli.py | 2 + src/pyro_mcp/contract.py | 41 +++++++++++++++ tests/test_public_contract.py | 94 +++++++++++++++++++++++++++++++++++ 18 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 TASKS.tmp.md create mode 100644 docs/host-requirements.md create mode 100644 docs/install.md create mode 100644 docs/public-contract.md create mode 100644 docs/troubleshooting.md create mode 100644 examples/agent_vm_run.py create mode 100644 examples/mcp_client_config.md create mode 100644 examples/python_lifecycle.py create mode 100644 examples/python_run.py create mode 100644 src/pyro_mcp/contract.py create mode 100644 tests/test_public_contract.py diff --git a/.gitignore b/.gitignore index 1d640bb..441dbba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ __pycache__/ htmlcov/ dist/ build/ +.uv-cache/ *.egg-info/ diff --git a/AGENTS.md b/AGENTS.md index 3c481d7..12b6a2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Makefile b/Makefile index 75e9582..5e35ef4 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 4b2e81e..44b3181 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/TASKS.tmp.md b/TASKS.tmp.md new file mode 100644 index 0000000..c0b668b --- /dev/null +++ b/TASKS.tmp.md @@ -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. diff --git a/docs/host-requirements.md b/docs/host-requirements.md new file mode 100644 index 0000000..1b2838a --- /dev/null +++ b/docs/host-requirements.md @@ -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` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..2aa021b --- /dev/null +++ b/docs/install.md @@ -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 +cd pyro +git lfs pull +make setup +``` diff --git a/docs/public-contract.md b/docs/public-contract.md new file mode 100644 index 0000000..c0b4ea8 --- /dev/null +++ b/docs/public-contract.md @@ -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 -- ` 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..1b7340b --- /dev/null +++ b/docs/troubleshooting.md @@ -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/`. diff --git a/examples/agent_vm_run.py b/examples/agent_vm_run.py new file mode 100644 index 0000000..6c122ca --- /dev/null +++ b/examples/agent_vm_run.py @@ -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() diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md new file mode 100644 index 0000000..dab3e15 --- /dev/null +++ b/examples/mcp_client_config.md @@ -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. diff --git a/examples/python_lifecycle.py b/examples/python_lifecycle.py new file mode 100644 index 0000000..7d5b907 --- /dev/null +++ b/examples/python_lifecycle.py @@ -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() diff --git a/examples/python_run.py b/examples/python_run.py new file mode 100644 index 0000000..312a787 --- /dev/null +++ b/examples/python_run.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 8af43ec..4de7623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/pyro_mcp/__init__.py b/src/pyro_mcp/__init__.py index ec32f55..285242a 100644 --- a/src/pyro_mcp/__init__.py +++ b/src/pyro_mcp/__init__.py @@ -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"] diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 6bac51a..553b9fc 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -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.") diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py new file mode 100644 index 0000000..64da439 --- /dev/null +++ b/src/pyro_mcp/contract.py @@ -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", +) diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py new file mode 100644 index 0000000..ba309eb --- /dev/null +++ b/tests/test_public_contract.py @@ -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"}