From 535efc6919ca613dc5fdfcbfe836928ded76546d Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 15:51:47 -0300 Subject: [PATCH] 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. --- CHANGELOG.md | 12 ++ Makefile | 14 +- README.md | 29 +++- docs/first-run.md | 19 ++- docs/install.md | 22 ++- docs/integrations.md | 32 +++- docs/public-contract.md | 9 +- docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.1.0-project-aware-chat-startup.md | 2 +- docs/use-cases/repro-fix-loop.md | 17 +- examples/claude_code_mcp.md | 10 ++ examples/codex_mcp.md | 10 ++ examples/mcp_client_config.md | 4 + pyproject.toml | 2 +- src/pyro_mcp/api.py | 96 ++++++++++- src/pyro_mcp/cli.py | 59 ++++++- src/pyro_mcp/contract.py | 8 +- src/pyro_mcp/project_startup.py | 149 ++++++++++++++++++ src/pyro_mcp/server.py | 18 ++- src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/vm_manager.py | 50 +++++- src/pyro_mcp/workspace_use_case_smokes.py | 43 ++++- tests/test_api.py | 75 +++++++++ tests/test_cli.py | 58 +++++-- tests/test_project_startup.py | 115 ++++++++++++++ tests/test_server.py | 138 ++++++++++++++++ tests/test_workspace_use_case_smokes.py | 30 ++++ uv.lock | 2 +- 28 files changed, 968 insertions(+), 67 deletions(-) create mode 100644 src/pyro_mcp/project_startup.py create mode 100644 tests/test_project_startup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 863a6d0..70987e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 8c71e5b..1211c8f 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/README.md b/README.md index 65e2dfc..848fc29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/first-run.md b/docs/first-run.md index 4ac064f..6b40344 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -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: diff --git a/docs/install.md b/docs/install.md index 6ef5649..cb02312 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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` diff --git a/docs/integrations.md b/docs/integrations.md index c3bbcb8..7c0a96b 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -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 diff --git a/docs/public-contract.md b/docs/public-contract.md index 0a4de23..ae5c55e 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -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 diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index fa66522..6a40a81 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -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) diff --git a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md index 38a86fd..e3e6986 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md +++ b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md @@ -1,6 +1,6 @@ # `4.1.0` Project-Aware Chat Startup -Status: Planned +Status: Done ## Goal diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md index 3550143..fac4090 100644 --- a/docs/use-cases/repro-fix-loop.md +++ b/docs/use-cases/repro-fix-loop.md @@ -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. diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index d62cae4..3dbc5d0 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -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: diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index 6838a7d..aa38813 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -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: diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index 51d7419..10ee175 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b541162..7bf1bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 6dfc5fb..35feeb9 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -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, diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index c688984..4011d18 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -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) diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 714acf7..68ce11f 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -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", diff --git a/src/pyro_mcp/project_startup.py b/src/pyro_mcp/project_startup.py new file mode 100644 index 0000000..102d631 --- /dev/null +++ b/src/pyro_mcp/project_startup.py @@ -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}" diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index daf1820..7206ba9 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -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: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 6419792..1ba41bb 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -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) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index e25be5f..4384d97 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -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, diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 01c1338..40e0ed9 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -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") diff --git a/tests/test_api.py b/tests/test_api.py index 8772754..29bda64 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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( diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b276ab..4cd085c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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( diff --git a/tests/test_project_startup.py b/tests/test_project_startup.py new file mode 100644 index 0000000..667c05f --- /dev/null +++ b/tests/test_project_startup.py @@ -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" diff --git a/tests/test_server.py b/tests/test_server.py index 1481149..f36c1ff 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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", diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index 1a5a836..b02fd10 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -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 = ( diff --git a/uv.lock b/uv.lock index 95bd68b..eb006fe 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.0.0" +version = "4.1.0" source = { editable = "." } dependencies = [ { name = "mcp" },