---
phase: 01-foundation
plan: 02
type: execute
wave: 2
depends_on: [01-01]
files_modified:
  - vmcluster_mcp/models.py
  - vmcluster_mcp/server.py
  - vmcluster_mcp/__main__.py
  - tests/test_models.py
  - tests/test_server.py
autonomous: true
requirements:
  - FOUND-01
  - FOUND-02

must_haves:
  truths:
    - "python -m vmcluster_mcp.server starts and completes MCP initialize handshake"
    - "Any tool handler exception returns ToolResult with success=False and a descriptive error string"
    - "ToolResult is a Generic Pydantic model with success, result, and error fields"
    - "All downstream model types are importable from vmcluster_mcp.models"
  artifacts:
    - path: "vmcluster_mcp/models.py"
      provides: "All shared Pydantic v2 models for the data contract"
      exports: ["ToolResult", "ErrorDetail", "ClusterHandle", "ClusterStatus", "NodeStatus", "NodeDescriptor", "ExecResult", "SnapshotInfo", "BuildArtifact", "NetFaultHandle", "DmesgResult", "DmesgLine"]
      min_lines: 80
    - path: "vmcluster_mcp/server.py"
      provides: "FastMCP server skeleton with structured_tool_handler decorator"
      exports: ["mcp", "structured_tool_handler"]
      min_lines: 30
    - path: "vmcluster_mcp/__main__.py"
      provides: "python -m vmcluster_mcp entry point"
      contains: "mcp.run"
  key_links:
    - from: "vmcluster_mcp/server.py"
      to: "vmcluster_mcp/models.py"
      via: "imports ToolResult and ErrorDetail for error contract"
      pattern: "from .models import"
    - from: "vmcluster_mcp/__main__.py"
      to: "vmcluster_mcp/server.py"
      via: "imports and runs the FastMCP server"
      pattern: "mcp\\.run"
---

<objective>
Define all shared Pydantic v2 models (the complete data contract) and create the FastMCP server skeleton with the `@structured_tool_handler` catch-all decorator that enforces the ToolResult error contract.

Purpose: The data contract and error handling wrapper must exist before any tool is registered. Every subsequent phase imports these models and wraps tool handlers with this decorator.
Output: models.py with all types, server.py with FastMCP + decorator, __main__.py entry point, and tests proving the contract works.
</objective>

<execution_context>
@~/.copilot/get-shit-done/workflows/execute-plan.md
@~/.copilot/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/research/STACK.md
@.planning/research/ARCHITECTURE.md
@.planning/research/PITFALLS.md
@docs/vmcluster-mcp-design.md

<interfaces>
<!-- From Plan 01 — project structure exists, all subpackage __init__.py files present -->
<!-- pyproject.toml declares: mcp[cli]>=1.26.0, pydantic>=2.12.5 -->
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create models.py with all shared Pydantic v2 models</name>
  <files>vmcluster_mcp/models.py, tests/test_models.py</files>
  <read_first>
    - docs/vmcluster-mcp-design.md (section 2.1 ClusterHandle/NodeDescriptor, section 2.3 BuildArtifact, section 2.4 all tool return types including ClusterStatus/NodeStatus/ExecResult/SnapshotInfo/DmesgResult/DmesgLine/NetFaultHandle, section 2.8 ToolResult/ErrorDetail)
    - .planning/research/STACK.md (Pydantic v2 patterns, BaseModel usage)
    - .planning/research/PITFALLS.md (Pitfall #2: ToolResult contract, Pitfall #5: all_succeeded/failed_nodes fields)
  </read_first>
  <behavior>
    - ToolResult[str] can be instantiated with success=True, result="ok", error=None
    - ToolResult[ClusterStatus] can be instantiated with a ClusterStatus as result
    - ToolResult with success=False has error field with ErrorDetail containing code, message, recoverable
    - ToolResult serializes to JSON with correct field names (success, result, error)
    - ErrorDetail.code is a string (e.g., "INTERNAL_ERROR", "SSH_UNREACHABLE")
    - ErrorDetail.recoverable is a bool
    - NodeDescriptor defaults: ssh_port=22, ssh_user="root"
    - ClusterStatus.nodes is list[NodeStatus]
    - NodeStatus.domain_state accepts "running", "shut off", "paused", "crashed", "unknown"
    - ExecResult.timed_out is bool
    - All models round-trip through model_dump() and model_validate()
  </behavior>
  <action>
Create `vmcluster_mcp/models.py` with all Pydantic v2 models from the design doc. Use `from __future__ import annotations` for forward references. Use `typing.Generic` and `TypeVar` for `ToolResult[T]`.

**Core error contract (design doc section 2.8):**

```python
T = TypeVar("T")

class ErrorDetail(BaseModel):
    code: str           # Machine-readable: "INTERNAL_ERROR", "SSH_UNREACHABLE", "SNAPSHOT_NOT_FOUND", etc.
    message: str        # Human-readable description
    node: str | None = None   # Which node caused the error, if applicable
    recoverable: bool = False # Whether retrying may resolve this

class ToolResult(BaseModel, Generic[T]):
    success: bool
    result: T | None = None
    error: ErrorDetail | None = None
```

**Cluster types (design doc section 2.1 + 2.4):**

```python
class NodeDescriptor(BaseModel):
    name: str
    role: str
    ip: str
    ssh_port: int = 22
    ssh_user: str = "root"
    ssh_key_path: str

class ClusterHandle(BaseModel):
    cluster_name: str
    nodes: list[NodeDescriptor]
    artifact_path: str
    kernel_version: str

class NodeStatus(BaseModel):
    name: str
    role: str
    ip: str
    domain_state: Literal["running", "shut off", "paused", "crashed", "unknown"]
    ssh_reachable: bool
    artifact_id_installed: str | None = None
    loaded_modules: list[str] = []

class ClusterStatus(BaseModel):
    cluster_name: str
    network_active: bool
    nodes: list[NodeStatus]
```

**Execution types (design doc section 2.4):**

```python
class ExecResult(BaseModel):
    exit_code: int
    stdout: str
    stderr: str
    duration_s: float
    timed_out: bool

class ExecAllResult(BaseModel):
    results: dict[str, ExecResult]
    all_succeeded: bool
    failed_nodes: list[str]
```

**Snapshot types:**

```python
class SnapshotInfo(BaseModel):
    snapshot_name: str
    cluster_name: str
    created_at: str       # ISO 8601
    per_node: dict[str, SnapshotNodeInfo]

class SnapshotNodeInfo(BaseModel):
    node_name: str
    disk_path: str
    size_bytes: int
```

**Artifact types (design doc section 2.3 + 2.4):**

```python
class BuildArtifact(BaseModel):
    artifact_id: str              # sha256 of build tree content
    source_path: str
    build_type: Literal["raw_tree", "make_install", "rpm", "deb"]
    kernel_version: str
    modules: list[str]            # Relative paths to .ko files
    userspace_bins: list[str]
    test_framework_path: str | None = None
    git_sha: str | None = None
    git_branch: str | None = None
    build_timestamp: str          # ISO 8601
    metadata: dict[str, str] = {}

class SyncStatus(BaseModel):
    per_node: dict[str, SyncNodeResult]

class SyncNodeResult(BaseModel):
    node_name: str
    bytes_transferred: int
    duration_s: float
    rsync_exit_code: int
    skipped: bool

class InstallStatus(BaseModel):
    per_node: dict[str, InstallNodeResult]

class InstallNodeResult(BaseModel):
    node_name: str
    exit_code: int
    installed_module_paths: list[str]
    depmod_output: str

class CompilerError(BaseModel):
    file: str
    line: int
    column: int | None = None
    message: str

class CompilerWarning(BaseModel):
    file: str
    line: int
    column: int | None = None
    message: str

class ArtifactBuildResult(BaseModel):
    success: bool
    artifact: BuildArtifact | None = None
    errors: list[CompilerError] = []
    warnings: list[CompilerWarning] = []
    duration_s: float
    stdout_tail: str
```

**Observability types (design doc section 2.4):**

```python
class DmesgLine(BaseModel):
    timestamp: float
    level: Literal["debug", "info", "notice", "warning", "err", "crit", "alert", "emerg"]
    facility: str
    text: str
    classified_as: Literal["oops", "panic", "warn", "kasan", "ubsan", "normal"] | None = None

class DmesgResult(BaseModel):
    lines: list[DmesgLine]
    oops_detected: bool
    panic_detected: bool
    warning_count: int
    error_count: int
```

**Network fault types (design doc section 2.4):**

```python
class NetFaultHandle(BaseModel):
    handle_id: str
    fault_type: Literal["partition", "impairment"]
    affected_nodes: list[str]
    created_at: str              # ISO 8601

class NetFaultInfo(BaseModel):
    handle_id: str
    fault_type: Literal["partition", "impairment"]
    affected_nodes: list[str]
    created_at: str
    parameters: dict[str, str | int | float]  # latency_ms, loss_pct, etc.
```

Create `tests/test_models.py` with tests:
- Test ToolResult[str] instantiation and serialization
- Test ToolResult[ClusterStatus] with nested model
- Test ToolResult with success=False and ErrorDetail
- Test ErrorDetail defaults (node=None, recoverable=False)
- Test NodeDescriptor defaults (ssh_port=22, ssh_user="root")
- Test NodeStatus domain_state accepts all 5 Literal values
- Test ExecResult fields
- Test model round-trip (model_dump → model_validate)
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && uv run pytest tests/test_models.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/models.py contains `class ToolResult(BaseModel, Generic[T]):`
    - vmcluster_mcp/models.py contains `class ErrorDetail(BaseModel):`
    - vmcluster_mcp/models.py contains `class ClusterHandle(BaseModel):`
    - vmcluster_mcp/models.py contains `class ClusterStatus(BaseModel):`
    - vmcluster_mcp/models.py contains `class NodeStatus(BaseModel):`
    - vmcluster_mcp/models.py contains `class NodeDescriptor(BaseModel):`
    - vmcluster_mcp/models.py contains `class ExecResult(BaseModel):`
    - vmcluster_mcp/models.py contains `class BuildArtifact(BaseModel):`
    - vmcluster_mcp/models.py contains `class DmesgResult(BaseModel):`
    - vmcluster_mcp/models.py contains `class DmesgLine(BaseModel):`
    - vmcluster_mcp/models.py contains `class NetFaultHandle(BaseModel):`
    - vmcluster_mcp/models.py contains `class ExecAllResult(BaseModel):`
    - ErrorDetail has fields: code (str), message (str), node (str | None), recoverable (bool)
    - ToolResult has fields: success (bool), result (T | None), error (ErrorDetail | None)
    - tests/test_models.py exits 0 with all tests passing
  </acceptance_criteria>
  <done>All Pydantic v2 models from the design doc are defined in a single models.py with zero internal dependencies. Tests prove ToolResult[T] generics, ErrorDetail defaults, and model serialization round-trips work correctly.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create server.py with FastMCP skeleton and @structured_tool_handler decorator</name>
  <files>vmcluster_mcp/server.py, vmcluster_mcp/__main__.py, tests/test_server.py</files>
  <read_first>
    - vmcluster_mcp/models.py (must import ToolResult and ErrorDetail correctly)
    - .planning/research/STACK.md (FastMCP patterns: `from mcp.server.fastmcp import FastMCP`, `mcp = FastMCP("vmcluster-mcp")`, `mcp.run()`)
    - .planning/research/PITFALLS.md (Pitfall #2: exception leakage bypass — every tool must have catch-all)
    - docs/vmcluster-mcp-design.md (section 2.7 for server entrypoint, section 2.8 for error model)
  </read_first>
  <behavior>
    - A tool decorated with @structured_tool_handler that raises ValueError returns ToolResult(success=False, error=ErrorDetail(code="INTERNAL_ERROR", message containing "ValueError"))
    - A tool decorated with @structured_tool_handler that raises RuntimeError returns ToolResult(success=False, error=ErrorDetail(code="INTERNAL_ERROR"))
    - A tool decorated with @structured_tool_handler that returns normally passes through the return value unchanged
    - The decorator preserves the original function's name and docstring (functools.wraps)
    - `python -m vmcluster_mcp` invokes mcp.run() via __main__.py
  </behavior>
  <action>
Create `vmcluster_mcp/server.py`:

```python
from __future__ import annotations

import functools
import logging
from typing import Any

from mcp.server.fastmcp import FastMCP

from .models import ErrorDetail, ToolResult

logger = logging.getLogger("vmcluster_mcp")

mcp = FastMCP("vmcluster-mcp")


def structured_tool_handler(func):
    """Catch-all decorator ensuring every tool returns ToolResult, never a raw exception.

    Per design doc section 2.8: all tool exits must be ToolResult.
    Exception leakage from tool handlers is a contract violation.
    """

    @functools.wraps(func)
    async def wrapper(*args: Any, **kwargs: Any) -> ToolResult:
        try:
            return await func(*args, **kwargs)
        except Exception as e:
            logger.exception("Tool handler %s raised %s", func.__name__, type(e).__name__)
            return ToolResult(
                success=False,
                result=None,
                error=ErrorDetail(
                    code="INTERNAL_ERROR",
                    message=f"{type(e).__name__}: {e}",
                    node=None,
                    recoverable=False,
                ),
            )

    return wrapper
```

Create `vmcluster_mcp/__main__.py`:

```python
from .server import mcp

mcp.run()
```

Create `tests/test_server.py` with tests:

- Test `structured_tool_handler` catches ValueError and returns ToolResult(success=False) with error containing "ValueError"
- Test `structured_tool_handler` catches RuntimeError and returns ToolResult(success=False)
- Test `structured_tool_handler` passes through normal return value unchanged
- Test the decorator preserves `__name__` and `__doc__` via functools.wraps
- Test that error.code is "INTERNAL_ERROR" and error.recoverable is False for caught exceptions

For the decorator tests, create simple async test functions, decorate them, and verify return values:

```python
async def test_handler_catches_exception():
    @structured_tool_handler
    async def failing_tool():
        raise ValueError("test error")

    result = await failing_tool()
    assert result.success is False
    assert result.error is not None
    assert result.error.code == "INTERNAL_ERROR"
    assert "ValueError" in result.error.message
    assert "test error" in result.error.message
    assert result.error.recoverable is False
```
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && uv run pytest tests/test_server.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/server.py contains `mcp = FastMCP("vmcluster-mcp")`
    - vmcluster_mcp/server.py contains `def structured_tool_handler(func):`
    - vmcluster_mcp/server.py contains `from .models import ErrorDetail, ToolResult`
    - vmcluster_mcp/server.py contains `return ToolResult(` inside the except block
    - vmcluster_mcp/server.py contains `code="INTERNAL_ERROR"` in the fallback ErrorDetail
    - vmcluster_mcp/server.py contains `@functools.wraps(func)`
    - vmcluster_mcp/__main__.py contains `from .server import mcp`
    - vmcluster_mcp/__main__.py contains `mcp.run()`
    - tests/test_server.py exits 0 with all tests passing
  </acceptance_criteria>
  <done>FastMCP server skeleton exists with `structured_tool_handler` decorator that converts any unhandled exception into ToolResult(success=False, error=ErrorDetail(code="INTERNAL_ERROR")). Entry point at `python -m vmcluster_mcp` invokes mcp.run(). Tests prove exception-to-ToolResult conversion, passthrough of normal returns, and functools.wraps preservation.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/test_models.py tests/test_server.py -x -v` — all tests pass
- `grep -c "class.*BaseModel" vmcluster_mcp/models.py` returns 15+ (all model classes defined)
- `grep "structured_tool_handler" vmcluster_mcp/server.py` confirms decorator exists
- `grep "mcp.run" vmcluster_mcp/__main__.py` confirms entry point
</verification>

<success_criteria>
The complete Pydantic v2 data contract is defined in models.py with zero internal dependencies. The FastMCP server skeleton boots and the @structured_tool_handler decorator provably converts any exception into a structured ToolResult — no raw tracebacks can reach the agent. Tests validate both the data contract and the error handling contract.
</success_criteria>

<output>
After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md`
</output>
