Files
MCP-Project-bus/src/project_bus/operations.py
T
2026-07-30 20:06:34 +02:00

188 lines
7.4 KiB
Python

from __future__ import annotations
import argparse
import json
import os
import sqlite3
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Protocol
from .models import Role
class OperationsError(RuntimeError):
pass
class ToolCaller(Protocol):
def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: ...
class HTTPMCPClient:
def __init__(self, endpoint: str, token: str, timeout: float = 10.0):
self.endpoint = endpoint
self.token = token
self.timeout = timeout
self._request_id = 0
def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
self._request_id += 1
payload = {
"jsonrpc": "2.0",
"id": self._request_id,
"method": "tools/call",
"params": {"name": name, "arguments": arguments},
}
request = urllib.request.Request(
self.endpoint,
data=json.dumps(payload, separators=(",", ":")).encode(),
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
result = json.load(response)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
raise OperationsError(f"MCP request failed: {error}") from error
if "error" in result:
raise OperationsError(f"JSON-RPC error: {result['error']}")
tool_result = result.get("result", {})
if tool_result.get("isError"):
content = tool_result.get("content", [])
message = content[0].get("text", "unknown tool error") if content else "unknown tool error"
raise OperationsError(message)
content = tool_result.get("content", [])
if not content or content[0].get("type") != "text":
raise OperationsError("MCP tool returned no JSON text content")
try:
return json.loads(content[0]["text"])
except json.JSONDecodeError as error:
raise OperationsError("MCP tool returned invalid JSON text") from error
def load_manifest(path: Path) -> dict[str, Any]:
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise OperationsError(f"Cannot read manifest: {error}") from error
project = manifest.get("project")
actors = manifest.get("actors")
if not isinstance(project, dict) or not isinstance(actors, list) or not actors:
raise OperationsError("Manifest requires a project object and non-empty actors array")
for field in ("project_id", "name"):
if not isinstance(project.get(field), str) or not project[field]:
raise OperationsError(f"Manifest project.{field} must be a non-empty string")
seen_ids: set[str] = set()
seen_subjects: set[str] = set()
for index, actor in enumerate(actors):
if not isinstance(actor, dict):
raise OperationsError(f"Manifest actor {index} must be an object")
for field in ("actor_id", "auth_subject", "display_name", "role"):
if not isinstance(actor.get(field), str) or not actor[field]:
raise OperationsError(f"Manifest actor {index}.{field} must be a non-empty string")
try:
role = Role(actor["role"])
except ValueError as error:
raise OperationsError(f"Manifest actor {index} has invalid role") from error
if role is Role.SYSTEM:
raise OperationsError("SYSTEM is a transport bootstrap identity, not a project actor")
if actor["actor_id"] in seen_ids or actor["auth_subject"] in seen_subjects:
raise OperationsError("Actor IDs and auth subjects must be unique")
seen_ids.add(actor["actor_id"])
seen_subjects.add(actor["auth_subject"])
return manifest
def bootstrap_project(client: ToolCaller, manifest: dict[str, Any]) -> dict[str, Any]:
project = manifest["project"]
project_id = project["project_id"]
created = client.call(
"create_project",
{
"project_id": project_id,
"name": project["name"],
"description": project.get("description", ""),
"idempotency_key": f"bootstrap:{project_id}:project:v1",
},
)
registered = []
for actor in manifest["actors"]:
registered.append(
client.call(
"register_actor",
{
"project_id": project_id,
**actor,
"idempotency_key": f"bootstrap:{project_id}:actor:{actor['actor_id']}:v1",
},
)
)
return {"project": created, "actors": registered}
def backup_database(source: Path, destination: Path) -> dict[str, Any]:
if source.resolve() == destination.resolve():
raise OperationsError("Backup destination must differ from source")
destination.parent.mkdir(parents=True, exist_ok=True)
try:
with sqlite3.connect(source) as source_db, sqlite3.connect(destination) as backup_db:
source_db.backup(backup_db)
integrity = backup_db.execute("PRAGMA integrity_check").fetchone()[0]
cursor_row = backup_db.execute(
"SELECT COALESCE(MAX(cursor), 0) FROM event_log"
).fetchone()
except sqlite3.Error as error:
raise OperationsError(f"Database backup failed: {error}") from error
if integrity != "ok":
raise OperationsError(f"Backup integrity check failed: {integrity}")
return {"destination": str(destination), "integrity": integrity, "event_cursor": cursor_row[0]}
def _bootstrap_command(arguments: argparse.Namespace) -> int:
token = os.environ.get(arguments.token_env)
if not token:
raise OperationsError(f"Required secret environment variable is unset: {arguments.token_env}")
manifest = load_manifest(arguments.manifest)
result = bootstrap_project(HTTPMCPClient(arguments.endpoint, token), manifest)
print(json.dumps(result, indent=2))
return 0
def _backup_command(arguments: argparse.Namespace) -> int:
print(json.dumps(backup_database(arguments.source, arguments.destination), indent=2))
return 0
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description="Project Bus operational tooling")
subparsers = result.add_subparsers(dest="command", required=True)
bootstrap = subparsers.add_parser("bootstrap", help="Provision a project and actor roster")
bootstrap.add_argument("--endpoint", required=True)
bootstrap.add_argument("--manifest", type=Path, required=True)
bootstrap.add_argument("--token-env", default="PROJECT_BUS_BOOTSTRAP_TOKEN")
bootstrap.set_defaults(handler=_bootstrap_command)
backup = subparsers.add_parser("backup", help="Create and integrity-check a live SQLite backup")
backup.add_argument("--source", type=Path, required=True)
backup.add_argument("--destination", type=Path, required=True)
backup.set_defaults(handler=_backup_command)
return result
def main() -> None:
arguments = parser().parse_args()
try:
raise SystemExit(arguments.handler(arguments))
except OperationsError as error:
print(f"error: {error}", file=sys.stderr)
raise SystemExit(2) from error
if __name__ == "__main__":
main()