Add opinionated MCP modes for workspace workflows

Introduce explicit repro-fix, inspect, cold-start, and review-eval modes across the MCP server, CLI, and host helpers, with canonical mode-to-tool mappings, narrowed schemas, and mode-specific tool descriptions on top of the existing workspace runtime.

Reposition the docs, host onramps, and use-case recipes so named modes are the primary user-facing startup story while the generic no-mode workspace-core path remains the escape hatch, and update the shared smoke runner to validate repro-fix and cold-start through mode-backed servers.

Validation: UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_api.py tests/test_server.py tests/test_host_helpers.py tests/test_public_contract.py tests/test_cli.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed make smoke-repro-fix-loop smoke-cold-start-validation outside the sandbox.
This commit is contained in:
Thales Maciel 2026-03-13 20:00:35 -03:00
parent dc86d84e96
commit d0cf6d8f21
33 changed files with 1034 additions and 274 deletions

View file

@ -8,7 +8,12 @@ from typing import Any, Literal, cast
from mcp.server.fastmcp import FastMCP
from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_MODES,
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
@ -30,12 +35,77 @@ from pyro_mcp.vm_manager import (
)
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"]
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
"workspace-full": PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
}
_MODE_TOOLS: dict[str, tuple[str, ...]] = {
"repro-fix": PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
"inspect": PUBLIC_MCP_INSPECT_MODE_TOOLS,
"cold-start": PUBLIC_MCP_COLD_START_MODE_TOOLS,
"review-eval": PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
}
_MODE_CREATE_INTENT: dict[str, str] = {
"repro-fix": "to reproduce a failure, patch files, rerun, diff, export, and reset",
"inspect": "to inspect suspicious or unfamiliar code with the smallest persistent surface",
"cold-start": (
"to validate a fresh repo, keep one service alive, and export a "
"validation report"
),
"review-eval": "to review interactively, checkpoint work, and export the final report",
}
_MODE_TOOL_DESCRIPTIONS: dict[str, dict[str, str]] = {
"repro-fix": {
"workspace_file_read": "Read one workspace file while investigating the broken state.",
"workspace_file_write": "Write one workspace file directly as part of the fix loop.",
"workspace_patch_apply": "Apply a structured text patch inside the workspace fix loop.",
"workspace_export": "Export the fixed result or patch-ready file back to the host.",
"workspace_summary": (
"Summarize the current repro/fix session for review before export "
"or reset."
),
},
"inspect": {
"workspace_file_list": "List suspicious files under the current workspace path.",
"workspace_file_read": "Read one suspicious or unfamiliar workspace file.",
"workspace_export": (
"Export only the inspection report or artifact you chose to "
"materialize."
),
"workspace_summary": "Summarize the current inspection session and its exported results.",
},
"cold-start": {
"workspace_create": "Create and start a persistent workspace for cold-start validation.",
"workspace_export": "Export the validation report or other final host-visible result.",
"workspace_summary": (
"Summarize the current validation session, service state, and "
"exports."
),
"service_start": "Start a named validation or app service and wait for typed readiness.",
"service_list": "List running and exited validation services in the current workspace.",
"service_status": "Inspect one validation service and its readiness outcome.",
"service_logs": "Read stdout and stderr from one validation service.",
"service_stop": "Stop one validation service in the workspace.",
},
"review-eval": {
"workspace_create": (
"Create and start a persistent workspace for interactive review "
"or evaluation."
),
"workspace_summary": "Summarize the current review session before exporting or resetting.",
"shell_open": "Open an interactive review shell inside the workspace.",
"shell_read": "Read chat-friendly PTY output from the current review shell.",
"shell_write": "Send one line of input to the current review shell.",
"shell_signal": "Interrupt or terminate the current review shell process group.",
"shell_close": "Close the current review shell.",
"snapshot_create": "Create a checkpoint before the review branch diverges.",
"snapshot_list": "List the baseline plus named review checkpoints.",
"snapshot_delete": "Delete one named review checkpoint.",
},
}
def _validate_mcp_profile(profile: str) -> McpToolProfile:
@ -45,16 +115,42 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
return cast(McpToolProfile, profile)
def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str:
def _validate_workspace_mode(mode: str) -> WorkspaceUseCaseMode:
if mode not in PUBLIC_MCP_MODES:
expected = ", ".join(PUBLIC_MCP_MODES)
raise ValueError(f"unknown workspace mode {mode!r}; expected one of: {expected}")
return cast(WorkspaceUseCaseMode, mode)
def _workspace_create_description(
startup_source: ProjectStartupSource | None,
*,
mode: WorkspaceUseCaseMode | None = None,
) -> str:
if mode is not None:
prefix = (
"Create and start a persistent workspace "
f"{_MODE_CREATE_INTENT[mode]}."
)
else:
prefix = "Create and start a persistent workspace."
if startup_source is None:
return "Create and start a persistent workspace."
return prefix
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}."
)
return prefix
return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}."
def _tool_description(
tool_name: str,
*,
mode: WorkspaceUseCaseMode | None,
fallback: str,
) -> str:
if mode is None:
return fallback
return _MODE_TOOL_DESCRIPTIONS.get(mode, {}).get(tool_name, fallback)
class Pyro:
@ -487,6 +583,7 @@ class Pyro:
self,
*,
profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
@ -502,13 +599,20 @@ class Pyro:
`repo_url`, and `no_project_source` override that behavior explicitly.
"""
normalized_profile = _validate_mcp_profile(profile)
normalized_mode = _validate_workspace_mode(mode) if mode is not None else None
if normalized_mode is not None and normalized_profile != "workspace-core":
raise ValueError("mode and profile are mutually exclusive")
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])
enabled_tools = set(
_MODE_TOOLS[normalized_mode]
if normalized_mode is not None
else _PROFILE_TOOLS[normalized_profile]
)
server = FastMCP(name="pyro_mcp")
def _enabled(tool_name: str) -> bool:
@ -621,7 +725,10 @@ class Pyro:
return self.reap_expired()
if _enabled("workspace_create"):
workspace_create_description = _workspace_create_description(startup_source)
workspace_create_description = _workspace_create_description(
startup_source,
mode=normalized_mode,
)
def _create_workspace_from_server_defaults(
*,
@ -668,7 +775,7 @@ class Pyro:
_prepared_seed=prepared_seed,
)
if normalized_profile == "workspace-core":
if normalized_mode is not None or normalized_profile == "workspace-core":
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_core(
@ -749,15 +856,21 @@ class Pyro:
)
if _enabled("workspace_exec"):
if normalized_profile == "workspace-core":
if normalized_mode is not None or normalized_profile == "workspace-core":
@server.tool(name="workspace_exec")
@server.tool(
name="workspace_exec",
description=_tool_description(
"workspace_exec",
mode=normalized_mode,
fallback="Run one command inside an existing persistent workspace.",
),
)
async def workspace_exec_core(
workspace_id: str,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace(
workspace_id,
command=command,
@ -767,14 +880,20 @@ class Pyro:
else:
@server.tool(name="workspace_exec")
@server.tool(
name="workspace_exec",
description=_tool_description(
"workspace_exec",
mode=normalized_mode,
fallback="Run one command inside an existing persistent workspace.",
),
)
async def workspace_exec_full(
workspace_id: str,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace(
workspace_id,
command=command,
@ -823,20 +942,30 @@ class Pyro:
if _enabled("workspace_summary"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_summary",
mode=normalized_mode,
fallback="Summarize the current workspace session for human review.",
)
)
async def workspace_summary(workspace_id: str) -> dict[str, Any]:
"""Summarize the current workspace session for human review."""
return self.summarize_workspace(workspace_id)
if _enabled("workspace_export"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_export",
mode=normalized_mode,
fallback="Export one file or directory from `/workspace` back to the host.",
)
)
async def workspace_export(
workspace_id: str,
path: str,
output_path: str,
) -> dict[str, Any]:
"""Export one file or directory from `/workspace` back to the host."""
return self.export_workspace(workspace_id, path, output_path=output_path)
if _enabled("workspace_diff"):
@ -848,13 +977,21 @@ class Pyro:
if _enabled("workspace_file_list"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_list",
mode=normalized_mode,
fallback=(
"List metadata for files and directories under one "
"live workspace path."
),
)
)
async def workspace_file_list(
workspace_id: str,
path: str = "/workspace",
recursive: bool = False,
) -> dict[str, Any]:
"""List metadata for files and directories under one live workspace path."""
return self.list_workspace_files(
workspace_id,
path=path,
@ -863,13 +1000,18 @@ class Pyro:
if _enabled("workspace_file_read"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_read",
mode=normalized_mode,
fallback="Read one regular text file from a live workspace path.",
)
)
async def workspace_file_read(
workspace_id: str,
path: str,
max_bytes: int = 65536,
) -> dict[str, Any]:
"""Read one regular text file from a live workspace path."""
return self.read_workspace_file(
workspace_id,
path,
@ -878,13 +1020,18 @@ class Pyro:
if _enabled("workspace_file_write"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_write",
mode=normalized_mode,
fallback="Create or replace one regular text file under `/workspace`.",
)
)
async def workspace_file_write(
workspace_id: str,
path: str,
text: str,
) -> dict[str, Any]:
"""Create or replace one regular text file under `/workspace`."""
return self.write_workspace_file(
workspace_id,
path,
@ -893,12 +1040,17 @@ class Pyro:
if _enabled("workspace_patch_apply"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_patch_apply",
mode=normalized_mode,
fallback="Apply a unified text patch inside one live workspace.",
)
)
async def workspace_patch_apply(
workspace_id: str,
patch: str,
) -> dict[str, Any]:
"""Apply a unified text patch inside one live workspace."""
return self.apply_workspace_patch(
workspace_id,
patch=patch,
@ -976,27 +1128,62 @@ class Pyro:
return self.reset_workspace(workspace_id, snapshot=snapshot)
if _enabled("shell_open"):
if normalized_mode == "review-eval":
@server.tool()
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Open a persistent interactive shell inside one workspace."""
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=secret_env,
@server.tool(
description=_tool_description(
"shell_open",
mode=normalized_mode,
fallback="Open a persistent interactive shell inside one workspace.",
)
)
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
) -> dict[str, Any]:
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=None,
)
else:
@server.tool(
description=_tool_description(
"shell_open",
mode=normalized_mode,
fallback="Open a persistent interactive shell inside one workspace.",
)
)
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=secret_env,
)
if _enabled("shell_read"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_read",
mode=normalized_mode,
fallback="Read merged PTY output from a workspace shell.",
)
)
async def shell_read(
workspace_id: str,
shell_id: str,
@ -1005,7 +1192,6 @@ class Pyro:
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
"""Read merged PTY output from a workspace shell."""
return self.read_shell(
workspace_id,
shell_id,
@ -1017,14 +1203,19 @@ class Pyro:
if _enabled("shell_write"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_write",
mode=normalized_mode,
fallback="Write text input to a persistent workspace shell.",
)
)
async def shell_write(
workspace_id: str,
shell_id: str,
input: str,
append_newline: bool = True,
) -> dict[str, Any]:
"""Write text input to a persistent workspace shell."""
return self.write_shell(
workspace_id,
shell_id,
@ -1034,13 +1225,18 @@ class Pyro:
if _enabled("shell_signal"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_signal",
mode=normalized_mode,
fallback="Send a signal to the shell process group.",
)
)
async def shell_signal(
workspace_id: str,
shell_id: str,
signal_name: str = "INT",
) -> dict[str, Any]:
"""Send a signal to the shell process group."""
return self.signal_shell(
workspace_id,
shell_id,
@ -1049,74 +1245,142 @@ class Pyro:
if _enabled("shell_close"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_close",
mode=normalized_mode,
fallback="Close a persistent workspace shell.",
)
)
async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]:
"""Close a persistent workspace shell."""
return self.close_shell(workspace_id, shell_id)
if _enabled("service_start"):
if normalized_mode == "cold-start":
@server.tool()
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
secret_env: dict[str, str] | None = None,
published_ports: list[dict[str, int | None]] | None = None,
) -> dict[str, Any]:
"""Start a named long-running service inside a workspace."""
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=secret_env,
published_ports=published_ports,
@server.tool(
description=_tool_description(
"service_start",
mode=normalized_mode,
fallback="Start a named long-running service inside a workspace.",
)
)
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
) -> dict[str, Any]:
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=None,
published_ports=None,
)
else:
@server.tool(
description=_tool_description(
"service_start",
mode=normalized_mode,
fallback="Start a named long-running service inside a workspace.",
)
)
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
secret_env: dict[str, str] | None = None,
published_ports: list[dict[str, int | None]] | None = None,
) -> dict[str, Any]:
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=secret_env,
published_ports=published_ports,
)
if _enabled("service_list"):
@server.tool()
@server.tool(
description=_tool_description(
"service_list",
mode=normalized_mode,
fallback="List named services in one workspace.",
)
)
async def service_list(workspace_id: str) -> dict[str, Any]:
"""List named services in one workspace."""
return self.list_services(workspace_id)
if _enabled("service_status"):
@server.tool()
@server.tool(
description=_tool_description(
"service_status",
mode=normalized_mode,
fallback="Inspect one named workspace service.",
)
)
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Inspect one named workspace service."""
return self.status_service(workspace_id, service_name)
if _enabled("service_logs"):
@server.tool()
@server.tool(
description=_tool_description(
"service_logs",
mode=normalized_mode,
fallback="Read persisted stdout/stderr for one workspace service.",
)
)
async def service_logs(
workspace_id: str,
service_name: str,
tail_lines: int = 200,
all: bool = False,
) -> dict[str, Any]:
"""Read persisted stdout/stderr for one workspace service."""
return self.logs_service(
workspace_id,
service_name,
@ -1126,9 +1390,14 @@ class Pyro:
if _enabled("service_stop"):
@server.tool()
@server.tool(
description=_tool_description(
"service_stop",
mode=normalized_mode,
fallback="Stop one running service in a workspace.",
)
)
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Stop one running service in a workspace."""
return self.stop_service(workspace_id, service_name)
if _enabled("workspace_delete"):

View file

@ -11,8 +11,8 @@ from textwrap import dedent
from typing import Any, cast
from pyro_mcp import __version__
from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
from pyro_mcp.demo import run_demo
from pyro_mcp.host_helpers import (
HostDoctorEntry,
@ -181,6 +181,7 @@ def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
return HostServerConfig(
installed_package=bool(getattr(args, "installed_package", False)),
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)),
project_path=getattr(args, "project_path", None),
repo_url=getattr(args, "repo_url", None),
repo_ref=getattr(args, "repo_ref", None),
@ -856,11 +857,17 @@ def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
)
parser.add_argument(
profile_group = parser.add_mutually_exclusive_group()
profile_group.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help="Server profile to configure for the host helper flow.",
help="Explicit profile for the host helper flow when not using a named mode.",
)
profile_group.add_argument(
"--mode",
choices=PUBLIC_MCP_MODES,
help="Opinionated use-case mode for the host helper flow.",
)
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument(
@ -1017,6 +1024,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host connect claude-code
pyro host connect claude-code --mode cold-start
pyro host connect codex --project-path /abs/path/to/repo
pyro host print-config opencode
pyro host repair opencode
@ -1037,7 +1045,9 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host connect claude-code
pyro host connect claude-code --mode cold-start
pyro host connect codex --installed-package
pyro host connect codex --mode repro-fix
pyro host connect codex --project-path /abs/path/to/repo
"""
),
@ -1061,6 +1071,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host print-config opencode
pyro host print-config opencode --mode repro-fix
pyro host print-config opencode --output ./opencode.json
pyro host print-config opencode --project-path /abs/path/to/repo
"""
@ -1089,6 +1100,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host doctor
pyro host doctor --mode inspect
pyro host doctor --project-path /abs/path/to/repo
pyro host doctor --installed-package
"""
@ -1112,6 +1124,7 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro host repair claude-code
pyro host repair claude-code --mode review-eval
pyro host repair codex --project-path /abs/path/to/repo
pyro host repair opencode
"""
@ -1141,6 +1154,8 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro mcp serve
pyro mcp serve --mode repro-fix
pyro mcp serve --mode inspect
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
pyro mcp serve --profile vm-run
@ -1155,19 +1170,27 @@ def _build_parser() -> argparse.ArgumentParser:
help="Run the MCP server over stdio.",
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. When launched from inside a Git checkout, it "
"also seeds the first workspace from that repo by default."
"serve` starts the generic `workspace-core` path. Use `--mode` to "
"start from an opinionated use-case flow, or `--profile` to choose "
"a generic profile directly. 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:
Generic default path:
pyro mcp serve
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
Named modes:
repro-fix: structured edit / diff / export / reset loop
inspect: smallest persistent inspection surface
cold-start: validation plus service readiness
review-eval: shell plus snapshots for review workflows
Profiles:
workspace-core: default for normal persistent chat editing
workspace-core: default for normal persistent chat editing and the
recommended first profile for most chat hosts
vm-run: smallest one-shot-only surface
workspace-full: larger opt-in surface for shells, services,
snapshots, secrets, network policy, and disk tools
@ -1178,22 +1201,28 @@ def _build_parser() -> argparse.ArgumentParser:
- 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.
Use --mode when one named use case already matches the job. Fall
back to the generic no-mode path when the mode feels too narrow.
"""
),
formatter_class=_HelpFormatter,
)
mcp_serve_parser.add_argument(
mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_profile_group.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help=(
"Expose only one model-facing tool profile. `workspace-core` is "
"the default and recommended first profile for most chat hosts; "
"`workspace-full` is the larger opt-in profile."
"Expose one generic model-facing tool profile instead of a named mode. "
"`workspace-core` is the generic default and `workspace-full` is the "
"larger opt-in profile."
),
)
mcp_profile_group.add_argument(
"--mode",
choices=PUBLIC_MCP_MODES,
help="Expose one opinionated use-case mode instead of the generic profile path.",
)
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_source_group.add_argument(
"--project-path",
@ -2794,6 +2823,7 @@ def main() -> None:
if args.command == "mcp":
pyro.create_server(
profile=args.profile,
mode=getattr(args, "mode", None),
project_path=args.project_path,
repo_url=args.repo_url,
repo_ref=args.repo_ref,

View file

@ -8,6 +8,7 @@ PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
PUBLIC_CLI_HOST_COMMON_FLAGS = (
"--installed-package",
"--mode",
"--profile",
"--project-path",
"--repo-url",
@ -20,6 +21,7 @@ PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--mode",
"--profile",
"--project-path",
"--repo-url",
@ -140,6 +142,7 @@ PUBLIC_CLI_RUN_FLAGS = (
"--json",
)
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
PUBLIC_SDK_METHODS = (
"apply_workspace_patch",
@ -258,4 +261,77 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_file_write",
"workspace_list",
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_INSPECT_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_COLD_START_MODE_TOOLS = (
"service_list",
"service_logs",
"service_start",
"service_status",
"service_stop",
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = (
"shell_close",
"shell_open",
"shell_read",
"shell_signal",
"shell_write",
"snapshot_create",
"snapshot_delete",
"snapshot_list",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS

View file

@ -11,7 +11,7 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Literal
from pyro_mcp.api import McpToolProfile
from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
@ -26,6 +26,7 @@ HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
class HostServerConfig:
installed_package: bool = False
profile: McpToolProfile = "workspace-core"
mode: WorkspaceUseCaseMode | None = None
project_path: str | None = None
repo_url: str | None = None
repo_ref: str | None = None
@ -60,6 +61,8 @@ def _host_binary(host: str) -> str:
def _canonical_server_command(config: HostServerConfig) -> list[str]:
if config.mode is not None and config.profile != "workspace-core":
raise ValueError("--mode and --profile are mutually exclusive")
if config.project_path is not None and config.repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if config.no_project_source and (
@ -76,7 +79,9 @@ def _canonical_server_command(config: HostServerConfig) -> list[str]:
command = ["pyro", "mcp", "serve"]
if not config.installed_package:
command = ["uvx", "--from", "pyro-mcp", *command]
if config.profile != "workspace-core":
if config.mode is not None:
command.extend(["--mode", config.mode])
elif config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
@ -97,7 +102,9 @@ def _repair_command(host: str, config: HostServerConfig, *, config_path: Path |
command = ["pyro", "host", "repair", host]
if config.installed_package:
command.append("--installed-package")
if config.profile != "workspace-core":
if config.mode is not None:
command.extend(["--mode", config.mode])
elif config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])

View file

@ -6,7 +6,7 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP
from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.vm_manager import VmManager
@ -14,6 +14,7 @@ def create_server(
manager: VmManager | None = None,
*,
profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
@ -21,7 +22,8 @@ def create_server(
) -> FastMCP:
"""Create and return a configured MCP server instance.
`workspace-core` is the default stable chat-host profile in 4.x. Use
Bare server creation uses the generic `workspace-core` path in 4.x. Use
`mode=...` for one of the named use-case surfaces, or
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory for
@ -29,6 +31,7 @@ def create_server(
"""
return Pyro(manager=manager).create_server(
profile=profile,
mode=mode,
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,

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.3.0"
DEFAULT_CATALOG_VERSION = "4.4.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.3.0,<5.0.0"
compatibility: str = ">=4.4.0,<5.0.0"
@dataclass(frozen=True)

View file

@ -29,7 +29,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC
class WorkspaceUseCaseRecipe:
scenario: str
title: str
profile: Literal["workspace-core", "workspace-full"]
mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"]
smoke_target: str
doc_path: str
summary: str
@ -39,7 +39,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="cold-start-validation",
title="Cold-Start Repo Validation",
profile="workspace-full",
mode="cold-start",
smoke_target="smoke-cold-start-validation",
doc_path="docs/use-cases/cold-start-repo-validation.md",
summary=(
@ -50,7 +50,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="repro-fix-loop",
title="Repro Plus Fix Loop",
profile="workspace-core",
mode="repro-fix",
smoke_target="smoke-repro-fix-loop",
doc_path="docs/use-cases/repro-fix-loop.md",
summary=(
@ -61,7 +61,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="parallel-workspaces",
title="Parallel Isolated Workspaces",
profile="workspace-core",
mode="repro-fix",
smoke_target="smoke-parallel-workspaces",
doc_path="docs/use-cases/parallel-workspaces.md",
summary=(
@ -72,7 +72,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="untrusted-inspection",
title="Unsafe Or Untrusted Code Inspection",
profile="workspace-core",
mode="inspect",
smoke_target="smoke-untrusted-inspection",
doc_path="docs/use-cases/untrusted-inspection.md",
summary=(
@ -83,7 +83,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="review-eval",
title="Review And Evaluation Workflows",
profile="workspace-full",
mode="review-eval",
smoke_target="smoke-review-eval",
doc_path="docs/use-cases/review-eval-workflows.md",
summary=(
@ -141,11 +141,12 @@ def _create_project_aware_workspace(
*,
environment: str,
project_path: Path,
mode: Literal["repro-fix", "cold-start"],
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)
server = pyro.create_server(mode=mode, project_path=project_path)
return _extract_structured_tool_result(
await server.call_tool(
"workspace_create",
@ -194,14 +195,19 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
)
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,
mode="cold-start",
name="cold-start-validation",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"},
)
workspace_id = str(created["workspace_id"])
_log(f"cold-start-validation workspace_id={workspace_id}")
workspace_seed = created["workspace_seed"]
assert isinstance(workspace_seed, dict), created
assert workspace_seed["origin_kind"] == "project_path", created
validation = pyro.exec_workspace(workspace_id, command="sh validate.sh")
assert int(validation["exit_code"]) == 0, validation
assert str(validation["stdout"]) == "validated\n", validation
@ -259,6 +265,7 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
pyro,
environment=environment,
project_path=seed_dir,
mode="repro-fix",
name="repro-fix-loop",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
)
@ -495,7 +502,7 @@ def run_workspace_use_case_scenario(
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
for scenario_name in scenario_names:
recipe = _RECIPE_BY_SCENARIO[scenario_name]
_log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}")
_log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}")
scenario_root = root / scenario_name
scenario_root.mkdir(parents=True, exist_ok=True)
runner = _SCENARIO_RUNNERS[scenario_name]