diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f26bd..da17211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 3.4.0 + +- Added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and + `workspace-full` so chat hosts can expose only the right model-facing surface. +- Added `--profile` to `pyro mcp serve` plus matching `profile=` support on + `Pyro.create_server()` and the package-level `create_server()` factory. +- Added canonical `workspace-core` integration examples for OpenAI Responses + and MCP client configuration, and narrowed the `workspace-core` schemas so + secrets, network policy, shells, services, snapshots, and disk tools stay out + of the default persistent chat profile. + ## 3.3.0 - Added first-class workspace naming and discovery across the CLI, Python SDK, and MCP server diff --git a/README.md b/README.md index c6bbe92..c5841da 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ It exposes the same runtime in three public forms: - 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) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -- What's new in 3.3.0: [CHANGELOG.md#330](CHANGELOG.md#330) +- What's new in 3.4.0: [CHANGELOG.md#340](CHANGELOG.md#340) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Integration targets: [docs/integrations.md](docs/integrations.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) @@ -59,7 +59,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 3.3.0 +Catalog version: 3.4.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -189,7 +189,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 3.3.0 +Catalog version: 3.4.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. @@ -305,7 +305,7 @@ Persistent workspaces start in `/workspace` and keep command history until you d machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import -later host-side changes into a started workspace. Sync is non-atomic in `3.3.0`; if it fails +later host-side changes into a started workspace. Sync is non-atomic in `3.4.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use @@ -333,7 +333,7 @@ The public user-facing interface is `pyro` and `Pyro`. After the CLI validation - `pyro` for direct CLI usage, including one-shot `run` and persistent `workspace` workflows - `from pyro_mcp import Pyro` for Python orchestration -- `pyro mcp serve` for MCP clients +- `pyro mcp serve --profile workspace-core` for MCP clients Command forms: @@ -399,9 +399,15 @@ Use `--allow-host-compat` only if you explicitly want host execution. Run the MCP server after the CLI path above works: ```bash -pyro mcp serve +pyro mcp serve --profile workspace-core ``` +Profile progression for chat hosts: + +- `vm-run`: expose only `vm_run` +- `workspace-core`: expose the practical persistent chat loop +- `workspace-full`: expose shells, services, snapshots, secrets, network policy, and disk tools + Run the deterministic demo: ```bash @@ -520,6 +526,12 @@ Persistent workspace tools: - `workspace_logs(workspace_id)` - `workspace_delete(workspace_id)` +Recommended MCP tool profiles: + +- `vm-run`: `vm_run` only +- `workspace-core`: `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, `workspace_delete` +- `workspace-full`: the complete stable MCP surface above + ## Integration Examples - Python one-shot SDK example: [examples/python_run.py](examples/python_run.py) @@ -529,6 +541,7 @@ Persistent workspace tools: - Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json) - Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json) - OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py) +- OpenAI Responses `workspace-core` example: [examples/openai_responses_workspace_core.py](examples/openai_responses_workspace_core.py) - LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py) - Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py) diff --git a/docs/first-run.md b/docs/first-run.md index 4d0f033..cf41d68 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 3.3.0 +Catalog version: 3.4.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. @@ -116,7 +116,7 @@ $ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TO $ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' $ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports $ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -$ uvx --from pyro-mcp pyro mcp serve +$ uvx --from pyro-mcp pyro mcp serve --profile workspace-core ``` `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. @@ -252,7 +252,7 @@ State: started Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use `pyro workspace sync push` when you need to import later host-side changes into a started -workspace. Sync is non-atomic in `3.3.0`; if it fails partway through, prefer `pyro workspace reset` +workspace. Sync is non-atomic in `3.4.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current `/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the diff --git a/docs/install.md b/docs/install.md index c1c1475..b40d593 100644 --- a/docs/install.md +++ b/docs/install.md @@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 3.3.0 +Catalog version: 3.4.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. @@ -224,7 +224,7 @@ After the CLI path works, you can move on to: - interactive shells: `pyro workspace shell open WORKSPACE_ID` - long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` - localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` -- MCP: `pyro mcp serve` +- MCP: `pyro mcp serve --profile workspace-core` - Python SDK: `from pyro_mcp import Pyro` - Demos: `pyro demo` or `pyro demo --network` @@ -274,7 +274,7 @@ Workspace commands default to the persistent `/workspace` directory inside the g the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync -is non-atomic in `3.3.0`; if it fails partway through, prefer `pyro workspace reset` to recover +is non-atomic in `3.4.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use diff --git a/docs/integrations.md b/docs/integrations.md index cceb5f1..866044b 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -7,8 +7,10 @@ CLI path in [install.md](install.md) or [first-run.md](first-run.md). ## Recommended Default -Use `vm_run` first for one-shot commands, then move to the stable workspace surface when the -agent needs to inhabit one sandbox across multiple calls. +Use `vm_run` first for one-shot commands, then move to `workspace-core` when the +agent needs to inhabit one sandbox across multiple calls. Only promote the chat +surface to `workspace-full` when it truly needs shells, services, snapshots, +secrets, network policy, or disk tools. That keeps the model-facing contract small: @@ -17,8 +19,11 @@ That keeps the model-facing contract small: - one ephemeral VM - automatic cleanup -Move to `workspace_*` when the agent needs repeated commands, shells, services, snapshots, reset, -diff, or export in one stable workspace across multiple calls. +Profile progression: + +- `vm-run`: one-shot only +- `workspace-core`: persistent workspace create/list/update/status/sync/exec/logs/file ops/diff/export/reset/delete +- `workspace-full`: the full stable workspace surface, including shells, services, snapshots, secrets, network policy, and disk tools ## OpenAI Responses API @@ -30,20 +35,14 @@ Best when: Recommended surface: -- `vm_run` -- `workspace_create(name=..., labels=...)` + `workspace_list` + `workspace_update` when the agent needs to rediscover or retag long-lived workspaces between turns -- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state -- `workspace_file_list` / `workspace_file_read` / `workspace_file_write` / `workspace_patch_apply` when the agent needs model-native file inspection and text edits inside one live workspace -- `workspace_create(..., secrets=...)` + `workspace_exec(..., secret_env=...)` when the workspace needs private tokens or authenticated setup -- `workspace_create(..., network_policy="egress+published-ports")` + `start_service(..., published_ports=[...])` when the host must probe one workspace service -- `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer -- `stop_workspace(...)` + `list_workspace_disk(...)` / `read_workspace_disk(...)` / `export_workspace_disk(...)` when one stopped guest-backed workspace needs offline inspection or a raw ext4 copy -- `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace -- `open_shell(..., secret_env=...)` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace +- `vm_run` for one-shot loops +- the `workspace-core` tool set for the normal persistent chat loop +- the `workspace-full` tool set only when the host explicitly needs advanced workspace capabilities Canonical example: - [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py) +- [examples/openai_responses_workspace_core.py](../examples/openai_responses_workspace_core.py) ## MCP Clients @@ -55,7 +54,13 @@ Best when: Recommended entrypoint: -- `pyro mcp serve` +- `pyro mcp serve --profile workspace-core` + +Profile progression: + +- `pyro mcp serve --profile vm-run` for the smallest one-shot surface +- `pyro mcp serve --profile workspace-core` for the normal persistent chat loop +- `pyro mcp serve --profile workspace-full` only when the model truly needs advanced workspace tools Starter config: diff --git a/docs/public-contract.md b/docs/public-contract.md index cd5167e..ad96936 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -82,6 +82,7 @@ Behavioral guarantees: - `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. - `pyro workspace create --name NAME --label KEY=VALUE` attaches human-oriented discovery metadata without changing the stable `workspace_id`. - `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports. +- `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}` narrows the model-facing MCP surface without changing runtime behavior. - `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`. - `pyro workspace list` returns persisted workspaces sorted by most recent `last_activity_at`. - `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace. @@ -125,8 +126,8 @@ Primary facade: Supported public entrypoints: -- `create_server()` -- `Pyro.create_server()` +- `create_server(profile="workspace-full")` +- `Pyro.create_server(profile="workspace-full")` - `Pyro.list_environments()` - `Pyro.pull_environment(environment)` - `Pyro.inspect_environment(environment)` @@ -176,7 +177,7 @@ Supported public entrypoints: Stable public method names: -- `create_server()` +- `create_server(profile="workspace-full")` - `list_environments()` - `pull_environment(environment)` - `inspect_environment(environment)` @@ -265,6 +266,18 @@ Behavioral defaults: ## MCP Contract +Stable MCP profiles: + +- `vm-run`: exposes only `vm_run` +- `workspace-core`: exposes `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, and `workspace_delete` +- `workspace-full`: exposes the complete stable MCP surface below + +Behavioral defaults: + +- `pyro mcp serve` and `create_server()` default to `workspace-full`. +- `workspace-core` narrows `workspace_create` by omitting `network_policy` and `secrets`. +- `workspace-core` narrows `workspace_exec` by omitting `secret_env`. + Primary tool: - `vm_run` diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index a7d98f4..2293153 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 `3.3.0`: +Current baseline is `3.4.0`: - the stable workspace contract exists across CLI, SDK, and MCP - one-shot `pyro run` still exists as the narrow entrypoint @@ -47,7 +47,7 @@ More concretely, the model should not need to: 1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done 2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done -3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) +3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done 4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) 5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md) @@ -58,6 +58,9 @@ Completed so far: - `3.3.0` added workspace names, key/value labels, `workspace list`, `workspace update`, and `last_activity_at` tracking so humans and chat-driven agents can rediscover and resume the right workspace without external notes. +- `3.4.0` added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and + `workspace-full`, plus canonical profile-based OpenAI and MCP examples so chat hosts can start + narrow and widen only when needed. ## Expected Outcome diff --git a/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md b/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md index 50a3a9c..7db490c 100644 --- a/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md +++ b/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md @@ -1,6 +1,6 @@ # `3.4.0` Tool Profiles And Canonical Chat Flows -Status: Planned +Status: Done ## Goal diff --git a/examples/claude_desktop_mcp_config.json b/examples/claude_desktop_mcp_config.json index 81f0bd6..f828de0 100644 --- a/examples/claude_desktop_mcp_config.json +++ b/examples/claude_desktop_mcp_config.json @@ -2,7 +2,7 @@ "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-core"] } } } diff --git a/examples/cursor_mcp_config.json b/examples/cursor_mcp_config.json index 81f0bd6..f828de0 100644 --- a/examples/cursor_mcp_config.json +++ b/examples/cursor_mcp_config.json @@ -2,7 +2,7 @@ "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-core"] } } } diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index 9d47ffc..74e1ce1 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -9,7 +9,7 @@ Generic stdio MCP configuration using `uvx`: "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-core"] } } } @@ -22,15 +22,21 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi "mcpServers": { "pyro": { "command": "pyro", - "args": ["mcp", "serve"] + "args": ["mcp", "serve", "--profile", "workspace-core"] } } } ``` -Primary tool for most agents: +Profile progression: -- `vm_run` +- `vm-run`: expose only `vm_run` +- `workspace-core`: the default persistent chat profile +- `workspace-full`: shells, services, snapshots, secrets, network policy, and disk tools + +Primary profile for most agents: + +- `workspace-core` Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls. diff --git a/examples/openai_responses_workspace_core.py b/examples/openai_responses_workspace_core.py new file mode 100644 index 0000000..0ef9c27 --- /dev/null +++ b/examples/openai_responses_workspace_core.py @@ -0,0 +1,89 @@ +"""Canonical OpenAI Responses API integration centered on workspace-core. + +Requirements: +- `pip install openai` or `uv add openai` +- `OPENAI_API_KEY` + +This example mirrors the `workspace-core` MCP profile by deriving tool schemas +from `Pyro.create_server(profile="workspace-core")` and dispatching tool calls +back through that same profiled server. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any, cast + +from pyro_mcp import Pyro + +DEFAULT_MODEL = "gpt-5" + + +def _tool_to_openai(tool: Any) -> dict[str, Any]: + return { + "type": "function", + "name": str(tool.name), + "description": str(getattr(tool, "description", "") or ""), + "strict": True, + "parameters": dict(tool.inputSchema), + } + + +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_openai_workspace_core_example(*, prompt: str, model: str = DEFAULT_MODEL) -> str: + from openai import OpenAI # type: ignore[import-not-found] + + pyro = Pyro() + server = pyro.create_server(profile="workspace-core") + tools = [_tool_to_openai(tool) for tool in await server.list_tools()] + client = OpenAI() + input_items: list[dict[str, Any]] = [{"role": "user", "content": prompt}] + + while True: + response = client.responses.create( + model=model, + input=input_items, + tools=tools, + ) + input_items.extend(response.output) + + tool_calls = [item for item in response.output if item.type == "function_call"] + if not tool_calls: + return str(response.output_text) + + for tool_call in tool_calls: + result = _extract_structured( + await server.call_tool(tool_call.name, json.loads(tool_call.arguments)) + ) + input_items.append( + { + "type": "function_call_output", + "call_id": tool_call.call_id, + "output": json.dumps(result, sort_keys=True), + } + ) + + +def main() -> None: + model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL) + prompt = ( + "Use the workspace-core tools to create a Debian 12 workspace named " + "`chat-fix`, write `app.py` with `print(\"fixed\")`, run it with " + "`python3 app.py`, export the file to `./app.py`, then delete the workspace. " + "Do not use one-shot vm_run for this request." + ) + print(asyncio.run(run_openai_workspace_core_example(prompt=prompt, model=model))) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 81c0329..ff5944a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "3.3.0" +version = "3.4.0" description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 28ba252..df7f0a4 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -3,10 +3,16 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, Literal, cast from mcp.server.fastmcp import FastMCP +from pyro_mcp.contract import ( + PUBLIC_MCP_PROFILES, + PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, + PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, + PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, +) from pyro_mcp.vm_manager import ( DEFAULT_ALLOW_HOST_COMPAT, DEFAULT_MEM_MIB, @@ -17,6 +23,21 @@ from pyro_mcp.vm_manager import ( VmManager, ) +McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"] + +_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, +} + + +def _validate_mcp_profile(profile: str) -> McpToolProfile: + if profile not in PUBLIC_MCP_PROFILES: + expected = ", ".join(PUBLIC_MCP_PROFILES) + raise ValueError(f"unknown MCP profile {profile!r}; expected one of: {expected}") + return cast(McpToolProfile, profile) + class Pyro: """High-level facade over the ephemeral VM runtime.""" @@ -437,440 +458,580 @@ class Pyro: allow_host_compat=allow_host_compat, ) - def create_server(self) -> FastMCP: + def create_server(self, *, profile: McpToolProfile = "workspace-full") -> FastMCP: + normalized_profile = _validate_mcp_profile(profile) + enabled_tools = set(_PROFILE_TOOLS[normalized_profile]) server = FastMCP(name="pyro_mcp") - @server.tool() - async def vm_run( - environment: str, - command: str, - vcpu_count: int = DEFAULT_VCPU_COUNT, - mem_mib: int = DEFAULT_MEM_MIB, - timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, - ttl_seconds: int = DEFAULT_TTL_SECONDS, - network: bool = False, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, - ) -> dict[str, Any]: - """Create, start, execute, and clean up an ephemeral VM.""" - return self.run_in_vm( - environment=environment, - command=command, - vcpu_count=vcpu_count, - mem_mib=mem_mib, - timeout_seconds=timeout_seconds, - ttl_seconds=ttl_seconds, - network=network, - allow_host_compat=allow_host_compat, - ) + def _enabled(tool_name: str) -> bool: + return tool_name in enabled_tools - @server.tool() - async def vm_list_environments() -> list[dict[str, object]]: - """List curated Linux environments and installation status.""" - return self.list_environments() + if _enabled("vm_run"): - @server.tool() - async def vm_create( - environment: str, - vcpu_count: int = DEFAULT_VCPU_COUNT, - mem_mib: int = DEFAULT_MEM_MIB, - ttl_seconds: int = DEFAULT_TTL_SECONDS, - network: bool = False, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, - ) -> dict[str, Any]: - """Create an ephemeral VM record with environment and resource sizing.""" - return self.create_vm( - environment=environment, - vcpu_count=vcpu_count, - mem_mib=mem_mib, - ttl_seconds=ttl_seconds, - network=network, - allow_host_compat=allow_host_compat, - ) + @server.tool() + async def vm_run( + environment: str, + command: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + ) -> dict[str, Any]: + """Create, start, execute, and clean up an ephemeral VM.""" + return self.run_in_vm( + environment=environment, + command=command, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + timeout_seconds=timeout_seconds, + ttl_seconds=ttl_seconds, + network=network, + allow_host_compat=allow_host_compat, + ) - @server.tool() - async def vm_start(vm_id: str) -> dict[str, Any]: - """Start a created VM and transition it into a command-ready state.""" - return self.start_vm(vm_id) + if _enabled("vm_list_environments"): - @server.tool() - async def vm_exec(vm_id: str, command: str, timeout_seconds: int = 30) -> dict[str, Any]: - """Run one non-interactive command and auto-clean the VM.""" - return self.exec_vm(vm_id, command=command, timeout_seconds=timeout_seconds) + @server.tool() + async def vm_list_environments() -> list[dict[str, object]]: + """List curated Linux environments and installation status.""" + return self.list_environments() - @server.tool() - async def vm_stop(vm_id: str) -> dict[str, Any]: - """Stop a running VM.""" - return self.stop_vm(vm_id) + if _enabled("vm_create"): - @server.tool() - async def vm_delete(vm_id: str) -> dict[str, Any]: - """Delete a VM and its runtime artifacts.""" - return self.delete_vm(vm_id) + @server.tool() + async def vm_create( + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + ) -> dict[str, Any]: + """Create an ephemeral VM record with environment and resource sizing.""" + return self.create_vm( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network=network, + allow_host_compat=allow_host_compat, + ) - @server.tool() - async def vm_status(vm_id: str) -> dict[str, Any]: - """Get the current state and metadata for a VM.""" - return self.status_vm(vm_id) + if _enabled("vm_start"): - @server.tool() - async def vm_network_info(vm_id: str) -> dict[str, Any]: - """Get the current network configuration assigned to a VM.""" - return self.network_info_vm(vm_id) + @server.tool() + async def vm_start(vm_id: str) -> dict[str, Any]: + """Start a created VM and transition it into a command-ready state.""" + return self.start_vm(vm_id) - @server.tool() - async def vm_reap_expired() -> dict[str, Any]: - """Delete VMs whose TTL has expired.""" - return self.reap_expired() + if _enabled("vm_exec"): - @server.tool() - async def workspace_create( - environment: str, - vcpu_count: int = DEFAULT_VCPU_COUNT, - mem_mib: int = DEFAULT_MEM_MIB, - ttl_seconds: int = DEFAULT_TTL_SECONDS, - network_policy: str = DEFAULT_WORKSPACE_NETWORK_POLICY, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, - seed_path: str | None = None, - secrets: list[dict[str, str]] | None = None, - name: str | None = None, - labels: dict[str, str] | None = None, - ) -> dict[str, Any]: - """Create and start a persistent workspace.""" - 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, - ) + @server.tool() + async def vm_exec( + vm_id: str, + command: str, + timeout_seconds: int = 30, + ) -> dict[str, Any]: + """Run one non-interactive command and auto-clean the VM.""" + return self.exec_vm(vm_id, command=command, timeout_seconds=timeout_seconds) - @server.tool() - async def workspace_list() -> dict[str, Any]: - """List persisted workspaces with summary metadata.""" - return self.list_workspaces() + if _enabled("vm_stop"): - @server.tool() - async def workspace_update( - workspace_id: str, - name: str | None = None, - clear_name: bool = False, - labels: dict[str, str] | None = None, - clear_labels: list[str] | None = None, - ) -> dict[str, Any]: - """Update optional workspace name and labels.""" - return self.update_workspace( - workspace_id, - name=name, - clear_name=clear_name, - labels=labels, - clear_labels=clear_labels, - ) + @server.tool() + async def vm_stop(vm_id: str) -> dict[str, Any]: + """Stop a running VM.""" + return self.stop_vm(vm_id) - @server.tool() - async def workspace_exec( - 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, - timeout_seconds=timeout_seconds, - secret_env=secret_env, - ) + if _enabled("vm_delete"): - @server.tool() - async def workspace_sync_push( - workspace_id: str, - source_path: str, - dest: str = "/workspace", - ) -> dict[str, Any]: - """Push host content into the persistent `/workspace` of a started workspace.""" - return self.push_workspace_sync(workspace_id, source_path=source_path, dest=dest) + @server.tool() + async def vm_delete(vm_id: str) -> dict[str, Any]: + """Delete a VM and its runtime artifacts.""" + return self.delete_vm(vm_id) - @server.tool() - async def workspace_status(workspace_id: str) -> dict[str, Any]: - """Inspect workspace state and latest command metadata.""" - return self.status_workspace(workspace_id) + if _enabled("vm_status"): - @server.tool() - async def workspace_stop(workspace_id: str) -> dict[str, Any]: - """Stop one persistent workspace without resetting `/workspace`.""" - return self.stop_workspace(workspace_id) + @server.tool() + async def vm_status(vm_id: str) -> dict[str, Any]: + """Get the current state and metadata for a VM.""" + return self.status_vm(vm_id) - @server.tool() - async def workspace_start(workspace_id: str) -> dict[str, Any]: - """Start one stopped persistent workspace without resetting `/workspace`.""" - return self.start_workspace(workspace_id) + if _enabled("vm_network_info"): - @server.tool() - async def workspace_logs(workspace_id: str) -> dict[str, Any]: - """Return persisted command history for one workspace.""" - return self.logs_workspace(workspace_id) + @server.tool() + async def vm_network_info(vm_id: str) -> dict[str, Any]: + """Get the current network configuration assigned to a VM.""" + return self.network_info_vm(vm_id) - @server.tool() - 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("vm_reap_expired"): - @server.tool() - async def workspace_diff(workspace_id: str) -> dict[str, Any]: - """Compare `/workspace` to the immutable create-time baseline.""" - return self.diff_workspace(workspace_id) + @server.tool() + async def vm_reap_expired() -> dict[str, Any]: + """Delete VMs whose TTL has expired.""" + return self.reap_expired() - @server.tool() - 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, - recursive=recursive, - ) + if _enabled("workspace_create"): + if normalized_profile == "workspace-core": - @server.tool() - 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, - max_bytes=max_bytes, - ) + @server.tool(name="workspace_create") + async def workspace_create_core( + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + seed_path: str | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Create and start a persistent workspace.""" + return self.create_workspace( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network_policy=DEFAULT_WORKSPACE_NETWORK_POLICY, + allow_host_compat=allow_host_compat, + seed_path=seed_path, + secrets=None, + name=name, + labels=labels, + ) - @server.tool() - 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, - text=text, - ) + else: - @server.tool() - 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, - ) + @server.tool(name="workspace_create") + async def workspace_create_full( + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network_policy: str = DEFAULT_WORKSPACE_NETWORK_POLICY, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + seed_path: str | None = None, + secrets: list[dict[str, str]] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Create and start a persistent workspace.""" + 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, + ) - @server.tool() - async def workspace_disk_export( - workspace_id: str, - output_path: str, - ) -> dict[str, Any]: - """Export the raw stopped workspace rootfs image to one host path.""" - return self.export_workspace_disk(workspace_id, output_path=output_path) + if _enabled("workspace_list"): - @server.tool() - async def workspace_disk_list( - workspace_id: str, - path: str = "/workspace", - recursive: bool = False, - ) -> dict[str, Any]: - """Inspect one stopped workspace rootfs path without booting the guest.""" - return self.list_workspace_disk( - workspace_id, - path=path, - recursive=recursive, - ) + @server.tool() + async def workspace_list() -> dict[str, Any]: + """List persisted workspaces with summary metadata.""" + return self.list_workspaces() - @server.tool() - async def workspace_disk_read( - workspace_id: str, - path: str, - max_bytes: int = 65536, - ) -> dict[str, Any]: - """Read one regular file from a stopped workspace rootfs without booting the guest.""" - return self.read_workspace_disk( - workspace_id, - path, - max_bytes=max_bytes, - ) + if _enabled("workspace_update"): - @server.tool() - async def snapshot_create(workspace_id: str, snapshot_name: str) -> dict[str, Any]: - """Create one named workspace snapshot from the current `/workspace` tree.""" - return self.create_snapshot(workspace_id, snapshot_name) + @server.tool() + async def workspace_update( + workspace_id: str, + name: str | None = None, + clear_name: bool = False, + labels: dict[str, str] | None = None, + clear_labels: list[str] | None = None, + ) -> dict[str, Any]: + """Update optional workspace name and labels.""" + return self.update_workspace( + workspace_id, + name=name, + clear_name=clear_name, + labels=labels, + clear_labels=clear_labels, + ) - @server.tool() - async def snapshot_list(workspace_id: str) -> dict[str, Any]: - """List the baseline plus named snapshots for one workspace.""" - return self.list_snapshots(workspace_id) + if _enabled("workspace_exec"): + if normalized_profile == "workspace-core": - @server.tool() - async def snapshot_delete(workspace_id: str, snapshot_name: str) -> dict[str, Any]: - """Delete one named snapshot from a workspace.""" - return self.delete_snapshot(workspace_id, snapshot_name) + @server.tool(name="workspace_exec") + 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, + timeout_seconds=timeout_seconds, + secret_env=None, + ) - @server.tool() - async def workspace_reset( - workspace_id: str, - snapshot: str = "baseline", - ) -> dict[str, Any]: - """Recreate a workspace and restore `/workspace` from baseline or one named snapshot.""" - return self.reset_workspace(workspace_id, snapshot=snapshot) + else: - @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(name="workspace_exec") + 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, + timeout_seconds=timeout_seconds, + secret_env=secret_env, + ) - @server.tool() - async def shell_read( - workspace_id: str, - shell_id: str, - cursor: int = 0, - max_chars: int = 65536, - ) -> dict[str, Any]: - """Read merged PTY output from a workspace shell.""" - return self.read_shell( - workspace_id, - shell_id, - cursor=cursor, - max_chars=max_chars, - ) + if _enabled("workspace_sync_push"): - @server.tool() - 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, - input=input, - append_newline=append_newline, - ) + @server.tool() + async def workspace_sync_push( + workspace_id: str, + source_path: str, + dest: str = "/workspace", + ) -> dict[str, Any]: + """Push host content into the persistent `/workspace` of a started workspace.""" + return self.push_workspace_sync(workspace_id, source_path=source_path, dest=dest) - @server.tool() - 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, - signal_name=signal_name, - ) + if _enabled("workspace_status"): - @server.tool() - 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) + @server.tool() + async def workspace_status(workspace_id: str) -> dict[str, Any]: + """Inspect workspace state and latest command metadata.""" + return self.status_workspace(workspace_id) - @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, - ) + if _enabled("workspace_stop"): - @server.tool() - async def service_list(workspace_id: str) -> dict[str, Any]: - """List named services in one workspace.""" - return self.list_services(workspace_id) + @server.tool() + async def workspace_stop(workspace_id: str) -> dict[str, Any]: + """Stop one persistent workspace without resetting `/workspace`.""" + return self.stop_workspace(workspace_id) - @server.tool() - 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("workspace_start"): - @server.tool() - 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, - tail_lines=tail_lines, - all=all, - ) + @server.tool() + async def workspace_start(workspace_id: str) -> dict[str, Any]: + """Start one stopped persistent workspace without resetting `/workspace`.""" + return self.start_workspace(workspace_id) - @server.tool() - 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_logs"): - @server.tool() - async def workspace_delete(workspace_id: str) -> dict[str, Any]: - """Delete a persistent workspace and its backing sandbox.""" - return self.delete_workspace(workspace_id) + @server.tool() + async def workspace_logs(workspace_id: str) -> dict[str, Any]: + """Return persisted command history for one workspace.""" + return self.logs_workspace(workspace_id) + + if _enabled("workspace_export"): + + @server.tool() + 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"): + + @server.tool() + async def workspace_diff(workspace_id: str) -> dict[str, Any]: + """Compare `/workspace` to the immutable create-time baseline.""" + return self.diff_workspace(workspace_id) + + if _enabled("workspace_file_list"): + + @server.tool() + 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, + recursive=recursive, + ) + + if _enabled("workspace_file_read"): + + @server.tool() + 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, + max_bytes=max_bytes, + ) + + if _enabled("workspace_file_write"): + + @server.tool() + 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, + text=text, + ) + + if _enabled("workspace_patch_apply"): + + @server.tool() + 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, + ) + + if _enabled("workspace_disk_export"): + + @server.tool() + async def workspace_disk_export( + workspace_id: str, + output_path: str, + ) -> dict[str, Any]: + """Export the raw stopped workspace rootfs image to one host path.""" + return self.export_workspace_disk(workspace_id, output_path=output_path) + + if _enabled("workspace_disk_list"): + + @server.tool() + async def workspace_disk_list( + workspace_id: str, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + """Inspect one stopped workspace rootfs path without booting the guest.""" + return self.list_workspace_disk( + workspace_id, + path=path, + recursive=recursive, + ) + + if _enabled("workspace_disk_read"): + + @server.tool() + async def workspace_disk_read( + workspace_id: str, + path: str, + max_bytes: int = 65536, + ) -> dict[str, Any]: + """Read one regular file from a stopped workspace rootfs offline.""" + return self.read_workspace_disk( + workspace_id, + path, + max_bytes=max_bytes, + ) + + if _enabled("snapshot_create"): + + @server.tool() + async def snapshot_create(workspace_id: str, snapshot_name: str) -> dict[str, Any]: + """Create one named workspace snapshot from the current `/workspace` tree.""" + return self.create_snapshot(workspace_id, snapshot_name) + + if _enabled("snapshot_list"): + + @server.tool() + async def snapshot_list(workspace_id: str) -> dict[str, Any]: + """List the baseline plus named snapshots for one workspace.""" + return self.list_snapshots(workspace_id) + + if _enabled("snapshot_delete"): + + @server.tool() + async def snapshot_delete(workspace_id: str, snapshot_name: str) -> dict[str, Any]: + """Delete one named snapshot from a workspace.""" + return self.delete_snapshot(workspace_id, snapshot_name) + + if _enabled("workspace_reset"): + + @server.tool() + async def workspace_reset( + workspace_id: str, + snapshot: str = "baseline", + ) -> dict[str, Any]: + """Recreate a workspace and restore `/workspace` from one snapshot.""" + return self.reset_workspace(workspace_id, snapshot=snapshot) + + if _enabled("shell_open"): + + @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, + ) + + if _enabled("shell_read"): + + @server.tool() + async def shell_read( + workspace_id: str, + shell_id: str, + cursor: int = 0, + max_chars: int = 65536, + ) -> dict[str, Any]: + """Read merged PTY output from a workspace shell.""" + return self.read_shell( + workspace_id, + shell_id, + cursor=cursor, + max_chars=max_chars, + ) + + if _enabled("shell_write"): + + @server.tool() + 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, + input=input, + append_newline=append_newline, + ) + + if _enabled("shell_signal"): + + @server.tool() + 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, + signal_name=signal_name, + ) + + if _enabled("shell_close"): + + @server.tool() + 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"): + + @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, + ) + + if _enabled("service_list"): + + @server.tool() + 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() + 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() + 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, + tail_lines=tail_lines, + all=all, + ) + + if _enabled("service_stop"): + + @server.tool() + 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"): + + @server.tool() + async def workspace_delete(workspace_id: str) -> dict[str, Any]: + """Delete a persistent workspace and its backing sandbox.""" + return self.delete_workspace(workspace_id) return server diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index f616f50..cd7964b 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -11,6 +11,7 @@ from typing import Any from pyro_mcp import __version__ from pyro_mcp.api import Pyro +from pyro_mcp.contract import PUBLIC_MCP_PROFILES from pyro_mcp.demo import run_demo from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report @@ -740,24 +741,45 @@ def _build_parser() -> argparse.ArgumentParser: "Run the MCP server after you have already validated the host and " "guest execution with `pyro doctor` and `pyro run`." ), - epilog="Example:\n pyro mcp serve", + epilog=dedent( + """ + Examples: + pyro mcp serve --profile vm-run + pyro mcp serve --profile workspace-core + pyro mcp serve --profile workspace-full + """ + ), formatter_class=_HelpFormatter, ) mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True, metavar="MCP") - mcp_subparsers.add_parser( + mcp_serve_parser = mcp_subparsers.add_parser( "serve", help="Run the MCP server over stdio.", description="Expose pyro tools over stdio for an MCP client.", epilog=dedent( """ Example: - pyro mcp serve + pyro mcp serve --profile workspace-core + + Profiles: + vm-run: only the vm_run tool + workspace-core: vm_run plus the practical workspace chat loop + workspace-full: the full stable workspace surface Use this from an MCP client config after the CLI evaluation path works. """ ), formatter_class=_HelpFormatter, ) + mcp_serve_parser.add_argument( + "--profile", + choices=PUBLIC_MCP_PROFILES, + default="workspace-full", + help=( + "Expose only one model-facing tool profile. " + "`workspace-full` preserves the current full MCP surface." + ), + ) run_parser = subparsers.add_parser( "run", @@ -2175,7 +2197,7 @@ def main() -> None: _print_prune_human(prune_payload) return if args.command == "mcp": - pyro.create_server().run(transport="stdio") + pyro.create_server(profile=args.profile).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 b224565..9b51acf 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -5,6 +5,8 @@ from __future__ import annotations 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_WORKSPACE_SUBCOMMANDS = ( "create", "delete", @@ -108,6 +110,7 @@ PUBLIC_CLI_RUN_FLAGS = ( "--allow-host-compat", "--json", ) +PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full") PUBLIC_SDK_METHODS = ( "apply_workspace_patch", @@ -204,3 +207,23 @@ PUBLIC_MCP_TOOLS = ( "workspace_sync_push", "workspace_update", ) +PUBLIC_MCP_VM_RUN_PROFILE_TOOLS = ("vm_run",) +PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = ( + "vm_run", + "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_status", + "workspace_sync_push", + "workspace_update", +) +PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index bd094e0..c3a26ea 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -4,13 +4,17 @@ from __future__ import annotations from mcp.server.fastmcp import FastMCP -from pyro_mcp.api import Pyro +from pyro_mcp.api import McpToolProfile, Pyro from pyro_mcp.vm_manager import VmManager -def create_server(manager: VmManager | None = None) -> FastMCP: +def create_server( + manager: VmManager | None = None, + *, + profile: McpToolProfile = "workspace-full", +) -> FastMCP: """Create and return a configured MCP server instance.""" - return Pyro(manager=manager).create_server() + return Pyro(manager=manager).create_server(profile=profile) def main() -> None: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 600cb57..0290562 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 = "3.3.0" +DEFAULT_CATALOG_VERSION = "3.4.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", diff --git a/tests/test_api.py b/tests/test_api.py index 533ad43..2c8fba6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,6 +6,11 @@ from pathlib import Path from typing import Any, cast from pyro_mcp.api import Pyro +from pyro_mcp.contract import ( + PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, + PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, + PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, +) from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_network import TapNetworkManager @@ -47,37 +52,54 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None: return sorted(tool.name for tool in tools) tool_names = asyncio.run(_run()) - assert "vm_run" in tool_names - assert "vm_create" in tool_names - assert "workspace_create" in tool_names - assert "workspace_list" in tool_names - assert "workspace_update" in tool_names - assert "workspace_start" in tool_names - assert "workspace_stop" in tool_names - assert "workspace_diff" in tool_names - assert "workspace_sync_push" in tool_names - assert "workspace_export" in tool_names - assert "workspace_file_list" in tool_names - assert "workspace_file_read" in tool_names - assert "workspace_file_write" in tool_names - assert "workspace_patch_apply" in tool_names - assert "workspace_disk_export" in tool_names - assert "workspace_disk_list" in tool_names - assert "workspace_disk_read" in tool_names - assert "snapshot_create" in tool_names - assert "snapshot_list" in tool_names - assert "snapshot_delete" in tool_names - assert "workspace_reset" in tool_names - assert "shell_open" in tool_names - assert "shell_read" in tool_names - assert "shell_write" in tool_names - assert "shell_signal" in tool_names - assert "shell_close" in tool_names - assert "service_start" in tool_names - assert "service_list" in tool_names - assert "service_status" in tool_names - assert "service_logs" in tool_names - assert "service_stop" in tool_names + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS)) + + +def test_pyro_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> list[str]: + server = pyro.create_server(profile="vm-run") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS + + +def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_schemas( + tmp_path: Path, +) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + server = pyro.create_server(profile="workspace-core") + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return sorted(tool_map), tool_map + + tool_names, tool_map = asyncio.run(_run()) + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) + create_properties = tool_map["workspace_create"]["inputSchema"]["properties"] + assert "network_policy" not in create_properties + assert "secrets" not in create_properties + exec_properties = tool_map["workspace_exec"]["inputSchema"]["properties"] + assert "secret_env" not in exec_properties + assert "shell_open" not in tool_map + assert "service_start" not in tool_map + assert "snapshot_create" not in tool_map + assert "workspace_disk_export" not in tool_map def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index c853d75..49e6f75 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,6 +63,10 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: mcp_help = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help() assert "Expose pyro tools over stdio for an MCP client." in mcp_help + assert "--profile" in mcp_help + assert "workspace-core" in mcp_help + assert "workspace-full" in mcp_help + assert "vm-run" in mcp_help assert "Use this from an MCP client config after the CLI evaluation path works." in mcp_help workspace_help = _subparser_choice(parser, "workspace").format_help() @@ -3435,7 +3439,8 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: observed: dict[str, str] = {} class StubPyro: - def create_server(self) -> Any: + def create_server(self, *, profile: str) -> Any: + observed["profile"] = profile return type( "StubServer", (), @@ -3444,12 +3449,12 @@ 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") + return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core") monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() - assert observed == {"transport": "stdio"} + assert observed == {"profile": "workspace-core", "transport": "stdio"} def test_cli_demo_default_prints_json( diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 0add351..6009d66 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -16,6 +16,8 @@ from pyro_mcp.contract import ( PUBLIC_CLI_COMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS, PUBLIC_CLI_ENV_SUBCOMMANDS, + PUBLIC_CLI_MCP_SERVE_FLAGS, + PUBLIC_CLI_MCP_SUBCOMMANDS, PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, @@ -54,6 +56,7 @@ from pyro_mcp.contract import ( PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, + PUBLIC_MCP_PROFILES, PUBLIC_MCP_TOOLS, PUBLIC_SDK_METHODS, ) @@ -99,6 +102,14 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: env_help_text = _subparser_choice(parser, "env").format_help() for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: assert subcommand_name in env_help_text + mcp_help_text = _subparser_choice(parser, "mcp").format_help() + for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: + assert subcommand_name in mcp_help_text + mcp_serve_help_text = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help() + for flag in PUBLIC_CLI_MCP_SERVE_FLAGS: + assert flag in mcp_serve_help_text + for profile_name in PUBLIC_MCP_PROFILES: + assert profile_name in mcp_serve_help_text workspace_help_text = _subparser_choice(parser, "workspace").format_help() for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS: diff --git a/tests/test_server.py b/tests/test_server.py index 2f1b9e3..9571dcd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -7,6 +7,11 @@ from typing import Any, cast import pytest import pyro_mcp.server as server_module +from pyro_mcp.contract import ( + PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, + PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, + PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, +) from pyro_mcp.server import create_server from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_network import TapNetworkManager @@ -25,42 +30,37 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None: return sorted(tool.name for tool in tools) tool_names = asyncio.run(_run()) - assert "vm_create" in tool_names - assert "vm_exec" in tool_names - assert "vm_list_environments" in tool_names - assert "vm_network_info" in tool_names - assert "vm_run" in tool_names - assert "vm_status" in tool_names - assert "workspace_create" in tool_names - assert "workspace_list" in tool_names - assert "workspace_update" in tool_names - assert "workspace_start" in tool_names - assert "workspace_stop" in tool_names - assert "workspace_diff" in tool_names - assert "workspace_export" in tool_names - assert "workspace_file_list" in tool_names - assert "workspace_file_read" in tool_names - assert "workspace_file_write" in tool_names - assert "workspace_patch_apply" in tool_names - assert "workspace_disk_export" in tool_names - assert "workspace_disk_list" in tool_names - assert "workspace_disk_read" in tool_names - assert "workspace_logs" in tool_names - assert "workspace_sync_push" in tool_names - assert "shell_open" in tool_names - assert "shell_read" in tool_names - assert "shell_write" in tool_names - assert "shell_signal" in tool_names - assert "shell_close" in tool_names - assert "service_start" in tool_names - assert "service_list" in tool_names - assert "service_status" in tool_names - assert "service_logs" in tool_names - assert "service_stop" in tool_names - assert "snapshot_create" in tool_names - assert "snapshot_delete" in tool_names - assert "snapshot_list" in tool_names - assert "workspace_reset" in tool_names + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS)) + + +def test_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + async def _run() -> list[str]: + server = create_server(manager=manager, profile="vm-run") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS + + +def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + async def _run() -> list[str]: + server = create_server(manager=manager, profile="workspace-core") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) def test_vm_run_round_trip(tmp_path: Path) -> None: @@ -193,6 +193,91 @@ def test_server_main_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> No assert called == {"transport": "stdio"} +def test_workspace_core_profile_round_trip(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("old\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], ...]: + server = create_server(manager=manager, profile="workspace-core") + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + "seed_path": str(source_dir), + "name": "chat-loop", + "labels": {"issue": "123"}, + }, + ) + ) + workspace_id = str(created["workspace_id"]) + written = _extract_structured( + await server.call_tool( + "workspace_file_write", + { + "workspace_id": workspace_id, + "path": "note.txt", + "text": "fixed\n", + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": workspace_id, + "command": "cat note.txt", + }, + ) + ) + diffed = _extract_structured( + await server.call_tool("workspace_diff", {"workspace_id": workspace_id}) + ) + export_path = tmp_path / "exported-note.txt" + exported = _extract_structured( + await server.call_tool( + "workspace_export", + { + "workspace_id": workspace_id, + "path": "note.txt", + "output_path": str(export_path), + }, + ) + ) + reset = _extract_structured( + await server.call_tool("workspace_reset", {"workspace_id": workspace_id}) + ) + deleted = _extract_structured( + await server.call_tool("workspace_delete", {"workspace_id": workspace_id}) + ) + return created, written, executed, diffed, exported, reset, deleted + + created, written, executed, diffed, exported, reset, deleted = asyncio.run(_run()) + assert created["name"] == "chat-loop" + assert created["labels"] == {"issue": "123"} + assert written["bytes_written"] == len("fixed\n".encode("utf-8")) + assert executed["stdout"] == "fixed\n" + assert diffed["changed"] is True + assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "fixed\n" + assert reset["command_count"] == 0 + assert deleted["deleted"] is True + + def test_workspace_tools_round_trip(tmp_path: Path) -> None: manager = VmManager( backend_name="mock", diff --git a/uv.lock b/uv.lock index 3944d11..209a741 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "3.3.0" +version = "3.4.0" source = { editable = "." } dependencies = [ { name = "mcp" },