# Plan: Domain Models and Ports

## Context

Scaffolding is complete: the repo structure, CI, tooling, and ADRs are in place. The `domain/` package is empty. Before building any adapter or graph logic, we need the domain core — the Pydantic models that flow through the system and the Protocol ports that define how the hexagon interior interacts with adapters.

This is the foundation everything else depends on. Getting the types right now means the graph nodes, adapters, and CLI can all be built against stable contracts.

## Approach

Two files in `src/mcp_auditor/domain/`:
- `models.py` — all Pydantic models and enums
- `ports.py` — Protocol classes for MCP client and LLM

Unit tests cover observable behavior: validation rules, enum constraints, serialization round-trips, edge cases on optional fields. Tests follow the Given/When/Then pattern from CLAUDE.md, with given/then helpers extracted only when they abstract something meaningful.

## Files to create

### `src/mcp_auditor/domain/models.py`

All domain types, ordered top-to-bottom by dependency (enums first, then simple models, then composite ones).

```python
from enum import Enum
from typing import Any

from pydantic import BaseModel, Field


class AuditCategory(str, Enum):
    INPUT_VALIDATION = "input_validation"
    ERROR_HANDLING = "error_handling"
    INJECTION = "injection"
    INFO_LEAKAGE = "info_leakage"
    RESOURCE_ABUSE = "resource_abuse"


class Severity(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


class EvalVerdict(str, Enum):
    PASS = "pass"
    FAIL = "fail"


class ToolDefinition(BaseModel):
    """Domain representation of an MCP tool. Decoupled from mcp.types.Tool."""

    name: str
    description: str
    input_schema: dict[str, Any]


class AuditPayload(BaseModel):
    """Payload generated by the LLM to test an MCP tool."""

    tool_name: str
    category: AuditCategory
    description: str = Field(description="What this test case verifies")
    arguments: dict[str, Any]


class TestCaseBatch(BaseModel):
    """Wrapper for structured output — with_structured_output returns a single BaseModel, not a list."""

    cases: list[AuditPayload]


class EvalResult(BaseModel):
    """Evaluation result from the LLM-as-a-judge."""

    tool_name: str
    category: AuditCategory
    payload: dict[str, Any]
    verdict: EvalVerdict
    justification: str
    severity: Severity


class TestCase(BaseModel):
    """A complete test case: payload + response + evaluation."""

    payload: AuditPayload
    response: str | dict[str, Any] | None = None
    error: str | None = None
    eval_result: EvalResult | None = None


class TokenUsage(BaseModel):
    """LLM token consumption tracker. Cost is computed at report time, not here."""

    input_tokens: int = 0
    output_tokens: int = 0

    def add(self, other: "TokenUsage") -> "TokenUsage":
        """Return a new TokenUsage with accumulated totals."""
        return TokenUsage(
            input_tokens=self.input_tokens + other.input_tokens,
            output_tokens=self.output_tokens + other.output_tokens,
        )
```

### `src/mcp_auditor/domain/ports.py`

Protocol classes. The domain defines these interfaces; graph nodes use them, adapters implement them.

```python
from typing import Any, Protocol

from pydantic import BaseModel

from mcp_auditor.domain.models import TokenUsage, ToolDefinition


class MCPClientPort(Protocol):
    """Port for interacting with an MCP server.

    Lifecycle (connect/disconnect) is managed by the adapter via an async
    context manager. The port only exposes business operations.
    """

    async def list_tools(self) -> list[ToolDefinition]: ...
    async def call_tool(self, name: str, args: dict[str, Any]) -> Any: ...


class LLMPort(Protocol):
    """Port for LLM calls — generic structured output."""

    async def generate_structured[T: BaseModel](
        self, prompt: str, output_schema: type[T]
    ) -> T: ...

    @property
    def usage_stats(self) -> TokenUsage: ...
```

Note: `generate_structured` uses PEP 695 type parameter syntax (Python 3.12+, project targets 3.13+). The generic ensures callers get back the exact type they asked for, not a bare `BaseModel`.

### `tests/unit/test_models.py`

Tests for model validation behavior. Focus on what Pydantic enforces, not on Pydantic internals.

```python
"""Tests for domain model validation and behavior.

Only tests that validate OUR design choices — enum constraints, union types,
custom methods, serialization of non-trivial types. Does not test that
Pydantic constructors work or that Python lists hold items.
"""

import pytest

from mcp_auditor.domain import (
    AuditCategory,
    AuditPayload,
    EvalResult,
    EvalVerdict,
    Severity,
    TestCase,
    TokenUsage,
    ToolDefinition,
)


def _a_payload(**overrides: object) -> AuditPayload:
    defaults = {
        "tool_name": "test_tool",
        "category": AuditCategory.INPUT_VALIDATION,
        "description": "test payload",
        "arguments": {},
    }
    return AuditPayload(**(defaults | overrides))


class TestEnumConstraints:
    def test_rejects_unknown_category(self):
        with pytest.raises(ValueError):
            _a_payload(category="unknown_category")

    def test_rejects_unknown_severity(self):
        with pytest.raises(ValueError):
            EvalResult(
                tool_name="t",
                category=AuditCategory.INJECTION,
                payload={},
                verdict=EvalVerdict.FAIL,
                justification="j",
                severity="unknown",
            )


class TestToolDefinition:
    def test_roundtrip_serialization(self):
        tool = ToolDefinition(
            name="get_user",
            description="Fetch a user by ID",
            input_schema={"type": "object", "properties": {"id": {"type": "integer"}}},
        )

        rebuilt = ToolDefinition.model_validate_json(tool.model_dump_json())

        assert rebuilt == tool


class TestTestCase:
    def test_response_accepts_dict(self):
        test_case = TestCase(payload=_a_payload(), response={"result": "ok"})

        assert test_case.response == {"result": "ok"}

    def test_response_accepts_string(self):
        test_case = TestCase(payload=_a_payload(), response="raw text")

        assert test_case.response == "raw text"


class TestTokenUsage:
    def test_add_accumulates(self):
        a = TokenUsage(input_tokens=100, output_tokens=50)
        b = TokenUsage(input_tokens=200, output_tokens=100)

        total = a.add(b)

        assert total.input_tokens == 300
        assert total.output_tokens == 150

    def test_add_does_not_mutate(self):
        a = TokenUsage(input_tokens=100, output_tokens=50)
        b = TokenUsage(input_tokens=200, output_tokens=100)

        a.add(b)

        assert a.input_tokens == 100
        assert a.output_tokens == 50
```

No given/then extraction — the test setup is simple enough to inline everywhere. Extracting would just add indirection without abstracting anything.

### `tests/unit/test_ports.py`

Verify that concrete classes satisfying the Protocol are accepted by pyright. This is a compile-time check, not a runtime test — but having the file ensures pyright runs against it in CI.

```python
"""Structural typing checks for domain ports.

These tests verify that classes implementing the Protocol interfaces
are accepted by the type checker. They run at test time but the real
value is in the pyright check during CI.
"""

from typing import Any

from pydantic import BaseModel

from mcp_auditor.domain import LLMPort, MCPClientPort, TokenUsage, ToolDefinition


class FakeMCPClient:
    async def list_tools(self) -> list[ToolDefinition]:
        return []

    async def call_tool(self, name: str, args: dict[str, Any]) -> Any:
        return None


class FakeLLM:
    async def generate_structured[T: BaseModel](
        self, prompt: str, output_schema: type[T]
    ) -> T:
        return output_schema.model_validate({})

    @property
    def usage_stats(self) -> TokenUsage:
        return TokenUsage()


def test_fake_mcp_client_satisfies_port() -> None:
    client: MCPClientPort = FakeMCPClient()
    assert client is not None


def test_fake_llm_satisfies_port() -> None:
    llm: LLMPort = FakeLLM()
    assert llm is not None
```

## Files to modify

### `src/mcp_auditor/domain/__init__.py`
Replace the empty file with re-exports of public domain types.

```python
from mcp_auditor.domain.models import (
    AuditCategory,
    AuditPayload,
    EvalResult,
    EvalVerdict,
    Severity,
    TestCase,
    TestCaseBatch,
    TokenUsage,
    ToolDefinition,
)
from mcp_auditor.domain.ports import LLMPort, MCPClientPort

__all__ = [
    "AuditCategory",
    "AuditPayload",
    "EvalResult",
    "EvalVerdict",
    "LLMPort",
    "MCPClientPort",
    "Severity",
    "TestCase",
    "TestCaseBatch",
    "TokenUsage",
    "ToolDefinition",
]
```

## What stays unchanged

- `src/mcp_auditor/graph/` — empty, not touched yet
- `src/mcp_auditor/adapters/` — empty, not touched yet
- `src/mcp_auditor/cli.py` — not touched yet
- `tests/integration/` — not touched yet
- `docs/adr/` — immutable, no changes
- `pyproject.toml` — no new dependencies needed

## Edge cases

- **`TestCase.response` is `str | dict[str, Any] | None`**: MCP tool responses can be structured (dict) or raw text (str). Both must be accepted. The union type handles this; Pydantic validates accordingly.
- **`TestCaseBatch.cases` can be empty**: A valid edge case if the LLM generates no payloads. The model allows it; the graph node will handle this condition.
- **`TokenUsage.add` returns a new instance**: Immutability prevents accidental mutation when accumulating across multiple LLM calls.

## Verification

```bash
uv run pytest tests/unit/test_models.py tests/unit/test_ports.py -v  # Tests pass
uv run ruff check src/mcp_auditor/domain/ tests/unit/test_models.py tests/unit/test_ports.py  # No lint errors
uv run pyright src/mcp_auditor/domain/ tests/unit/test_models.py tests/unit/test_ports.py  # No type errors
```

## Implementation steps

### Step 1: Domain models, ports, and their tests

**Files** (create/modify):
- `tests/unit/test_models.py` (create) -- test file first
- `tests/unit/test_ports.py` (create) -- test file first
- `src/mcp_auditor/domain/models.py` (create)
- `src/mcp_auditor/domain/ports.py` (create)
- `src/mcp_auditor/domain/__init__.py` (modify -- replace empty file with re-exports)

**Do**:

1. Create `tests/unit/test_models.py` with test classes as specified in the plan: `TestEnumConstraints`, `TestToolDefinition`, `TestTestCase`, `TestTokenUsage`. Only tests that validate our design choices (enum constraints, union types, custom methods, serialization). No given/then extraction -- inline everything.

2. Create `tests/unit/test_ports.py` with `FakeMCPClient`, `FakeLLM`, and the two structural typing tests, exactly as specified in the plan.

3. Run the tests to confirm they fail (modules do not exist yet).

4. Create `src/mcp_auditor/domain/models.py` with all enums (`AuditCategory`, `Severity`, `EvalVerdict`) and models (`ToolDefinition`, `AuditPayload`, `TestCaseBatch`, `EvalResult`, `TestCase`, `TokenUsage`) exactly as specified in the plan.

5. Create `src/mcp_auditor/domain/ports.py` with `MCPClientPort` and `LLMPort` Protocol classes exactly as specified in the plan. `LLMPort.generate_structured` uses PEP 695 type parameter syntax.

6. Replace `src/mcp_auditor/domain/__init__.py` with re-exports of all public domain types and an `__all__` list, exactly as specified in the plan.

7. Run the tests again to confirm they pass.

**Test**: All scenarios listed in the plan:
- Enum constraints: rejects unknown category; rejects unknown severity
- `ToolDefinition`: JSON serialization round-trip
- `TestCase`: response accepts dict; response accepts string (union type validation)
- `TokenUsage`: `add` accumulates; `add` does not mutate
- Ports: `FakeMCPClient` satisfies `MCPClientPort`; `FakeLLM` satisfies `LLMPort`

**Verify**:
```bash
uv run pytest tests/unit/test_models.py tests/unit/test_ports.py -v
uv run ruff check src/mcp_auditor/domain/ tests/unit/test_models.py tests/unit/test_ports.py
uv run ruff format --check src/mcp_auditor/domain/ tests/unit/test_models.py tests/unit/test_ports.py
uv run pyright src/mcp_auditor/domain/ tests/unit/test_models.py tests/unit/test_ports.py
```
