feat(poc): rewind MCP, skill, and recover-ladder docs

Initial PoC: find_protection, tagged checkpoints, FLR, mutating catalog.
Lab 10.9 status enums (0=Initializing, 1=MeetingSLA). Credentials stay in gitignored config.json.
This commit is contained in:
2026-09-21 12:09:11 -04:00
commit 38ba9c1b50
24 changed files with 1811 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
"""Rewind-shaped MCP. One process for the demo. Not a second ZVM catalog."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
from zerto_rewind_mcp.catalog import CatalogEntry, MutatingCatalog
from zerto_rewind_mcp.checkpoints import checkpoint_id, checkpoint_tag, make_tag, tag_vpgs
from zerto_rewind_mcp.client import ZertoClient, ZertoError
from zerto_rewind_mcp.config import load_catalog, load_config
from zerto_rewind_mcp.protection import find_from_rows
from zerto_rewind_mcp.recover import download_token_from, session_id_from, wait_flr_ready
from zerto_rewind_mcp.util import pick
mcp = FastMCP("zerto_rewind_mcp")
_client: ZertoClient | None = None
_catalog: MutatingCatalog | None = None
_settings: dict[str, Any] = {}
def _dump(payload: Any) -> str:
return json.dumps(payload, indent=2, default=str)
def _ensure_settings() -> dict[str, Any]:
global _settings, _catalog
if not _settings:
_settings = load_config()
_catalog = load_catalog(_settings)
return _settings
def get_client() -> ZertoClient:
global _client
settings = _ensure_settings()
if _client is None:
if not settings.get("zerto_url") or not settings.get("username"):
raise ZertoError(
"Missing zerto_url/username. Copy config.example.json to config.json "
"or set ZERTO_URL and ZERTO_USERNAME."
)
_client = ZertoClient(
base_url=str(settings["zerto_url"]),
username=str(settings["username"]),
password=str(settings.get("password") or ""),
client_id=str(settings.get("client_id") or "zerto-client"),
verify_tls=bool(settings.get("verify_tls")),
)
return _client
def get_catalog() -> MutatingCatalog:
_ensure_settings()
assert _catalog is not None
return _catalog
async def _find(query: str):
client = get_client()
q = query.strip()
looks_id = len(q) >= 32 and "-" in q
rows = await client.get_vms(vm_identifier=q) if looks_id else await client.get_vms(vm_name=q)
if not rows and looks_id:
rows = await client.get_vms(vm_name=q)
elif not rows:
rows = await client.get_vms(vm_identifier=q)
return find_from_rows(q, rows)
@mcp.tool(
name="zerto_find_protection",
annotations={
"title": "Find Zerto protection for a VM",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def zerto_find_protection(query: str) -> str:
"""Resolve a VM name, hostname, or Zerto vmIdentifier to exactly one VM and every VPG it is in.
Zero matches or two-plus VMs: stop. Do not mutate. A VM can be in up to three VPGs.
"""
try:
result = await _find(query)
except ZertoError as exc:
return _dump({"ok": False, "message": str(exc)})
payload = result.as_dict()
payload["ok"] = result.outcome == "ok" and bool(result.taggable_vpgs)
return _dump(payload)
@mcp.tool(
name="zerto_create_tagged_checkpoint",
annotations={
"title": "Insert tagged checkpoints on every protecting VPG",
"readOnlyHint": False,
"destructiveHint": False,
"idempotentHint": False,
"openWorldHint": True,
},
)
async def zerto_create_tagged_checkpoint(
query: str,
change_id: str,
agent: str = "agent",
tag: str | None = None,
) -> str:
"""Insert the same tagged checkpoint on every protecting VPG, then wait.
Call this before every cataloged guest-mutating tool. If it fails, refuse the change.
Tag format defaults to ai:<agent>:<change_id>:<utc>.
Docs: tagged checkpoints are not supported when the protected site is Azure or AWS;
run this against the vSphere protected ZVM.
"""
try:
result = await _find(query)
if result.outcome != "ok" or not result.taggable_vpgs:
payload = result.as_dict()
payload["ok"] = False
return _dump(payload)
use_tag = tag or make_tag(agent, change_id)
out = await tag_vpgs(get_client(), result, use_tag)
return _dump(out)
except ZertoError as exc:
return _dump({"ok": False, "message": str(exc)})
@mcp.tool(
name="zerto_list_checkpoints",
annotations={
"title": "List journal checkpoints for a VPG",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": True,
},
)
async def zerto_list_checkpoints(vpg_identifier: str, tag: str | None = None) -> str:
"""List checkpoints on a VPG. Pass tag to keep only matching tagged checkpoints."""
try:
rows = await get_client().list_checkpoints(vpg_identifier)
except ZertoError as exc:
return _dump({"ok": False, "message": str(exc)})
items = []
for row in rows:
item = {
"checkpoint_id": checkpoint_id(row),
"tag": checkpoint_tag(row),
"timestamp": pick(row, "Timestamp", "TimeStamp", "timestamp"),
}
if tag and item["tag"] != tag:
continue
items.append(item)
return _dump(
{
"ok": True,
"vpg_identifier": vpg_identifier,
"count": len(items),
"checkpoints": items,
}
)
@mcp.tool(
name="zerto_guard_before_mutate",
annotations={
"title": "Guard: find protection and tag before a mutating call",
"readOnlyHint": False,
"destructiveHint": False,
"idempotentHint": False,
"openWorldHint": True,
},
)
async def zerto_guard_before_mutate(
query: str,
change_id: str,
agent: str = "agent",
) -> str:
"""Find protection and tag. If ok is not true, do not mutate.
The skill and any cataloged tool wrapper call this first. Capture-before-execute:
if this does not return ok=true, do not run the guest-mutating call.
"""
return await zerto_create_tagged_checkpoint(query=query, change_id=change_id, agent=agent)
@mcp.tool(
name="zerto_list_mutating_tools",
annotations={
"title": "List the mutating catalog",
"readOnlyHint": True,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def zerto_list_mutating_tools() -> str:
"""Tools that must be guarded. Unlisted tools pass through."""
entries = [e.as_dict() for e in get_catalog().list()]
return _dump({"ok": True, "count": len(entries), "mutating_tools": entries})
@mcp.tool(
name="zerto_add_mutating_tool",
annotations={
"title": "Add a tool to the mutating catalog",
"readOnlyHint": False,
"destructiveHint": False,
"idempotentHint": True,
"openWorldHint": False,
},
)
async def zerto_add_mutating_tool(
server: str,
tool: str,
vm_arg: str,
notes: str = "",
) -> str:
"""Register an MCP tool as guest-mutating. vm_arg holds VM name/hostname/id."""
try:
entry = CatalogEntry(server=server, tool=tool, vm_arg=vm_arg, notes=notes)
get_catalog().add(entry)
except ValueError as exc:
return _dump({"ok": False, "message": str(exc)})
return _dump({"ok": True, "entry": entry.as_dict()})
@mcp.tool(
name="zerto_recover_file",
annotations={
"title": "FLR: pull a file from a tagged checkpoint",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": False,
"openWorldHint": True,
},
)
async def zerto_recover_file(
vpg_identifier: str,
vm_identifier: str,
checkpoint_identifier: str,
guest_path: str,
confirmed: bool = False,
dest_dir: str | None = None,
) -> str:
"""File-level recovery from a journal checkpoint. The VM stays up.
Requires confirmed=true (human yes). Cannot run during clone/test/live/EJC.
10.9 FLR Operator role fails; use an Administrator account.
"""
if not confirmed:
return _dump(
{
"ok": False,
"needs_confirm": True,
"message": (
"Set confirmed=true after a human yes. "
"FLR mounts a disk on the recovery site."
),
}
)
client = get_client()
dest = Path(dest_dir or _settings.get("recovery_dir") or "./recovered")
dest.mkdir(parents=True, exist_ok=True)
session_id = None
try:
started = await client.start_flr(
vpg_identifier,
vm_identifier,
checkpoint_identifier,
str(dest.resolve()),
)
session_id = session_id_from(started)
await wait_flr_ready(client, session_id)
await client.browse_flr(session_id, path="", recursive=False)
token_payload = await client.download_flr(session_id, [guest_path])
token = download_token_from(token_payload)
blob = await client.fetch_download(token)
name = Path(guest_path.replace("\\", "/")).name or "recovered.bin"
out_path = dest / name
out_path.write_bytes(blob)
return _dump(
{
"ok": True,
"path": str(out_path.resolve()),
"bytes": len(blob),
"session_id": session_id,
"message": f"Wrote {len(blob)} bytes to {out_path}",
}
)
except ZertoError as exc:
return _dump({"ok": False, "message": str(exc)})
finally:
if session_id:
try:
await client.end_flr(session_id)
except ZertoError:
pass
@mcp.tool(
name="zerto_start_failover_test",
annotations={
"title": "Start a failover test (inspect only)",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": False,
"openWorldHint": True,
},
)
async def zerto_start_failover_test(
vpg_identifier: str,
confirmed: bool = False,
checkpoint_identifier: str | None = None,
) -> str:
"""Spin up test VMs from a checkpoint. Not a production failback. Requires confirmed=true."""
if not confirmed:
return _dump(
{
"ok": False,
"needs_confirm": True,
"message": (
"Set confirmed=true after a human yes. "
"Failover test creates VMs on the test network."
),
}
)
try:
result = await get_client().start_failover_test(vpg_identifier, checkpoint_identifier)
return _dump({"ok": True, "result": result})
except ZertoError as exc:
return _dump({"ok": False, "message": str(exc)})
@mcp.tool(
name="zerto_stop_failover_test",
annotations={
"title": "Stop a failover test",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": False,
"openWorldHint": True,
},
)
async def zerto_stop_failover_test(
vpg_identifier: str,
success: bool = True,
summary: str = "stopped by zerto rewind mcp",
confirmed: bool = False,
) -> str:
"""Stop an in-progress failover test. Requires confirmed=true."""
if not confirmed:
return _dump(
{
"ok": False,
"needs_confirm": True,
"message": "Set confirmed=true after a human yes.",
}
)
try:
result = await get_client().stop_failover_test(vpg_identifier, success, summary)
return _dump({"ok": True, "result": result})
except ZertoError as exc:
return _dump({"ok": False, "message": str(exc)})
@mcp.tool(
name="zerto_offsite_clone",
annotations={
"title": "Offsite clone from a checkpoint",
"readOnlyHint": False,
"destructiveHint": True,
"idempotentHint": False,
"openWorldHint": True,
},
)
async def zerto_offsite_clone(
vpg_identifier: str,
checkpoint_identifier: str,
confirmed: bool = False,
datastore_identifier: str | None = None,
) -> str:
"""Copy VMs at the recovery site from a checkpoint. Prod stays up. Requires confirmed=true."""
if not confirmed:
return _dump(
{
"ok": False,
"needs_confirm": True,
"message": (
"Set confirmed=true after a human yes. "
"Clone consumes recovery datastore."
),
}
)
try:
result = await get_client().start_clone(
vpg_identifier,
checkpoint_identifier,
datastore_identifier=datastore_identifier,
)
return _dump({"ok": True, "result": result})
except ZertoError as exc:
return _dump({"ok": False, "message": str(exc)})
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()