---
phase: 03-agent-interfaces
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - pyproject.toml
  - src/corpus_analyzer/mcp/__init__.py
  - src/corpus_analyzer/mcp/server.py
autonomous: true
requirements:
  - MCP-01
  - MCP-02
  - MCP-03
  - MCP-04
  - MCP-05
  - MCP-06

must_haves:
  truths:
    - "FastMCP server module exists and imports without error"
    - "corpus_search tool accepts query + optional source/type/construct/top_k params"
    - "MCP response includes path, score, snippet, full_content, construct_type, summary, file_type per result"
    - "Empty results return {results: [], message: 'No results found for query: X'}"
    - "Index/model errors raise ValueError with actionable message (not crash silently)"
    - "Server writes nothing to stdout — stdlib logging redirected to sys.stderr"
    - "Embedding model pre-warmed in lifespan before first tool call"
  artifacts:
    - path: "src/corpus_analyzer/mcp/__init__.py"
      provides: "MCP package initializer"
    - path: "src/corpus_analyzer/mcp/server.py"
      provides: "FastMCP server with corpus_search tool and lifespan pre-warm"
      min_lines: 60
    - path: "pyproject.toml"
      provides: "fastmcp dependency + corpus entry point alias"
      contains: "fastmcp"
  key_links:
    - from: "src/corpus_analyzer/mcp/server.py"
      to: "corpus_analyzer.search.engine.CorpusSearch"
      via: "corpus_lifespan stores engine in lifespan context"
      pattern: "lifespan_context.*engine"
    - from: "corpus_search tool"
      to: "CorpusSearch.hybrid_search"
      via: "ctx.lifespan_context['engine'].hybrid_search()"
      pattern: "hybrid_search"
---

<objective>
Install FastMCP dependency, add `corpus` entry point alias, and implement the FastMCP server module
with lifespan-based embedding pre-warm and a single `corpus_search` tool.

Purpose: MCP server is the primary consumer interface for Claude Code and other agents. Getting
it right requires careful attention to stdout/stderr routing (MCP-04), schema compatibility
(MCP-05), and cold-start elimination (MCP-06).

Output: `src/corpus_analyzer/mcp/server.py` with working FastMCP server; `pyproject.toml` updated
with fastmcp dependency and `corpus` entry point alias.
</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/02-search-core/02-02-SUMMARY.md
@.planning/phases/02-search-core/02-05-SUMMARY.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: pyproject.toml — add fastmcp dependency and corpus entry point alias</name>
  <files>pyproject.toml</files>
  <action>
Make two additions to pyproject.toml:

1. Add `fastmcp` to `[project.dependencies]` (per RESEARCH.md: `fastmcp>=2.14.4` is the only new
   Phase 3 dependency). Insert it alphabetically after `lancedb`.

2. Add `corpus = "corpus_analyzer.cli:app"` to `[project.scripts]` alongside the existing
   `corpus-analyzer` entry. This enables `corpus mcp serve` as the Claude Code invocation (per
   CONTEXT.md locked decision: `{ "command": "corpus", "args": ["mcp", "serve"] }`).

3. Add `"src/corpus"` to `[tool.hatch.build.targets.wheel] packages` list (alongside
   `"src/corpus_analyzer"`). This is required for Plan 02's `from corpus import search` to work
   after install (see RESEARCH.md Pitfall 6).

After editing pyproject.toml, run `uv sync` to install fastmcp and register the new entry point.
Verify with `which corpus` (or `uv run corpus --help`) — must succeed.

Note: Use `fastmcp>=2.14.4` (per RESEARCH.md standard stack).
  </action>
  <verify>
    <automated>uv sync && uv run corpus --help</automated>
    <manual>Confirm `corpus` command resolves to the same CLI as `corpus-analyzer`</manual>
    <sampling_rate>run after this task commits, before Task 2 begins</sampling_rate>
  </verify>
  <done>
`uv run corpus --help` exits 0 and shows the same top-level commands as `corpus-analyzer`.
`pyproject.toml` contains `fastmcp>=2.14.4` in dependencies, `corpus = "corpus_analyzer.cli:app"` in scripts,
and `"src/corpus"` in the wheel packages list.
  </done>
</task>

<task type="auto">
  <name>Task 2: Implement FastMCP server module with lifespan pre-warm and corpus_search tool</name>
  <files>
    src/corpus_analyzer/mcp/__init__.py
    src/corpus_analyzer/mcp/server.py
  </files>
  <action>
Create `src/corpus_analyzer/mcp/__init__.py` — module docstring only:
```python
"""MCP server package for corpus semantic search."""
```

Create `src/corpus_analyzer/mcp/server.py` implementing:

**Imports and stderr logging setup (MCP-04):**
```python
"""FastMCP server exposing corpus_search tool for Claude Code and other agents."""

import logging
import sys
from pathlib import Path
from typing import Any, Optional

logging.basicConfig(stream=sys.stderr, level=logging.WARNING)

from fastmcp import Context, FastMCP
from fastmcp.server.lifespan import lifespan

from corpus_analyzer.config import load_config
from corpus_analyzer.ingest.embedder import OllamaEmbedder
from corpus_analyzer.ingest.indexer import CorpusIndex
from corpus_analyzer.search.engine import CorpusSearch
from corpus_analyzer.search.formatter import extract_snippet
from corpus_analyzer.cli import CONFIG_PATH, DATA_DIR
```

**Lifespan for pre-warm (MCP-06):**
```python
@lifespan
async def corpus_lifespan(server: FastMCP) -> Any:  # type: ignore[type-arg]
    """Pre-warm embedding model and open index at startup."""
    config = load_config(CONFIG_PATH)
    embedder = OllamaEmbedder(model=config.embedding.model, host=config.embedding.host)
    try:
        embedder.embed_batch(["warmup"])
    except Exception as exc:
        logging.warning("Pre-warm failed: %s — first query may be slow", exc)
    try:
        index = CorpusIndex.open(DATA_DIR, embedder)
        engine: CorpusSearch | None = CorpusSearch(index.table, embedder)
    except Exception as exc:
        logging.error("Index open failed: %s", exc)
        engine = None
    yield {"engine": engine, "config": config}
```

**FastMCP instance:**
```python
mcp = FastMCP("corpus", lifespan=corpus_lifespan)
```

**corpus_search tool (MCP-01, MCP-02, MCP-03, MCP-05):**
Use `Optional[str]` from `typing` (NOT `str | None`) for all optional parameters to guarantee
Claude-compatible schema with no `anyOf`/`oneOf` unions (per RESEARCH.md Pitfall 3 and MCP-05):

```python
@mcp.tool
async def corpus_search(
    query: str,
    source: Optional[str] = None,
    type: Optional[str] = None,
    construct: Optional[str] = None,
    top_k: Optional[int] = 5,
    ctx: Context = None,  # type: ignore[assignment]
) -> dict[str, Any]:
    """Search the corpus index with a natural language query.

    Returns full file content for top matching files plus metadata.
    Use 'source', 'type', 'construct' for filtering; 'top_k' for result count.
    """
    engine_or_none: CorpusSearch | None = ctx.lifespan_context.get("engine")
    if engine_or_none is None:
        raise ValueError(
            "Index not found or embedding model unreachable. "
            "Run 'corpus index' first and ensure Ollama is running."
        )
    engine = engine_or_none

    limit = top_k if top_k is not None else 5

    try:
        raw_results = engine.hybrid_search(
            query,
            source=source,
            file_type=type,
            construct_type=construct,
            limit=limit,
        )
    except Exception as exc:
        raise ValueError(
            f"Search failed: {exc}. Ensure 'corpus index' has been run."
        ) from exc

    if not raw_results:
        return {"results": [], "message": f"No results found for query: {query}"}

    results = []
    for row in raw_results:
        file_path = str(row.get("file_path", ""))
        try:
            full_content = Path(file_path).read_text(errors="replace")
        except OSError:
            full_content = ""
        results.append({
            "path": file_path,
            "score": float(row.get("_relevance_score", 0.0)),
            "snippet": extract_snippet(str(row.get("text", "")), query),
            "full_content": full_content,
            "construct_type": str(row.get("construct_type") or "documentation"),
            "summary": str(row.get("summary") or ""),
            "file_type": str(row.get("file_type", "")),
        })

    return {"results": results}
```

**Key constraints:**
- NEVER use `print()` or `Console()` anywhere in this file (stdout corruption, MCP-04)
- All logging via `logging.warning/error()` (goes to stderr) or `await ctx.info()` (MCP protocol)
- Use `Optional[str]` not `str | None` for tool params (MCP-05 schema compatibility)
- Engine opened once in lifespan, reused via `ctx.lifespan_context` (never open in tool handler)
- Lifespan gracefully handles Ollama-down: server starts, tool returns actionable error

After writing the file, verify imports resolve:
```bash
uv run python -c "from corpus_analyzer.mcp.server import mcp; print('OK')"
```
  </action>
  <verify>
    <automated>uv run python -c "from corpus_analyzer.mcp.server import mcp; print(type(mcp))"</automated>
    <manual>Confirm the module imports without error and `mcp` is a FastMCP instance</manual>
    <sampling_rate>run after this task, before Plan 02 begins</sampling_rate>
  </verify>
  <done>
`from corpus_analyzer.mcp.server import mcp` succeeds without error.
`src/corpus_analyzer/mcp/server.py` exists with `corpus_search` tool, lifespan, and
`logging.basicConfig(stream=sys.stderr)` at module level.
`uv run mypy src/corpus_analyzer/mcp/server.py` passes (or produces only fastmcp stub warnings).
  </done>
</task>

</tasks>

<verification>
After both tasks complete:
1. `uv run corpus --help` — shows corpus CLI commands (entry point alias works)
2. `uv run python -c "from corpus_analyzer.mcp.server import mcp; print('OK')"` — server module imports cleanly
3. `uv run mypy src/corpus_analyzer/mcp/` — no strict errors (fastmcp type: ignore stubs acceptable)
4. `uv run ruff check src/corpus_analyzer/mcp/` — no lint errors
5. `grep -n "print(" src/corpus_analyzer/mcp/server.py` — must return nothing (no stdout writes)
</verification>

<success_criteria>
- `pyproject.toml` has `fastmcp>=2.14.4` in dependencies, `corpus` entry point, and `src/corpus` in packages
- `uv run corpus --help` exits 0
- FastMCP server module (`mcp/server.py`) is importable, has lifespan pre-warm, single `corpus_search` tool
- Tool uses `Optional[str]` for all optional params (not `str | None`)
- `logging.basicConfig(stream=sys.stderr)` is the first import-time statement
- No `print()` anywhere in `mcp/server.py`
</success_criteria>

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