"""MCP Client — connects to external MCP servers.

Allows AgntSpace to use tools from external MCP servers
(databases, APIs, file systems, custom tools).
External tools appear in the agent's tool list alongside
built-in tools, all contract-enforced.

§9.4 review-before-connect (shipped 2026-04-24)
----------------------------------------------
Connecting to an external MCP server used to mean its tool schemas
went straight into the agent's system prompt. A malicious server can
put prompt-injection payloads in tool descriptions that the LLM acts
on, bypassing the contract gate (which checks *what tool was called*,
not *what text the attacker smuggled into the system prompt*).

Every server now carries a `trust_state` ∈ {unreviewed, review_pending,
reviewed, rejected}. `connect_server()` always fetches the tool list,
but tools from a non-`reviewed` server land in `pending_tools` — they
are NEVER handed to the agent via `get_external_tool_definitions()`
until the user approves them via `/api/mcp/servers/{name}/review/approve`.

On reconnect, if any tool's canonical schema hash differs from the
hash captured at review time, the server drops back to `review_pending`
and the user must re-review. This catches a trusted server that later
swaps its tool descriptions to something adversarial.
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import re
from contextlib import AsyncExitStack, suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path

from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client

log = logging.getLogger(__name__)

# Per-server connect timeout. The boot-independence rule (CLAUDE.md MCP
# resilience): no single MCP server can hang the boot longer than this.
# Server boot stays uninterrupted regardless of npx install delays, network
# stalls, broken SDK lifecycles, or compromised servers refusing to
# initialize. Tunable via MCPClientManager.connect_timeout_seconds at
# construction time; this constant is the deterministic default.
_CONNECT_TIMEOUT_SECONDS = 30.0  # arch:deterministic — universal external-call timeout, not user-tunable strategy


# Trust states. The migration path grandfathers existing servers as
# "reviewed" on first boot — users who already configured a server have
# already made an implicit trust decision. Only NEW servers go through
# explicit review. Schema-change detection still catches compromise.
TRUST_UNREVIEWED = "unreviewed"      # newly added, never connected
TRUST_REVIEW_PENDING = "review_pending"  # connected, tools fetched, awaiting user review
TRUST_REVIEWED = "reviewed"          # user explicitly approved; tools visible to agent
TRUST_REJECTED = "rejected"          # user explicitly rejected; stays disconnected

_VALID_TRUST_STATES = frozenset({
    TRUST_UNREVIEWED, TRUST_REVIEW_PENDING, TRUST_REVIEWED, TRUST_REJECTED,
})

# Layer 4 — connection state machine. Replaces the old binary
# `connected: bool` view with explicit lifecycle states so the UI, the
# agent, and ops tooling can distinguish "user hasn't connected yet"
# from "we tried and it failed" from "the server is broken and we've
# stopped retrying".
STATUS_DISCONNECTED = "disconnected"   # never connected since process start (or after explicit disconnect)
STATUS_CONNECTING = "connecting"        # connect_server is in flight
STATUS_CONNECTED = "connected"          # session is live and usable
STATUS_FAILED = "failed"                # last connect attempt failed; backoff applies
STATUS_CIRCUIT_OPEN = "circuit_open"    # too many consecutive failures; no more attempts until reset

_VALID_STATUSES = frozenset({  # arch:deterministic — engine state machine, not user-tunable strategy
    STATUS_DISCONNECTED, STATUS_CONNECTING, STATUS_CONNECTED, STATUS_FAILED, STATUS_CIRCUIT_OPEN,
})

# Circuit-breaker thresholds. Conservative defaults — fail fast, recover
# slowly. Adjustable via MCPClientManager constructor for tests + ops.
_CIRCUIT_FAILURE_THRESHOLD = 3            # arch:deterministic — N consecutive failures opens the circuit; engine constant
_CIRCUIT_OPEN_SECONDS = 3600.0            # arch:deterministic — circuit stays open this long before auto-retry; user resets manually if they need it sooner
_BACKOFF_SCHEDULE_SECONDS = (10.0, 30.0, 90.0)  # arch:deterministic — exponential backoff between failed attempts before circuit opens

# Health probe — Layer 4b. Detects subprocess crashes mid-session that
# today only surface at the next tool call. Lightweight `list_tools()`
# ping; results recorded into the state machine so the UI can show
# "connected but unhealthy" before the user tries to use the server.
_HEALTH_PROBE_INTERVAL_SECONDS = 300.0    # arch:deterministic — 5min between pings
_HEALTH_PROBE_TIMEOUT_SECONDS = 10.0      # arch:deterministic — single-ping wall clock cap
_HEALTH_FAILURE_THRESHOLD = 3             # arch:deterministic — N consecutive missed pings → mark dead

# Prompt-injection patterns scanned in tool descriptions. Shown to the user
# in the review UI as "suspicious" — NOT auto-rejected. The user decides.
# Kept deliberately conservative: false positives here annoy every legitimate
# MCP server author. Strict detection lives in §9.1 hardening, not here.
_INJECTION_PATTERNS = [  # arch:deterministic — structural pattern matcher, not strategy
    (re.compile(r"ignore\s+(all\s+)?(previous|prior|above)\s+instructions", re.I), "ignore-instructions"),
    (re.compile(r"disregard\s+(all\s+)?(previous|prior|above|system)", re.I), "disregard-previous"),
    (re.compile(r"system\s*[:\-=<>]*\s*(prompt|message|role)", re.I), "system-role-override"),
    (re.compile(r"\b(you are now|you must now|new instructions?:)", re.I), "role-hijack"),
    (re.compile(r"<\s*/?\s*(system|user|assistant|instructions?)\s*>", re.I), "fake-chat-tag"),
    (re.compile(r"\[\[?\s*(SYSTEM|OVERRIDE|ADMIN|ROOT)\s*\]?\]", re.I), "admin-marker"),
]


def _canonical_tool_hash(tool_def: dict) -> str:
    """Return a stable sha256 over the user-visible fields of a tool.

    We hash `(name, description, input_schema)` — the three things the LLM
    sees. Changes to internal `_mcp_*` bookkeeping fields do NOT trigger
    re-review. Sorting keys ensures hash stability across Python versions.
    """
    payload = {
        "name": tool_def.get("name", ""),
        "description": tool_def.get("description", ""),
        "input_schema": tool_def.get("input_schema", {}),
    }
    # sort_keys=True makes nested dict hashing deterministic
    serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False)
    return "sha256:" + hashlib.sha256(serialized.encode("utf-8")).hexdigest()


def _scan_injection_patterns(text: str) -> list[str]:
    """Return the names of injection patterns that matched; [] if clean."""
    if not text:
        return []
    hits: list[str] = []
    for pattern, label in _INJECTION_PATTERNS:
        if pattern.search(text):
            hits.append(label)
    return hits


@dataclass
class ExternalMCPServer:
    """Configuration for an external MCP server."""

    name: str
    command: str
    args: list[str] = field(default_factory=list)
    env: dict[str, str] = field(default_factory=dict)
    auto_connect: bool = True
    # §9.4 fields — added 2026-04-24. Default `unreviewed` so a NEW server
    # registered today goes through review. Migration path grandfathers
    # pre-existing entries to `reviewed` on first load.
    trust_state: str = TRUST_UNREVIEWED
    reviewed_at: str | None = None  # ISO timestamp
    reviewed_tool_hashes: dict[str, str] = field(default_factory=dict)  # tool_name -> sha256 at review time
    rejection_reason: str | None = None


@dataclass
class _RuntimeState:
    """Per-server runtime state — the connection state machine view.

    **Layer 4 invariant**: this lives in memory only. Restart resets every
    field — that's a deliberate "user has decided to try again" signal.
    Persisting ``consecutive_failures`` to ``mcp-servers.json`` would make
    a transient blip stick across the user's intentional retry, which is
    the wrong product behavior.

    Status transitions:

      disconnected ─[connect_server]→ connecting
      connecting   ─[ready]→           connected
      connecting   ─[error]→           failed (resets to disconnected after backoff)
      connecting   ─[error, n≥3]→     circuit_open
      connected    ─[disconnect_server]→ disconnected
      connected    ─[mid-flight death]→ failed
      failed       ─[backoff elapsed + connect]→ connecting
      circuit_open ─[reset_circuit]→  disconnected (manual user action)
      circuit_open ─[circuit_open_seconds elapsed + connect]→ connecting

    ``last_error`` is preserved across the failed → connecting transition
    so the UI can keep showing the most recent reason; cleared on
    successful ``connected``.
    """

    status: str = STATUS_DISCONNECTED
    last_error: str = ""
    last_error_type: str = ""
    last_connect_attempt: str = ""  # ISO timestamp; empty if never tried
    last_connected_at: str = ""     # ISO timestamp; empty if never succeeded
    consecutive_failures: int = 0
    next_retry_at: str = ""         # ISO timestamp; empty when no retry scheduled
    circuit_opened_at: str = ""     # ISO timestamp; empty unless circuit_open
    # Layer 4b — health probe counters. The probe pings connected
    # servers every health_probe_interval_seconds; each successful ping
    # resets ``consecutive_health_failures`` to 0 and updates
    # ``last_health_check``, each missed/timed-out ping increments. After
    # ``health_failure_threshold`` consecutive misses the probe marks the
    # server dead (status=failed, session disconnected). These counters
    # are independent of ``consecutive_failures`` so a single bad ping
    # doesn't trip the connect-side circuit breaker.
    consecutive_health_failures: int = 0
    last_health_check: str = ""     # ISO timestamp of most recent ping (success or failure)
    last_health_status: str = ""    # "ok" | "timeout" | "error: <type>" | "" (never probed)


@dataclass
class _ConnectionHolder:
    """Per-server connection state owned by a dedicated worker task.

    **Layer 3 lifecycle rule** (CLAUDE.md MCP resilience): every
    ``stdio_client`` ``__aenter__``/``__aexit__`` pair MUST execute in the
    SAME task — anyio's TaskGroup-based cancel scope enforces this with a
    ``RuntimeError`` and the SDK's ``stdio_client`` is built on it. Cross-
    task close was the literal hang we hit in production
    (`Attempted to exit cancel scope in a different task than it was
    entered in`).

    The holder is the rendezvous point between the dedicated worker (which
    owns the lifetime) and external callers (which observe it):

      * ``ready`` resolves once the connection is fully initialized + the
        session is usable. Caller awaits it from ``connect_server`` and
        receives the live ``ClientSession``.
      * ``shutdown`` is set by ``disconnect_server`` to ask the worker to
        close the stack. The worker sees the event, exits the
        ``async with`` blocks (in its own task → no cross-task error),
        then signals ``done``.
      * ``done`` resolves when the worker has fully torn down — used by
        ``disconnect_server`` to wait for clean shutdown with a fallback
        ``task.cancel()`` on timeout.
      * ``session`` is populated by the worker AFTER ``initialize()``
        succeeds, BEFORE ``ready`` is set. Callers can then use the
        session from any task — only the open/close pair is task-scoped.

    No connection state lives outside the holder; ``MCPClientManager``
    holds a ``{name: holder}`` map and a parallel ``{name: task}`` map.
    """

    ready: asyncio.Future[ClientSession] = field(default_factory=lambda: asyncio.get_event_loop().create_future())
    shutdown: asyncio.Event = field(default_factory=asyncio.Event)
    done: asyncio.Event = field(default_factory=asyncio.Event)
    session: ClientSession | None = None


class MCPClientManager:
    """Manages connections to external MCP servers."""

    def __init__(
        self,
        config_path: Path | None = None,
        *,
        connect_timeout_seconds: float = _CONNECT_TIMEOUT_SECONDS,
        circuit_failure_threshold: int = _CIRCUIT_FAILURE_THRESHOLD,
        circuit_open_seconds: float = _CIRCUIT_OPEN_SECONDS,
        backoff_schedule_seconds: tuple[float, ...] = _BACKOFF_SCHEDULE_SECONDS,
        health_probe_interval_seconds: float = _HEALTH_PROBE_INTERVAL_SECONDS,
        health_probe_timeout_seconds: float = _HEALTH_PROBE_TIMEOUT_SECONDS,
        health_failure_threshold: int = _HEALTH_FAILURE_THRESHOLD,
    ) -> None:
        self._servers: dict[str, ExternalMCPServer] = {}
        self._sessions: dict[str, ClientSession] = {}
        self._transports: dict[str, object] = {}  # keep transport refs alive
        # Layer 3 — per-server lifetime management. The holder is the
        # rendezvous between caller and worker; the task is the actual
        # owner of the ``async with stdio_client(...) as ...`` scope.
        # Both maps are kept in sync; ``disconnect_server`` and
        # ``disconnect_all`` clear them together.
        self._holders: dict[str, _ConnectionHolder] = {}
        self._connection_tasks: dict[str, asyncio.Task] = {}  # type: ignore[type-arg]
        # Tools visible to the agent — ONLY populated from reviewed servers.
        self._external_tools: dict[str, dict] = {}
        # §9.4: tools fetched but not yet approved by the user. Keyed by
        # full tool name (`mcp_{server}_{tool}`). Hidden from the agent.
        self._pending_tools: dict[str, dict] = {}
        self._config_path = config_path
        # Per-server connect timeout, applied uniformly across stdio + SSE.
        # Tests inject a small value; production keeps the 30s default.
        self.connect_timeout_seconds: float = connect_timeout_seconds
        # Disconnect timeout — how long to wait for the worker task to
        # close cleanly before falling back to ``task.cancel()``. Short
        # because ``stdio_client``'s subprocess kill is fast; if it's not
        # done by 5s, something's wrong and we want to move on.
        self.disconnect_timeout_seconds: float = 5.0
        # Layer 4 — connection state machine + circuit breaker.
        # ``_states`` is the per-server state machine; lives in memory
        # only (restart resets every field — see _RuntimeState docstring).
        self._states: dict[str, _RuntimeState] = {}
        self.circuit_failure_threshold: int = circuit_failure_threshold
        self.circuit_open_seconds: float = circuit_open_seconds
        self.backoff_schedule_seconds: tuple[float, ...] = backoff_schedule_seconds
        # Layer 4b — periodic health probe on connected servers.
        # Off by default at construction time; wire-up calls
        # ``start_health_probe()`` after the boot auto-connect wave.
        self.health_probe_interval_seconds: float = health_probe_interval_seconds
        self.health_probe_timeout_seconds: float = health_probe_timeout_seconds
        self.health_failure_threshold: int = health_failure_threshold
        self._health_probe_task: asyncio.Task | None = None  # type: ignore[type-arg]

    # ---- Config persistence ----

    def _save_config(self) -> None:
        """Persist server configs to JSON file."""
        if not self._config_path:
            return
        data = {
            name: {
                "command": s.command,
                "args": s.args,
                "env": s.env,
                "auto_connect": s.auto_connect,
                "trust_state": s.trust_state,
                "reviewed_at": s.reviewed_at,
                "reviewed_tool_hashes": s.reviewed_tool_hashes,
                "rejection_reason": s.rejection_reason,
            }
            for name, s in self._servers.items()
        }
        self._config_path.parent.mkdir(parents=True, exist_ok=True)
        self._config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")

    def load_config(self) -> None:
        """Load server configs from JSON file.

        Migration behavior: legacy entries without a `trust_state` field
        are grandfathered in as `reviewed`. Users who configured a server
        before §9.4 shipped already made an implicit trust decision — we
        don't break their working setup on upgrade. The schema-change
        detector (`reviewed_tool_hashes` empty on first post-migration
        connect → captured on that connect, compared on subsequent ones)
        still protects them against a grandfathered server that later
        swaps its tool descriptions.
        """
        if not self._config_path or not self._config_path.exists():
            return
        try:
            data = json.loads(self._config_path.read_text(encoding="utf-8"))
            migrated_count = 0
            for name, cfg in data.items():
                has_trust_field = "trust_state" in cfg
                trust_state = cfg.get("trust_state", TRUST_REVIEWED)
                if trust_state not in _VALID_TRUST_STATES:
                    trust_state = TRUST_REVIEWED  # defensive fallback
                if not has_trust_field:
                    migrated_count += 1
                self._servers[name] = ExternalMCPServer(
                    name=name,
                    command=cfg["command"],
                    args=cfg.get("args", []),
                    env=cfg.get("env", {}),
                    auto_connect=cfg.get("auto_connect", True),
                    trust_state=trust_state,
                    reviewed_at=cfg.get("reviewed_at"),
                    reviewed_tool_hashes=dict(cfg.get("reviewed_tool_hashes", {})),
                    rejection_reason=cfg.get("rejection_reason"),
                )
            if migrated_count:
                # Grandfathered — persist the trust_state field so the
                # migration runs exactly once.
                log.info(
                    "MCP config: grandfathered %d legacy server(s) as 'reviewed' "
                    "(§9.4 migration). Schema-change detection still applies.",
                    migrated_count,
                )
                self._save_config()
            log.debug("Loaded %d MCP server configs", len(data))
        except Exception:
            log.warning("Failed to load MCP config", exc_info=True)

    # ─────────────────────────────────────────────────────────────────────
    # Layer 3 — Connection holder worker (single-task lifecycle)
    # ─────────────────────────────────────────────────────────────────────

    async def _run_connection_holder(
        self,
        name: str,
        params: StdioServerParameters,
        holder: _ConnectionHolder,
        errlog,
    ) -> None:
        """Per-server worker that owns the ``stdio_client`` lifetime.

        **Single-task lifecycle invariant**: the entire ``async with``
        block — ``stdio_client.__aenter__``, ``ClientSession.__aenter__``,
        ``initialize()``, the wait loop, ``ClientSession.__aexit__``,
        ``stdio_client.__aexit__`` — runs in this coroutine, in this
        task. anyio's cancel-scope ownership rule (entered + exited from
        the same task) is satisfied by construction.

        External callers communicate via the holder:

          * ``connect_server`` awaits ``holder.ready`` to receive the
            initialized session.
          * ``disconnect_server`` sets ``holder.shutdown`` to ask the
            worker to close, then awaits ``holder.done`` for clean exit.
          * ``call_tool`` reads ``holder.session`` from any task — the
            session itself isn't task-scoped, only its open/close are.

        Failure modes:
          * connect raises before ``ready`` resolves → exception is
            attached to ``holder.ready`` so the caller surfaces it
          * connect succeeds, then session dies later → logged at
            warning, ``holder.session`` cleared so subsequent
            ``call_tool`` returns "not connected" cleanly
          * worker is cancelled mid-flight (e.g. server shutdown) →
            ``AsyncExitStack.aclose()`` runs in this task's finally
        """
        try:
            async with AsyncExitStack() as stack:
                read, write = await stack.enter_async_context(
                    stdio_client(params, errlog=errlog)
                )
                session = ClientSession(read, write)
                await stack.enter_async_context(session)
                await session.initialize()

                # Make the session visible to callers BEFORE resolving the
                # future, so by the time await holder.ready returns the
                # caller sees a fully-usable session.
                holder.session = session
                if not holder.ready.done():
                    holder.ready.set_result(session)

                # Park here until disconnect_server asks us to close.
                # The await suspends inside the AsyncExitStack — when
                # shutdown.set() fires, control resumes, the with-block
                # exits, and __aexit__ runs in this task (no cross-task
                # cancel-scope error).
                await holder.shutdown.wait()
        except BaseException as exc:
            # Connect failure path: surface to caller awaiting ready.
            if not holder.ready.done():
                holder.ready.set_exception(exc)
            else:
                # Mid-flight death: caller already has the session, so
                # log and let call_tool surface the broken state.
                log.warning(
                    "MCP %s connection died mid-flight: %s: %s",
                    name, type(exc).__name__, exc,
                )
        finally:
            holder.session = None
            holder.done.set()

    # ─────────────────────────────────────────────────────────────────────
    # Layer 4 — Connection state machine + circuit breaker
    # ─────────────────────────────────────────────────────────────────────

    def _state(self, name: str) -> _RuntimeState:
        """Return the runtime state for ``name``, creating a fresh
        ``disconnected`` entry on first access. State map is in-memory
        only; not persisted to disk."""
        s = self._states.get(name)
        if s is None:
            s = _RuntimeState()
            self._states[name] = s
        return s

    def get_server_state(self, name: str) -> dict:
        """Public read-only view of a server's state machine.

        Returns the dict shape the API + UI consume. Always returns a
        dict (default ``disconnected`` shape) even for unknown server
        names — callers don't have to special-case missing entries.
        """
        s = self._state(name)
        return {
            "status": s.status,
            "last_error": s.last_error,
            "last_error_type": s.last_error_type,
            "last_connect_attempt": s.last_connect_attempt,
            "last_connected_at": s.last_connected_at,
            "consecutive_failures": s.consecutive_failures,
            "next_retry_at": s.next_retry_at,
            "circuit_opened_at": s.circuit_opened_at,
            # Layer 4b — health probe state surfaced for the UI's
            # "connected but unhealthy" indicator and the agent's
            # world-model awareness.
            "consecutive_health_failures": s.consecutive_health_failures,
            "last_health_check": s.last_health_check,
            "last_health_status": s.last_health_status,
        }

    def _now_iso(self) -> str:
        return datetime.now(UTC).isoformat()

    def _backoff_for(self, failures: int) -> float:
        """Return seconds to wait after the Nth consecutive failure.

        N=1 → schedule[0], N=2 → schedule[1], … N≥len(schedule) →
        schedule[-1] (last value repeats). Independent of the circuit
        threshold so backoff applies to attempts BEFORE the circuit
        trips, and the circuit's own ``circuit_open_seconds`` takes
        over after.
        """
        if not self.backoff_schedule_seconds:
            return 0.0
        idx = max(0, min(failures - 1, len(self.backoff_schedule_seconds) - 1))
        return self.backoff_schedule_seconds[idx]

    def _is_circuit_open_now(self, name: str) -> bool:
        """True iff the circuit is currently blocking new attempts.

        Auto-recovery: once ``circuit_open_seconds`` has elapsed since
        the circuit opened, the circuit auto-resets to ``disconnected``
        so the next ``connect_server`` call gets a real attempt. The
        user can also force-reset via :meth:`reset_circuit`.
        """
        s = self._states.get(name)
        if s is None or s.status != STATUS_CIRCUIT_OPEN:
            return False
        if not s.circuit_opened_at:
            return True
        try:
            opened = datetime.fromisoformat(s.circuit_opened_at)
        except ValueError:
            return True
        elapsed = (datetime.now(UTC) - opened).total_seconds()
        if elapsed >= self.circuit_open_seconds:
            # Auto-recovery: drop the circuit so the next connect tries again.
            s.status = STATUS_DISCONNECTED
            s.circuit_opened_at = ""
            s.next_retry_at = ""
            log.info("MCP %s circuit auto-recovered after %.0fs", name, elapsed)
            return False
        return True

    def reset_circuit(self, name: str) -> dict:
        """Manually clear the circuit breaker for a server.

        Returns the new state dict. Idempotent — safe to call when the
        circuit isn't actually open. Used by the Settings UI's
        "Reset & retry" button when a user knows the upstream issue
        is resolved and wants to skip the auto-recovery wait.
        """
        s = self._state(name)
        s.status = STATUS_DISCONNECTED
        s.circuit_opened_at = ""
        s.consecutive_failures = 0
        s.next_retry_at = ""
        log.info("MCP %s circuit manually reset", name)
        return self.get_server_state(name)

    def _record_connect_attempt(self, name: str) -> None:
        s = self._state(name)
        s.status = STATUS_CONNECTING
        s.last_connect_attempt = self._now_iso()

    def _record_connect_success(self, name: str) -> None:
        s = self._state(name)
        s.status = STATUS_CONNECTED
        s.last_connected_at = self._now_iso()
        # Wipe the failure trail on success — the next failure starts
        # backoff fresh, not from where the previous outage left off.
        s.consecutive_failures = 0
        s.last_error = ""
        s.last_error_type = ""
        s.next_retry_at = ""
        s.circuit_opened_at = ""

    def _record_connect_failure(self, name: str, exc: BaseException) -> None:
        """Increment failure counter, schedule next retry, open circuit
        once the threshold is crossed."""
        s = self._state(name)
        s.consecutive_failures += 1
        s.last_error = str(exc)[:500]
        s.last_error_type = type(exc).__name__
        if s.consecutive_failures >= self.circuit_failure_threshold:
            s.status = STATUS_CIRCUIT_OPEN
            s.circuit_opened_at = self._now_iso()
            s.next_retry_at = ""
            log.warning(
                "MCP %s circuit OPENED after %d consecutive failures (last: %s: %s); "
                "auto-recovery in %.0fs",
                name, s.consecutive_failures, s.last_error_type,
                s.last_error[:200], self.circuit_open_seconds,
            )
        else:
            s.status = STATUS_FAILED
            backoff = self._backoff_for(s.consecutive_failures)
            next_at = datetime.now(UTC).timestamp() + backoff
            s.next_retry_at = datetime.fromtimestamp(next_at, UTC).isoformat()
            log.info(
                "MCP %s connect failed (%d/%d): %s: %s; next retry in %.0fs",
                name, s.consecutive_failures, self.circuit_failure_threshold,
                s.last_error_type, s.last_error[:200], backoff,
            )

    def _record_disconnect(self, name: str) -> None:
        s = self._state(name)
        s.status = STATUS_DISCONNECTED
        # Don't clear failure counters here — disconnect is a clean user
        # action, but if the user immediately tries to reconnect we want
        # the backoff schedule to remember where it was. The counter is
        # cleared on a SUCCESSFUL connect, not on a clean disconnect.

    # ─────────────────────────────────────────────────────────────────────
    # Layer 4b — Periodic health probe
    # ─────────────────────────────────────────────────────────────────────

    async def _probe_one(self, name: str) -> str:
        """Single-ping health check on a connected server.

        Returns ``"ok"`` on success, ``"timeout"`` if the ping exceeds
        ``health_probe_timeout_seconds``, or ``"error: <type>"`` on any
        other exception. Records the result into the state machine and
        increments / resets ``consecutive_health_failures`` accordingly.

        When the failure counter crosses ``health_failure_threshold``,
        the probe disconnects the dead server (transitions to FAILED
        + tears down the worker) so subsequent ``call_tool`` requests
        get an immediate "not connected" error instead of a hang.
        """
        session = self._sessions.get(name)
        s = self._state(name)
        s.last_health_check = self._now_iso()

        if session is None:
            s.last_health_status = ""
            return "skipped"

        try:
            await asyncio.wait_for(
                session.list_tools(),
                timeout=self.health_probe_timeout_seconds,
            )
            s.last_health_status = "ok"
            s.consecutive_health_failures = 0
            return "ok"
        except TimeoutError:
            s.last_health_status = "timeout"
            s.consecutive_health_failures += 1
        except Exception as exc:  # noqa: BLE001 — boundary
            s.last_health_status = f"error: {type(exc).__name__}"
            s.consecutive_health_failures += 1

        if s.consecutive_health_failures >= self.health_failure_threshold:
            log.warning(
                "MCP %s missed %d consecutive health pings; disconnecting "
                "(last status: %s). State machine moves to FAILED so "
                "callers see the broken connection immediately.",
                name, s.consecutive_health_failures, s.last_health_status,
            )
            try:
                await self.disconnect_server(name)
            except Exception:
                log.debug("disconnect during health-failure cleanup raised", exc_info=True)
            # _record_disconnect set status=DISCONNECTED; promote to FAILED
            # so the UI distinguishes "user disconnected" (clean) from
            # "we disconnected because pings stopped working" (broken).
            s.status = STATUS_FAILED
            s.last_error = f"health probe: {s.last_health_status}"
            s.last_error_type = "HealthCheckFailed"

        return s.last_health_status

    async def _run_health_probe_loop(self) -> None:
        """Background coroutine: ping every connected server periodically.

        Runs until the manager is torn down. Each cycle iterates a
        snapshot of ``_sessions`` (so disconnects mid-cycle don't crash
        the loop) and pings them concurrently with bounded timeout.
        Probe failures are CONTAINED — one server's broken ping must
        never crash the loop; we want the next cycle to keep checking
        the healthy servers. Cancellation (during shutdown) exits cleanly.
        """
        log.info(
            "MCP health probe started (interval=%.0fs, timeout=%.0fs, threshold=%d)",
            self.health_probe_interval_seconds,
            self.health_probe_timeout_seconds,
            self.health_failure_threshold,
        )
        try:
            while True:
                await asyncio.sleep(self.health_probe_interval_seconds)
                # Snapshot to avoid mutation-during-iteration when a
                # parallel disconnect drops a server mid-cycle.
                names = list(self._sessions.keys())
                if not names:
                    continue
                # Concurrent so one slow server doesn't extend the cycle.
                # return_exceptions=True is belt-and-braces: _probe_one
                # already swallows everything, but if a future change
                # accidentally re-raises, the loop survives.
                await asyncio.gather(
                    *(self._probe_one(n) for n in names),
                    return_exceptions=True,
                )
        except asyncio.CancelledError:
            log.info("MCP health probe stopped (cancelled)")
            raise
        except Exception:
            # Defense against a programming error in the loop body —
            # the probe is supposed to be self-contained, so this should
            # never fire, but if it does we DON'T want the whole manager
            # silently losing health monitoring.
            log.exception("MCP health probe loop crashed; will not auto-restart")

    def start_health_probe(self) -> None:
        """Start the background health-probe loop.

        Idempotent: if the loop is already running, this is a no-op.
        Wired into ``main.py`` lifespan after MCP auto-connect so the
        probe begins after the initial connection wave settles. The
        first ping happens after one full ``health_probe_interval_seconds``
        — by design — so we don't ping a server we just connected.
        """
        if self._health_probe_task is not None and not self._health_probe_task.done():
            return
        self._health_probe_task = asyncio.create_task(
            self._run_health_probe_loop(),
            name="mcp_health_probe",
        )

    async def stop_health_probe(self) -> None:
        """Cancel the background health-probe loop. Idempotent."""
        task = self._health_probe_task
        self._health_probe_task = None
        if task is None or task.done():
            return
        task.cancel()
        with suppress(BaseException):
            await task

    async def auto_connect_all(
        self, *, timeout_seconds: float | None = None
    ) -> dict[str, str]:
        """Connect to every server marked ``auto_connect`` in parallel.

        **Boot-independence guarantee** (Layer 2 of MCP resilience): no single
        server can stall this call for more than ``timeout_seconds`` (default
        :data:`_CONNECT_TIMEOUT_SECONDS`). Servers connect concurrently via
        ``asyncio.gather(return_exceptions=True)``, so the whole call's wall
        clock is bounded by the slowest single server's timeout, not the sum
        of all of them.

        Per-server failures are CONTAINED — a hang, a crash, a cancel-scope
        bug from inside the SDK, an unhandled exception from a malicious
        server: any of them logs a warning and moves on. The boot proceeds.

        Returns a ``{server_name: outcome}`` map where outcome ∈
        ``{"connected", "timeout", "error: <type>", "skipped"}`` so callers
        (and tests) can assert on the per-server result without scraping
        log lines.

        Caller should fire-and-forget this from ``asyncio.create_task(...)``
        so even the bounded ``timeout_seconds * 1`` (parallel) wall clock
        doesn't sit on the FastAPI lifespan critical path.
        """
        import os
        import sys

        deadline = timeout_seconds or self.connect_timeout_seconds

        # Suppress MCP subprocess banner noise across the whole batch — one
        # devnull swap, not one per server. Restored in finally so a partial
        # failure can't leave stdout/stderr redirected.
        old_stderr, old_stdout = sys.stderr, sys.stdout
        devnull = open(os.devnull, "w")  # noqa: SIM115
        try:
            sys.stderr = sys.stdout = devnull

            async def _attempt(name: str) -> tuple[str, str]:
                try:
                    await asyncio.wait_for(
                        self.connect_server(name), timeout=deadline
                    )
                    return name, "connected"
                except TimeoutError:
                    log.warning(
                        "MCP %s connect exceeded %.0fs timeout; will not block boot",
                        name, deadline,
                    )
                    return name, "timeout"
                except Exception as exc:  # noqa: BLE001 — boundary handler
                    log.warning(
                        "MCP %s connect failed: %s: %s",
                        name, type(exc).__name__, exc,
                    )
                    return name, f"error: {type(exc).__name__}"

            targets = [
                name for name, server in self._servers.items()
                if server.auto_connect
            ]
            if not targets:
                return {}

            # Parallel: total wall clock = max(per-server time), not sum.
            # `return_exceptions=True` is belt-and-braces — _attempt() already
            # never raises, but if a future change accidentally lets one
            # through, we still finish the batch instead of bubbling.
            results = await asyncio.gather(
                *(_attempt(n) for n in targets),
                return_exceptions=True,
            )

            outcomes: dict[str, str] = {}
            for name, item in zip(targets, results, strict=True):
                if isinstance(item, BaseException):
                    outcomes[name] = f"error: {type(item).__name__}"
                else:
                    _, status = item
                    outcomes[name] = status

            connected = [n for n, s in outcomes.items() if s == "connected"]
            failed = [(n, s) for n, s in outcomes.items() if s != "connected"]
            if connected:
                log.info("MCP auto-connect: %d/%d connected (%s)",
                         len(connected), len(targets), ", ".join(connected))
            if failed:
                log.warning("MCP auto-connect: %d failed (%s)",
                            len(failed), ", ".join(f"{n}={s}" for n, s in failed))
            return outcomes
        finally:
            sys.stderr, sys.stdout = old_stderr, old_stdout
            with suppress(Exception):
                devnull.close()

    # ---- Server management ----

    def register_server(self, server: ExternalMCPServer) -> None:
        """Register an external MCP server configuration."""
        self._servers[server.name] = server
        self._save_config()
        log.info("MCP server registered: %s (%s)", server.name, server.command)

    def remove_server(self, name: str) -> bool:
        """Remove a server config (disconnects first if needed)."""
        if name not in self._servers:
            return False
        # Clean up connection if active
        if name in self._sessions:
            # Best-effort cleanup — will be fully cleaned in disconnect
            self._sessions.pop(name, None)
            self._transports.pop(name, None)
        # Remove tools from this server
        to_remove = [k for k, v in self._external_tools.items() if v.get("_mcp_server") == name]
        for k in to_remove:
            del self._external_tools[k]
        del self._servers[name]
        # Layer 4 — drop runtime state too. A full remove is more than a
        # disconnect; a future re-register starts fresh, no stale failure
        # history to inherit.
        self._states.pop(name, None)
        self._save_config()
        log.info("MCP server removed: %s", name)
        return True

    async def connect_server(self, name: str) -> list[dict]:
        """Connect to an external MCP server and discover its tools.

        §9.4 trust gate: tools fetched here only become visible to the
        agent if `server.trust_state == reviewed` AND their canonical
        hashes match `server.reviewed_tool_hashes`. Any mismatch drops
        the server back to `review_pending` and keeps the tools out of
        `_external_tools` until the user re-approves via the API.

        Returns the full tool list (whether they're visible or pending)
        so the review endpoint can render them in the UI.
        """
        server = self._servers.get(name)
        if not server:
            raise ValueError(f"Unknown MCP server: {name}")

        # Layer 4 — circuit breaker gate. Bail FAST without spawning the
        # subprocess if the circuit is currently open. Caller gets a clear
        # error explaining when auto-recovery will fire and how to override.
        if self._is_circuit_open_now(name):
            state = self.get_server_state(name)
            raise ConnectionRefusedError(
                f"MCP server '{name}' circuit is OPEN "
                f"(consecutive_failures={state['consecutive_failures']}, "
                f"last_error={state['last_error_type']}: {state['last_error'][:100]}). "
                f"Use reset_circuit('{name}') to force-retry now."
            )

        # Disconnect existing session if reconnecting
        if name in self._sessions:
            await self.disconnect_server(name)

        # Mark CONNECTING so observers see we're in flight; transitions
        # to CONNECTED on success or FAILED/CIRCUIT_OPEN on exception.
        self._record_connect_attempt(name)

        # Rejected servers stay rejected — the user already made a decision.
        # They can explicitly un-reject via `remove_server(name)` + re-register
        # if they change their mind; we don't want an auto-connect to quietly
        # reanimate a server the user said no to.
        if server.trust_state == TRUST_REJECTED:
            log.info(
                "MCP server '%s' is rejected (%s); refusing to connect.",
                name, server.rejection_reason or "no reason recorded",
            )
            raise PermissionError(
                f"MCP server '{name}' was rejected. Remove and re-register to reconnect."
            )

        # Merge env with current environment so the subprocess inherits PATH etc.
        import os

        merged_env = {**os.environ, **(server.env or {})}
        params = StdioServerParameters(
            command=server.command,
            args=server.args,
            env=merged_env,
        )

        # Layer 3: spawn a dedicated worker that owns the stdio_client
        # async-with scope. The worker stays parked on `holder.shutdown`
        # until disconnect_server signals it. Caller awaits `holder.ready`
        # to receive the live session — no cross-task __aenter__/__aexit__
        # ever happens, so anyio's cancel-scope rule is satisfied.
        self._devnull = open(os.devnull, "w")  # noqa: SIM115
        holder = _ConnectionHolder()
        worker = asyncio.create_task(
            self._run_connection_holder(name, params, holder, self._devnull),
            name=f"mcp_holder_{name}",
        )
        self._holders[name] = holder
        self._connection_tasks[name] = worker

        try:
            session = await asyncio.wait_for(
                asyncio.shield(holder.ready),
                timeout=self.connect_timeout_seconds,
            )
        except TimeoutError as exc:
            # Clean up the orphaned worker so it doesn't leak when we
            # raise. shutdown.set() asks it to exit cleanly; cancel is
            # the fallback for a worker stuck inside SDK code.
            holder.shutdown.set()
            with suppress(Exception):
                await asyncio.wait_for(worker, timeout=2.0)
            if not worker.done():
                worker.cancel()
            self._holders.pop(name, None)
            self._connection_tasks.pop(name, None)
            log.warning("MCP %s connect timed out after %.0fs", name, self.connect_timeout_seconds)
            self._record_connect_failure(name, exc)
            raise
        except Exception as exc:
            # Connect failure surfaced by the worker. Worker has already
            # exited by the time the future resolves with an exception
            # (its finally block runs before set_exception completes).
            self._holders.pop(name, None)
            self._connection_tasks.pop(name, None)
            log.exception("MCP connection failed for '%s'", name)
            self._record_connect_failure(name, exc)
            raise

        self._sessions[name] = session
        self._transports[name] = holder  # holder pins the worker via _connection_tasks

        # Discover tools
        try:
            tools_result = await session.list_tools()
        except Exception as exc:
            log.exception("MCP tool discovery failed for '%s'", name)
            await self.disconnect_server(name)
            self._record_connect_failure(name, exc)
            raise

        # Build tool definitions + their canonical hashes. Description gets
        # the `[{server_name}] ` prefix as before — injection patterns are
        # scanned against the RAW description (before the prefix) so the
        # prefix itself doesn't trigger a false positive.
        tools = []
        current_hashes: dict[str, str] = {}
        injection_flags: dict[str, list[str]] = {}
        for tool in tools_result.tools:
            raw_desc = tool.description or ""
            full_name = f"mcp_{name}_{tool.name}"
            tool_def = {
                "name": full_name,
                "description": f"[{name}] {raw_desc}",
                "input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {"type": "object", "properties": {}},
                "_mcp_server": name,
                "_mcp_tool": tool.name,
                "_raw_description": raw_desc,  # preserved for review UI
            }
            tools.append(tool_def)
            current_hashes[full_name] = _canonical_tool_hash(tool_def)
            flags = _scan_injection_patterns(raw_desc)
            if flags:
                injection_flags[full_name] = flags

        # Decide: are these tools allowed to be visible to the agent?
        # Rules:
        #   reviewed   + captured hashes match current       → visible (happy path)
        #   reviewed   + NO captured hashes (grandfathered)  → visible, capture baseline now
        #   reviewed   + any hash differs (schema drift)     → review_pending + hidden
        #   unreviewed                                        → review_pending + hidden
        #   review_pending                                    → stay pending + hidden
        schema_drift = False
        grandfathered_baseline = False
        if server.trust_state == TRUST_REVIEWED:
            if not server.reviewed_tool_hashes:
                # Grandfathered from pre-§9.4 config OR a fresh approval
                # that somehow didn't capture hashes. Either way, trust the
                # CURRENT view as the baseline and stamp it. Subsequent
                # reconnects will do real drift detection.
                grandfathered_baseline = True
            elif set(current_hashes.keys()) != set(server.reviewed_tool_hashes.keys()):
                schema_drift = True
            else:
                for tn, h in current_hashes.items():
                    if server.reviewed_tool_hashes.get(tn) != h:
                        schema_drift = True
                        break

        # Clear any previously-cached tools from this server so a reconnect
        # doesn't leave stale entries around.
        self._external_tools = {
            k: v for k, v in self._external_tools.items() if v.get("_mcp_server") != name
        }
        self._pending_tools = {
            k: v for k, v in self._pending_tools.items() if v.get("_mcp_server") != name
        }

        if server.trust_state == TRUST_REVIEWED and not schema_drift:
            # Happy path: server was reviewed, nothing changed. Tools visible.
            for tool_def in tools:
                self._external_tools[tool_def["name"]] = {
                    **tool_def,
                    "_injection_flags": injection_flags.get(tool_def["name"], []),
                }
            # Capture the baseline on first connect after grandfathering so
            # subsequent reconnects do proper drift detection. No-op if we
            # already had hashes (the equality check above would have passed).
            if grandfathered_baseline:
                server.reviewed_tool_hashes = dict(current_hashes)
                self._save_config()
                log.info(
                    "MCP server '%s': captured baseline tool hashes (%d tools) "
                    "for grandfathered config; drift detection armed.",
                    name, len(current_hashes),
                )
            log.debug(
                "Connected to reviewed MCP server '%s': %d tools visible",
                name, len(tools),
            )
        else:
            # Park tools under pending. Reason stamped for the review UI.
            if server.trust_state == TRUST_REVIEWED and schema_drift:
                reason = "schema_drift"
                # Downgrade state so the UI shows this as pending review.
                server.trust_state = TRUST_REVIEW_PENDING
                self._save_config()
                log.warning(
                    "MCP server '%s': tool schemas changed since last review; "
                    "tools quarantined until re-review.", name,
                )
            elif server.trust_state == TRUST_UNREVIEWED:
                reason = "first_connect"
                server.trust_state = TRUST_REVIEW_PENDING
                self._save_config()
            else:
                reason = "awaiting_review"

            for tool_def in tools:
                self._pending_tools[tool_def["name"]] = {
                    **tool_def,
                    "_pending_reason": reason,
                    "_current_hash": current_hashes[tool_def["name"]],
                    "_injection_flags": injection_flags.get(tool_def["name"], []),
                }
            log.info(
                "MCP server '%s' awaiting review (%s): %d tools pending; "
                "will be hidden from agent until approved.",
                name, reason, len(tools),
            )

        # Layer 4 — connection succeeded end-to-end. Reset failure counters
        # so the next outage starts the backoff schedule fresh, and
        # transition the state machine to CONNECTED.
        self._record_connect_success(name)
        return tools

    async def disconnect_server(self, name: str) -> bool:
        """Disconnect a single MCP server.

        **Layer 3 lifecycle**: signals the dedicated worker via
        ``holder.shutdown``. The worker exits its ``async with`` block in
        its own task — anyio's cancel-scope rule is preserved no matter
        which task this method runs in (FastAPI request handler, lifespan
        shutdown, agent reconnect path, etc.).

        Falls back to ``task.cancel()`` if the worker doesn't exit within
        ``disconnect_timeout_seconds``. Cancellation also runs ``__aexit__``
        in the worker's task (cleanup happens inside the awaited cancel),
        so the cross-task error is still avoided.
        """
        # Pop FIRST so ``call_tool`` immediately stops finding this server
        # while the worker is still tearing down.
        self._sessions.pop(name, None)
        self._transports.pop(name, None)
        holder = self._holders.pop(name, None)
        worker = self._connection_tasks.pop(name, None)

        if holder is not None:
            holder.shutdown.set()
        if worker is not None and not worker.done():
            try:
                await asyncio.wait_for(worker, timeout=self.disconnect_timeout_seconds)
            except TimeoutError:
                log.warning(
                    "MCP %s worker did not exit cleanly within %.0fs; cancelling",
                    name, self.disconnect_timeout_seconds,
                )
                worker.cancel()
                # Best-effort: await the cancellation so the cleanup
                # finally-block actually runs before we return.
                with suppress(BaseException):
                    await worker
            except BaseException as exc:  # noqa: BLE001 — boundary
                log.warning("MCP %s worker exited with error: %s: %s",
                            name, type(exc).__name__, exc)

        # Remove tools from this server from BOTH tables
        to_remove = [k for k, v in self._external_tools.items() if v.get("_mcp_server") == name]
        for k in to_remove:
            del self._external_tools[k]
        to_remove_pending = [k for k, v in self._pending_tools.items() if v.get("_mcp_server") == name]
        for k in to_remove_pending:
            del self._pending_tools[k]
        # Layer 4 — transition state machine. Disconnect is a clean user
        # action; we don't reset the failure counter here so a quick
        # reconnect inherits the backoff schedule (success on reconnect
        # would clear it via _record_connect_success).
        self._record_disconnect(name)
        log.debug("Disconnected MCP server: %s", name)
        return True

    async def call_tool(self, tool_name: str, arguments: dict) -> dict:
        """Call a tool on an external MCP server.

        Defense-in-depth check: `_external_tools` should already only contain
        reviewed-server tools, but we also verify the server's `trust_state`
        is `reviewed` at call time. A race (tool listed, then user rejected
        the server before the LLM's tool_call arrived) is impossible in
        practice but caught here anyway.
        """
        tool_def = self._external_tools.get(tool_name)
        if not tool_def:
            # Might be pending — give a more helpful error
            if tool_name in self._pending_tools:
                return {"error": (
                    f"MCP tool '{tool_name}' is pending user review and cannot be called. "
                    "Approve the server in Settings → MCP Servers before use."
                )}
            return {"error": f"Unknown MCP tool: {tool_name}"}

        server_name = tool_def["_mcp_server"]
        real_tool_name = tool_def["_mcp_tool"]
        session = self._sessions.get(server_name)

        # §9.4 runtime re-check: server must still be `reviewed`
        server = self._servers.get(server_name)
        if not server or server.trust_state != TRUST_REVIEWED:
            return {"error": (
                f"MCP server '{server_name}' is not approved for tool calls "
                f"(trust_state={server.trust_state if server else 'missing'})."
            )}

        if not session:
            return {"error": f"MCP server '{server_name}' not connected"}

        try:
            result = await session.call_tool(real_tool_name, arguments)
            text_parts = []
            for content in result.content:
                if hasattr(content, "text"):
                    text_parts.append(content.text)
            return {"result": "\n".join(text_parts)}
        except Exception as e:
            return {"error": str(e)}

    def get_external_tool_definitions(self) -> list[dict]:
        """Get all external tool definitions for the agent.

        Only returns tools from `reviewed` servers — pending tools are
        structurally prevented from reaching the LLM's tool list. This is
        the single load-bearing filter for §9.4; `call_tool` adds a runtime
        check as defense-in-depth.
        """
        return list(self._external_tools.values())

    def list_servers(self) -> list[dict]:
        """List registered MCP servers and their full state.

        **Layer 6 observability** — surface the connection state machine
        so the UI can render a traffic-light status (disconnected /
        connecting / connected / failed / circuit_open) with a human
        explanation, and the agent can know in its world model when its
        tools come and go.

        ``connected: bool`` is preserved as a backward-compat alias for
        ``status == "connected"``; existing UI code that reads it keeps
        working unchanged. New code should consume ``status`` directly.
        """
        out = []
        for name, server in self._servers.items():
            state = self.get_server_state(name)
            out.append({
                "name": name,
                "command": server.command,
                "args": server.args,
                "connected": name in self._sessions,  # back-compat alias
                "status": state["status"],
                "last_error": state["last_error"],
                "last_error_type": state["last_error_type"],
                "last_connect_attempt": state["last_connect_attempt"],
                "last_connected_at": state["last_connected_at"],
                "consecutive_failures": state["consecutive_failures"],
                "next_retry_at": state["next_retry_at"],
                "circuit_opened_at": state["circuit_opened_at"],
                # Layer 4b health probe fields
                "consecutive_health_failures": state["consecutive_health_failures"],
                "last_health_check": state["last_health_check"],
                "last_health_status": state["last_health_status"],
                "auto_connect": server.auto_connect,
                "trust_state": server.trust_state,
                "reviewed_at": server.reviewed_at,
                "rejection_reason": server.rejection_reason,
                "tools": len([
                    t for t in self._external_tools.values()
                    if t.get("_mcp_server") == name
                ]),
                "pending_tools": len([
                    t for t in self._pending_tools.values()
                    if t.get("_mcp_server") == name
                ]),
            })
        return out

    # ---- §9.4 review flow helpers ----

    def get_pending_review(self, name: str) -> dict:
        """Return pending tools for a server so the UI can render the review.

        Pure read — never mutates state. Returns the server's metadata plus
        every pending tool with its description, input_schema, current hash,
        and any detected injection-pattern flags.
        """
        server = self._servers.get(name)
        if not server:
            raise ValueError(f"Unknown MCP server: {name}")
        pending = [
            {
                "name": t["name"],
                "short_name": t.get("_mcp_tool", ""),
                "description": t.get("_raw_description", t.get("description", "")),
                "input_schema": t.get("input_schema", {}),
                "current_hash": t.get("_current_hash", ""),
                "previous_hash": server.reviewed_tool_hashes.get(t["name"], ""),
                "changed": (
                    server.reviewed_tool_hashes.get(t["name"], "") != t.get("_current_hash", "")
                    if server.reviewed_tool_hashes else False
                ),
                "injection_flags": t.get("_injection_flags", []),
            }
            for t in self._pending_tools.values()
            if t.get("_mcp_server") == name
        ]
        return {
            "name": name,
            "trust_state": server.trust_state,
            "reviewed_at": server.reviewed_at,
            "reason": pending[0].get("_pending_reason", "") if pending else "",
            "tools": pending,
            "connected": name in self._sessions,
            "was_previously_reviewed": bool(server.reviewed_tool_hashes),
        }

    def approve_server(self, name: str) -> dict:
        """Mark a server as reviewed: promote pending tools to live, stamp hashes.

        No LLM/network side effects. Pure state mutation + config save.
        The caller (API route) is responsible for contract authorization and
        activity-log emission.
        """
        server = self._servers.get(name)
        if not server:
            raise ValueError(f"Unknown MCP server: {name}")
        pending = [t for t in self._pending_tools.values() if t.get("_mcp_server") == name]
        if not pending:
            # Nothing to approve — might happen if the server was reconnected
            # and hashes all matched (no pending created). Treat as no-op.
            return {"approved": 0, "trust_state": server.trust_state}

        server.trust_state = TRUST_REVIEWED
        server.reviewed_at = datetime.now(UTC).isoformat()
        server.rejection_reason = None
        new_hashes: dict[str, str] = {}
        for t in pending:
            new_hashes[t["name"]] = t.get("_current_hash", _canonical_tool_hash(t))
        server.reviewed_tool_hashes = new_hashes

        # Promote pending → live. Strip the review-only bookkeeping keys.
        approved_names = []
        for t in pending:
            live = {k: v for k, v in t.items() if not k.startswith("_pending")}
            live.pop("_current_hash", None)
            self._external_tools[t["name"]] = live
            approved_names.append(t["name"])
        # Clear pending for this server
        self._pending_tools = {
            k: v for k, v in self._pending_tools.items() if v.get("_mcp_server") != name
        }
        self._save_config()
        log.info("MCP server '%s' approved: %d tools now visible to agent.", name, len(approved_names))
        return {
            "approved": len(approved_names),
            "tool_names": approved_names,
            "trust_state": server.trust_state,
            "reviewed_at": server.reviewed_at,
        }

    async def reject_server(self, name: str, reason: str = "") -> dict:
        """Mark a server as rejected + disconnect it + clear pending tools.

        Async because it calls `disconnect_server`. The server stays
        registered (so the user can see WHY they rejected it in the list)
        but will refuse to connect until they `remove_server` it.
        """
        server = self._servers.get(name)
        if not server:
            raise ValueError(f"Unknown MCP server: {name}")
        server.trust_state = TRUST_REJECTED
        server.rejection_reason = reason.strip()[:500] if reason else "rejected by user"
        server.auto_connect = False  # don't reconnect on startup
        server.reviewed_tool_hashes = {}
        self._save_config()
        await self.disconnect_server(name)
        log.info("MCP server '%s' rejected: %s", name, server.rejection_reason)
        return {
            "rejected": True,
            "trust_state": server.trust_state,
            "rejection_reason": server.rejection_reason,
        }

    async def disconnect_all(self) -> None:
        """Disconnect from all MCP servers."""
        for name in list(self._sessions.keys()):
            await self.disconnect_server(name)
