Add project-aware chat startup defaults

Make repo-root chat startup native by letting MCP servers carry a default project source for workspace creation. When a chat host starts from a Git checkout, workspace_create can now omit seed_path and inherit the server startup source; explicit --project-path and clean-clone --repo-url/--repo-ref paths are supported as fallbacks.

Add project startup resolution and materialization, surface origin_kind/origin_ref in workspace_seed, update chat-host docs and the repro/fix smoke to use project-aware workspace creation, and switch dist-check to uv run pyro so verification stays stable after uv reinstalls.

Validated with uv lock, focused startup/server/CLI pytest coverage, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and real guest-backed smokes for both explicit project_path and bare repo-root auto-detection.
This commit is contained in:
Thales Maciel 2026-03-13 15:51:47 -03:00
parent 9b9b83ebeb
commit 535efc6919
28 changed files with 968 additions and 67 deletions

View file

@ -2,6 +2,18 @@
All notable user-visible changes to `pyro-mcp` are documented here.
## 4.1.0
- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can
auto-detect the current Git checkout and let `workspace_create` omit
`seed_path` safely.
- Added explicit fallback startup flags for chat hosts that do not preserve the
server working directory: `--project-path`, `--repo-url`, `--repo-ref`, and
`--no-project-source`.
- Extended workspace seed metadata with startup origin fields so chat-facing
workspace creation can show whether a workspace came from a manual seed path,
the current project, or a clean cloned repo source.
## 4.0.0
- Flipped the default MCP/server profile from `workspace-full` to

View file

@ -82,13 +82,13 @@ test:
check: lint typecheck test
dist-check:
.venv/bin/pyro --version
.venv/bin/pyro --help >/dev/null
.venv/bin/pyro mcp --help >/dev/null
.venv/bin/pyro run --help >/dev/null
.venv/bin/pyro env list >/dev/null
.venv/bin/pyro env inspect debian:12 >/dev/null
.venv/bin/pyro doctor >/dev/null
uv run pyro --version
uv run pyro --help >/dev/null
uv run pyro mcp --help >/dev/null
uv run pyro run --help >/dev/null
uv run pyro env list >/dev/null
uv run pyro env inspect debian:12 >/dev/null
uv run pyro doctor >/dev/null
pypi-publish:
@if [ -z "$$TWINE_PASSWORD" ]; then \

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.0.0: [CHANGELOG.md#400](CHANGELOG.md#400)
- What's new in 4.1.0: [CHANGELOG.md#410](CHANGELOG.md#410)
- 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.0.0
Catalog version: 4.1.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -96,13 +96,26 @@ for the guest image.
## Chat Host Quickstart
After the quickstart works, the intended next step is to connect a chat host.
Bare `pyro mcp serve` starts `workspace-core`, which is the default product
path.
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
```
If the host does not preserve the server working directory, use:
```bash
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 mcp serve --repo-url https://github.com/example/project.git
```
Copy-paste host-specific starts:
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
@ -136,6 +149,10 @@ OpenCode `opencode.json` snippet:
}
```
If OpenCode launches the server from an unexpected cwd, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array.
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape.
@ -146,7 +163,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`.
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.
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.0.0
Catalog version: 4.1.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.
@ -71,13 +71,26 @@ streams, so they may appear in either order in terminals or capture tools. Use
## 5. Start the MCP server
Bare `pyro mcp serve` now starts `workspace-core`, which is the intended chat
path:
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`:
```bash
$ uvx --from pyro-mcp pyro mcp serve
```
If the host does not preserve the server working directory:
```bash
$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If you are outside a local checkout:
```bash
$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
```
## 6. Connect a chat host
Claude Code:

View file

@ -92,7 +92,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 4.0.0
Catalog version: 4.1.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,13 +140,26 @@ deterministic structured result.
## 5. Connect a chat host
Bare `pyro mcp serve` now starts `workspace-core`, which is the default
product path.
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`.
```bash
uvx --from pyro-mcp pyro mcp serve
```
If the host does not preserve the server working directory, use:
```bash
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 mcp serve --repo-url https://github.com/example/project.git
```
Copy-paste host-specific starts:
- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md)
@ -182,7 +195,8 @@ 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`
4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo
root, or use `--project-path` / `--repo-url` when needed
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

@ -15,12 +15,26 @@ through [install.md](install.md) or [first-run.md](first-run.md).
## Recommended Default
Bare `pyro mcp serve` starts `workspace-core`. That is the product path.
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`. That is the product path.
```bash
pyro mcp serve
```
If the host does not preserve cwd, fall back to:
```bash
pyro mcp serve --project-path /abs/path/to/repo
```
If you are outside a repo checkout entirely, start from a clean clone source:
```bash
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.
@ -33,6 +47,12 @@ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
claude mcp list
```
If Claude Code launches the server from an unexpected cwd, use:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
Already installed:
```bash
@ -53,6 +73,12 @@ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp list
```
If Codex launches the server from an unexpected cwd, use:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
Already installed:
```bash
@ -87,6 +113,10 @@ Minimal `opencode.json` snippet:
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same config shape.
If OpenCode launches the server from an unexpected cwd, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array.
## Generic MCP Fallback
Use this only when the host expects a plain `mcpServers` JSON config and does

View file

@ -76,10 +76,16 @@ pyro mcp serve
What to expect:
- bare `pyro mcp serve` starts `workspace-core`
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
checkout so `workspace_create` can omit `seed_path`
- `workspace-core` is the default product path for chat hosts
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
tool surface
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the
host does not preserve cwd
- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone
source instead of a local checkout
Host-specific setup docs:
@ -111,7 +117,8 @@ Host-specific setup docs:
That is enough for the normal persistent editing loop:
- create one workspace
- create one workspace, often without `seed_path` when the server already has a
project source
- sync or seed repo content
- inspect and edit files without shell quoting
- run commands repeatedly in one sandbox

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.0.0`:
Current baseline is `4.1.0`:
- `pyro mcp serve` is now the default product entrypoint
- `workspace-core` is now the default MCP profile
@ -79,7 +79,7 @@ capability gaps:
9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md) - Done
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) - Planned
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
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
@ -114,10 +114,12 @@ Completed so far:
config manually.
- `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now
matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories.
- `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.
Planned next:
- [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md)
- [`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)

View file

@ -1,6 +1,6 @@
# `4.1.0` Project-Aware Chat Startup
Status: Planned
Status: Done
## Goal

View file

@ -14,13 +14,16 @@ reset back to baseline.
Chat-host recipe:
1. Create one workspace from the broken repro seed.
2. Run the failing command.
3. Inspect the broken file with structured file reads.
4. Apply the fix with `workspace_patch_apply`.
5. Rerun the failing command in the same workspace.
6. Diff and export the changed result.
7. Reset to baseline and delete the workspace.
1. Start the server from the repo root with bare `pyro mcp serve`, or use
`--project-path` if the host does not preserve cwd.
2. Create one workspace from that project-aware server without manually passing
`seed_path`.
3. Run the failing command.
4. Inspect the broken file with structured file reads.
5. Apply the fix with `workspace_patch_apply`.
6. Rerun the failing command in the same workspace.
7. Diff and export the changed result.
8. Reset to baseline and delete the workspace.
This is the main `workspace-core` story: model-native file ops, repeatable exec,
structured diff, explicit export, and reset-over-repair.

View file

@ -9,6 +9,9 @@ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
claude mcp list
```
Run that from the repo root when you want the first `workspace_create` to start
from the current checkout automatically.
Already installed:
```bash
@ -16,6 +19,13 @@ claude mcp add pyro -- pyro mcp serve
claude mcp list
```
If Claude Code launches the server from an unexpected cwd, pin the project
explicitly:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools:

View file

@ -9,6 +9,9 @@ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp list
```
Run that from the repo root when you want the first `workspace_create` to start
from the current checkout automatically.
Already installed:
```bash
@ -16,6 +19,13 @@ codex mcp add pyro -- pyro mcp serve
codex mcp list
```
If Codex launches the server from an unexpected cwd, pin the project
explicitly:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools:

View file

@ -39,6 +39,10 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
}
```
If the host does not preserve the server working directory and you want the
first `workspace_create` to start from a specific checkout, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
Profile progression:
- `workspace-core`: the default and recommended first persistent chat profile

View file

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

View file

@ -13,6 +13,12 @@ from pyro_mcp.contract import (
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
)
from pyro_mcp.project_startup import (
ProjectStartupSource,
describe_project_startup_source,
materialize_project_startup_source,
resolve_project_startup_source,
)
from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB,
@ -39,6 +45,18 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
return cast(McpToolProfile, profile)
def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str:
if startup_source is None:
return "Create and start a persistent workspace."
described_source = describe_project_startup_source(startup_source)
if described_source is None:
return "Create and start a persistent workspace."
return (
"Create and start a persistent workspace. If `seed_path` is omitted, "
f"the server seeds from {described_source}."
)
class Pyro:
"""High-level facade over the ephemeral VM runtime."""
@ -462,14 +480,31 @@ class Pyro:
allow_host_compat=allow_host_compat,
)
def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP:
def create_server(
self,
*,
profile: McpToolProfile = "workspace-core",
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
) -> FastMCP:
"""Create an MCP server for one of the stable public tool profiles.
`workspace-core` is the default stable chat-host profile in 4.x. Use
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface.
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory and uses
that source when `workspace_create` omits `seed_path`. `project_path`,
`repo_url`, and `no_project_source` override that behavior explicitly.
"""
normalized_profile = _validate_mcp_profile(profile)
startup_source = resolve_project_startup_source(
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
server = FastMCP(name="pyro_mcp")
@ -583,9 +618,56 @@ class Pyro:
return self.reap_expired()
if _enabled("workspace_create"):
workspace_create_description = _workspace_create_description(startup_source)
def _create_workspace_from_server_defaults(
*,
environment: str,
vcpu_count: int,
mem_mib: int,
ttl_seconds: int,
network_policy: str,
allow_host_compat: bool,
seed_path: str | None,
secrets: list[dict[str, str]] | None,
name: str | None,
labels: dict[str, str] | None,
) -> dict[str, Any]:
if seed_path is not None or startup_source is None:
return self.create_workspace(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
ttl_seconds=ttl_seconds,
network_policy=network_policy,
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=secrets,
name=name,
labels=labels,
)
with materialize_project_startup_source(startup_source) as resolved_seed_path:
prepared_seed = self._manager._prepare_workspace_seed( # noqa: SLF001
resolved_seed_path,
origin_kind=startup_source.kind,
origin_ref=startup_source.origin_ref,
)
return self._manager.create_workspace(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
ttl_seconds=ttl_seconds,
network_policy=network_policy,
allow_host_compat=allow_host_compat,
secrets=secrets,
name=name,
labels=labels,
_prepared_seed=prepared_seed,
)
if normalized_profile == "workspace-core":
@server.tool(name="workspace_create")
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_core(
environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT,
@ -596,8 +678,7 @@ class Pyro:
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
return _create_workspace_from_server_defaults(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
@ -612,7 +693,7 @@ class Pyro:
else:
@server.tool(name="workspace_create")
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_full(
environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT,
@ -625,8 +706,7 @@ class Pyro:
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
return _create_workspace_from_server_defaults(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,

View file

@ -191,7 +191,16 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
if isinstance(workspace_seed, dict):
mode = str(workspace_seed.get("mode", "empty"))
seed_path = workspace_seed.get("seed_path")
if isinstance(seed_path, str) and seed_path != "":
origin_kind = workspace_seed.get("origin_kind")
origin_ref = workspace_seed.get("origin_ref")
if isinstance(origin_kind, str) and isinstance(origin_ref, str) and origin_ref != "":
if origin_kind == "project_path":
print(f"Workspace seed: {mode} from project {origin_ref}")
elif origin_kind == "repo_url":
print(f"Workspace seed: {mode} from clean clone {origin_ref}")
else:
print(f"Workspace seed: {mode} from {origin_ref}")
elif isinstance(seed_path, str) and seed_path != "":
print(f"Workspace seed: {mode} from {seed_path}")
else:
print(f"Workspace seed: {mode}")
@ -770,6 +779,8 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro mcp serve
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
pyro mcp serve --profile vm-run
pyro mcp serve --profile workspace-full
"""
@ -783,12 +794,15 @@ def _build_parser() -> argparse.ArgumentParser:
description=(
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
"serve` now starts `workspace-core`, the recommended first profile "
"for most chat hosts."
"for most chat hosts. When launched from inside a Git checkout, it "
"also seeds the first workspace from that repo by default."
),
epilog=dedent(
"""
Default and recommended first start:
pyro mcp serve
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
Profiles:
workspace-core: default for normal persistent chat editing
@ -796,6 +810,12 @@ def _build_parser() -> argparse.ArgumentParser:
workspace-full: larger opt-in surface for shells, services,
snapshots, secrets, network policy, and disk tools
Project-aware startup:
- bare `pyro mcp serve` auto-detects the nearest Git checkout
from the current working directory
- use --project-path when the host does not preserve cwd
- use --repo-url for a clean-clone source outside a local checkout
Use --profile workspace-full only when the host truly needs those
extra workspace capabilities.
"""
@ -812,6 +832,33 @@ def _build_parser() -> argparse.ArgumentParser:
"`workspace-full` is the larger opt-in profile."
),
)
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_source_group.add_argument(
"--project-path",
help=(
"Seed default workspaces from this local project path. If the path "
"is inside a Git checkout, pyro uses that repo root."
),
)
mcp_source_group.add_argument(
"--repo-url",
help=(
"Seed default workspaces from a clean host-side clone of this repo URL "
"when `workspace_create` omits `seed_path`."
),
)
mcp_serve_parser.add_argument(
"--repo-ref",
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
)
mcp_serve_parser.add_argument(
"--no-project-source",
action="store_true",
help=(
"Disable automatic Git checkout detection from the current working "
"directory."
),
)
run_parser = subparsers.add_parser(
"run",
@ -2304,7 +2351,13 @@ def main() -> None:
_print_prune_human(prune_payload)
return
if args.command == "mcp":
pyro.create_server(profile=args.profile).run(transport="stdio")
pyro.create_server(
profile=args.profile,
project_path=args.project_path,
repo_url=args.repo_url,
repo_ref=args.repo_ref,
no_project_source=bool(args.no_project_source),
).run(transport="stdio")
return
if args.command == "run":
command = _require_command(args.command_args)

View file

@ -6,7 +6,13 @@ PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"create",
"delete",

View file

@ -0,0 +1,149 @@
"""Server-scoped project startup source helpers for MCP chat flows."""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Literal
ProjectStartupSourceKind = Literal["project_path", "repo_url"]
@dataclass(frozen=True)
class ProjectStartupSource:
"""Server-scoped default source for workspace creation."""
kind: ProjectStartupSourceKind
origin_ref: str
resolved_path: Path | None = None
repo_ref: str | None = None
def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
command,
cwd=str(cwd) if cwd is not None else None,
check=False,
capture_output=True,
text=True,
)
def _detect_git_root(start_dir: Path) -> Path | None:
result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir)
if result.returncode != 0:
return None
stdout = result.stdout.strip()
if stdout == "":
return None
return Path(stdout).expanduser().resolve()
def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path:
resolved = Path(project_path).expanduser()
if not resolved.is_absolute():
resolved = (cwd / resolved).resolve()
else:
resolved = resolved.resolve()
if not resolved.exists():
raise ValueError(f"project_path {resolved} does not exist")
if not resolved.is_dir():
raise ValueError(f"project_path {resolved} must be a directory")
git_root = _detect_git_root(resolved)
if git_root is not None:
return git_root
return resolved
def resolve_project_startup_source(
*,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
cwd: Path | None = None,
) -> ProjectStartupSource | None:
working_dir = Path.cwd() if cwd is None else cwd.resolve()
if no_project_source:
if project_path is not None or repo_url is not None or repo_ref is not None:
raise ValueError(
"--no-project-source cannot be combined with --project-path, "
"--repo-url, or --repo-ref"
)
return None
if project_path is not None and repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if repo_ref is not None and repo_url is None:
raise ValueError("--repo-ref requires --repo-url")
if project_path is not None:
resolved_path = _resolve_project_path(project_path, cwd=working_dir)
return ProjectStartupSource(
kind="project_path",
origin_ref=str(resolved_path),
resolved_path=resolved_path,
)
if repo_url is not None:
normalized_repo_url = repo_url.strip()
if normalized_repo_url == "":
raise ValueError("--repo-url must not be empty")
normalized_repo_ref = None if repo_ref is None else repo_ref.strip()
if normalized_repo_ref == "":
raise ValueError("--repo-ref must not be empty")
return ProjectStartupSource(
kind="repo_url",
origin_ref=normalized_repo_url,
repo_ref=normalized_repo_ref,
)
detected_root = _detect_git_root(working_dir)
if detected_root is None:
return None
return ProjectStartupSource(
kind="project_path",
origin_ref=str(detected_root),
resolved_path=detected_root,
)
@contextmanager
def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]:
if source.kind == "project_path":
if source.resolved_path is None:
raise RuntimeError("project_path source is missing a resolved path")
yield source.resolved_path
return
temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-"))
clone_dir = temp_dir / "clone"
try:
clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)])
if clone_result.returncode != 0:
stderr = clone_result.stderr.strip() or "git clone failed"
raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}")
if source.repo_ref is not None:
checkout_result = _run_git(
["git", "checkout", "--quiet", source.repo_ref],
cwd=clone_dir,
)
if checkout_result.returncode != 0:
stderr = checkout_result.stderr.strip() or "git checkout failed"
raise RuntimeError(
f"failed to checkout repo_ref {source.repo_ref!r} for "
f"repo_url {source.origin_ref!r}: {stderr}"
)
yield clone_dir
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None:
if source is None:
return None
if source.kind == "project_path":
return f"the current project at {source.origin_ref}"
if source.repo_ref is None:
return f"the clean clone source {source.origin_ref}"
return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}"

View file

@ -2,6 +2,8 @@
from __future__ import annotations
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from pyro_mcp.api import McpToolProfile, Pyro
@ -12,14 +14,26 @@ def create_server(
manager: VmManager | None = None,
*,
profile: McpToolProfile = "workspace-core",
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
) -> FastMCP:
"""Create and return a configured MCP server instance.
`workspace-core` is the default stable chat-host profile in 4.x. Use
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface.
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory for
project-aware `workspace_create` calls.
"""
return Pyro(manager=manager).create_server(profile=profile)
return Pyro(manager=manager).create_server(
profile=profile,
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
def main() -> None:

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "4.0.0"
DEFAULT_CATALOG_VERSION = "4.1.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.0.0,<5.0.0"
compatibility: str = ">=4.1.0,<5.0.0"
@dataclass(frozen=True)

View file

@ -116,6 +116,7 @@ WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
WorkspaceSnapshotKind = Literal["baseline", "named"]
@ -524,6 +525,8 @@ class PreparedWorkspaceSeed:
mode: WorkspaceSeedMode
source_path: str | None
origin_kind: WorkspaceSeedOriginKind = "empty"
origin_ref: str | None = None
archive_path: Path | None = None
entry_count: int = 0
bytes_written: int = 0
@ -534,14 +537,19 @@ class PreparedWorkspaceSeed:
*,
destination: str = WORKSPACE_GUEST_PATH,
path_key: str = "seed_path",
include_origin: bool = True,
) -> dict[str, Any]:
return {
payload = {
"mode": self.mode,
path_key: self.source_path,
"destination": destination,
"entry_count": self.entry_count,
"bytes_written": self.bytes_written,
}
if include_origin:
payload["origin_kind"] = self.origin_kind
payload["origin_ref"] = self.origin_ref
return payload
def cleanup(self) -> None:
if self.cleanup_dir is not None:
@ -614,6 +622,8 @@ def _empty_workspace_seed_payload() -> dict[str, Any]:
return {
"mode": "empty",
"seed_path": None,
"origin_kind": "empty",
"origin_ref": None,
"destination": WORKSPACE_GUEST_PATH,
"entry_count": 0,
"bytes_written": 0,
@ -628,6 +638,8 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]:
{
"mode": str(value.get("mode", payload["mode"])),
"seed_path": _optional_str(value.get("seed_path")),
"origin_kind": str(value.get("origin_kind", payload["origin_kind"])),
"origin_ref": _optional_str(value.get("origin_ref")),
"destination": str(value.get("destination", payload["destination"])),
"entry_count": int(value.get("entry_count", payload["entry_count"])),
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
@ -3747,13 +3759,16 @@ class VmManager:
secrets: list[dict[str, str]] | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
_prepared_seed: PreparedWorkspaceSeed | None = None,
) -> dict[str, Any]:
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
get_environment(environment, runtime_paths=self._runtime_paths)
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
normalized_name = None if name is None else _normalize_workspace_name(name)
normalized_labels = _normalize_workspace_labels(labels)
prepared_seed = self._prepare_workspace_seed(seed_path)
if _prepared_seed is not None and seed_path is not None:
raise ValueError("_prepared_seed and seed_path are mutually exclusive")
prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path)
now = time.time()
workspace_id = uuid.uuid4().hex[:12]
workspace_dir = self._workspace_dir(workspace_id)
@ -3885,6 +3900,7 @@ class VmManager:
workspace_sync = prepared_seed.to_payload(
destination=normalized_destination,
path_key="source_path",
include_origin=False,
)
workspace_sync["entry_count"] = int(import_summary["entry_count"])
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
@ -5663,12 +5679,30 @@ class VmManager:
execution_mode = instance.metadata.get("execution_mode", "unknown")
return exec_result, execution_mode
def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed:
def _prepare_workspace_seed(
self,
seed_path: str | Path | None,
*,
origin_kind: WorkspaceSeedOriginKind | None = None,
origin_ref: str | None = None,
) -> PreparedWorkspaceSeed:
if seed_path is None:
return PreparedWorkspaceSeed(mode="empty", source_path=None)
return PreparedWorkspaceSeed(
mode="empty",
source_path=None,
origin_kind="empty" if origin_kind is None else origin_kind,
origin_ref=origin_ref,
)
resolved_source_path = Path(seed_path).expanduser().resolve()
if not resolved_source_path.exists():
raise ValueError(f"seed_path {resolved_source_path} does not exist")
effective_origin_kind: WorkspaceSeedOriginKind = (
"manual_seed_path" if origin_kind is None else origin_kind
)
effective_origin_ref = str(resolved_source_path) if origin_ref is None else origin_ref
public_source_path = (
None if effective_origin_kind == "repo_url" else str(resolved_source_path)
)
if resolved_source_path.is_dir():
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
archive_path = cleanup_dir / "workspace-seed.tar"
@ -5680,7 +5714,9 @@ class VmManager:
raise
return PreparedWorkspaceSeed(
mode="directory",
source_path=str(resolved_source_path),
source_path=public_source_path,
origin_kind=effective_origin_kind,
origin_ref=effective_origin_ref,
archive_path=archive_path,
entry_count=entry_count,
bytes_written=bytes_written,
@ -5696,7 +5732,9 @@ class VmManager:
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
return PreparedWorkspaceSeed(
mode="tar_archive",
source_path=str(resolved_source_path),
source_path=public_source_path,
origin_kind=effective_origin_kind,
origin_ref=effective_origin_ref,
archive_path=resolved_source_path,
entry_count=entry_count,
bytes_written=bytes_written,

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import asyncio
import tempfile
import time
from dataclasses import dataclass
@ -107,6 +108,15 @@ def _log(message: str) -> None:
print(f"[smoke] {message}", flush=True)
def _extract_structured_tool_result(raw_result: object) -> dict[str, object]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected MCP tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return structured
def _create_workspace(
pyro: Pyro,
*,
@ -126,6 +136,30 @@ def _create_workspace(
return str(created["workspace_id"])
def _create_project_aware_workspace(
pyro: Pyro,
*,
environment: str,
project_path: Path,
name: str,
labels: dict[str, str],
) -> dict[str, object]:
async def _run() -> dict[str, object]:
server = pyro.create_server(profile="workspace-core", project_path=project_path)
return _extract_structured_tool_result(
await server.call_tool(
"workspace_create",
{
"environment": environment,
"name": name,
"labels": labels,
},
)
)
return asyncio.run(_run())
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
if workspace_id is None:
return
@ -221,14 +255,19 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
)
workspace_id: str | None = None
try:
workspace_id = _create_workspace(
created = _create_project_aware_workspace(
pyro,
environment=environment,
seed_path=seed_dir,
project_path=seed_dir,
name="repro-fix-loop",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
)
workspace_id = str(created["workspace_id"])
_log(f"repro-fix-loop workspace_id={workspace_id}")
workspace_seed = created["workspace_seed"]
assert isinstance(workspace_seed, dict), created
assert workspace_seed["origin_kind"] == "project_path", created
assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
assert str(initial_read["content"]) == "broken\n", initial_read
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import subprocess
import time
from pathlib import Path
from typing import Any, cast
@ -15,6 +16,28 @@ from pyro_mcp.vm_manager import VmManager
from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
root.mkdir()
_git(root, "init")
_git(root, "config", "user.name", "Pyro Tests")
_git(root, "config", "user.email", "pyro-tests@example.com")
(root / "note.txt").write_text(content, encoding="utf-8")
_git(root, "add", "note.txt")
_git(root, "commit", "-m", "init")
return root
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
@ -134,6 +157,58 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
assert "workspace_disk_export" not in tool_map
def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
tmp_path: Path,
) -> None:
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
server = pyro.create_server(project_path=repo)
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
created = _extract_structured(
await server.call_tool(
"workspace_create",
{
"environment": "debian:12-base",
"allow_host_compat": True,
},
)
)
executed = _extract_structured(
await server.call_tool(
"workspace_exec",
{
"workspace_id": created["workspace_id"],
"command": "cat note.txt",
},
)
)
return tool_map["workspace_create"], created, executed
workspace_create_tool, created, executed = asyncio.run(_run())
assert "If `seed_path` is omitted" in str(workspace_create_tool["description"])
assert str(repo.resolve()) in str(workspace_create_tool["description"])
assert created["workspace_seed"]["origin_kind"] == "project_path"
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
assert executed["stdout"] == "project-aware\n"
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(

View file

@ -73,6 +73,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "recommended first profile for most chat hosts" in mcp_help
assert "workspace-core: default for normal persistent chat editing" in mcp_help
assert "workspace-full: larger opt-in surface" in mcp_help
assert "--project-path" in mcp_help
assert "--repo-url" in mcp_help
assert "--repo-ref" in mcp_help
assert "--no-project-source" in mcp_help
assert "pyro mcp serve --project-path ." in mcp_help
assert "pyro mcp serve --repo-url https://github.com/example/project.git" in mcp_help
workspace_help = _subparser_choice(parser, "workspace").format_help()
assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help
@ -2825,25 +2831,30 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
assert claude_cmd in readme
assert codex_cmd in readme
assert "examples/opencode_mcp_config.json" in readme
assert "Bare `pyro mcp serve` starts `workspace-core`" in readme
assert "bare `pyro mcp serve` starts `workspace-core`" in readme
assert "auto-detects\nthe current Git checkout" in readme
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 "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 "--project-path /abs/path/to/repo" in first_run
assert (
"Bare `pyro mcp serve` starts `workspace-core`. That is the product path."
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
assert "examples/codex_mcp.md" in integrations
assert "examples/opencode_mcp_config.json" in integrations
assert "That is the product path." in integrations
assert "--project-path /abs/path/to/repo" in integrations
assert "--repo-url https://github.com/example/project.git" in integrations
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
assert "Use the host-specific examples first when they apply:" in mcp_config
@ -2854,10 +2865,12 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
assert claude_cmd in claude_code
assert "claude mcp list" in claude_code
assert "workspace-full" in claude_code
assert "--project-path /abs/path/to/repo" in claude_code
assert codex_cmd in codex
assert "codex mcp list" in codex
assert "workspace-full" in codex
assert "--project-path /abs/path/to/repo" in codex
assert opencode == {
"mcp": {
@ -4020,11 +4033,23 @@ def test_cli_run_json_error_exits_nonzero(
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
observed: dict[str, str] = {}
observed: dict[str, Any] = {}
class StubPyro:
def create_server(self, *, profile: str) -> Any:
def create_server(
self,
*,
profile: str,
project_path: str | None,
repo_url: str | None,
repo_ref: str | None,
no_project_source: bool,
) -> Any:
observed["profile"] = profile
observed["project_path"] = project_path
observed["repo_url"] = repo_url
observed["repo_ref"] = repo_ref
observed["no_project_source"] = no_project_source
return type(
"StubServer",
(),
@ -4033,12 +4058,27 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
return argparse.Namespace(
command="mcp",
mcp_command="serve",
profile="workspace-core",
project_path="/repo",
repo_url=None,
repo_ref=None,
no_project_source=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
assert observed == {"profile": "workspace-core", "transport": "stdio"}
assert observed == {
"profile": "workspace-core",
"project_path": "/repo",
"repo_url": None,
"repo_ref": None,
"no_project_source": False,
"transport": "stdio",
}
def test_cli_demo_default_prints_json(

View file

@ -0,0 +1,115 @@
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from pyro_mcp.project_startup import (
ProjectStartupSource,
describe_project_startup_source,
materialize_project_startup_source,
resolve_project_startup_source,
)
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path:
root.mkdir()
_git(root, "init")
_git(root, "config", "user.name", "Pyro Tests")
_git(root, "config", "user.email", "pyro-tests@example.com")
(root / filename).write_text(content, encoding="utf-8")
_git(root, "add", filename)
_git(root, "commit", "-m", "init")
return root
def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
nested = repo / "src" / "pkg"
nested.mkdir(parents=True)
resolved = resolve_project_startup_source(cwd=nested)
assert resolved == ProjectStartupSource(
kind="project_path",
origin_ref=str(repo.resolve()),
resolved_path=repo.resolve(),
)
def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
nested = repo / "nested"
nested.mkdir()
resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path)
assert resolved == ProjectStartupSource(
kind="project_path",
origin_ref=str(repo.resolve()),
resolved_path=repo.resolve(),
)
def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
with pytest.raises(ValueError, match="mutually exclusive"):
resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git")
with pytest.raises(ValueError, match="requires --repo-url"):
resolve_project_startup_source(repo_ref="main")
with pytest.raises(ValueError, match="cannot be combined"):
resolve_project_startup_source(project_path=repo, no_project_source=True)
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")
(repo / "note.txt").write_text("two\n", encoding="utf-8")
_git(repo, "add", "note.txt")
_git(repo, "commit", "-m", "update")
source = ProjectStartupSource(
kind="repo_url",
origin_ref=str(repo.resolve()),
repo_ref=first_commit,
)
with materialize_project_startup_source(source) as clone_dir:
assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n"
def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
project_description = describe_project_startup_source(
ProjectStartupSource(
kind="project_path",
origin_ref=str(repo.resolve()),
resolved_path=repo.resolve(),
)
)
repo_description = describe_project_startup_source(
ProjectStartupSource(
kind="repo_url",
origin_ref="https://example.com/repo.git",
repo_ref="main",
)
)
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"

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import subprocess
from pathlib import Path
from typing import Any, cast
@ -16,6 +17,28 @@ from pyro_mcp.vm_manager import VmManager
from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
root.mkdir()
_git(root, "init")
_git(root, "config", "user.name", "Pyro Tests")
_git(root, "config", "user.email", "pyro-tests@example.com")
(root / "note.txt").write_text(content, encoding="utf-8")
_git(root, "add", "note.txt")
_git(root, "commit", "-m", "init")
return root
def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
@ -62,6 +85,121 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path:
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
repo = _make_repo(tmp_path / "repo")
async def _run() -> dict[str, Any]:
server = create_server(manager=manager, project_path=repo)
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return tool_map["workspace_create"]
workspace_create = asyncio.run(_run())
description = cast(str, workspace_create["description"])
assert "If `seed_path` is omitted" in description
assert str(repo.resolve()) in description
def test_create_server_project_path_seeds_workspace_when_seed_path_is_omitted(
tmp_path: Path,
) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
server = create_server(manager=manager, project_path=repo)
created = _extract_structured(
await server.call_tool(
"workspace_create",
{
"environment": "debian:12-base",
"allow_host_compat": True,
},
)
)
executed = _extract_structured(
await server.call_tool(
"workspace_exec",
{
"workspace_id": created["workspace_id"],
"command": "cat note.txt",
},
)
)
return created, executed
created, executed = asyncio.run(_run())
assert created["workspace_seed"]["mode"] == "directory"
assert created["workspace_seed"]["seed_path"] == str(repo.resolve())
assert created["workspace_seed"]["origin_kind"] == "project_path"
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
assert executed["stdout"] == "project-aware\n"
def test_create_server_repo_url_seeds_workspace_when_seed_path_is_omitted(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
repo = _make_repo(tmp_path / "repo", content="committed\n")
(repo / "note.txt").write_text("dirty\n", encoding="utf-8")
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
server = create_server(manager=manager, repo_url=str(repo.resolve()))
created = _extract_structured(
await server.call_tool(
"workspace_create",
{
"environment": "debian:12-base",
"allow_host_compat": True,
},
)
)
executed = _extract_structured(
await server.call_tool(
"workspace_exec",
{
"workspace_id": created["workspace_id"],
"command": "cat note.txt",
},
)
)
return created, executed
created, executed = asyncio.run(_run())
assert created["workspace_seed"]["mode"] == "directory"
assert created["workspace_seed"]["seed_path"] is None
assert created["workspace_seed"]["origin_kind"] == "repo_url"
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
assert executed["stdout"] == "committed\n"
def test_vm_run_round_trip(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",

View file

@ -436,6 +436,36 @@ class _FakePyro:
workspace.shells.pop(shell_id, None)
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
def create_server(self, *, profile: str, project_path: Path) -> Any:
assert profile == "workspace-core"
seed_path = Path(project_path)
outer = self
class _FakeServer:
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any],
) -> tuple[None, dict[str, Any]]:
if tool_name != "workspace_create":
raise AssertionError(f"unexpected tool call: {tool_name}")
result = outer.create_workspace(
environment=cast(str, arguments["environment"]),
seed_path=seed_path,
name=cast(str | None, arguments.get("name")),
labels=cast(dict[str, str] | None, arguments.get("labels")),
)
created = outer.status_workspace(cast(str, result["workspace_id"]))
created["workspace_seed"] = {
"mode": "directory",
"seed_path": str(seed_path.resolve()),
"origin_kind": "project_path",
"origin_ref": str(seed_path.resolve()),
}
return None, created
return _FakeServer()
def test_use_case_registry_has_expected_scenarios() -> None:
expected = (

2
uv.lock generated
View file

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