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

@ -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,