---
phase: 03-ssh-execution
plan: 02
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
  - vmcluster_mcp/exec/__init__.py
  - vmcluster_mcp/server.py
  - tests/test_node_exec.py
autonomous: true
requirements:
  - EXEC-01

must_haves:
  truths:
    - "Agent can run a shell command on a single named node and receive exit_code, stdout, stderr, and duration_s"
    - "node_exec returns ToolResult[ExecResult] — never raw exceptions"
    - "Invalid cluster_name or node_name returns ToolResult with success=False and descriptive error"
  artifacts:
    - path: "vmcluster_mcp/exec/__init__.py"
      provides: "node_exec MCP tool"
      exports: ["node_exec"]
      min_lines: 40
    - path: "tests/test_node_exec.py"
      provides: "Unit tests for node_exec tool"
      min_lines: 40
  key_links:
    - from: "vmcluster_mcp/exec/__init__.py"
      to: "vmcluster_mcp/exec/ssh.py"
      via: "SSHConnectionManager.execute() for command execution"
      pattern: "_ssh_manager\\.execute\\("
    - from: "vmcluster_mcp/exec/__init__.py"
      to: "vmcluster_mcp/topology/parser.py"
      via: "load_topology to resolve node IP and SSH credentials"
      pattern: "load_topology\\("
    - from: "vmcluster_mcp/server.py"
      to: "vmcluster_mcp/exec/__init__.py"
      via: "import to trigger tool registration"
      pattern: "from vmcluster_mcp import.*exec"
---

<objective>
Create the `node_exec` MCP tool that runs a shell command on a single named cluster node and returns structured output. Wire the exec module into the server.

Purpose: EXEC-01 — agent's primary interface for running commands on individual cluster nodes.

Output: `vmcluster_mcp/exec/__init__.py` with `node_exec` tool, server import, and unit tests.
</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/REQUIREMENTS.md
@.planning/phases/03-ssh-execution/03-01-PLAN.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From vmcluster_mcp/models.py:
```python
class ExecResult(BaseModel):
    exit_code: int
    stdout: str
    stderr: str
    duration_s: float
    timed_out: bool

class ErrorDetail(BaseModel):
    code: str
    message: str
    node: str | None = None
    recoverable: bool = False

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

From vmcluster_mcp/server.py:
```python
from .config import load_config
from .models import ErrorDetail, ToolResult
config = load_config()
mcp = FastMCP("vmcluster-mcp")
def structured_tool_handler(func): ...
# Current last import:
from vmcluster_mcp import cluster  # noqa: E402, F401
```

From vmcluster_mcp/topology/schema.py:
```python
class NodeSpec(BaseModel):
    name: str
    role: str
    vcpus: int
    memory_mb: int
    ip: str

class SshSpec(BaseModel):
    key_path: str
    user: str = "root"
    connect_timeout_s: int = 30

class TopologySpec(BaseModel):
    cluster_name: str
    nodes: list[NodeSpec]
    ssh: SshSpec
```

From vmcluster_mcp/topology/parser.py:
```python
def load_topology(topology_name: str, topology_dir: str) -> TopologySpec: ...
```

From vmcluster_mcp/exec/ssh.py (created in 03-01):
```python
class SSHConnectionManager:
    async def execute(self, ip, port, user, key_path, command, timeout_s=30) -> ExecResult: ...
    async def invalidate(self, ip: str) -> None: ...
    async def close_all(self) -> None: ...
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create node_exec tool with structured ExecResult output</name>
  <files>vmcluster_mcp/exec/__init__.py, tests/test_node_exec.py</files>
  <read_first>
    - vmcluster_mcp/exec/ssh.py (SSHConnectionManager API — created in 03-01)
    - vmcluster_mcp/models.py (ExecResult, ToolResult, ErrorDetail)
    - vmcluster_mcp/server.py (mcp, config, structured_tool_handler imports)
    - vmcluster_mcp/cluster/__init__.py (pattern reference — how cluster tools are structured, load_topology usage)
    - vmcluster_mcp/topology/schema.py (TopologySpec, NodeSpec, SshSpec)
    - vmcluster_mcp/topology/parser.py (load_topology signature)
  </read_first>
  <behavior>
    - Test 1: node_exec with valid cluster_name, node_name, command returns ToolResult with success=True and ExecResult containing exit_code=0, stdout populated, stderr empty
    - Test 2: node_exec with non-existent node_name returns ToolResult with success=False, error.code="NODE_NOT_FOUND"
    - Test 3: node_exec where command has non-zero exit code returns ToolResult with success=True and ExecResult.exit_code matching the actual exit code (non-zero exit is NOT a tool failure)
    - Test 4: node_exec where SSH command times out returns ToolResult with success=True and ExecResult.timed_out=True
    - Test 5: node_exec where SSHConnectionManager raises an exception returns ToolResult with success=False via structured_tool_handler
    - Test 6: node_exec passes timeout_s parameter through to SSHConnectionManager.execute()
  </behavior>
  <action>
    Create `vmcluster_mcp/exec/__init__.py`:

    ```python
    """SSH execution tools — node_exec, node_exec_all.

    Tools register on the FastMCP server instance imported from server.py.
    All tool handlers are wrapped with @structured_tool_handler.
    """
    from __future__ import annotations

    import logging
    from vmcluster_mcp.exec.ssh import SSHConnectionManager
    from vmcluster_mcp.models import ErrorDetail, ExecResult, ToolResult
    from vmcluster_mcp.server import config, mcp, structured_tool_handler
    from vmcluster_mcp.topology.parser import load_topology

    logger = logging.getLogger("vmcluster_mcp.exec")

    # Module-level SSH connection manager — shared across all exec tool calls
    _ssh_manager = SSHConnectionManager()
    ```

    **`node_exec` tool:**
    - Signature: `async def node_exec(cluster_name: str, node_name: str, command: str, timeout_s: int = 30) -> ToolResult[ExecResult]`
    - Decorated with `@mcp.tool()` then `@structured_tool_handler`
    - Load topology via `load_topology(cluster_name, config.topology_dir)`
    - Find the node by `node_name` in `topo.nodes` — if not found, return `ToolResult(success=False, error=ErrorDetail(code="NODE_NOT_FOUND", message=f"Node '{node_name}' not found in cluster '{cluster_name}'", node=node_name, recoverable=False))`
    - Call `_ssh_manager.execute(ip=node.ip, port=22, user=topo.ssh.user, key_path=topo.ssh.key_path, command=command, timeout_s=timeout_s)`
    - Return `ToolResult(success=True, result=exec_result)`
    - Note: non-zero exit codes are NOT tool failures — the tool succeeded in executing the command. The caller inspects `ExecResult.exit_code`.

    **Docstring** must describe: args (cluster_name, node_name, command, timeout_s), returns (ToolResult[ExecResult] with exit_code, stdout, stderr, duration_s, timed_out).
  </action>
  <acceptance_criteria>
    - vmcluster_mcp/exec/__init__.py contains `@mcp.tool()` decorator
    - vmcluster_mcp/exec/__init__.py contains `@structured_tool_handler` decorator
    - vmcluster_mcp/exec/__init__.py contains `async def node_exec(cluster_name: str, node_name: str, command: str, timeout_s: int = 30)`
    - vmcluster_mcp/exec/__init__.py contains `_ssh_manager = SSHConnectionManager()`
    - vmcluster_mcp/exec/__init__.py contains `load_topology(cluster_name, config.topology_dir)`
    - vmcluster_mcp/exec/__init__.py contains `NODE_NOT_FOUND` error code
    - vmcluster_mcp/exec/__init__.py contains `_ssh_manager.execute(`
    - tests/test_node_exec.py exists with at least 5 test functions
    - `pytest tests/test_node_exec.py -x` exits 0
    - `ruff check vmcluster_mcp/exec/__init__.py` exits 0
  </acceptance_criteria>
  <verify>
    <automated>pytest tests/test_node_exec.py -x && ruff check vmcluster_mcp/exec/__init__.py</automated>
  </verify>
  <done>node_exec tool registered on FastMCP, loads topology, finds node, executes command via SSHConnectionManager, returns structured ToolResult[ExecResult]. All tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Wire exec module import in server.py</name>
  <files>vmcluster_mcp/server.py</files>
  <read_first>
    - vmcluster_mcp/server.py (current import structure — cluster import is at bottom after mcp definition)
  </read_first>
  <action>
    Add exec module import to `vmcluster_mcp/server.py` alongside the existing cluster import. The import must come AFTER the `mcp` definition (same pattern as the cluster import).

    Change the existing import line:
    ```python
    from vmcluster_mcp import cluster  # noqa: E402, F401 — must follow mcp definition; triggers tool registration
    ```

    To:
    ```python
    from vmcluster_mcp import cluster  # noqa: E402, F401 — must follow mcp definition; triggers tool registration
    from vmcluster_mcp import exec  # noqa: E402, F401 — triggers exec tool registration
    ```
  </action>
  <acceptance_criteria>
    - vmcluster_mcp/server.py contains `from vmcluster_mcp import exec`
    - `python -c "from vmcluster_mcp.server import mcp"` exits 0 (no import errors)
    - `ruff check vmcluster_mcp/server.py` exits 0
  </acceptance_criteria>
  <verify>
    <automated>ruff check vmcluster_mcp/server.py && python -c "from vmcluster_mcp import server" 2>&1 | head -5</automated>
  </verify>
  <done>server.py imports exec module, triggering node_exec tool registration on startup.</done>
</task>

</tasks>

<verification>
- `pytest tests/test_node_exec.py -x` passes all tests
- `ruff check vmcluster_mcp/exec/__init__.py vmcluster_mcp/server.py` shows no lint errors
- `grep "node_exec" vmcluster_mcp/exec/__init__.py` confirms tool exists
- `grep "from vmcluster_mcp import exec" vmcluster_mcp/server.py` confirms wiring
</verification>

<success_criteria>
`node_exec` tool is registered on FastMCP, accepts cluster_name/node_name/command/timeout_s, returns structured ToolResult[ExecResult] with exit_code, stdout, stderr, duration_s, timed_out. Server imports exec module on startup. All tests pass with mocked SSH.
</success_criteria>

<output>
After completion, create `.planning/phases/03-ssh-execution/03-02-SUMMARY.md`
</output>
