Add MCP tool profiles for workspace chat flows
Expose stable MCP/server tool profiles so chat hosts can start narrow and widen only when needed. This adds vm-run, workspace-core, and workspace-full across the CLI serve path, Pyro.create_server(), and the package-level create_server() factory while keeping workspace-full as the default. Register profile-specific tool sets from one shared contract mapping, and narrow the workspace-core schemas so secrets, network policy, shells, services, snapshots, and disk tools do not leak into the default persistent chat profile. The full surface remains available unchanged under workspace-full. Refresh the public docs and examples around the profile progression, add a canonical OpenAI Responses workspace-core example, mark the 3.4.0 roadmap milestone done, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed workspace-core smoke for create, file write, exec, diff, export, reset, and delete.
This commit is contained in:
parent
446f7fce04
commit
eecfd7a7d7
23 changed files with 984 additions and 511 deletions
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
25
README.md
25
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# `3.4.0` Tool Profiles And Canonical Chat Flows
|
||||
|
||||
Status: Planned
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
89
examples/openai_responses_workspace_core.py
Normal file
89
examples/openai_responses_workspace_core.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "3.3.0"
|
||||
version = "3.4.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue