---
phase: 03-agent-interfaces
plan: 03
type: execute
wave: 2
depends_on:
  - 03-01
  - 03-02
files_modified:
  - src/corpus_analyzer/cli.py
  - tests/mcp/__init__.py
  - tests/mcp/test_server.py
  - tests/api/__init__.py
  - tests/api/test_public.py
autonomous: true
requirements:
  - MCP-01
  - MCP-02
  - MCP-03
  - MCP-04
  - MCP-05
  - MCP-06
  - API-01
  - API-02
  - API-03

must_haves:
  truths:
    - "corpus mcp serve subcommand exists in the CLI and delegates to mcp.run()"
    - "MCP server tests cover: tool response shape, empty results, error path, stdout isolation"
    - "Python API tests cover: SearchResult field presence, search() calls hybrid_search, index() triggers index_source"
    - "All Phase 3 tests pass with uv run pytest tests/mcp/ tests/api/ -v"
    - "Full test suite still passes after Phase 3 additions"
  artifacts:
    - path: "tests/mcp/__init__.py"
      provides: "MCP test package initializer"
    - path: "tests/mcp/test_server.py"
      provides: "Unit tests for corpus_search tool behavior"
      min_lines: 50
    - path: "tests/api/__init__.py"
      provides: "API test package initializer"
    - path: "tests/api/test_public.py"
      provides: "Unit tests for search(), index(), SearchResult, _find_config()"
      min_lines: 50
  key_links:
    - from: "src/corpus_analyzer/cli.py"
      to: "corpus_analyzer.mcp.server.mcp"
      via: "mcp_serve command calls mcp.run()"
      pattern: "mcp\\.run\\(\\)"
    - from: "tests/mcp/test_server.py"
      to: "corpus_analyzer.mcp.server.corpus_search"
      via: "direct function call with mocked ctx.lifespan_context"
      pattern: "corpus_search"
    - from: "tests/api/test_public.py"
      to: "corpus_analyzer.api.public.search"
      via: "patched _open_engine() returning mock CorpusSearch"
      pattern: "mock.*hybrid_search"
---

<objective>
Wire the `corpus mcp serve` CLI subcommand and write unit tests for the MCP server module
and Python public API that verify all Phase 3 behaviors without requiring a live Ollama instance.

Purpose: The CLI subcommand completes the MCP entry point story (user runs `corpus mcp serve`,
Claude Code gets a working MCP server). Tests lock in the contract so future refactors don't
silently break the agent-facing interfaces.

Output: `corpus mcp serve` CLI command, `tests/mcp/test_server.py`,
`tests/api/test_public.py`.
</objective>

<execution_context>
@/home/ollie/.claude/get-shit-done/workflows/execute-plan.md
@/home/ollie/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-agent-interfaces/03-CONTEXT.md
@.planning/phases/03-agent-interfaces/03-RESEARCH.md
@.planning/phases/03-agent-interfaces/03-01-SUMMARY.md
@.planning/phases/03-agent-interfaces/03-02-SUMMARY.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add corpus mcp serve CLI subcommand to cli.py</name>
  <files>src/corpus_analyzer/cli.py</files>
  <action>
Add a `mcp_app` Typer sub-application to `src/corpus_analyzer/cli.py`, following the same pattern
as the existing `db_app` and `samples_app` sub-groups.

**Where to insert:**
After the `templates_app` sub-group (around line 243), add:

```python
mcp_app = typer.Typer(help="MCP server commands.")
app.add_typer(mcp_app, name="mcp")
```

**The serve command:**
```python
@mcp_app.command("serve")
def mcp_serve() -> None:
    """Start the corpus MCP server over stdio (for Claude Code and other MCP clients).

    Register with Claude Code:
        { "command": "corpus", "args": ["mcp", "serve"] }
    """
    from corpus_analyzer.mcp.server import mcp  # lazy import to avoid fastmcp at CLI startup

    mcp.run()  # defaults to stdio transport; writes nothing to stdout (MCP-04)
```

**Why lazy import:** Importing `corpus_analyzer.mcp.server` at module load time would execute
`logging.basicConfig(stream=sys.stderr)` on every CLI invocation, which is harmless but
cleaner to avoid. The lazy import also prevents fastmcp's startup side effects on other commands.

After editing:
```bash
uv run corpus mcp --help
uv run corpus mcp serve --help
```
Both must succeed. `corpus mcp serve --help` must show the docstring and NOT start the server.
  </action>
  <verify>
    <automated>uv run corpus mcp --help && uv run corpus mcp serve --help</automated>
    <manual>Confirm both help outputs exit 0 without starting the MCP server</manual>
    <sampling_rate>run after this task, before Task 2</sampling_rate>
  </verify>
  <done>
`uv run corpus mcp serve --help` exits 0 and shows the "Start the corpus MCP server" docstring.
`uv run mypy src/corpus_analyzer/cli.py` passes (no new errors).
  </done>
</task>

<task type="auto">
  <name>Task 2: Write unit tests for MCP server and Python public API</name>
  <files>
    tests/mcp/__init__.py
    tests/mcp/test_server.py
    tests/api/__init__.py
    tests/api/test_public.py
  </files>
  <action>
Create `tests/mcp/__init__.py` — empty file with module docstring:
```python
"""Tests for the corpus MCP server."""
```

Create `tests/api/__init__.py` — empty file with module docstring:
```python
"""Tests for the corpus Python public API."""
```

Create `tests/mcp/test_server.py` with the following tests. All tests mock external dependencies
(CorpusSearch, OllamaEmbedder, CorpusIndex) to avoid requiring a live Ollama or LanceDB instance.

**Test strategy for MCP server:** Since FastMCP tool functions are async, test the `corpus_search`
function directly by calling it with a mock `Context` object that has a populated
`lifespan_context` dict. Do NOT start the server process.

```python
"""Unit tests for the corpus MCP server (corpus_analyzer.mcp.server)."""

from __future__ import annotations

import sys
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

def _make_ctx(engine: Any) -> MagicMock:
    """Create a mock FastMCP Context with a populated lifespan_context."""
    ctx = MagicMock()
    ctx.lifespan_context = {"engine": engine, "config": MagicMock()}
    ctx.info = AsyncMock()
    return ctx


def _make_engine(raw_results: list[dict[str, Any]]) -> MagicMock:
    """Create a mock CorpusSearch engine returning the provided raw results."""
    engine = MagicMock()
    engine.hybrid_search.return_value = raw_results
    return engine


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_corpus_search_returns_results_shape() -> None:
    """Tool returns list of result dicts with all required fields."""
    from corpus_analyzer.mcp.server import corpus_search

    raw = [
        {
            "file_path": "/some/file.md",
            "_relevance_score": 0.9,
            "text": "hello world",
            "construct_type": "skill",
            "summary": "A skill file",
            "file_type": ".md",
        }
    ]
    engine = _make_engine(raw)
    ctx = _make_ctx(engine)

    with patch("corpus_analyzer.mcp.server.Path") as mock_path:
        mock_path.return_value.read_text.return_value = "# Hello\nContent"
        result = await corpus_search(query="hello", ctx=ctx)

    assert "results" in result
    assert len(result["results"]) == 1
    r = result["results"][0]
    assert r["path"] == "/some/file.md"
    assert r["score"] == pytest.approx(0.9)
    assert r["construct_type"] == "skill"
    assert r["summary"] == "A skill file"
    assert r["file_type"] == ".md"
    assert "snippet" in r
    assert "full_content" in r


@pytest.mark.asyncio
async def test_corpus_search_empty_results() -> None:
    """Empty result set returns {results: [], message: 'No results found for query: X'}."""
    from corpus_analyzer.mcp.server import corpus_search

    engine = _make_engine([])
    ctx = _make_ctx(engine)

    result = await corpus_search(query="nonexistent", ctx=ctx)

    assert result == {"results": [], "message": "No results found for query: nonexistent"}


@pytest.mark.asyncio
async def test_corpus_search_engine_none_raises_value_error() -> None:
    """When engine is None (startup failed), tool raises ValueError with actionable message."""
    from corpus_analyzer.mcp.server import corpus_search

    ctx = MagicMock()
    ctx.lifespan_context = {"engine": None, "config": MagicMock()}

    with pytest.raises(ValueError, match="corpus index"):
        await corpus_search(query="hello", ctx=ctx)


@pytest.mark.asyncio
async def test_corpus_search_passes_filters_to_hybrid_search() -> None:
    """Tool passes source, file_type, construct_type, and limit through to hybrid_search."""
    from corpus_analyzer.mcp.server import corpus_search

    engine = _make_engine([])
    ctx = _make_ctx(engine)

    await corpus_search(
        query="test",
        source="my-source",
        type=".py",
        construct="skill",
        top_k=3,
        ctx=ctx,
    )

    engine.hybrid_search.assert_called_once_with(
        "test",
        source="my-source",
        file_type=".py",
        construct_type="skill",
        limit=3,
    )


def test_server_module_does_not_write_to_stdout(capsys: pytest.CaptureFixture[str]) -> None:
    """Importing and using the server module must not write anything to stdout (MCP-04)."""
    import corpus_analyzer.mcp.server  # noqa: F401 (import for side effects check)

    captured = capsys.readouterr()
    assert captured.out == "", f"Unexpected stdout output: {captured.out!r}"
```

Note: `@pytest.mark.asyncio` requires `pytest-asyncio`. Check if it's in dev dependencies; if
not, add `pytest-asyncio>=0.23.0` to `[project.optional-dependencies] dev` in pyproject.toml
and re-run `uv sync`. Alternatively, run async tests with `asyncio.run()` inside sync test
functions:
```python
def test_corpus_search_returns_results_shape() -> None:
    import asyncio
    asyncio.run(_async_test_body())
```
Use whichever approach avoids adding a new dependency. If pytest-asyncio is NOT installed, use
`asyncio.run()` wrappers for all async tests.

---

Create `tests/api/test_public.py`:

```python
"""Unit tests for the corpus Python public API (corpus_analyzer.api.public)."""

from __future__ import annotations

from dataclasses import fields
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest


def test_search_result_has_required_fields() -> None:
    """SearchResult dataclass must have all 6 fields from API-01."""
    from corpus_analyzer.api.public import SearchResult

    field_names = {f.name for f in fields(SearchResult)}
    assert field_names == {"path", "file_type", "construct_type", "summary", "score", "snippet"}


def test_search_result_instantiation() -> None:
    """SearchResult can be instantiated with positional args and accessed by attribute."""
    from corpus_analyzer.api.public import SearchResult

    r = SearchResult(
        path="/foo/bar.md",
        file_type=".md",
        construct_type="skill",
        summary="A skill",
        score=0.85,
        snippet="hello world",
    )
    assert r.path == "/foo/bar.md"
    assert r.score == pytest.approx(0.85)


def test_search_calls_hybrid_search_and_maps_to_dataclasses() -> None:
    """search() delegates to CorpusSearch.hybrid_search() and maps results to SearchResult."""
    from corpus_analyzer.api.public import search

    mock_engine = MagicMock()
    mock_engine.hybrid_search.return_value = [
        {
            "file_path": "/x/y.md",
            "file_type": ".md",
            "construct_type": "prompt",
            "summary": "A prompt",
            "_relevance_score": 0.7,
            "text": "some prompt text",
        }
    ]

    with patch("corpus_analyzer.api.public._open_engine", return_value=(mock_engine, MagicMock())):
        results = search("test query")

    assert len(results) == 1
    assert results[0].path == "/x/y.md"
    assert results[0].construct_type == "prompt"
    assert results[0].score == pytest.approx(0.7)
    mock_engine.hybrid_search.assert_called_once_with(
        "test query",
        source=None,
        file_type=None,
        construct_type=None,
        limit=10,
    )


def test_search_passes_filters_to_hybrid_search() -> None:
    """search() passes keyword arguments through to hybrid_search filters."""
    from corpus_analyzer.api.public import search

    mock_engine = MagicMock()
    mock_engine.hybrid_search.return_value = []

    with patch("corpus_analyzer.api.public._open_engine", return_value=(mock_engine, MagicMock())):
        results = search("q", source="my-source", file_type=".py", construct_type="code", limit=5)

    assert results == []
    mock_engine.hybrid_search.assert_called_once_with(
        "q",
        source="my-source",
        file_type=".py",
        construct_type="code",
        limit=5,
    )


def test_index_calls_index_source_for_each_source() -> None:
    """index() calls CorpusIndex.index_source() once per configured source."""
    from corpus_analyzer.api.public import index

    mock_result = MagicMock()
    mock_result.files_indexed = 3
    mock_result.chunks_written = 12
    mock_result.files_skipped = 1
    mock_result.elapsed = 0.5

    mock_index = MagicMock()
    mock_index.index_source.return_value = mock_result

    mock_config = MagicMock()
    source_a = MagicMock()
    source_a.name = "source-a"
    source_b = MagicMock()
    source_b.name = "source-b"
    mock_config.sources = [source_a, source_b]

    with (
        patch("corpus_analyzer.api.public._find_config", return_value=Path("/fake/corpus.toml")),
        patch("corpus_analyzer.api.public.load_config", return_value=mock_config),
        patch("corpus_analyzer.api.public.OllamaEmbedder"),
        patch("corpus_analyzer.api.public.CorpusIndex") as mock_ci_class,
    ):
        mock_ci_class.open.return_value = mock_index
        results = index()

    assert len(results) == 2
    assert mock_index.index_source.call_count == 2
    assert results[0]["source_name"] == "source-a"
    assert results[0]["files_indexed"] == 3


def test_find_config_walks_up_from_cwd(tmp_path: Path) -> None:
    """_find_config() finds corpus.toml in a parent directory above CWD."""
    from corpus_analyzer.api.public import _find_config

    # Create corpus.toml in tmp_path/project/ and CWD at tmp_path/project/src/
    (tmp_path / "project").mkdir()
    config_file = tmp_path / "project" / "corpus.toml"
    config_file.write_text("")
    subdir = tmp_path / "project" / "src"
    subdir.mkdir()

    with patch("corpus_analyzer.api.public.Path") as mock_path_class:
        # Simulate CWD as subdir; walk will find corpus.toml in parent
        # This test is best run with an actual tmp_path monkeypatch on Path.cwd()
        pass  # Use monkeypatch approach below

    # Direct approach with monkeypatch
    import os
    original_cwd = os.getcwd()
    try:
        os.chdir(subdir)
        found = _find_config()
        # It will find the corpus.toml in the parent since .git doesn't exist and
        # will walk up until filesystem root — fallback to XDG. Instead, place .git sentinel:
        (tmp_path / "project" / ".git").mkdir()
        os.chdir(subdir)
        found2 = _find_config()
        # corpus.toml exists in parent of .git dir, so it should be found first
        assert found2 == config_file
    finally:
        os.chdir(original_cwd)
```

After writing all test files, run:
```bash
uv run pytest tests/mcp/ tests/api/ -v --no-header
```

Fix any import errors or mock mismatches. The tests must all pass. If pytest-asyncio is required
and not installed, use `asyncio.run()` wrappers instead of `@pytest.mark.asyncio`.

Also run the full suite to confirm nothing regressed:
```bash
uv run pytest -x -q
```
  </action>
  <verify>
    <automated>uv run pytest tests/mcp/ tests/api/ -v --no-header</automated>
    <manual>Confirm all new tests pass; confirm test count matches number of test functions written</manual>
    <sampling_rate>run after this task commits</sampling_rate>
  </verify>
  <done>
`uv run pytest tests/mcp/ tests/api/ -v` — all tests pass, 0 failures.
`uv run pytest -x -q` — full suite passes (no regressions from Phase 3 additions).
`uv run mypy src/corpus_analyzer/mcp/ src/corpus_analyzer/api/ src/corpus/` — clean.
`uv run ruff check src/corpus_analyzer/mcp/ src/corpus_analyzer/api/ src/corpus/ tests/mcp/ tests/api/` — clean.
  </done>
</task>

</tasks>

<verification>
After both tasks complete:
1. `uv run corpus mcp serve --help` — exits 0, shows docstring
2. `uv run pytest tests/mcp/ tests/api/ -v` — all tests pass
3. `uv run pytest -x -q` — full suite passes (no regressions)
4. `uv run mypy src/corpus_analyzer/mcp/ src/corpus_analyzer/api/ src/corpus/ src/corpus_analyzer/cli.py` — clean
5. `uv run ruff check src/` — clean (or same pre-existing E501 issues only)
6. Smoke-test: `uv run corpus --help` shows `mcp` as a subcommand group
</verification>

<success_criteria>
- `corpus mcp serve --help` works; `corpus mcp serve` would start the FastMCP stdio server
- `tests/mcp/test_server.py` tests: response shape, empty results, engine-None error, filter passthrough, stdout isolation
- `tests/api/test_public.py` tests: SearchResult fields, search() delegation, index() indexing, _find_config() walk-up
- All Phase 3 tests pass; no regressions in Phase 1/2 tests
- All Phase 3 requirement IDs (MCP-01 through MCP-06, API-01 through API-03) satisfied end-to-end
</success_criteria>

<output>
After completion, create `.planning/phases/03-agent-interfaces/03-03-SUMMARY.md`
</output>
