---
phase: 03-mcp-surface-generation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/mcp/response.py
  - src/ztlctl/mcp/generator.py
  - src/ztlctl/mcp/server.py
  - src/ztlctl/mcp/tools.py
  - tests/mcp/test_response.py
  - tests/mcp/test_generator.py
  - tests/mcp/test_tools.py
  - tests/mcp/test_tools_impl.py
autonomous: true
requirements: [ACTN-03]

must_haves:
  truths:
    - "McpResponse.from_result() converts any ServiceResult into a Pydantic-validated MCP response dict"
    - "generate_tools() registers one MCP tool per ActionDefinition in the registry (all 59)"
    - "Generated tool wrappers call the ActionDefinition handler with vault and return McpResponse-shaped dicts"
    - "server.py calls generate_tools() instead of register_tools()"
    - "tools.py is deleted — no _impl functions, no _TOOL_CATALOG, no register_tools()"
  artifacts:
    - path: "src/ztlctl/mcp/response.py"
      provides: "McpResponse and McpError Pydantic models with from_result() classmethod"
      contains: "class McpResponse"
    - path: "src/ztlctl/mcp/generator.py"
      provides: "generate_tools(), _make_tool_fn(), _build_annotations(), _render_action_doc()"
      contains: "def generate_tools"
    - path: "tests/mcp/test_response.py"
      provides: "Unit tests for McpResponse.from_result()"
      contains: "def test_from_result"
    - path: "tests/mcp/test_generator.py"
      provides: "Generator tests: tool count, annotations, DummyServer integration"
      contains: "def test_generate_tools"
  key_links:
    - from: "src/ztlctl/mcp/generator.py"
      to: "src/ztlctl/actions/registry.py"
      via: "get_action_registry().list_actions()"
      pattern: "get_action_registry\\(\\)\\.list_actions\\(\\)"
    - from: "src/ztlctl/mcp/generator.py"
      to: "src/ztlctl/mcp/response.py"
      via: "McpResponse.from_result(result)"
      pattern: "McpResponse\\.from_result"
    - from: "src/ztlctl/mcp/server.py"
      to: "src/ztlctl/mcp/generator.py"
      via: "generate_tools(server, vault)"
      pattern: "generate_tools\\(server.*vault\\)"
---

<objective>
Build the MCP tool generator that replaces the hand-written 1498-line `mcp/tools.py` with ActionRegistry-driven tool registration.

Purpose: ACTN-03 — auto-generate MCP tools from ActionDefinitions, eliminating hand-written registration code and achieving structural parity between the registry and the MCP surface.

Output: `mcp/response.py` (Pydantic MCP response model), `mcp/generator.py` (tool generator), updated `server.py`, deleted `tools.py`, and comprehensive test coverage.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-mcp-surface-generation/03-CONTEXT.md
@.planning/phases/03-mcp-surface-generation/03-RESEARCH.md
@.planning/phases/02-action-registry/02-04-SUMMARY.md

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

From src/ztlctl/actions/definitions.py:
```python
@dataclass(frozen=True)
class ActionParam:
    name: str
    type: type
    required: bool = True
    default: Any = None
    description: str = ""
    choices: tuple[str, ...] | None = None
    cli_multiple: bool = False
    cli_is_argument: bool = False
    cli_flag: bool = False
    mcp_example: str = ""

@dataclass(frozen=True)
class ActionDefinition:
    name: str
    description: str
    category: str
    params: tuple[ActionParam, ...]
    handler: Callable[..., Any]
    side_effect: Literal["read", "write"]
    mcp_when_to_use: str = ""
    mcp_avoid_when: str = ""
    mcp_common_errors: tuple[str, ...] = ()
    cli_group: str | None = None
    cli_examples: str = ""
    cli_interactive_params: tuple[str, ...] = ()
    custom_presentation: bool = False
```

From src/ztlctl/actions/registry.py:
```python
class ActionRegistry:
    def register(self, action: ActionDefinition) -> None: ...
    def get(self, name: str) -> ActionDefinition: ...
    def list_actions(self, *, category=None, side_effect=None, custom_presentation=None) -> list[ActionDefinition]: ...

def get_action_registry() -> ActionRegistry: ...
```

From src/ztlctl/services/result.py:
```python
class ServiceError(BaseModel):
    model_config = {"frozen": True}
    code: str
    message: str
    detail: dict[str, Any] = Field(default_factory=dict)

class ServiceResult(BaseModel):
    model_config = {"frozen": True}
    ok: bool
    op: str
    data: dict[str, Any] = Field(default_factory=dict)
    warnings: list[str] = Field(default_factory=list)
    error: ServiceError | None = None
    meta: dict[str, Any] | None = None
```

From src/ztlctl/mcp/tools.py (existing patterns to replicate, then delete):
```python
COMMON_ERROR_RECOVERY: dict[str, str] = {
    "NOT_FOUND": "Verify the target ID with search(), list_items(), or get_document().",
    "VALIDATION_FAILED": "Check required params and field types...",
    "ID_COLLISION": "Search for the existing item first...",
    "NO_ACTIVE_SESSION": "Start a session with create_log(topic)...",
    "ACTIVE_SESSION_EXISTS": "Use session_status() to inspect...",
    "INVALID_TRANSITION": "Inspect the current item with get_document()...",
    "EMPTY_QUERY": "Provide a non-empty search string...",
    "UNKNOWN_TYPE": "Use a supported content type/subtype...",
    "NO_PATH": "Confirm both IDs exist and are connected...",
}

def _register_tool(server, fn, *, entry=None, vault=None) -> None:
    resolved_entry = entry or _catalog_entry(fn.__name__, vault)
    fn.__doc__ = _render_tool_doc(resolved_entry)
    server.tool()(fn)

def _to_mcp_response(result: ServiceResult) -> dict[str, Any]:
    response = {"ok": result.ok, "op": result.op, "data": result.data}
    if result.warnings:
        response["warnings"] = result.warnings
    if result.error is not None:
        response["error"] = {"code": result.error.code, "message": result.error.message}
    return response
```

From src/ztlctl/mcp/server.py:
```python
def create_server(*, vault_root=None, host="127.0.0.1", port=8000) -> Any:
    # Currently imports and calls register_tools(server, vault)
```

From src/ztlctl/plugins/manager.py:
```python
def mcp_tool_contributions(self, reserved_names: set[str]) -> list[...]: ...
```
Note: No built-in plugins implement mcp_tool_contributions (grep confirmed). The compatibility shim is for future plugin support only.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create McpResponse model and MCP tool generator</name>
  <files>
    src/ztlctl/mcp/response.py,
    src/ztlctl/mcp/generator.py,
    tests/mcp/test_response.py,
    tests/mcp/test_generator.py
  </files>
  <read_first>
    src/ztlctl/mcp/tools.py,
    src/ztlctl/services/result.py,
    src/ztlctl/actions/definitions.py,
    src/ztlctl/actions/registry.py,
    src/ztlctl/actions/_register_core.py,
    tests/mcp/test_tools.py
  </read_first>
  <behavior>
    - McpResponse.from_result(ok_result) returns McpResponse with ok=True, op set, data dict, no error
    - McpResponse.from_result(error_result) returns McpResponse with ok=False, error.code and error.message set
    - McpResponse.from_result(result_with_warnings) includes warnings list
    - McpResponse.from_result(result_with_meta) drops meta field (not forwarded to MCP)
    - model_dump(exclude_none=True) on McpResponse produces same shape as old _to_mcp_response()
    - _build_annotations() maps ActionParam(type=str, required=True) to {"name": str}
    - _build_annotations() maps ActionParam(type=str, required=False) to {"name": str | None}
    - _build_annotations() maps ActionParam(type=list, required=False) to {"name": list[Any] | None}
    - _build_annotations() maps ActionParam(choices=("a","b"), type=str) to {"name": Literal["a","b"]}
    - _build_defaults() returns {name: default} for all non-required params
    - _make_tool_fn() produces a function with __name__ == action.name
    - _make_tool_fn() produces a function with __doc__ containing action.description
    - _make_tool_fn() produces a function with __annotations__ matching action.params types
    - _make_tool_fn() produces a function with __kwdefaults__ matching optional param defaults
    - _make_tool_fn() wrapper calls action.handler(vault, **kwargs) and returns McpResponse dict
    - _render_action_doc() includes "What it does:", "When to use:", "Avoid when:", "Side effects:", "Args:", "Common errors:"
    - generate_tools() registers exactly len(registry.list_actions()) tools on the server
    - generate_tools() calls _register_plugin_tools() for plugin compatibility shim
    - DummyServer captures all registered tool functions and they are callable
  </behavior>
  <action>
    **Create `src/ztlctl/mcp/response.py`:**

    1. Define `COMMON_ERROR_RECOVERY` dict — copy the exact 9 entries from the existing `tools.py` (NOT_FOUND, VALIDATION_FAILED, ID_COLLISION, NO_ACTIVE_SESSION, ACTIVE_SESSION_EXISTS, INVALID_TRANSITION, EMPTY_QUERY, UNKNOWN_TYPE, NO_PATH) with identical string values.

    2. Define `McpError(BaseModel)` with `model_config = {"frozen": True}`, fields: `code: str`, `message: str`, `detail: dict[str, Any] = Field(default_factory=dict)`.

    3. Define `McpResponse(BaseModel)` with `model_config = {"frozen": True}`, fields: `ok: bool`, `op: str`, `data: dict[str, Any] = Field(default_factory=dict)`, `warnings: list[str] = Field(default_factory=list)`, `error: McpError | None = None`.

    4. Add `@classmethod from_result(cls, result: ServiceResult) -> McpResponse` that maps `result.ok`, `result.op`, `result.data`, `result.warnings`, and converts `result.error` to `McpError(code=result.error.code, message=result.error.message)` if present. Explicitly does NOT forward `result.meta`.

    **Create `src/ztlctl/mcp/generator.py`:**

    1. Module-level `_vault_ref: Any = None` for server-scoped vault accessor (NOT closure binding per locked decision).

    2. `set_vault(vault: Any) -> None` sets the global `_vault_ref`.

    3. `_build_annotations(params: tuple[ActionParam, ...]) -> dict[str, Any]`:
       - For each param: if `param.choices` is set, use `Literal[tuple(param.choices)]` (Python 3.13 supports this).
       - If `param.type is list`: use `list[Any]` if required, `list[Any] | None` if not.
       - If `param.type is dict`: use `dict[str, Any]` if required, `dict[str, Any] | None` if not.
       - Otherwise: use `param.type` if required, `param.type | None` if not.
       - Add `# type: ignore[misc]` on the Literal line for mypy.

    4. `_build_defaults(params: tuple[ActionParam, ...]) -> dict[str, Any]`:
       - Return `{p.name: p.default for p in params if not p.required}`.

    5. `_render_action_doc(action: ActionDefinition) -> str`:
       - Build docstring with sections: "What it does: {action.description}", "When to use: {action.mcp_when_to_use}" (if set), "Avoid when: {action.mcp_avoid_when}" (if set), "Side effects: write. Mutates vault state." or "Side effects: read. Does not mutate vault state." based on `action.side_effect`.
       - If action.params: add "Args:" section with `- {p.name}: {p.description}` for each param with description.
       - If action.mcp_common_errors: add "Common errors:" section with `- {code}: {COMMON_ERROR_RECOVERY.get(code, code)}` for each.
       - Filter out empty lines and join with `\n`.

    6. `_make_tool_fn(action: ActionDefinition, vault: Any) -> Callable[..., dict[str, Any]]`:
       - Create inner `def tool_fn(**kwargs: Any) -> dict[str, Any]` that calls `action.handler(vault, **kwargs)` and returns `McpResponse.from_result(result).model_dump(exclude_none=True)`.
       - Set `tool_fn.__name__ = action.name`.
       - Set `tool_fn.__doc__ = _render_action_doc(action)`.
       - Set `tool_fn.__annotations__ = {**_build_annotations(action.params), "return": dict}`.
       - Set `tool_fn.__kwdefaults__ = _build_defaults(action.params)` (only if there are optional params, otherwise leave as None).
       - Do NOT use `functools.wraps` (Pitfall 1 from research — breaks `inspect.signature()`).
       - Return `tool_fn`.

    7. `generate_tools(server: Any, vault: Any) -> None`:
       - Call `set_vault(vault)`.
       - `registry = get_action_registry()`.
       - Iterate `registry.list_actions()`, call `_make_tool_fn(action, vault)` for each, then `server.tool()(fn)`.
       - Call `_register_plugin_tools(server, vault)`.

    8. `_register_plugin_tools(server: Any, vault: Any) -> None`:
       - Get `plugin_manager = getattr(vault, "plugin_manager", None)`.
       - If None, return.
       - `reserved = {a.name for a in get_action_registry().list_actions()}`.
       - Iterate `plugin_manager.mcp_tool_contributions(reserved_names=reserved)`.
       - For each contribution, create a wrapper similar to old `_plugin_tool_wrapper` pattern:
         - `def wrapped(**kwargs): return contribution.handler(vault, **kwargs)`.
         - Set `wrapped.__name__ = contribution.name`.
         - Call `server.tool()(wrapped)`.

    9. Use guarded MCP imports: the module itself should NOT import mcp at the top level. The `server` parameter is typed as `Any`.

    **Create `tests/mcp/test_response.py`:**

    Unit tests for McpResponse:
    - `test_from_result_ok`: Create `ServiceResult(ok=True, op="test", data={"key": "val"})`, assert `McpResponse.from_result(r).ok is True`, `.op == "test"`, `.data == {"key": "val"}`.
    - `test_from_result_error`: Create `ServiceResult(ok=False, op="test", error=ServiceError(code="NOT_FOUND", message="gone"))`, assert `.error.code == "NOT_FOUND"`.
    - `test_from_result_warnings`: Create with `warnings=["w1"]`, assert `.warnings == ["w1"]`.
    - `test_from_result_drops_meta`: Create with `meta={"timing": 1.0}`, assert `model_dump(exclude_none=True)` has no "meta" key.
    - `test_model_dump_shape`: Assert `model_dump(exclude_none=True)` on ok result produces `{"ok": True, "op": ..., "data": ...}` (no warnings key when empty, no error key when None).

    **Create `tests/mcp/test_generator.py`:**

    Use DummyServer pattern (from existing test_tools.py):
    ```python
    class DummyServer:
        def __init__(self):
            self.tools: dict[str, Any] = {}
        def tool(self):
            def decorator(fn):
                self.tools[fn.__name__] = fn
                return fn
            return decorator
    ```

    Tests:
    - `test_generate_tools_count`: Call `generate_tools(dummy_server, mock_vault)`, assert `len(dummy_server.tools) >= 59`.
    - `test_tool_has_annotations`: Pick a known action (e.g. "create_note"), verify the registered function has `__annotations__` with at least "title" key.
    - `test_tool_has_doc`: Verify registered "create_note" function has `__doc__` containing "What it does:".
    - `test_tool_has_kwdefaults`: Pick "list_items", verify `__kwdefaults__` contains "limit" key.
    - `test_tool_callable_returns_mcp_response`: Create a mock vault, call a registered tool function with correct kwargs, verify result is dict with "ok" key. Use a mock handler on a test ActionDefinition for isolation.
    - `test_build_annotations_required_str`: Verify `_build_annotations((ActionParam("q", str, required=True),))` == `{"q": str}`.
    - `test_build_annotations_optional_str`: Verify result has `str | None` for optional param.
    - `test_build_annotations_choices`: Verify Literal type for choices param.
    - `test_build_annotations_list`: Verify `list[Any]` for list type param.
    - `test_render_action_doc_sections`: Verify output contains "What it does:", "Side effects:", "Args:" for a full ActionDefinition.
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/mcp/test_response.py tests/mcp/test_generator.py -x -q</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/mcp/response.py contains `class McpResponse(BaseModel)` and `class McpError(BaseModel)` and `COMMON_ERROR_RECOVERY`
    - src/ztlctl/mcp/response.py contains `def from_result(cls, result`
    - src/ztlctl/mcp/generator.py contains `def generate_tools(server` and `def _make_tool_fn(action` and `def _build_annotations(params` and `def _render_action_doc(action`
    - src/ztlctl/mcp/generator.py contains `_vault_ref` module-level variable
    - src/ztlctl/mcp/generator.py does NOT contain `import mcp` or `from mcp` at module level
    - tests/mcp/test_response.py contains `def test_from_result_ok` and `def test_from_result_error` and `def test_from_result_drops_meta`
    - tests/mcp/test_generator.py contains `class DummyServer` and `def test_generate_tools_count`
    - `uv run pytest tests/mcp/test_response.py tests/mcp/test_generator.py -x -q` exits 0
  </acceptance_criteria>
  <done>McpResponse model validates and converts ServiceResult. Generator registers all 59 ActionDefinitions as MCP tools with correct annotations, defaults, and docstrings. All new tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Wire generator into server.py, delete tools.py, remove old tests</name>
  <files>
    src/ztlctl/mcp/server.py,
    src/ztlctl/mcp/tools.py,
    tests/mcp/test_tools.py,
    tests/mcp/test_tools_impl.py,
    tests/mcp/test_server.py
  </files>
  <read_first>
    src/ztlctl/mcp/server.py,
    src/ztlctl/mcp/tools.py,
    src/ztlctl/mcp/generator.py,
    tests/mcp/test_server.py,
    tests/mcp/test_tools.py,
    tests/mcp/test_tools_impl.py
  </read_first>
  <action>
    **Update `src/ztlctl/mcp/server.py`:**

    1. Change the import inside `create_server()` from:
       `from ztlctl.mcp.tools import register_tools`
       to:
       `from ztlctl.mcp.generator import generate_tools`

    2. Change the call from:
       `register_tools(server, vault)`
       to:
       `generate_tools(server, vault)`

    3. No other changes to server.py. The function signature, vault creation, resource/prompt registration all stay identical.

    **Delete `src/ztlctl/mcp/tools.py`:**

    Remove the entire file. This eliminates:
    - `_TOOL_CATALOG` (29 entries) — replaced by ActionDefinitions
    - 29 `_impl` functions — replaced by controller handlers
    - `register_tools()` — replaced by `generate_tools()`
    - `_register_tool()` — replaced by `_make_tool_fn()` + `server.tool()(fn)`
    - `_to_mcp_response()` — replaced by `McpResponse.from_result()`
    - `COMMON_ERROR_RECOVERY` — moved to `mcp/response.py`
    - `_render_tool_doc()` — replaced by `_render_action_doc()`
    - `ToolCatalogEntry` TypedDict — replaced by ActionDefinition
    - `tool_catalog()` — replaced by `get_action_registry().list_actions()`

    **Delete `tests/mcp/test_tools.py`:**

    Remove entirely — it imports `_impl` functions and `register_tools` from `mcp/tools.py` which no longer exists. Coverage is replaced by `test_generator.py`.

    **Delete `tests/mcp/test_tools_impl.py`:**

    Remove entirely — it tests `_impl` functions that no longer exist. Controller unit tests (in `tests/controllers/`) already cover the business logic.

    **Update `tests/mcp/test_server.py`:**

    Read the existing file. If it imports from `ztlctl.mcp.tools`, update those imports to use `ztlctl.mcp.generator` equivalents. If it references `register_tools`, change to `generate_tools`. The test should verify that `create_server()` returns a server object (existing behavior).

    **Grep for any remaining imports of `ztlctl.mcp.tools` across the entire codebase:**

    Run `grep -r "from ztlctl.mcp.tools" src/ tests/` and `grep -r "import ztlctl.mcp.tools" src/ tests/` to find any remaining references. Update or remove all of them.

    **Run full validation:**
    ```bash
    uv run ruff check . && uv run ruff format --check . && uv run pytest -x -q && uv run mypy src/
    ```
    Fix any issues (missing imports, broken references, type errors).
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run ruff check . && uv run ruff format --check . && uv run pytest -x -q && uv run mypy src/</automated>
  </verify>
  <acceptance_criteria>
    - src/ztlctl/mcp/server.py contains `from ztlctl.mcp.generator import generate_tools`
    - src/ztlctl/mcp/server.py contains `generate_tools(server, vault)`
    - src/ztlctl/mcp/server.py does NOT contain `from ztlctl.mcp.tools` or `register_tools`
    - src/ztlctl/mcp/tools.py does NOT exist
    - tests/mcp/test_tools.py does NOT exist
    - tests/mcp/test_tools_impl.py does NOT exist
    - No file in src/ or tests/ contains `from ztlctl.mcp.tools import` or `import ztlctl.mcp.tools`
    - `uv run pytest -x -q` exits 0 (full suite passes)
    - `uv run mypy src/` exits 0
    - `uv run ruff check .` exits 0
  </acceptance_criteria>
  <done>server.py uses generate_tools(). tools.py and its tests are deleted. Full test suite passes with zero lint/type errors. No remaining references to the old module.</done>
</task>

</tasks>

<verification>
After both tasks:
- `uv run pytest -x -q` — full test suite passes
- `uv run mypy src/` — zero type errors
- `uv run ruff check .` — zero lint errors
- `ls src/ztlctl/mcp/tools.py` — file does not exist
- `grep -r "from ztlctl.mcp.tools" src/ tests/` — no results
- `python -c "from ztlctl.actions import get_action_registry; print(len(get_action_registry().list_actions()))"` — prints 59
</verification>

<success_criteria>
1. McpResponse Pydantic model exists and converts ServiceResult correctly (drops meta, maps error)
2. generate_tools() registers all 59 ActionDefinitions as MCP tools with correct __annotations__, __kwdefaults__, and __doc__
3. server.py calls generate_tools() instead of register_tools()
4. tools.py (1498 lines) is deleted with no remaining imports
5. Old test files (test_tools.py, test_tools_impl.py) deleted, replaced by test_response.py and test_generator.py
6. Full suite passes: pytest, mypy, ruff
</success_criteria>

<output>
After completion, create `.planning/phases/03-mcp-surface-generation/03-01-SUMMARY.md`
</output>
