Add host bootstrap and repair helpers

Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits.

Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material.

Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here.

Validation:
- uv lock
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check
This commit is contained in:
Thales Maciel 2026-03-13 16:46:10 -03:00
parent 535efc6919
commit 899a6760c4
25 changed files with 1658 additions and 58 deletions

View file

@ -2,6 +2,18 @@
All notable user-visible changes to `pyro-mcp` are documented here.
## 4.2.0
- Added host bootstrap and repair helpers with `pyro host connect`,
`pyro host print-config`, `pyro host doctor`, and `pyro host repair` for the
supported Claude Code, Codex, and OpenCode flows.
- Repositioned the docs and examples so supported hosts now start from the
helper flow first, while keeping raw `pyro mcp serve` commands as the
underlying MCP entrypoint and advanced fallback.
- Added deterministic host-helper coverage so the shipped helper commands and
OpenCode config snippet stay aligned with the canonical `pyro mcp serve`
command shape.
## 4.1.0
- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can

View file

@ -1,6 +1,7 @@
PYTHON ?= uv run python
UV_CACHE_DIR ?= .uv-cache
PYTEST_FLAGS ?= -n auto
PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi')
PYTEST_FLAGS ?= -n $(PYTEST_WORKERS)
OLLAMA_BASE_URL ?= http://localhost:11434/v1
OLLAMA_MODEL ?= llama3.2:3b
OLLAMA_DEMO_FLAGS ?=
@ -84,6 +85,8 @@ check: lint typecheck test
dist-check:
uv run pyro --version
uv run pyro --help >/dev/null
uv run pyro host --help >/dev/null
uv run pyro host doctor >/dev/null
uv run pyro mcp --help >/dev/null
uv run pyro run --help >/dev/null
uv run pyro env list >/dev/null

View file

@ -30,7 +30,7 @@ SDK-first platform.
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- 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)
- What's new in 4.1.0: [CHANGELOG.md#410](CHANGELOG.md#410)
- What's new in 4.2.0: [CHANGELOG.md#420](CHANGELOG.md#420)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
## Who It's For
@ -76,7 +76,7 @@ What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 4.1.0
Catalog version: 4.2.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -96,9 +96,26 @@ for the guest image.
## Chat Host Quickstart
After the quickstart works, the intended next step is to connect a chat host.
From a repo root, bare `pyro mcp serve` starts `workspace-core`, auto-detects
the current Git checkout, and lets the first `workspace_create` omit
`seed_path`.
Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect claude-code
uvx --from pyro-mcp pyro host connect codex
uvx --from pyro-mcp pyro host print-config opencode
```
If setup drifts or you want to inspect it first:
```bash
uvx --from pyro-mcp pyro host doctor
uvx --from pyro-mcp pyro host repair claude-code
uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Those helpers wrap the same `pyro mcp serve` entrypoint. From a repo root,
bare `pyro mcp serve` starts `workspace-core`, auto-detects the current Git
checkout, and lets the first `workspace_create` omit `seed_path`.
```bash
uvx --from pyro-mcp pyro mcp serve
@ -107,12 +124,14 @@ uvx --from pyro-mcp pyro mcp serve
If the host does not preserve the server working directory, use:
```bash
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If you are starting outside a local checkout, use a clean clone source:
```bash
uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
```
@ -149,7 +168,8 @@ OpenCode `opencode.json` snippet:
}
```
If OpenCode launches the server from an unexpected cwd, add
If OpenCode launches the server from an unexpected cwd, use
`pyro host print-config opencode --project-path /abs/path/to/repo` or add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array.
@ -163,9 +183,9 @@ snapshots, secrets, network policy, or disk tools.
1. Validate the host with `pyro doctor`.
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`.
3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo
root, or use `--project-path` / `--repo-url` when cwd is not the source of
truth.
3. Connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
`pyro host print-config opencode`, then fall back to raw `pyro mcp serve`
with `--project-path` / `--repo-url` when cwd is not the source of truth.
4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
`repro-fix-loop` is the shortest chat-first story.
5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path

View file

@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes
```bash
$ uvx --from pyro-mcp pyro env list
Catalog version: 4.1.0
Catalog version: 4.2.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.
@ -93,9 +93,27 @@ $ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/proje
## 6. Connect a chat host
Use the helper flow first:
```bash
$ uvx --from pyro-mcp pyro host connect claude-code
$ uvx --from pyro-mcp pyro host connect codex
$ uvx --from pyro-mcp pyro host print-config opencode
```
If setup drifts later:
```bash
$ uvx --from pyro-mcp pyro host doctor
$ uvx --from pyro-mcp pyro host repair claude-code
$ uvx --from pyro-mcp pyro host repair codex
$ uvx --from pyro-mcp pyro host repair opencode
```
Claude Code:
```bash
$ uvx --from pyro-mcp pyro host connect claude-code
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
$ claude mcp list
```
@ -103,6 +121,7 @@ $ claude mcp list
Codex:
```bash
$ uvx --from pyro-mcp pyro host connect codex
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
$ codex mcp list
```

View file

@ -62,7 +62,8 @@ pyro run debian:12 -- git --version
If you are running from a repo checkout instead, replace `pyro` with
`uv run pyro`.
After that one-shot proof works, the intended next step is `pyro mcp serve`.
After that one-shot proof works, the intended next step is `pyro host connect`
or `pyro host print-config`.
## 1. Check the host
@ -92,7 +93,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 4.1.0
Catalog version: 4.2.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.
@ -140,6 +141,23 @@ deterministic structured result.
## 5. Connect a chat host
Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect claude-code
uvx --from pyro-mcp pyro host connect codex
uvx --from pyro-mcp pyro host print-config opencode
```
If setup drifts later, inspect and repair it with:
```bash
uvx --from pyro-mcp pyro host doctor
uvx --from pyro-mcp pyro host repair claude-code
uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`.
@ -170,12 +188,14 @@ Copy-paste host-specific starts:
Claude Code:
```bash
pyro host connect claude-code
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
```
Codex:
```bash
pyro host connect codex
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
```
@ -195,8 +215,9 @@ The intended user journey is:
1. validate the host with `pyro doctor`
2. pull `debian:12`
3. prove guest execution with `pyro run debian:12 -- git --version`
4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo
root, or use `--project-path` / `--repo-url` when needed
4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
`pyro host print-config opencode`, then use raw `pyro mcp serve` only when
you need `--project-path` / `--repo-url`
5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
6. trust but verify with `make smoke-use-cases`

View file

@ -38,8 +38,35 @@ pyro mcp serve --repo-url https://github.com/example/project.git
Use `--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## Helper First
Use the helper flow before the raw host CLI commands:
```bash
pyro host connect claude-code
pyro host connect codex
pyro host print-config opencode
pyro host doctor
pyro host repair opencode
```
These helpers wrap the same `pyro mcp serve` entrypoint, preserve the current
`workspace-core` default, and make it obvious how to repair drift later.
## Claude Code
Preferred:
```bash
pyro host connect claude-code
```
Repair:
```bash
pyro host repair claude-code
```
Package without install:
```bash
@ -66,6 +93,18 @@ Reference:
## Codex
Preferred:
```bash
pyro host connect codex
```
Repair:
```bash
pyro host repair codex
```
Package without install:
```bash
@ -92,6 +131,13 @@ Reference:
## OpenCode
Preferred:
```bash
pyro host print-config opencode
pyro host repair opencode
```
Use the local MCP config shape from:
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)

View file

@ -94,6 +94,17 @@ Host-specific setup docs:
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- [mcp_client_config.md](../examples/mcp_client_config.md)
The chat-host bootstrap helper surface is:
- `pyro host connect claude-code`
- `pyro host connect codex`
- `pyro host print-config opencode`
- `pyro host doctor`
- `pyro host repair HOST`
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
setup and repair path for supported hosts.
## Chat-Facing Workspace Contract
`workspace-core` is the normal chat path. It exposes:

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 `4.1.0`:
Current baseline is `4.2.0`:
- `pyro mcp serve` is now the default product entrypoint
- `workspace-core` is now the default MCP profile
@ -80,7 +80,7 @@ capability gaps:
10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done
11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Planned
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Planned
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned
16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned
@ -117,10 +117,12 @@ Completed so far:
- `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect
the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit
`--project-path` and `--repo-url` fallbacks when cwd is not the source of truth.
- `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code,
Codex, and OpenCode users can connect or repair the supported chat-host path
without manually composing raw MCP commands or config edits.
Planned next:
- [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md)
- [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md)
- [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md)
- [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md)

View file

@ -1,6 +1,6 @@
# `4.2.0` Host Bootstrap And Repair
Status: Planned
Status: Done
## Goal

View file

@ -2,6 +2,13 @@
Recommended profile: `workspace-core`.
Preferred helper flow:
```bash
pyro host connect claude-code
pyro host doctor
```
Package without install:
```bash
@ -23,9 +30,16 @@ If Claude Code launches the server from an unexpected cwd, pin the project
explicitly:
```bash
pyro host connect claude-code --project-path /abs/path/to/repo
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If the local config drifts later:
```bash
pyro host repair claude-code
```
Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools:

View file

@ -2,6 +2,13 @@
Recommended profile: `workspace-core`.
Preferred helper flow:
```bash
pyro host connect codex
pyro host doctor
```
Package without install:
```bash
@ -23,9 +30,16 @@ If Codex launches the server from an unexpected cwd, pin the project
explicitly:
```bash
pyro host connect codex --project-path /abs/path/to/repo
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If the local config drifts later:
```bash
pyro host repair codex
```
Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools:

View file

@ -8,6 +8,14 @@ Use the host-specific examples first when they apply:
- Codex: [examples/codex_mcp.md](codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json)
Preferred repair/bootstrap helpers:
- `pyro host connect claude-code`
- `pyro host connect codex`
- `pyro host print-config opencode`
- `pyro host doctor`
- `pyro host repair opencode`
Use this generic config only when the host expects a plain `mcpServers` JSON
shape.

View file

@ -1,6 +1,6 @@
[project]
name = "pyro-mcp"
version = "4.1.0"
version = "4.2.0"
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
readme = "README.md"
license = { file = "LICENSE" }

View file

@ -8,12 +8,20 @@ import shlex
import sys
from pathlib import Path
from textwrap import dedent
from typing import Any
from typing import Any, cast
from pyro_mcp import __version__
from pyro_mcp.api import Pyro
from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
from pyro_mcp.demo import run_demo
from pyro_mcp.host_helpers import (
HostDoctorEntry,
HostServerConfig,
connect_cli_host,
doctor_hosts,
print_or_write_opencode_config,
repair_host,
)
from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
@ -169,6 +177,62 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
print(f"- {issue}")
def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
return HostServerConfig(
installed_package=bool(getattr(args, "installed_package", False)),
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
project_path=getattr(args, "project_path", None),
repo_url=getattr(args, "repo_url", None),
repo_ref=getattr(args, "repo_ref", None),
no_project_source=bool(getattr(args, "no_project_source", False)),
)
def _print_host_connect_human(payload: dict[str, Any]) -> None:
host = str(payload.get("host", "unknown"))
server_command = payload.get("server_command")
verification_command = payload.get("verification_command")
print(f"Connected pyro to {host}.")
if isinstance(server_command, list):
print("Server command: " + shlex.join(str(item) for item in server_command))
if isinstance(verification_command, list):
print("Verify with: " + shlex.join(str(item) for item in verification_command))
def _print_host_print_config_human(payload: dict[str, Any]) -> None:
rendered_config = payload.get("rendered_config")
if isinstance(rendered_config, str):
_write_stream(rendered_config, stream=sys.stdout)
return
output_path = payload.get("output_path")
if isinstance(output_path, str):
print(f"Wrote OpenCode config to {output_path}")
def _print_host_repair_human(payload: dict[str, Any]) -> None:
host = str(payload.get("host", "unknown"))
if host == "opencode":
print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.")
backup_path = payload.get("backup_path")
if isinstance(backup_path, str):
print(f"Backed up the previous config to {backup_path}.")
return
_print_host_connect_human(payload)
def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None:
for index, entry in enumerate(entries):
print(
f"{entry.host}: {entry.status} "
f"installed={'yes' if entry.installed else 'no'} "
f"configured={'yes' if entry.configured else 'no'}"
)
print(f" details: {entry.details}")
print(f" repair: {entry.repair_command}")
if index != len(entries) - 1:
print()
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
name = payload.get("name")
@ -645,6 +709,38 @@ class _HelpFormatter(
return help_string
def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--installed-package",
action="store_true",
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
)
parser.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help="Server profile to configure for the host helper flow.",
)
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument(
"--project-path",
help="Pin the server to this local project path instead of relying on host cwd.",
)
source_group.add_argument(
"--repo-url",
help="Seed default workspaces from a clean clone of this repository URL.",
)
source_group.add_argument(
"--no-project-source",
action="store_true",
help="Disable automatic Git checkout detection from the current working directory.",
)
parser.add_argument(
"--repo-ref",
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
@ -658,11 +754,12 @@ def _build_parser() -> argparse.ArgumentParser:
pyro env list
pyro env pull debian:12
pyro run debian:12 -- git --version
pyro mcp serve
pyro host connect claude-code
Connect a chat host after that:
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
pyro host connect claude-code
pyro host connect codex
pyro host print-config opencode
If you want terminal-level visibility into the workspace model:
pyro workspace create debian:12 --seed-path ./repo --id-only
@ -767,6 +864,129 @@ def _build_parser() -> argparse.ArgumentParser:
help="Print structured JSON instead of human-readable output.",
)
host_parser = subparsers.add_parser(
"host",
help="Bootstrap and repair supported chat-host configs.",
description=(
"Connect or repair the supported Claude Code, Codex, and OpenCode "
"host setups without hand-writing MCP commands or config."
),
epilog=dedent(
"""
Examples:
pyro host connect claude-code
pyro host connect codex --project-path /abs/path/to/repo
pyro host print-config opencode
pyro host repair opencode
pyro host doctor
"""
),
formatter_class=_HelpFormatter,
)
host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST")
host_connect_parser = host_subparsers.add_parser(
"connect",
help="Connect Claude Code or Codex in one step.",
description=(
"Ensure the supported host has a `pyro` MCP server entry that wraps "
"the canonical `pyro mcp serve` command."
),
epilog=dedent(
"""
Examples:
pyro host connect claude-code
pyro host connect codex --installed-package
pyro host connect codex --project-path /abs/path/to/repo
"""
),
formatter_class=_HelpFormatter,
)
host_connect_parser.add_argument(
"host",
choices=("claude-code", "codex"),
help="Chat host to connect and update in place.",
)
_add_host_server_source_args(host_connect_parser)
host_print_config_parser = host_subparsers.add_parser(
"print-config",
help="Print or write the canonical OpenCode config snippet.",
description=(
"Render the canonical OpenCode `mcp.pyro` config entry so it can be "
"copied into or written to `opencode.json`."
),
epilog=dedent(
"""
Examples:
pyro host print-config opencode
pyro host print-config opencode --output ./opencode.json
pyro host print-config opencode --project-path /abs/path/to/repo
"""
),
formatter_class=_HelpFormatter,
)
host_print_config_parser.add_argument(
"host",
choices=("opencode",),
help="Host config shape to render.",
)
_add_host_server_source_args(host_print_config_parser)
host_print_config_parser.add_argument(
"--output",
help="Write the rendered JSON to this path instead of printing it to stdout.",
)
host_doctor_parser = host_subparsers.add_parser(
"doctor",
help="Inspect supported host setup status.",
description=(
"Report whether Claude Code, Codex, and OpenCode are installed, "
"configured, missing, or drifted relative to the canonical `pyro` MCP setup."
),
epilog=dedent(
"""
Examples:
pyro host doctor
pyro host doctor --project-path /abs/path/to/repo
pyro host doctor --installed-package
"""
),
formatter_class=_HelpFormatter,
)
_add_host_server_source_args(host_doctor_parser)
host_doctor_parser.add_argument(
"--config-path",
help="Override the OpenCode config path when inspecting or repairing that host.",
)
host_repair_parser = host_subparsers.add_parser(
"repair",
help="Repair one supported host to the canonical `pyro` setup.",
description=(
"Repair a stale or broken host config by reapplying the canonical "
"`pyro mcp serve` setup for that host."
),
epilog=dedent(
"""
Examples:
pyro host repair claude-code
pyro host repair codex --project-path /abs/path/to/repo
pyro host repair opencode
"""
),
formatter_class=_HelpFormatter,
)
host_repair_parser.add_argument(
"host",
choices=("claude-code", "codex", "opencode"),
help="Host config to repair.",
)
_add_host_server_source_args(host_repair_parser)
host_repair_parser.add_argument(
"--config-path",
help="Override the OpenCode config path when repairing that host.",
)
mcp_parser = subparsers.add_parser(
"mcp",
help="Run the MCP server.",
@ -2350,6 +2570,57 @@ def main() -> None:
else:
_print_prune_human(prune_payload)
return
if args.command == "host":
config = _build_host_server_config(args)
if args.host_command == "connect":
try:
payload = connect_cli_host(args.host, config=config)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_connect_human(payload)
return
if args.host_command == "print-config":
try:
output_path = (
None if args.output is None else Path(args.output).expanduser().resolve()
)
payload = print_or_write_opencode_config(config=config, output_path=output_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_print_config_human(payload)
return
if args.host_command == "doctor":
try:
config_path = (
None
if args.config_path is None
else Path(args.config_path).expanduser().resolve()
)
entries = doctor_hosts(config=config, config_path=config_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_doctor_human(entries)
return
if args.host_command == "repair":
try:
if args.host != "opencode" and args.config_path is not None:
raise ValueError(
"--config-path is only supported for `pyro host repair opencode`"
)
config_path = (
None
if args.config_path is None
else Path(args.config_path).expanduser().resolve()
)
payload = repair_host(args.host, config=config, config_path=config_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_repair_human(payload)
return
if args.command == "mcp":
pyro.create_server(
profile=args.profile,
@ -2497,7 +2768,8 @@ def main() -> None:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_workspace_exec_human(payload)
exit_code = int(payload.get("exit_code", 1))
exit_code_raw = payload.get("exit_code", 1)
exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1
if exit_code != 0:
raise SystemExit(exit_code)
return

View file

@ -2,9 +2,22 @@
from __future__ import annotations
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "run", "workspace")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
PUBLIC_CLI_HOST_COMMON_FLAGS = (
"--installed-package",
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS
PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",)
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--profile",

View file

@ -0,0 +1,363 @@
"""Helpers for bootstrapping and repairing supported MCP chat hosts."""
from __future__ import annotations
import json
import shlex
import shutil
import subprocess
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal
from pyro_mcp.api import McpToolProfile
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",)
DEFAULT_HOST_SERVER_NAME = "pyro"
DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json"
HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
@dataclass(frozen=True)
class HostServerConfig:
installed_package: bool = False
profile: McpToolProfile = "workspace-core"
project_path: str | None = None
repo_url: str | None = None
repo_ref: str | None = None
no_project_source: bool = False
@dataclass(frozen=True)
class HostDoctorEntry:
host: str
installed: bool
configured: bool
status: HostStatus
details: str
repair_command: str
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
command,
check=False,
capture_output=True,
text=True,
)
def _host_binary(host: str) -> str:
if host == "claude-code":
return "claude"
if host == "codex":
return "codex"
raise ValueError(f"unsupported CLI host {host!r}")
def _canonical_server_command(config: HostServerConfig) -> list[str]:
if config.project_path is not None and config.repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if config.no_project_source and (
config.project_path is not None
or config.repo_url is not None
or config.repo_ref is not None
):
raise ValueError(
"--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref"
)
if config.repo_ref is not None and config.repo_url is None:
raise ValueError("--repo-ref requires --repo-url")
command = ["pyro", "mcp", "serve"]
if not config.installed_package:
command = ["uvx", "--from", "pyro-mcp", *command]
if config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
elif config.repo_url is not None:
command.extend(["--repo-url", config.repo_url])
if config.repo_ref is not None:
command.extend(["--repo-ref", config.repo_ref])
elif config.no_project_source:
command.append("--no-project-source")
return command
def _render_cli_command(command: list[str]) -> str:
return shlex.join(command)
def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str:
command = ["pyro", "host", "repair", host]
if config.installed_package:
command.append("--installed-package")
if config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
elif config.repo_url is not None:
command.extend(["--repo-url", config.repo_url])
if config.repo_ref is not None:
command.extend(["--repo-ref", config.repo_ref])
elif config.no_project_source:
command.append("--no-project-source")
if config_path is not None:
command.extend(["--config-path", str(config_path)])
return _render_cli_command(command)
def _command_matches(output: str, expected: list[str]) -> bool:
normalized_output = output.strip()
if ":" in normalized_output:
normalized_output = normalized_output.split(":", 1)[1].strip()
try:
parsed = shlex.split(normalized_output)
except ValueError:
parsed = normalized_output.split()
return parsed == expected
def _upsert_opencode_config(
*,
config_path: Path,
config: HostServerConfig,
) -> tuple[dict[str, object], Path | None]:
existing_payload: dict[str, object] = {}
backup_path: Path | None = None
if config_path.exists():
raw_text = config_path.read_text(encoding="utf-8")
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError:
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
shutil.move(str(config_path), str(backup_path))
parsed = {}
if isinstance(parsed, dict):
existing_payload = parsed
else:
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
shutil.move(str(config_path), str(backup_path))
payload = dict(existing_payload)
mcp_payload = payload.get("mcp")
if not isinstance(mcp_payload, dict):
mcp_payload = {}
else:
mcp_payload = dict(mcp_payload)
mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config)
payload["mcp"] = mcp_payload
return payload, backup_path
def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]:
return {
"type": "local",
"enabled": True,
"command": _canonical_server_command(config),
}
def render_opencode_config(config: HostServerConfig) -> str:
return (
json.dumps(
{"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}},
indent=2,
)
+ "\n"
)
def print_or_write_opencode_config(
*,
config: HostServerConfig,
output_path: Path | None = None,
) -> dict[str, object]:
rendered = render_opencode_config(config)
if output_path is None:
return {
"host": "opencode",
"rendered_config": rendered,
"server_command": _canonical_server_command(config),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(rendered, encoding="utf-8")
return {
"host": "opencode",
"output_path": str(output_path),
"server_command": _canonical_server_command(config),
}
def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]:
binary = _host_binary(host)
if shutil.which(binary) is None:
raise RuntimeError(f"{binary} CLI is not installed or not on PATH")
server_command = _canonical_server_command(config)
_run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME])
result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command])
if result.returncode != 0:
details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed"
raise RuntimeError(details)
return {
"host": host,
"server_command": server_command,
"verification_command": [binary, "mcp", "list"],
}
def repair_opencode_host(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> dict[str, object]:
resolved_path = (
DEFAULT_OPENCODE_CONFIG_PATH
if config_path is None
else config_path.expanduser().resolve()
)
resolved_path.parent.mkdir(parents=True, exist_ok=True)
payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config)
resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
result: dict[str, object] = {
"host": "opencode",
"config_path": str(resolved_path),
"server_command": _canonical_server_command(config),
}
if backup_path is not None:
result["backup_path"] = str(backup_path)
return result
def repair_host(
host: str,
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> dict[str, object]:
if host == "opencode":
return repair_opencode_host(config=config, config_path=config_path)
return connect_cli_host(host, config=config)
def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry:
binary = _host_binary(host)
repair_command = _repair_command(host, config)
if shutil.which(binary) is None:
return HostDoctorEntry(
host=host,
installed=False,
configured=False,
status="unavailable",
details=f"{binary} CLI was not found on PATH",
repair_command=repair_command,
)
expected_command = _canonical_server_command(config)
get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME])
combined_get_output = (get_result.stdout + get_result.stderr).strip()
if get_result.returncode == 0:
status: HostStatus = (
"ok" if _command_matches(combined_get_output, expected_command) else "drifted"
)
return HostDoctorEntry(
host=host,
installed=True,
configured=True,
status=status,
details=combined_get_output or f"{binary} MCP entry exists",
repair_command=repair_command,
)
list_result = _run_command([binary, "mcp", "list"])
combined_list_output = (list_result.stdout + list_result.stderr).strip()
configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split()
return HostDoctorEntry(
host=host,
installed=True,
configured=configured,
status="drifted" if configured else "missing",
details=combined_get_output or combined_list_output or f"{binary} MCP entry missing",
repair_command=repair_command,
)
def _doctor_opencode_host(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> HostDoctorEntry:
resolved_path = (
DEFAULT_OPENCODE_CONFIG_PATH
if config_path is None
else config_path.expanduser().resolve()
)
repair_command = _repair_command("opencode", config, config_path=config_path)
installed = shutil.which("opencode") is not None
if not resolved_path.exists():
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="missing" if installed else "unavailable",
details=f"OpenCode config missing at {resolved_path}",
repair_command=repair_command,
)
try:
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="drifted" if installed else "unavailable",
details=f"OpenCode config is invalid JSON: {exc}",
repair_command=repair_command,
)
if not isinstance(payload, dict):
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="drifted" if installed else "unavailable",
details="OpenCode config must be a JSON object",
repair_command=repair_command,
)
mcp_payload = payload.get("mcp")
if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload:
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="missing" if installed else "unavailable",
details=f"OpenCode config at {resolved_path} is missing mcp.pyro",
repair_command=repair_command,
)
configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME]
expected_entry = canonical_opencode_entry(config)
status: HostStatus = "ok" if configured_entry == expected_entry else "drifted"
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=True,
status=status,
details=f"OpenCode config path: {resolved_path}",
repair_command=repair_command,
)
def doctor_hosts(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> list[HostDoctorEntry]:
return [
_doctor_cli_host("claude-code", config=config),
_doctor_cli_host("codex", config=config),
_doctor_opencode_host(config=config, config_path=config_path),
]

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 = "4.1.0"
DEFAULT_CATALOG_VERSION = "4.2.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None
oci_reference: str | None = None
source_digest: str | None = None
compatibility: str = ">=4.1.0,<5.0.0"
compatibility: str = ">=4.2.0,<5.0.0"
@dataclass(frozen=True)

View file

@ -18,7 +18,7 @@ from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", *args],
["git", "-c", "commit.gpgsign=false", *args],
cwd=repo,
check=True,
capture_output=True,

View file

@ -9,6 +9,7 @@ from typing import Any, cast
import pytest
import pyro_mcp.cli as cli
from pyro_mcp.host_helpers import HostDoctorEntry
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser:
@ -31,10 +32,11 @@ def test_cli_help_guides_first_run() -> None:
assert "pyro env list" in help_text
assert "pyro env pull debian:12" in help_text
assert "pyro run debian:12 -- git --version" in help_text
assert "pyro mcp serve" in help_text
assert "pyro host connect claude-code" in help_text
assert "Connect a chat host after that:" in help_text
assert "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text
assert "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text
assert "pyro host connect claude-code" in help_text
assert "pyro host connect codex" in help_text
assert "pyro host print-config opencode" in help_text
assert "If you want terminal-level visibility into the workspace model:" in help_text
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text
@ -57,6 +59,31 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "pyro env pull debian:12" in env_help
assert "downloads from public Docker Hub" in env_help
host_help = _subparser_choice(parser, "host").format_help()
assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_help
assert "pyro host connect claude-code" in host_help
assert "pyro host repair opencode" in host_help
host_connect_help = _subparser_choice(
_subparser_choice(parser, "host"), "connect"
).format_help()
assert "--installed-package" in host_connect_help
assert "--project-path" in host_connect_help
assert "--repo-url" in host_connect_help
assert "--repo-ref" in host_connect_help
assert "--no-project-source" in host_connect_help
host_print_config_help = _subparser_choice(
_subparser_choice(parser, "host"), "print-config"
).format_help()
assert "--output" in host_print_config_help
host_doctor_help = _subparser_choice(_subparser_choice(parser, "host"), "doctor").format_help()
assert "--config-path" in host_doctor_help
host_repair_help = _subparser_choice(_subparser_choice(parser, "host"), "repair").format_help()
assert "--config-path" in host_repair_help
doctor_help = _subparser_choice(parser, "doctor").format_help()
assert "Check host prerequisites and embedded runtime health" in doctor_help
assert "pyro doctor --json" in doctor_help
@ -316,6 +343,94 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "Close a persistent workspace shell" in workspace_shell_close_help
def test_cli_host_connect_dispatch(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
pass
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="host",
host_command="connect",
host="codex",
installed_package=False,
profile="workspace-core",
project_path=None,
repo_url=None,
repo_ref=None,
no_project_source=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
monkeypatch.setattr(
cli,
"connect_cli_host",
lambda host, *, config: {
"host": host,
"server_command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
"verification_command": ["codex", "mcp", "list"],
},
)
cli.main()
captured = capsys.readouterr()
assert captured.out == (
"Connected pyro to codex.\n"
"Server command: uvx --from pyro-mcp pyro mcp serve\n"
"Verify with: codex mcp list\n"
)
assert captured.err == ""
def test_cli_host_doctor_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
pass
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="host",
host_command="doctor",
installed_package=False,
profile="workspace-core",
project_path=None,
repo_url=None,
repo_ref=None,
no_project_source=False,
config_path=None,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
monkeypatch.setattr(
cli,
"doctor_hosts",
lambda **_: [
HostDoctorEntry(
host="codex",
installed=True,
configured=False,
status="missing",
details="codex entry missing",
repair_command="pyro host repair codex",
)
],
)
cli.main()
captured = capsys.readouterr()
assert "codex: missing installed=yes configured=no" in captured.out
assert "repair: pyro host repair codex" in captured.out
assert captured.err == ""
def test_cli_run_prints_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@ -2823,30 +2938,38 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8")
opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8"))
claude_helper = "pyro host connect claude-code"
codex_helper = "pyro host connect codex"
opencode_helper = "pyro host print-config opencode"
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
assert "## Chat Host Quickstart" in readme
assert "uvx --from pyro-mcp pyro mcp serve" in readme
assert claude_cmd in readme
assert codex_cmd in readme
assert claude_helper in readme
assert codex_helper in readme
assert opencode_helper in readme
assert "examples/opencode_mcp_config.json" in readme
assert "pyro host doctor" in readme
assert "bare `pyro mcp serve` starts `workspace-core`" in readme
assert "auto-detects\nthe current Git checkout" in readme
assert "auto-detects the current Git checkout" in readme.replace("\n", " ")
assert "--project-path /abs/path/to/repo" in readme
assert "--repo-url https://github.com/example/project.git" in readme
assert "## 5. Connect a chat host" in install
assert "uvx --from pyro-mcp pyro mcp serve" in install
assert claude_cmd in install
assert codex_cmd in install
assert claude_helper in install
assert codex_helper in install
assert opencode_helper in install
assert "workspace-full" in install
assert "--project-path /abs/path/to/repo" in install
assert claude_cmd in first_run
assert codex_cmd in first_run
assert claude_helper in first_run
assert codex_helper in first_run
assert opencode_helper in first_run
assert "--project-path /abs/path/to/repo" in first_run
assert claude_helper in integrations
assert codex_helper in integrations
assert opencode_helper in integrations
assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations
assert "auto-detects the current Git checkout" in integrations
assert "examples/claude_code_mcp.md" in integrations
@ -2862,13 +2985,17 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
assert "codex_mcp.md" in mcp_config
assert "opencode_mcp_config.json" in mcp_config
assert claude_helper in claude_code
assert claude_cmd in claude_code
assert "claude mcp list" in claude_code
assert "pyro host repair claude-code" in claude_code
assert "workspace-full" in claude_code
assert "--project-path /abs/path/to/repo" in claude_code
assert codex_helper in codex
assert codex_cmd in codex
assert "codex mcp list" in codex
assert "pyro host repair codex" in codex
assert "workspace-full" in codex
assert "--project-path /abs/path/to/repo" in codex

484
tests/test_host_helpers.py Normal file
View file

@ -0,0 +1,484 @@
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
from subprocess import CompletedProcess
import pytest
import pyro_mcp.host_helpers as host_helpers
from pyro_mcp.host_helpers import (
DEFAULT_OPENCODE_CONFIG_PATH,
HostServerConfig,
_canonical_server_command,
_command_matches,
_repair_command,
connect_cli_host,
doctor_hosts,
print_or_write_opencode_config,
repair_host,
)
def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]:
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True)
state_path = tmp_path / f"{name}-state.json"
script_path = bin_dir / name
script_path.write_text(
"\n".join(
[
f"#!{sys.executable}",
"import json",
"import shlex",
"import sys",
f"STATE_PATH = {str(state_path)!r}",
"try:",
" with open(STATE_PATH, 'r', encoding='utf-8') as handle:",
" state = json.load(handle)",
"except FileNotFoundError:",
" state = {}",
"args = sys.argv[1:]",
"if args[:2] == ['mcp', 'add']:",
" name = args[2]",
" marker = args.index('--')",
" state[name] = args[marker + 1:]",
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
" json.dump(state, handle)",
" print(f'added {name}')",
" raise SystemExit(0)",
"if args[:2] == ['mcp', 'remove']:",
" name = args[2]",
" if name in state:",
" del state[name]",
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
" json.dump(state, handle)",
" print(f'removed {name}')",
" raise SystemExit(0)",
" print('not found', file=sys.stderr)",
" raise SystemExit(1)",
"if args[:2] == ['mcp', 'get']:",
" name = args[2]",
" if name not in state:",
" print('not found', file=sys.stderr)",
" raise SystemExit(1)",
" print(f'{name}: {shlex.join(state[name])}')",
" raise SystemExit(0)",
"if args[:2] == ['mcp', 'list']:",
" for item in sorted(state):",
" print(item)",
" raise SystemExit(0)",
"print('unsupported', file=sys.stderr)",
"raise SystemExit(2)",
]
),
encoding="utf-8",
)
script_path.chmod(0o755)
return bin_dir, state_path
def test_connect_cli_host_replaces_existing_entry(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex")
state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8")
monkeypatch.setenv("PATH", str(bin_dir))
payload = connect_cli_host("codex", config=HostServerConfig())
assert payload["host"] == "codex"
assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
assert json.loads(state_path.read_text(encoding="utf-8")) == {
"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
}
def test_canonical_server_command_validates_and_renders_variants() -> None:
assert _canonical_server_command(HostServerConfig(installed_package=True)) == [
"pyro",
"mcp",
"serve",
]
assert _canonical_server_command(
HostServerConfig(profile="workspace-full", project_path="/repo")
) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"workspace-full",
"--project-path",
"/repo",
]
assert _canonical_server_command(
HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main")
) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--repo-url",
"https://example.com/repo.git",
"--repo-ref",
"main",
]
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--no-project-source",
]
with pytest.raises(ValueError, match="mutually exclusive"):
_canonical_server_command(
HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git")
)
with pytest.raises(ValueError, match="cannot be combined"):
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
with pytest.raises(ValueError, match="requires --repo-url"):
_canonical_server_command(HostServerConfig(repo_ref="main"))
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex"
assert _repair_command("codex", HostServerConfig(project_path="/repo")) == (
"pyro host repair codex --project-path /repo"
)
assert _repair_command(
"opencode",
HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"),
config_path=Path("/tmp/opencode.json"),
) == (
"pyro host repair opencode --installed-package --profile workspace-full "
"--repo-url file:///repo --config-path /tmp/opencode.json"
)
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
"pyro host repair codex --no-project-source"
)
assert _command_matches(
"pyro: uvx --from pyro-mcp pyro mcp serve",
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
)
assert _command_matches(
'"uvx --from pyro-mcp pyro mcp serve',
['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"],
)
assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
])
def test_connect_cli_host_reports_missing_cli_and_add_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
with pytest.raises(ValueError, match="unsupported CLI host"):
connect_cli_host("unsupported", config=HostServerConfig())
monkeypatch.setenv("PATH", "")
with pytest.raises(RuntimeError, match="codex CLI is not installed"):
connect_cli_host("codex", config=HostServerConfig())
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
script_path = bin_dir / "codex"
script_path.write_text(
"\n".join(
[
f"#!{sys.executable}",
"import sys",
"raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)",
]
),
encoding="utf-8",
)
script_path.chmod(0o755)
monkeypatch.setenv("PATH", str(bin_dir))
with pytest.raises(RuntimeError, match="codex mcp add failed"):
connect_cli_host("codex", config=HostServerConfig())
def test_doctor_hosts_reports_ok_and_drifted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex")
claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude")
combined_path = str(codex_bin) + ":" + str(claude_bin)
monkeypatch.setenv("PATH", combined_path)
codex_state.write_text(
json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}),
encoding="utf-8",
)
claude_state.write_text(
json.dumps(
{
"pyro": [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"workspace-full",
]
}
),
encoding="utf-8",
)
opencode_config = tmp_path / "opencode.json"
opencode_config.write_text(
json.dumps(
{
"mcp": {
"pyro": {
"type": "local",
"enabled": True,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
}
}
}
),
encoding="utf-8",
)
entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config)
by_host = {entry.host: entry for entry in entries}
assert by_host["codex"].status == "ok"
assert by_host["codex"].configured is True
assert by_host["claude-code"].status == "drifted"
assert by_host["claude-code"].configured is True
assert by_host["opencode"].status == "ok"
assert by_host["opencode"].configured is True
def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("[]", encoding="utf-8")
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert "JSON object" in by_host["opencode"].details
config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8")
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert "missing mcp.pyro" in by_host["opencode"].details
config_path.write_text(
json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}),
encoding="utf-8",
)
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "drifted"
assert by_host["opencode"].configured is True
def test_doctor_hosts_reports_invalid_json_for_installed_opencode(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_path = tmp_path / "opencode.json"
config_path.write_text("{invalid", encoding="utf-8")
monkeypatch.setattr(
shutil,
"which",
lambda name: "/usr/bin/opencode" if name == "opencode" else None,
)
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "drifted"
assert "invalid JSON" in by_host["opencode"].details
def test_repair_opencode_preserves_unrelated_keys(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text(
json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}),
encoding="utf-8",
)
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
assert payload["config_path"] == str(config_path.resolve())
repaired = json.loads(config_path.read_text(encoding="utf-8"))
assert repaired["theme"] == "light"
assert repaired["mcp"]["other"] == {"type": "local"}
assert repaired["mcp"]["pyro"] == {
"type": "local",
"enabled": True,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
}
def test_repair_opencode_backs_up_non_object_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("[]", encoding="utf-8")
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
backup_path = Path(str(payload["backup_path"]))
assert backup_path.exists()
assert backup_path.read_text(encoding="utf-8") == "[]"
def test_repair_opencode_backs_up_invalid_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("{invalid", encoding="utf-8")
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
backup_path = Path(str(payload["backup_path"]))
assert backup_path.exists()
assert backup_path.read_text(encoding="utf-8") == "{invalid"
repaired = json.loads(config_path.read_text(encoding="utf-8"))
assert repaired["mcp"]["pyro"]["command"] == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
]
def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None:
output_path = tmp_path / "opencode.json"
payload = print_or_write_opencode_config(
config=HostServerConfig(project_path="/repo"),
output_path=output_path,
)
assert payload["output_path"] == str(output_path)
rendered = json.loads(output_path.read_text(encoding="utf-8"))
assert rendered == {
"mcp": {
"pyro": {
"type": "local",
"enabled": True,
"command": [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--project-path",
"/repo",
],
}
}
}
def test_print_or_write_opencode_config_returns_rendered_text() -> None:
payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run"))
assert payload["host"] == "opencode"
assert payload["server_command"] == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"vm-run",
]
rendered = str(payload["rendered_config"])
assert '"type": "local"' in rendered
assert '"command": [' in rendered
def test_doctor_reports_opencode_missing_when_config_absent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
entries = doctor_hosts(
config=HostServerConfig(),
config_path=tmp_path / "missing-opencode.json",
)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details
def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
host_helpers,
"connect_cli_host",
lambda host, *, config: {"host": host, "profile": config.profile},
)
assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == {
"host": "codex",
"profile": "vm-run",
}
commands: list[list[str]] = []
def _fake_run_command(command: list[str]) -> CompletedProcess[str]:
commands.append(command)
if command[:3] == ["codex", "mcp", "get"]:
return CompletedProcess(command, 1, "", "not found")
if command[:3] == ["codex", "mcp", "list"]:
return CompletedProcess(command, 0, "pyro\n", "")
raise AssertionError(command)
monkeypatch.setattr(
shutil,
"which",
lambda name: "/usr/bin/codex" if name == "codex" else None,
)
monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command)
entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig())
assert entry.status == "drifted"
assert entry.configured is True
assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]]

View file

@ -5,6 +5,7 @@ from pathlib import Path
import pytest
import pyro_mcp.project_startup as project_startup
from pyro_mcp.project_startup import (
ProjectStartupSource,
describe_project_startup_source,
@ -15,7 +16,7 @@ from pyro_mcp.project_startup import (
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", *args],
["git", "-c", "commit.gpgsign=false", *args],
cwd=repo,
check=True,
capture_output=True,
@ -76,6 +77,52 @@ def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Pa
resolve_project_startup_source(project_path=repo, no_project_source=True)
def test_resolve_project_startup_source_handles_explicit_none_and_empty_values(
tmp_path: Path,
) -> None:
repo = _make_repo(tmp_path / "repo")
outside = tmp_path / "outside"
outside.mkdir()
assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None
assert resolve_project_startup_source(cwd=outside) is None
with pytest.raises(ValueError, match="must not be empty"):
resolve_project_startup_source(repo_url=" ", cwd=repo)
with pytest.raises(ValueError, match="must not be empty"):
resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ")
def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path(
tmp_path: Path,
) -> None:
missing = tmp_path / "missing"
file_path = tmp_path / "note.txt"
file_path.write_text("hello\n", encoding="utf-8")
with pytest.raises(ValueError, match="does not exist"):
resolve_project_startup_source(project_path=missing, cwd=tmp_path)
with pytest.raises(ValueError, match="must be a directory"):
resolve_project_startup_source(project_path=file_path, cwd=tmp_path)
def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo(
tmp_path: Path,
) -> None:
plain = tmp_path / "plain"
plain.mkdir()
resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path)
assert resolved == ProjectStartupSource(
kind="project_path",
origin_ref=str(plain.resolve()),
resolved_path=plain.resolve(),
)
def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo", content="one\n")
first_commit = _git(repo, "rev-parse", "HEAD")
@ -93,6 +140,60 @@ def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_pat
assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n"
def test_materialize_project_startup_source_validates_project_source_and_clone_failures(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
with pytest.raises(RuntimeError, match="missing a resolved path"):
with materialize_project_startup_source(
ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None)
):
pass
source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
def _clone_failure(
command: list[str],
*,
cwd: Path | None = None,
) -> subprocess.CompletedProcess[str]:
del cwd
return subprocess.CompletedProcess(command, 1, "", "clone failed")
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure)
with pytest.raises(RuntimeError, match="failed to clone repo_url"):
with materialize_project_startup_source(source):
pass
def test_materialize_project_startup_source_reports_checkout_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = _make_repo(tmp_path / "repo", content="one\n")
source = ProjectStartupSource(
kind="repo_url",
origin_ref=str(repo.resolve()),
repo_ref="missing-ref",
)
original_run_git = project_startup._run_git
def _checkout_failure(
command: list[str],
*,
cwd: Path | None = None,
) -> subprocess.CompletedProcess[str]:
if command[:2] == ["git", "checkout"]:
return subprocess.CompletedProcess(command, 1, "", "checkout failed")
return original_run_git(command, cwd=cwd)
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure)
with pytest.raises(RuntimeError, match="failed to checkout repo_ref"):
with materialize_project_startup_source(source):
pass
def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
@ -113,3 +214,23 @@ def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_pa
assert project_description == f"the current project at {repo.resolve()}"
assert repo_description == "the clean clone source https://example.com/repo.git at ref main"
def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None:
assert describe_project_startup_source(None) is None
assert (
describe_project_startup_source(
ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
)
== "the clean clone source https://example.com/repo.git"
)
def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
project_startup,
"_run_git",
lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""),
)
assert project_startup._detect_git_root(Path.cwd()) is None

View file

@ -16,6 +16,11 @@ from pyro_mcp.contract import (
PUBLIC_CLI_COMMANDS,
PUBLIC_CLI_DEMO_SUBCOMMANDS,
PUBLIC_CLI_ENV_SUBCOMMANDS,
PUBLIC_CLI_HOST_CONNECT_FLAGS,
PUBLIC_CLI_HOST_DOCTOR_FLAGS,
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS,
PUBLIC_CLI_HOST_REPAIR_FLAGS,
PUBLIC_CLI_HOST_SUBCOMMANDS,
PUBLIC_CLI_MCP_SERVE_FLAGS,
PUBLIC_CLI_MCP_SUBCOMMANDS,
PUBLIC_CLI_RUN_FLAGS,
@ -102,6 +107,29 @@ 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
host_help_text = _subparser_choice(parser, "host").format_help()
for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS:
assert subcommand_name in host_help_text
host_connect_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "connect"
).format_help()
for flag in PUBLIC_CLI_HOST_CONNECT_FLAGS:
assert flag in host_connect_help_text
host_doctor_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "doctor"
).format_help()
for flag in PUBLIC_CLI_HOST_DOCTOR_FLAGS:
assert flag in host_doctor_help_text
host_print_config_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "print-config"
).format_help()
for flag in PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS:
assert flag in host_print_config_help_text
host_repair_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "repair"
).format_help()
for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS:
assert flag in host_repair_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

View file

@ -19,7 +19,7 @@ from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", *args],
["git", "-c", "commit.gpgsign=false", *args],
cwd=repo,
check=True,
capture_output=True,

View file

@ -15,6 +15,13 @@ import pytest
from pyro_mcp import workspace_ports
def _socketpair_or_skip() -> tuple[socket.socket, socket.socket]:
try:
return socket.socketpair()
except PermissionError as exc:
pytest.skip(f"socketpair unavailable in this environment: {exc}")
class _EchoHandler(socketserver.BaseRequestHandler):
def handle(self) -> None:
data = self.request.recv(65536)
@ -50,18 +57,26 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure(
def test_workspace_port_proxy_forwards_tcp_traffic() -> None:
upstream = socketserver.ThreadingTCPServer(
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
_EchoHandler,
)
try:
upstream = socketserver.ThreadingTCPServer(
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
_EchoHandler,
)
except PermissionError as exc:
pytest.skip(f"TCP bind unavailable in this environment: {exc}")
upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True)
upstream_thread.start()
upstream_host = str(upstream.server_address[0])
upstream_port = int(upstream.server_address[1])
proxy = workspace_ports._ProxyServer( # noqa: SLF001
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
(upstream_host, upstream_port),
)
try:
proxy = workspace_ports._ProxyServer( # noqa: SLF001
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
(upstream_host, upstream_port),
)
except PermissionError as exc:
upstream.shutdown()
upstream.server_close()
pytest.skip(f"proxy TCP bind unavailable in this environment: {exc}")
proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True)
proxy_thread.start()
try:
@ -202,8 +217,8 @@ def test_workspace_ports_main_shutdown_handler_stops_server(
def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events(
monkeypatch: Any,
) -> None:
source, source_peer = socket.socketpair()
upstream, upstream_peer = socket.socketpair()
source, source_peer = _socketpair_or_skip()
upstream, upstream_peer = _socketpair_or_skip()
source_peer.close()
class FakeSelector:
@ -246,10 +261,17 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors(
monkeypatch: Any,
) -> None:
def _run_once(*, close_source: bool) -> None:
source, source_peer = socket.socketpair()
upstream, upstream_peer = socket.socketpair()
source, source_peer = _socketpair_or_skip()
upstream, upstream_peer = _socketpair_or_skip()
if not close_source:
source_peer.sendall(b"hello")
try:
source_peer.sendall(b"hello")
except PermissionError as exc:
source.close()
source_peer.close()
upstream.close()
upstream_peer.close()
pytest.skip(f"socket send unavailable in this environment: {exc}")
class FakeSelector:
def register(self, *_args: Any, **_kwargs: Any) -> None:

2
uv.lock generated
View file

@ -715,7 +715,7 @@ crypto = [
[[package]]
name = "pyro-mcp"
version = "4.1.0"
version = "4.2.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },