# Track: MCP Architecture Refactor (Sub-MCP Extraction)

**Status:** Active (spec approved 2026-06-06)
**Initialized:** 2026-06-06
**Owner:** Tier 2 Tech Lead
**Priority:** High (structural; 2,205-line mcp_client.py is the largest single file in the project; reduces future maintenance cost)

---

## 1. Overview

This track splits `src/mcp_client.py` (currently 2,205 lines with 45 module-level functions) into a **main controller** plus **6 native sub-MCPs** + **1 external sub-MCP**. The controller owns the 3-layer security model (Allowlist → Validate → Resolve), the dispatch logic, and the tool-schema export. Each sub-MCP owns a category of tools:

- `mcp_file_io.py` — File I/O (read_file, list_directory, search_files, get_file_summary, get_file_slice, set_file_slice, edit_file, get_tree, get_git_diff; ~9 funcs)
- `mcp_python.py` — Python AST (py_* family; ~14 funcs)
- `mcp_c.py` — C AST (ts_c_* family; 5 funcs)
- `mcp_cpp.py` — C++ AST (ts_cpp_* family; 5 funcs)
- `mcp_web.py` — Web (web_search, fetch_url; 2 funcs)
- `mcp_analysis.py` — Analysis (derive_code_path, get_ui_performance; 2 funcs)
- `mcp_external.py` — External MCPs (the existing `ExternalMCPManager`; runtime-loaded)

**Sub-MCP shape:** each `mcp_<type>.py` exports a class (e.g., `class PythonMCP`) that implements a `SubMCP` Protocol: `name: str`, `tools: dict[str, Callable]`, `invoke(tool_name, args) -> Result[str, ErrorInfo]`. The controller holds a list `ALL_SUB_MCPS` and dispatches via the `tools` dict. **Adding a new sub-MCP = create a new `mcp_<type>.py` file + add 2 lines to `mcp_client.py`'s `ALL_SUB_MCPS` list.**

**File naming convention:** `mcp_<type>.py` for native MCPs (per user direction). For externals, the existing `ExternalMCPManager` class name is preserved (the class moves to `mcp_external.py`; the name doesn't change to avoid breaking the existing import surface).

**DSL future:** the user noted a future interest in per-MCP compact DSLs (APL/K/Cosy-inspired) for tool calling instead of JSON. **This is explicitly OUT OF SCOPE for this track** (per user: "no time for that"). A future track MAY introduce a DSL layer; this track stays JSON-compatible and lays no groundwork that would prevent a future DSL.

## 2. Goals (Priority Order)

| Priority | Goal | Rationale |
|---|---|---|
| **A (foundational)** | New `SubMCP` Protocol + `MCPController` class in `src/mcp_client.py`. Controller dispatches via `ALL_SUB_MCPS` list; holds the 3-layer security model; holds the schema export. | The controller is the central abstraction. Per Casey Muratori's module-layer boundary: each module owns its data and exposes a clean interface; consumers adapt. |
| **A (primary value)** | Extract 6 native sub-MCPs (File I/O, Python, C, C++, Web, Analysis) into separate `mcp_<type>.py` files. Each is a class with `name`, `tools`, `invoke()`. | The current monolithic file is the largest in the project. Extracting by category aligns with the user's mental model and makes future maintenance tractable. |
| **A (primary value)** | Extract the existing `ExternalMCPManager` into `mcp_external.py`. The class name is preserved. | The external MCPs (Beads, etc.) are a separate concern; they were already a class. Moving them to their own file clarifies the architecture. |
| **A (backward compat)** | New `src/mcp_client_legacy.py` re-exports all 45+ old function names. Old `mcp_client.py` becomes a thin shim that imports from `mcp_client_legacy` and re-exports. | The 4 existing test files (`test_mcp_client_beads.py`, `test_mcp_config.py`, `test_mcp_perf_tool.py`, `test_mcp_ts_integration.py`) and `src/app_controller.py:61` (the direct `mcp_client.py_get_symbol_info` call) keep working during the transition. |
| **B (architectural)** | Sub-MCPs return `Result[str, ErrorInfo]` (from `data_oriented_error_handling_20260606`). Path parameters use the `Metadata` family aliases (from `data_structure_strengthening_20260606`). | Consistent with the project's post-Fleury conventions. The 3-layer security becomes `Result.errors` entries. |
| **B (architectural)** | The 3-layer security model (`_is_allowed`, `_resolve_and_check`) is extracted to `src/mcp_client_security.py` (a sub-module of the controller). The controller calls it BEFORE delegating to sub-MCPs. Sub-MCPs receive already-validated paths. | Clean separation: sub-MCPs are testable in isolation without security; one place to update security policy. |
| **C (optimization)** | `dispatch()` and `async_dispatch()` in the controller use the `ALL_SUB_MCPS` list for tool lookup (O(1) per dispatch via inverted dict), not the current if/elif chain (O(n) per dispatch). | At ~60 tools today, the if/elif is fast enough but doesn't scale. The inverted-dict lookup is the same code complexity and the right shape. |
| **C (optimization)** | `get_tool_schemas()` aggregates the schemas from all registered sub-MCPs. Single source of truth for the AI-facing tool catalog. | The current `get_tool_schemas()` is a manual list; the new version is auto-derived from the registered sub-MCPs. |
| **D (forward-looking)** | Plan a future "MCP DSL Track" that introduces a per-MCP compact dialect (replacing or augmenting JSON for tool calls). NOT in this track; documented in §13.1. | The user expressed interest in this idea; this track lays the groundwork (each sub-MCP is a self-contained unit that could be paired with a DSL emitter) but does not implement it. |

### 2.1 Non-Goals (this track)

- **Not** implementing a DSL for tool calls. JSON-only for now. A future track can layer a DSL on top.
- **Not** touching the agent runtime's tool-calling format. The agent still calls `mcp_client.dispatch("py_get_skeleton", {"path": "/src/foo.py"})` — the format is unchanged.
- **Not** merging or splitting sub-MCPs. The 6-7 categories are fixed for this track.
- **Not** adding new tool categories. If a future tool doesn't fit any of the 7 categories, that's a separate concern (either add a new `mcp_<type>.py` or extend an existing one).
- **Not** migrating to `TypedDict` schemas for tool arguments. The `Metadata` family aliases are used; the deeper schema is deferred to the `typed_dict_migration_20260606` follow-up.
- **Not** changing the public API of any tool function. The tools' signatures stay the same; the return type changes from `str` to `Result[str, ErrorInfo]` but the legacy shim unwraps `.data` for backward compat.

## 3. Architecture

### 3.1 The `SubMCP` Protocol

`src/mcp_client.py` (slim controller) defines the Protocol:

```python
from typing import Protocol, Any, Callable, TYPE_CHECKING
from src.result_types import Result

if TYPE_CHECKING:
 from src.mcp_sub_file_io import FileIOMCP
 # ... etc (avoid runtime circular imports)

class SubMCP(Protocol):
 """A native MCP that owns a category of tools.
 Implementations live in src/mcp_<type>.py."""
 name: str
 description: str
 tools: dict[str, Callable[..., str]]
 def invoke(self, tool_name: str, args: dict[str, Any]) -> Result[str, Any]: ...
 def list_tool_schemas(self) -> list[dict[str, Any]]:
  """Return the JSON-serializable tool schemas for this sub-MCP's tools.
  Used by MCPController.get_tool_schemas() to aggregate the full list
  for the AI's initial context. Per nagent_review takeaway #5 (the
  self-describing tool pattern), this is the data-driven alternative
  to a hard-coded dispatch chain. Implementations return OpenAI-
  shaped tool definitions (the same shape that the existing
  mcp_client.get_tool_schemas() returns).
  """
  ...
```

The `tools` dict is the public API: tool_name → function. The `invoke` method is the dispatch entry point. The `list_tool_schemas` method is the *self-describing* interface — the sub-MCP advertises its own capabilities rather than relying on a central registry. Implementations are not required to be classes; they can be modules with a `register_sub_mcp()` function, or dataclasses. **The Protocol is the contract; the implementation strategy is flexible.**

> **Note (added 2026-06-08 per nagent_review Pitfall #6 + takeaway #5).** The current `src/mcp_client.py:dispatch` is a flat 45-branch `if/elif` chain (per `docs/guide_mcp_client.md` and the nagent_review deep-dive). The new sub-MCP structure replaces this with the `SubMCP.list_tool_schemas()` pattern. Each sub-MCP **owns its own tool list** (the dict, the schemas, the dispatch); `MCPController` is the aggregator. This is the equivalent of nagent's `collect_bin_tool_descriptions` per sub-MCP.

### 3.2 The `MCPController` Class

```python
class MCPController:
 def __init__(self) -> None:
  self._sub_mcps: list[SubMCP] = []
  self._tool_index: dict[str, SubMCP] = {} # tool_name -> owning SubMCP
  self._external_mcp = ExternalMCP() # the new mcp_external.py's class

 def register(self, sub_mcp: SubMCP) -> None:
  self._sub_mcps.append(sub_mcp)
  for tool_name in sub_mcp.tools:
   if tool_name in self._tool_index:
    raise ValueError(f"Tool {tool_name!r} already registered by {self._tool_index[tool_name].name}")
   self._tool_index[tool_name] = sub_mcp

 def dispatch(self, tool_name: str, tool_input: dict[str, Any]) -> Result[str, Any]:
  # 1. Check native sub-MCPs (O(1) lookup)
  if tool_name in self._tool_index:
   return self._tool_index[tool_name].invoke(tool_name, tool_input)
  # 2. Check external MCPs (runtime-loaded)
  ext_result = self._external_mcp.try_invoke(tool_name, tool_input)
  if ext_result is not None:
   return ext_result
  # 3. Not found
  return Result(data="", errors=[ErrorInfo(kind=ErrorKind.NOT_FOUND, message=f"Tool {tool_name!r} not found", source="mcp_client.dispatch")])

 async def async_dispatch(self, tool_name: str, tool_input: dict[str, Any]) -> Result[str, Any]:
  # Similar; uses async tools for sub-MCPs that need them
  ...

 def get_tool_schemas(self) -> list[dict[str, Any]]:
  return [schema for sub_mcp in self._sub_mcps for schema in sub_mcp.schemas()]

# Module-level singleton
_controller = MCPController()
_controller.register(FileIOMCP())
controller.register(PythonMCP())
controller.register(CMCP())
controller.register(CppMCP())
controller.register(WebMCP())
controller.register(AnalysisMCP())
# ExternalMCP is NOT registered as a tool (it's a sub-controller for runtime-loaded tools)
```

The controller is a module-level singleton. The `ALL_SUB_MCPS` list is implicit in the registration calls at module bottom; the registration order doesn't matter.

### 3.3 The 3-Layer Security Model

**Important (added 2026-06-08):** the 3-layer security model (Allowlist Construction → Path Validation → Resolution Gate, per `docs/guide_mcp_client.md`) is not just refactored — it is the **contract** between `MCPController` and the sub-MCPs. Sub-MCPs receive a *pre-validated* `pathlib.Path` from `_resolve_and_check` and trust it. They do *not* re-validate. This is the security invariant that the refactor must preserve: the 3 layers run *before* the sub-MCP's `invoke()` is called, and the sub-MCP treats the path as already-allowed.

Concrete consequences:
- `_resolve_and_check` is called by `MCPController.dispatch` *before* the sub-MCP's `invoke()`. The sub-MCP sees a `Result[Path]` and the `data` field is either a real `Path` (allowed) or a `NilPath` (denied).
- Sub-MCPs that take a `path: str` parameter call `_resolve_and_check` themselves in their `invoke()` (or, if the path is already validated, they skip it). The current `src/mcp_client.py:_resolve_and_check` is moved to `src/mcp_client_security.py` unchanged.
- The 3-layer pattern is *not* weakened by the refactor. The `_is_allowed` check (Layer 1) still uses `_ALLOWED_BASE_DIRS`; the resolution (Layer 3) still uses `Path.resolve()`. The refactor is a *structural* change, not a *security* change.

`src/mcp_client_security.py` (NEW):

```python
from pathlib import Path
from typing import Any
from src.result_types import ErrorInfo, ErrorKind, Result, NilPath

_ALLOWED_BASE_DIRS: list[Path] = [Path(".").resolve()]

def configure(file_items: list[dict[str, Any]], extra_base_dirs: list[str] | None = None) -> None:
 """Configure the allowed base directories. Called by app_controller.py at startup."""
 global _ALLOWED_BASE_DIRS
 _ALLOWED_BASE_DIRS = [Path(".").resolve()]
 for item in file_items:
  p = Path(item.get("path", ".")).resolve()
  if p not in _ALLOWED_BASE_DIRS:
  _ALLOWED_BASE_DIRS.append(p)
 if extra_base_dirs:
  for d in extra_base_dirs:
  _ALLOWED_BASE_DIRS.append(Path(d).resolve())

def _is_allowed(path: Path) -> bool:
 """Layer 1: Is the path in an allowed base?"""
 for base in _ALLOWED_BASE_DIRS:
  try:
  if path.resolve().is_relative_to(base):
  return True
  except (ValueError, OSError):
  pass
 return False

def _resolve_and_check(raw_path: str) -> Result[Path]:
 """Layer 2 + 3: Resolve the path AND check it against the allowlist.
 Returns Result[Path]. data is a real Path on success or NilPath() on failure.
 errors contains the layered error info."""
 try:
  p = Path(raw_path).resolve()
 except (OSError, ValueError) as e:
  return Result(data=NilPath(), errors=[ErrorInfo(kind=ErrorKind.INVALID_INPUT, message=str(e), source="mcp_client_security", original=e)])
 if not _is_allowed(p):
  return Result(data=NilPath(), errors=[ErrorInfo(kind=ErrorKind.PERMISSION, message=f"path {raw_path!r} not in allowed base", source="mcp_client_security")])
 return Result(data=p)
```

The controller's `dispatch` runs `_resolve_and_check` BEFORE delegating to sub-MCPs (for path-taking tools). Sub-MCPs receive already-validated paths.

### 3.4 Per-Sub-MCP Shape

Each `mcp_<type>.py` exports a class. Example for File I/O:

```python
# src/mcp_file_io.py
from pathlib import Path
from typing import Any, Callable
from src.result_types import ErrorInfo, ErrorKind, Result
from src.type_aliases import FileItem, FileItems, Metadata
from src.mcp_client_security import _resolve_and_check

class FileIOMCP:
 name = "file_io"
 description = "File I/O: read, list, search, slice, edit, summary"

 def __init__(self) -> None:
  self.tools: dict[str, Callable[..., str]] = {
  "read_file": self.read_file,
  "list_directory": self.list_directory,
  # ... etc
  }

 def invoke(self, tool_name: str, args: dict[str, Any]) -> Result[str, Any]:
  if tool_name not in self.tools:
  return Result(data="", errors=[ErrorInfo(kind=ErrorKind.NOT_FOUND, message=f"{tool_name!r} not in {self.name}", source=f"mcp.{self.name}")])
  try:
  result = self.tools[tool_name](**args)
  return Result(data=result)
  except Exception as e:
  return Result(data="", errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=str(e), source=f"mcp.{self.name}.{tool_name}", original=e)])

 def read_file(self, path: str) -> str:
  resolved = _resolve_and_check(path)
  if not resolved.ok:
  return ""
  p = resolved.data
  if isinstance(p, NilPath):
  return ""
  if not p.exists() or not p.is_file():
  return f"ERROR: file not found: {path}"
  try:
  return p.read_text(encoding="utf-8")
  except Exception as e:
  return f"ERROR reading {path!r}: {e}"

 def list_directory(self, path: str) -> str:
 # ... similar pattern
```

Each sub-MCP:
- Exposes `name`, `description`, `tools` (dict), `invoke()` (Result-returning)
- Uses `_resolve_and_check` for path-taking tools (delegated to the security module)
- Uses the `Metadata` family aliases for dict parameters
- Returns `Result[str, Any]` from `invoke()`; converts exceptions to `ErrorInfo` at the boundary

### 3.5 Module Layout

```
src/
  mcp_client.py                # MODIFIED: slim controller; re-exports from mcp_client_legacy for compat
  mcp_client_legacy.py        # NEW: the OLD mcp_client.py code, re-exported
  mcp_client_security.py      # NEW: the 3-layer security model
  mcp_file_io.py              # NEW: FileIOMCP class
  mcp_python.py               # NEW: PythonMCP class
  mcp_c.py                    # NEW: CMCP class
  mcp_cpp.py                   # NEW: CppMCP class
  mcp_web.py                  # NEW: WebMCP class
  mcp_analysis.py             # NEW: AnalysisMCP class
  mcp_external.py             # NEW: ExternalMCP class (refactor of ExternalMCPManager)

tests/
  test_mcp_client.py            # NEW: controller tests (dispatch, registration, security)
  test_mcp_client_security.py  # NEW: security model tests
  test_mcp_file_io.py          # NEW: FileIOMCP tests
  test_mcp_python.py           # NEW: PythonMCP tests
  test_mcp_c.py                # NEW: CMCP tests
  test_mcp_cpp.py              # NEW: CppMCP tests
  test_mcp_web.py              # NEW: WebMCP tests
  test_mcp_analysis.py         # NEW: AnalysisMCP tests
  test_mcp_external.py         # NEW: ExternalMCP tests
  test_mcp_client_legacy.py    # NEW: legacy shim tests (verify all 45+ old symbols are re-exported)
  test_mcp_client_beads.py      # MODIFIED: existing; should pass unchanged
  test_mcp_config.py           # MODIFIED: existing; should pass unchanged
  test_mcp_perf_tool.py        # MODIFIED: existing; should pass unchanged
  test_mcp_ts_integration.py   # MODIFIED: existing; should pass unchanged
```

## 4. Per-Sub-MCP Design

### 4.1 File I/O (`mcp_file_io.py`)

**Tools (9):** read_file, list_directory, search_files, get_file_summary, get_file_slice, set_file_slice, edit_file, get_tree, get_git_diff

**Security:** all tools take `path: str` and use `_resolve_and_check` to validate.

**Returns:** `str` (the contents or error string). The `invoke()` method wraps in `Result[str, Any]`.

### 4.2 Python (`mcp_python.py`)

**Tools (14):** py_get_skeleton, py_get_code_outline, py_get_definition, py_get_signature, py_get_class_summary, py_get_var_declaration, py_get_hierarchy, py_get_docstring, py_get_symbol_info, py_find_usages, py_get_imports, py_check_syntax, py_update_definition, py_set_signature, py_set_var_declaration

**Security:** all take `path: str`; use `_resolve_and_check`.

**Returns:** `str` for read-only tools; `str` (the new content) for mutators.

### 4.3 C (`mcp_c.py`)

**Tools (5):** ts_c_get_skeleton, ts_c_get_code_outline, ts_c_get_definition, ts_c_get_signature, ts_c_update_definition

**Security:** path validation.

### 4.4 C++ (`mcp_cpp.py`)

**Tools (5):** ts_cpp_get_skeleton, ts_cpp_get_code_outline, ts_cpp_get_definition, ts_cpp_get_signature, ts_cpp_update_definition

**Security:** path validation.

### 4.5 Web (`mcp_web.py`)

**Tools (2):** web_search, fetch_url

**Security:** NO path validation. The Web sub-MCP handles URL validation internally (e.g., block internal IPs, no file:// scheme).

**Returns:** `str` (the search result or fetched content).

### 4.6 Analysis (`mcp_analysis.py`)

**Tools (2):** derive_code_path, get_ui_performance

**Security:** NO path validation (these tools don't take paths). `derive_code_path` takes a function/target name; `get_ui_performance` takes no arguments.

### 4.7 External (`mcp_external.py`)

**Class:** `ExternalMCP` (was `ExternalMCPManager`; the class name is preserved for compat).

**Methods:** `register_server(server)`, `unregister_server(name)`, `async_dispatch(tool_name, tool_input)`, `get_tool_schemas()`.

**Difference from native sub-MCPs:** the External MCP is NOT in `ALL_SUB_MCPS`; it's a sub-controller that the main controller delegates to AFTER the native sub-MCPs miss.

## 5. Migration / Rollout

| Phase | What | Risk |
|---|---|---|
| **Phase 1 — Foundation: security module + SubMCP Protocol + controller skeleton** | New `src/mcp_client_security.py`. New `MCPController` class in `src/mcp_client.py` (skeleton; no sub-MCPs yet). New `SubMCP` Protocol. Old `mcp_client.py` still has all 45 functions; the new controller is alongside. | Low. New files; the old code is untouched. |
| **Phase 2 — Move old code to `mcp_client_legacy.py`; `mcp_client.py` becomes the shim** | Move the current `mcp_client.py` content to `src/mcp_client_legacy.py`. Replace `mcp_client.py` with a thin shim that re-exports all 45+ old symbols from `mcp_client_legacy`. | Low. Re-exports preserve the import surface; existing tests pass unchanged. |
| **Phase 3 — Extract File I/O sub-MCP** | Create `src/mcp_file_io.py` with the `FileIOMCP` class. Register it in the controller. Update the existing `read_file`, `list_directory`, etc. functions in `mcp_client_legacy.py` to delegate to the File I/O sub-MCP (or remove them entirely; the legacy shim only re-exports what's not in a sub-MCP). | Medium. 9 functions moved. The dispatch function in the shim is updated to use the controller. |
| **Phase 4 — Extract Python sub-MCP** | Create `src/mcp_python.py` with the `PythonMCP` class. Register. | Medium. 14 functions moved. |
| **Phase 5 — Extract C, C++, Web, Analysis sub-MCPs** | One sub-MCP per phase task. Each extraction is a separate commit. | Medium each. 5 + 5 + 2 + 2 = 14 functions moved. |
| **Phase 6 — Extract External sub-MCP** | Move the `ExternalMCPManager` class to `mcp_external.py` (class name preserved as `ExternalMCP`). | Low. The class is already self-contained. |
| **Phase 7 — Update the dispatch + add security + use Result pattern; archive** | Update `dispatch` and `async_dispatch` to use the controller's `ALL_SUB_MCPS` lookup. Add the security check before path-taking tools. Convert the legacy shim to unwrap `Result.data` for backward compat. Update `docs/guide_mcp_client.md` with the new architecture. **Docs touchpoint (added 2026-06-08 per the docs Refresh Protocol):** `docs/guide_mcp_client.md` documents the 3-layer security model and the 45 tools; the refactor changes the *implementation* (sub-MCPs) but not the *security invariant* or the tool surface. The update should add a §"Sub-MCP Architecture" section describing the new layout, link the `SubMCP.list_tool_schemas()` pattern to `docs/guide_mcp_client.md §"3-Layer Security Model"`, and cross-link `docs/guide_context_aggregation.md` (the new pipeline guide, which `mcp_file_io.py` consumers use) and `docs/guide_state_lifecycle.md` (which documents the `App.__getattr__`/`__setattr__` state delegation that sub-MCPs must respect). Archive the track. | Low. The dispatch is the central change; everything else flows from it. |

Each phase has its own checkpoint commit and git note.

## 5.5 Opencode-stable swap (non-destructive development + quality-gated rollout)

**Why this section exists.** The current `scripts/mcp_server.py` (and the `mcp_client.dispatch` it wraps) is consumed by **opencode clients** via the MCP protocol. opencode is the AI agent tool that uses Manual Slop's tool surface. The new sub-MCP architecture MUST be developed in a way that does not break opencode's existing usage during development, AND the actual swap (the new dispatch becoming the default in `sloppy.py`'s controller) MUST be gated on a stability verification.

**Non-destructive development principle.** Throughout Phases 1-6, the existing `mcp_client.py` continues to work exactly as it does today. The new sub-MCPs, the new controller, the new security module are all added AS NEW FILES (or alongside the existing code in `mcp_client.py`). The legacy code path remains the default. opencode clients see zero behavioral change during Phases 1-6.

**The swap mechanism.** `sloppy.py` (the entry point) and `app_controller.py` (the controller init) introduce a single configuration flag:

```python
# In sloppy.py / app_controller.py
MCP_USE_NEW_DISPATCH: bool = False  # default during Phases 1-6; flipped to True after Phase 7 verification
```

When `MCP_USE_NEW_DISPATCH=False` (default during development):
- The legacy shim is the dispatch path (Phase 2's behavior; preserved as the safe default)
- All existing opencode workflows work unchanged
- The new sub-MCPs exist but are NOT in the dispatch path; they can be developed and unit-tested in isolation

When `MCP_USE_NEW_DISPATCH=True` (Phase 7's flip, gated on verification):
- The new controller (`MCPController`) is the dispatch path
- The legacy shim is still present (for any direct imports) but no longer called by the entry point
- opencode clients connect via the MCP server, which now uses the new dispatch
- All 45+ tools must work identically via the new path (verified by the opencode stability check)

**The verification (opencode stability check).** Before Phase 7 flips the default to `MCP_USE_NEW_DISPATCH=True`:

1. **Unit tests pass**: the per-sub-MCP unit tests + the controller tests + the legacy-shim regression tests all pass.
2. **Existing test files pass unchanged**: `test_mcp_client_beads.py`, `test_mcp_config.py`, `test_mcp_perf_tool.py`, `test_mcp_ts_integration.py` pass without modification (they use the legacy shim, which delegates correctly).
3. **Opencode integration test**: a manual or automated test where opencode connects to the MCP server (using `MCP_USE_NEW_DISPATCH=True`), lists the available tools, and invokes 5-10 representative tools (e.g., `read_file`, `list_directory`, `py_get_skeleton`, `py_find_usages`, `web_search`, `derive_code_path`). The results must match the expected outputs.
4. **Soak test**: the opencode integration test runs cleanly for 5+ consecutive sessions over 1+ day without regressions, errors, or performance degradation.

**When the verification passes, the track ships with `MCP_USE_NEW_DISPATCH=True` as the default in `sloppy.py`.** When it doesn't (e.g., a sub-MCP has a regression, or a new sub-MCP's tool doesn't work via opencode), the default stays `False` until the issues are resolved.

**The flag is the boundary.** It is the single point where the new system becomes the default. During Phases 1-6, the flag is `False` and opencode sees no change. After Phase 7, the flag is `True` (gated on verification). Future tracks can extend either path without re-architecting.

## 5.6 Compatibility surface preserved during development

To make the non-destructive development principle concrete, here is the public surface that MUST keep working throughout the track (i.e., across all 7 phases):

| Consumer | What it uses | How it keeps working |
|----------|--------------|----------------------|
| `scripts/mcp_server.py` | `mcp_client.dispatch("tool_name", args)` and `mcp_client.async_dispatch(...)` | These functions exist in the legacy shim throughout Phases 1-6; in Phase 7 they delegate to the new controller (when the flag is True) or stay as-is (when the flag is False). |
| `src/app_controller.py:61` | `mcp_client.py_get_symbol_info(...)` (a direct function call) | This function is in `mcp_client_legacy.py` and re-exported from `mcp_client.py` from Phase 2 onward. Unchanged for opencode. |
| opencode (via MCP protocol) | The 45+ tool names; the JSON tool-call format; the response shape | The legacy shim preserves all 45+ tool names + signatures + return shapes (string). opencode sees no change until the flag is flipped in Phase 7. |
| The 4 existing test files | `mcp_client.<func_name>(...)` and the dispatch result | Legacy shim re-exports; tests pass unchanged. |

Each phase has its own checkpoint commit and git note.

## 6. Configuration

No new dependencies. The existing stdlib `ast`, `pathlib`, `dataclasses`, etc. are used. The `result_types.py` and `type_aliases.py` modules are already in place from the previous tracks.

## 7. Testing Strategy

| Test File | Purpose | Coverage Target |
|---|---|---|
| `tests/test_mcp_client.py` | Controller: registration, dispatch (O(1) lookup), security check before delegation, schema aggregation. | 90% |
| `tests/test_mcp_client_security.py` | `_is_allowed`, `_resolve_and_check`, `configure` (with file_items + extra_base_dirs). | 100% |
| `tests/test_mcp_file_io.py` | `FileIOMCP`: each tool's read/write behavior; security integration. | 90% |
| `tests/test_mcp_python.py` | `PythonMCP`: each py_* tool. | 90% |
| `tests/test_mcp_c.py` | `CMCP`: each ts_c_* tool. | 90% |
| `tests/test_mcp_cpp.py` | `CppMCP`: each ts_cpp_* tool. | 90% |
| `tests/test_mcp_web.py` | `WebMCP`: web_search, fetch_url; URL validation. | 90% |
| `tests/test_mcp_analysis.py` | `AnalysisMCP`: derive_code_path, get_ui_performance. | 90% |
| `tests/test_mcp_external.py` | `ExternalMCP`: register_server, async_dispatch, get_tool_schemas. | 90% |
| `tests/test_mcp_client_legacy.py` | Verify all 45+ old symbols are re-exported from the legacy shim. | 100% |
| `tests/test_mcp_client_beads.py` (existing) | Verify Beads tools work via the new architecture. | 100% (regression) |
| `tests/test_mcp_config.py` (existing) | Verify config-related MCP tools work. | 100% (regression) |
| `tests/test_mcp_perf_tool.py` (existing) | Verify the perf tool works. | 100% (regression) |
| `tests/test_mcp_ts_integration.py` (existing) | Verify the ts_c / ts_cpp integration tests work. | 100% (regression) |
| `tests/test_mcp_client_opencode_integration.py` (NEW) | The opencode stability check (see section 5.5). Starts an MCP server with `MCP_USE_NEW_DISPATCH=True`, simulates opencode's tool-calling protocol, invokes 5-10 representative tools, and verifies the results. This is the quality gate that gates the Phase 7 default-flip. | 100% (quality gate) |

## 8. Risks & Mitigations

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| One of the 45+ function extractions introduces a regression. | Medium | Medium | Per-MCP unit tests + the existing 4 test files serve as regression tests. The legacy shim re-exports the old symbols, so the 4 test files don't need to change. |
| The dispatch inversion (if/elif → dict lookup) breaks some edge case (e.g., tool_name aliases). | Low | Low | The new dispatch preserves the existing alias behavior (`path` / `file_path` / `dir_path` are normalized in the current dispatch; the new dispatch does the same). |
| The `mcp_client_legacy.py` shim becomes permanent (never removed). | Medium | Low (acceptable) | The `public_api_migration_20260606` follow-up track (from the data_oriented_error_handling track) is the natural place to remove the legacy shim. |
| The `Result[str, Any]` return type from sub-MCPs is incompatible with the existing tests' `assert dispatch(...) == "text"` pattern. | Low | Low | The legacy shim's `dispatch` unwraps `.data` so existing tests see the same string. New tests can check `.data` and `.errors` directly. |
| The new sub-MCP architecture is "overkill" for the project's scale. | Low | Low (subjective) | The current 2,205-line file is the largest in the project; even if only 30% of the function count grew 2x in the next year, the file would be unmanageable. The investment now is bounded; the maintenance cost avoided is unbounded. |
| The DSL future becomes "we have to do it now" before this track is done. | Low | Low | The DSL is explicitly out of scope. This track stays JSON-compatible. A future DSL track can layer on top without breaking the architecture. |
| The new sub-MCP architecture is correct in isolation but breaks an opencode workflow that wasn't covered by the unit tests. | Medium | High (opencode is the primary external consumer) | The opencode stability check (section 5.5) is the explicit quality gate: opencode integration test + 5+ sessions soak test. The `MCP_USE_NEW_DISPATCH` flag stays `False` until the check passes. The legacy shim remains the dispatch path during Phases 1-6. |
| The `MCP_USE_NEW_DISPATCH` flag is left `False` indefinitely because the opencode stability check is too strict or too flaky. | Low | Low | The flag is a single line in `sloppy.py`. The user can flip it manually when they judge the new system is ready for opencode, even if the automated check is too strict. The check is a quality gate, not a hard requirement. |

## 9. Out of Scope (Explicit)

- **MCP DSL (APL/K/Cosy-inspired compact tool-call format).** Deferred to a future track; documented in §13.1.
- **Migrating to `TypedDict` schemas for tool arguments.** The `Metadata` family aliases are used; the deeper schema is deferred to `typed_dict_migration_20260606`.
- **Adding new tool categories beyond the 7.** If a future tool doesn't fit, that's a separate track.
- **Removing the `mcp_client_legacy.py` shim.** Deferred to the `public_api_migration_20260606` follow-up.
- **Touching the agent runtime's tool-calling format.** The format is unchanged.
- **Performance optimizations** (e.g., caching tool schemas, lazy-loading sub-MCPs). Out of scope; can be a follow-up.

## 10. Open Questions

1. **Sub-MCP implementation style.** The spec uses a class with `name` / `description` / `tools` / `invoke()`. Alternative: a module-level function `register(controller) -> None` that does the registration. (Proposal: class is the primary; module-level is an alternative for simple cases. Both are supported by the Protocol.)
2. **The `ExternalMCP` class name.** The spec preserves the existing `ExternalMCPManager` name (to avoid breaking the import surface). The new file is `mcp_external.py`. Should the class also be renamed to `ExternalMCP` (dropping the `Manager` suffix)? (Proposal: keep the existing name for now; the class name change is a separate concern. The file rename + class-internal refactor is enough for this track.)
3. **Backward compat scope.** The legacy shim re-exports all 45+ old function names. Should it also re-export the old `dispatch` and `async_dispatch` signatures (the current if/elif chain), or should the old function names delegate to the new controller? (Proposal: the old function names remain as functions (they may be called directly from `app_controller.py:61`); the old `dispatch` function in the shim is REPLACED by the new controller's `dispatch`.)

## 11. Configuration

**One new environment variable** is introduced for the opencode-stable swap (see section 5.5):

- **`MCP_USE_NEW_DISPATCH: bool`** — default `False` during Phases 1-6 of this track. Flipped to `True` in Phase 7 after the opencode stability check passes (or stays `False` if the check fails). Read by `sloppy.py` (the entry point) and `app_controller.py` (the controller init).

**How it works.** `sloppy.py` and `app_controller.py` check the env var at startup. When `MCP_USE_NEW_DISPATCH=False` (the default during development), the legacy shim is the dispatch path. When `True`, the new `MCPController` is the dispatch path. The flag is the single point where the new system becomes the default; it can be toggled without code changes for testing.

No other new env vars. The existing `config.toml` is unchanged. The `extra_base_dirs` and `file_items` security configuration is set by `app_controller.py` at startup (unchanged).

## 12. See Also

### 12.1 Follow-up Track (planned; not in this spec)

**"MCP DSL Track"** (`mcp_dsl_20260606` or similar) — introduces a per-MCP compact dialect for tool calls, replacing or augmenting the JSON format. Inspired by the user's notes on APL/K/Cosy DSLs. Examples:
- JSON: `{"name": "py_get_skeleton", "arguments": "{\"path\": \"/src/foo.py\"}"}` (~80 tokens per call)
- DSL: `py k /src/foo.py` (~10 tokens per call, ~8x reduction)
- A per-MCP grammar definition (`py_grammar.k`, `file_io_grammar.k`, etc.) could be authored and compiled to a parser
- A per-MCP DSL → JSON converter at the dispatch boundary
- Backward compat: the JSON path stays; the DSL is opt-in per MCP

Prerequisites: this track (the sub-MCP architecture is the natural unit to pair with a DSL).

### 12.2 Project References

- `docs/guide_ai_client.md` "Data-Oriented Error Handling (Fleury Pattern)" — the `Result[T]` pattern used by sub-MCPs.
- `docs/guide_mcp_client.md` (if it exists; will be created/updated) — the in-context guide for the MCP layer. **Added 2026-06-08:** the docs refresh created this guide; it documents the 45 tools, the 3-layer security model, and the `dispatch()`/`async_dispatch()` entry points. The Phase 7 update for this track should add a §"Sub-MCP Architecture" section.
- `docs/guide_context_aggregation.md` — added 2026-06-08. The `aggregate.py:142 build_file_items` function consumes the `FileItem` list and is the *upstream* consumer of `mcp_file_io.py`. The sub-MCP refactor must preserve the `FileItem` schema documented in §"The FileItem Schema (Full)".
- `docs/guide_state_lifecycle.md` — added 2026-06-08. The `App.__getattr__`/`__setattr__` state delegation (per `gui_2.py:666-675`) and the `UISnapshot` capture/restore are the *correctness* the sub-MCP refactor must preserve; sub-MCP tools are called from the `App` instance and any state mutation must go through the Controller.
- `docs/guide_discussions.md` — added 2026-06-08. The 23-operation matrix (A1-A7 + B1-B11 + C1-C5) drives several sub-MCP tool calls (read_file, py_get_skeleton, etc.); the refactor must not change the tool-call surface.
- `conductor/code_styleguides/error_handling.md` (from `data_oriented_error_handling_20260606`) — the `Result` / `ErrorInfo` convention.
- `conductor/code_styleguides/type_aliases.md` (from `data_structure_strengthening_20260606`) — the `Metadata` family aliases used by sub-MCPs.
- `conductor/tracks/data_oriented_error_handling_20260606/` — the previous track that established the `Result` pattern. Specifically: the new `ErrorKind.PROVIDER_HISTORY_DIVERGED_FROM_UI` kind (added 2026-06-08) is a *future* error category the sub-MCPs may surface.
- `conductor/tracks/data_structure_strengthening_20260606/` — the previous track that established the `Metadata` aliases. Specifically: the `FileItem` alias is the only alias in the 10 that points to a concrete dataclass (`models.FileItem`), not `Metadata`; sub-MCPs that consume `FileItem` should use the dataclass directly, not a dict round-trip.
- `conductor/tracks/qwen_llama_grok_integration_20260606/` — the parallel major track. The `send_openai_compatible()` helper is *expected* to return `Result` from day 1 (per the qwen spec §3.1 coordination note). The MCP refactor composes with this; the sub-MCP `invoke()` returns `Result[str, ErrorInfo]` and the helper returns `Result[NormalizedResponse, ErrorInfo]` — same shape, different layer.
- `conductor/tracks/public_api_migration_20260606/` (planned; from data_oriented_error_handling) — the natural track to remove the `mcp_client_legacy.py` shim.
- `conductor/tracks/nagent_review_20260608/report.md` — added 2026-06-08. §12 (Tool discovery) and §15 Pitfall #6 (hard-coded tool discovery) directly motivate this track's refactor. The 23-operation matrix in §3 (Conversations are editable state) is a use-case the sub-MCPs must continue to serve.
- `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md` — added 2026-06-08. §8 (self-describing tools / nagent `--description` pattern) is the conceptual model for the new `SubMCP.list_tool_schemas()` method.

### 12.3 External References

- **Ryan Fleury on module layer boundaries** — the convention that each module owns its data and exposes a clean interface; consumers adapt. The sub-MCP architecture follows this: each sub-MCP owns its tools; the controller owns dispatch; the security module owns validation.
- **Mike Acton on data-oriented design** — the "data is the API" framing. The `Result[str, ErrorInfo]` returned by `invoke()` is the API; sub-MCPs transform inputs to this shape.
- **Casey Muratori on Handmade Hero** — the spirit of explicit, self-contained modules with no magic. The `ALL_SUB_MCPS` registration at the bottom of `mcp_client.py` is explicit; no auto-discovery magic.
- **The user's friend on APL/K/Cosy DSLs for tool calling** — the inspiration for the future DSL track (§13.1).
