# @invar:allow file_size: MCP server module - single file for server logic
"""
MCP Server for Lattice - Agent-facing memory interface.

Implements stdio transport MCP server with:
- Session lifecycle (connect → session_id generation → disconnect)
- Dynamic server_instructions from current rules
- Tools: lattice_search, log_turn, get_instincts, propose_rule, get_status
- Resources: lattice://rules/{filename}, lattice://proposals/{filename}

Reference: RFC-002 §4.2, §6.2
"""

from __future__ import annotations

import contextlib
import logging
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import unquote

from mcp.server import Server
from mcp.types import AnyUrl, Resource, Tool  # type: ignore[misc]
from returns.result import Failure, Result, Success

from lattice.core.config import get_default_config
from lattice.core.rules import assemble_instincts
from lattice.core.sanitizer import sanitize
from lattice.core.types.config import Config, CaptureConfig
from lattice.core.types.enums import Role
from lattice.shell.config import (
    load_config_with_fallback,
    read_rules,
    resolve_global_dir,
    resolve_project_dir,
)
from lattice.shell.embeddings import embed_text, insert_embedding
from lattice.shell.global_evolution import read_global_rules
from lattice.shell.hybrid_search import hybrid_search, hybrid_search_global
from lattice.shell.schema import create_store, create_global_store
from lattice.shell.store import generate_session_id, insert_event, insert_log
from lattice.shell.telemetry import get_tracer, is_tracing_enabled

logger = logging.getLogger(__name__)
tracer = get_tracer(__name__)


def _validate_safe_path(base_dir: Path, filename: str) -> Result[Path, str]:
    """Validate that filename resolves to a path within base_dir.

    Security: Prevents path traversal attacks (e.g., "../../etc/passwd").

    Args:
        base_dir: Path object for the allowed base directory.
        filename: User-provided filename to validate.

    Returns:
        Success with resolved Path if safe, Failure with error message otherwise.
    """
    from pathlib import Path

    from returns.result import Failure, Result, Success

    # Reject path traversal components
    if ".." in filename or filename.startswith("/"):
        return Failure(f"Path traversal detected in: {filename}")

    # Reject absolute paths
    if Path(filename).is_absolute():
        return Failure(f"Absolute path not allowed: {filename}")

    # Resolve and check containment
    base_resolved = base_dir.resolve()
    target_path = (base_dir / filename).resolve()

    try:
        # Check if target is within base directory
        target_path.relative_to(base_resolved)
        return Success(target_path)
    except ValueError:
        return Failure(f"Path escapes base directory: {filename}")


class LatticeMCPServer:
    """Lattice MCP Server implementation.

    Handles session lifecycle and provides tools for:
    - lattice_search: Search project/global memory
    - log_turn: Record interaction (only when capture mode is active)
    - get_instincts: Refresh System 1 rules mid-session
    - propose_rule: Submit rule proposal
    - get_status: View status

    Args:
        capture_mode: Override capture mode. If provided, overrides config.toml
            [capture].mode. Use "active" to enable log_turn tool, "passive" to
            disable it. If None (default), uses config.toml setting.

    >>> LatticeMCPServer(capture_mode="active").config.capture.is_active()
    True
    >>> LatticeMCPServer(capture_mode="passive").config.capture.is_active()
    False
    >>> LatticeMCPServer(capture_mode=None).config.capture.mode  # Uses config.toml
    'passive'
    """

    def __init__(self, capture_mode: str | None = None) -> None:
        """Initialize MCP server.

        Args:
            capture_mode: Override capture mode ("active" or "passive").
                If None, uses config.toml [capture].mode default.
        """
        self.session_id: str | None = None
        self.config = get_default_config()
        self.project_path: str | None = None
        self._capture_mode_override = capture_mode

        # Create MCP server instance with dynamic instructions
        self.server = Server(
            name="lattice",
            version="1.0.0",
            instructions=self._get_server_instructions(),
        )

        # Register handlers
        self._register_handlers()

    def _get_server_instructions(self) -> str:
        """Generate dynamic server instructions from current rules.

        Per RFC-002 §4.2: Server instructions are dynamically generated
        from rules/*.md files (Global + Project, with overlay resolution).
        """
        from returns.result import Failure, Success

        try:
            logger.debug("Generating server instructions")
            # Resolve project directory
            dir_result = resolve_project_dir()
            if isinstance(dir_result, Failure):
                logger.debug(f"Project not initialized: {dir_result.failure()}")
                return self._get_default_instructions()

            lattice_dir = dir_result.unwrap()
            self.project_path = str(lattice_dir.parent)

            # Read config with fallback to global
            config_path = lattice_dir / "config.toml"
            self.config, _ = load_config_with_fallback(config_path)

            # Apply capture_mode override if provided
            if self._capture_mode_override is not None:
                self.config = Config(
                    compiler=self.config.compiler,
                    embedding=self.config.embedding,
                    thresholds=self.config.thresholds,
                    safety=self.config.safety,
                    capture=CaptureConfig(mode=self._capture_mode_override),
                    layer1=self.config.layer1,
                )

            # Read project rules
            rules_dir = lattice_dir / "rules"
            rules_result = read_rules(rules_dir)
            if isinstance(rules_result, Failure):
                project_rules = []
            else:
                project_rules = rules_result.unwrap()

            # Read global rules from ~/.config/lattice/rules/
            global_path_result = resolve_global_dir()
            if isinstance(global_path_result, Success):
                global_path = global_path_result.unwrap()
            else:
                global_path = Path.home() / ".config" / "lattice"
            global_rules_dir = global_path / "rules"
            global_rules_result = read_rules(global_rules_dir)
            if isinstance(global_rules_result, Failure):
                global_rules = []
                global_rule_names: set[str] = set()
            else:
                global_rules = global_rules_result.unwrap()
                global_rule_names = {r.file_path for r in global_rules}

            # Assemble instincts (merge global + project rules)
            all_rules = global_rules + project_rules
            return assemble_instincts(
                all_rules,
                global_rule_names,
                capture_active=self.config.capture.is_active(),
            )

        except Exception as e:
            logger.warning(f"Failed to generate instructions: {e}")
            return self._get_default_instructions()

    def _get_default_instructions(self) -> str:
        """Get default instructions when project not initialized."""
        if self.config.capture.is_active():
            return """## Lattice — Memory System

Lattice provides persistent memory for AI agents through:
- **Instincts (rules/)**: Behavioral patterns derived from session history
- **Episodic Memory (store.db)**: Searchable conversation logs

### Available Tools
- `lattice_search`: Search past conversations
- `log_turn`: Record the current turn
- `get_instincts`: Refresh rules mid-session
- `get_status`: View memory status

Project will be auto-initialized on first use (e.g., first search or log turn).
"""
        else:
            return """## Lattice — Memory System

Lattice provides persistent memory for AI agents through:
- **Instincts (rules/)**: Behavioral patterns derived from session history
- **Episodic Memory (store.db)**: Searchable conversation logs

### Available Tools
- `lattice_search`: Search past conversations
- `get_instincts`: Refresh rules mid-session
- `get_status`: View memory status

Project will be auto-initialized on first use (e.g., first search or log turn).
"""

    def _get_tool_definitions(self) -> list[Tool]:
        """Return list of MCP tool definitions.

        log_turn is only registered when capture mode is active.
        """
        tools = [
            Tool(
                name="lattice_search",
                description="Search project episodic memory. Hybrid BM25 + Vector + RRF.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "Search query",
                        },
                        "limit": {
                            "type": "integer",
                            "description": "Max results (default 10)",
                            "default": 10,
                        },
                        "scope": {
                            "type": "string",
                            "description": "'project' (default) or 'global'",
                            "default": "project",
                        },
                    },
                    "required": ["query"],
                },
            ),
        ]

        # Only include log_turn when capture mode is active
        if self.config.capture.is_active():
            tools.append(
                Tool(
                    name="log_turn",
                    description="Record interaction to store.db.",
                    inputSchema={
                        "type": "object",
                        "properties": {
                            "role": {
                                "type": "string",
                                "description": "Message role: user, assistant, or tool",
                            },
                            "content": {
                                "type": "string",
                                "description": "Message content",
                            },
                            "metadata": {
                                "type": "object",
                                "description": "Optional metadata dict",
                            },
                        },
                        "required": ["role", "content"],
                    },
                )
            )
            # New unified log tool for session compression
            tools.append(
                Tool(
                    name="log",
                    description=(
                        "Record event to Lattice memory. Keep it brief to save tokens. "
                        "type=user/assistant/reasoning: Pass full content. "
                        "type=tool: Pass only summary (status + error if any), NOT raw output. "
                        "Examples: log('user', 'auth 报错了'), "
                        "log('tool', 'read', input='src/auth.py', status='success'), "
                        "log('tool', 'edit', input='src/auth.py', status='error', error='syntax')"
                    ),
                    inputSchema={
                        "type": "object",
                        "properties": {
                            "type": {
                                "type": "string",
                                "description": "Event type: user, assistant, reasoning, or tool",
                            },
                            "content": {
                                "type": "string",
                                "description": "Event content (full text or tool name)",
                            },
                            "input": {
                                "type": "string",
                                "description": "Tool input summary (for type=tool)",
                            },
                            "status": {
                                "type": "string",
                                "description": "Tool status: success or error (for type=tool)",
                            },
                            "error": {
                                "type": "string",
                                "description": "Error message (for type=tool with status=error)",
                            },
                        },
                        "required": ["type", "content"],
                    },
                )
            )

        tools.extend(
            [
                Tool(
                    name="get_instincts",
                    description="Refresh System 1 rules mid-session. Call for long sessions (30+ min).",
                    inputSchema={
                        "type": "object",
                        "properties": {},
                    },
                ),
                Tool(
                    name="propose_rule",
                    description="Submit structured rule proposal for evolution.",
                    inputSchema={
                        "type": "object",
                        "properties": {
                            "pattern": {
                                "type": "string",
                                "description": "Pattern type: convention, preference, constraint",
                            },
                            "observation": {
                                "type": "string",
                                "description": "What was observed",
                            },
                            "evidence": {
                                "type": "string",
                                "description": "Evidence session IDs (comma-separated)",
                            },
                            "suggested_action": {
                                "type": "string",
                                "description": "ADD, MERGE, REMOVE, or REWORD",
                            },
                        },
                        "required": [
                            "pattern",
                            "observation",
                            "evidence",
                            "suggested_action",
                        ],
                    },
                ),
                Tool(
                    name="get_status",
                    description="View pending proposals, token budget, sessions pending.",
                    inputSchema={
                        "type": "object",
                        "properties": {},
                    },
                ),
            ]
        )

        return tools

    def _list_rule_resources(self, rules_dir: Path) -> list[Resource]:
        """List rule file resources."""
        resources: list[Resource] = []
        if rules_dir.exists() and rules_dir.is_dir():
            for rule_file in rules_dir.glob("*.md"):
                resources.append(
                    Resource(
                        uri=f"lattice://rules/{rule_file.name}",
                        name=f"Rule: {rule_file.stem}",
                        mimeType="text/markdown",
                        description=f"Project rule file: {rule_file.name}",
                    )
                )
        return resources

    def _list_proposal_resources(self, proposals_dir: Path) -> list[Resource]:
        """List proposal file resources."""
        resources: list[Resource] = []
        if proposals_dir.exists() and proposals_dir.is_dir():
            for proposal_file in proposals_dir.glob("*.md"):
                resources.append(
                    Resource(
                        uri=f"lattice://proposals/{proposal_file.name}",
                        name=f"Proposal: {proposal_file.stem}",
                        mimeType="text/markdown",
                        description=f"Pending proposal: {proposal_file.name}",
                    )
                )
        return resources

    def _read_rule_resource(self, filename: str) -> str:
        """Read a rule file resource."""
        from returns.result import Failure

        # Defense-in-depth: decode URL-encoded sequences before validation
        decoded_filename = unquote(filename)

        dir_result = resolve_project_dir()
        if isinstance(dir_result, Failure):
            return f"Error: Project not initialized - {dir_result.failure()}"

        lattice_dir = dir_result.unwrap()
        rules_dir = lattice_dir / "rules"

        path_result = _validate_safe_path(rules_dir, decoded_filename)
        if isinstance(path_result, Failure):
            return f"Invalid path: {path_result.failure()}"

        rule_path = path_result.unwrap()
        if not rule_path.exists():
            return f"Rule not found: {filename}"

        return rule_path.read_text(encoding="utf-8")

    def _read_proposal_resource(self, filename: str) -> str:
        """Read a proposal file resource."""
        from returns.result import Failure

        # Defense-in-depth: decode URL-encoded sequences before validation
        decoded_filename = unquote(filename)

        dir_result = resolve_project_dir()
        if isinstance(dir_result, Failure):
            return f"Error: Project not initialized - {dir_result.failure()}"

        lattice_dir = dir_result.unwrap()
        project_path = lattice_dir.parent
        proposals_dir = project_path / "drift" / "proposals"

        path_result = _validate_safe_path(proposals_dir, decoded_filename)
        if isinstance(path_result, Failure):
            return f"Invalid path: {path_result.failure()}"

        proposal_path = path_result.unwrap()
        if not proposal_path.exists():
            return f"Proposal not found: {filename}"

        return proposal_path.read_text(encoding="utf-8")

    def _register_handlers(self) -> None:
        """Register MCP server handlers."""

        @self.server.list_tools()
        async def list_tools() -> list[Tool]:
            """List available MCP tools."""
            return self._get_tool_definitions()

        @self.server.call_tool()
        async def call_tool(name: str, arguments: dict[str, Any]) -> list[Any]:
            """Execute MCP tool call."""
            # log_turn is only available when capture mode is active
            if name == "log_turn" and not self.config.capture.is_active():
                return [
                    {
                        "type": "text",
                        "text": "Error: log_turn is not available (capture mode is passive)",
                    }
                ]
            # log is also only available when capture mode is active
            if name == "log" and not self.config.capture.is_active():
                return [
                    {
                        "type": "text",
                        "text": "Error: log is not available (capture mode is passive)",
                    }
                ]
            if name == "lattice_search":
                return await self._handle_search(arguments)
            elif name == "log":
                return await self._handle_log(arguments)
            elif name == "log_turn":
                return await self._handle_log_turn(arguments)
            elif name == "get_instincts":
                return await self._handle_get_instincts(arguments)
            elif name == "propose_rule":
                return await self._handle_propose_rule(arguments)
            elif name == "get_status":
                return await self._handle_get_status(arguments)
            else:
                return [{"type": "text", "text": f"Unknown tool: {name}"}]

        @self.server.list_resources()
        async def list_resources() -> list[Resource]:
            """List available MCP resources.

            Per RFC-002 §6.2:
            - lattice://rules/{filename} - Individual rule files
            - lattice://proposals/{filename} - Pending evolution proposals
            """
            from returns.result import Failure

            resources: list[Resource] = []

            try:
                dir_result = resolve_project_dir()
                if isinstance(dir_result, Failure):
                    return resources

                lattice_dir = dir_result.unwrap()
                project_path = lattice_dir.parent

                # List rule files
                rules_dir = lattice_dir / "rules"
                resources.extend(self._list_rule_resources(rules_dir))

                # List proposal files
                proposals_dir = project_path / "drift" / "proposals"
                resources.extend(self._list_proposal_resources(proposals_dir))

            except Exception as e:
                logger.warning(f"Failed to list resources: {e}")

            return resources

        @self.server.read_resource()
        async def read_resource(uri: AnyUrl) -> str:
            """Read MCP resource content.

            Supports:
            - lattice://rules/{filename} - Read rule file
            - lattice://proposals/{filename} - Read proposal file

            Security: Validates paths to prevent traversal attacks.
            """
            uri_str = str(uri)

            try:
                if uri_str.startswith("lattice://rules/"):
                    filename = uri_str[len("lattice://rules/") :]
                    return self._read_rule_resource(filename)
                elif uri_str.startswith("lattice://proposals/"):
                    filename = uri_str[len("lattice://proposals/") :]
                    return self._read_proposal_resource(filename)
                else:
                    return f"Unknown resource URI: {uri_str}"

            except Exception as e:
                logger.exception(f"Failed to read resource: {uri}")
                return f"Error reading resource: {e}"

    # Tool handlers

    async def _handle_search(self, arguments: dict[str, Any]) -> list[dict[str, str]]:
        """Handle lattice_search tool call.

        Per RFC-002 §6.2: Hybrid BM25 + Vector + RRF search.
        If config.embedding configured, embed query; else FTS5-only.
        """
        query = arguments.get("query", "")
        limit = arguments.get("limit", 10)
        scope = arguments.get("scope", "project")

        logger.debug(
            f"Search request: query='{query[:50]}...', limit={limit}, scope={scope}"
        )

        if not query:
            return [{"type": "text", "text": "Error: query is required"}]

        # OpenTelemetry tracing for search tool
        with tracer.start_as_current_span("tool.search") as span:
            span.set_attribute("query.length", len(query))
            span.set_attribute("limit", limit)
            span.set_attribute("scope", scope)

            try:
                # Resolve store path
                dir_result = resolve_project_dir()
                if isinstance(dir_result, Failure):
                    span.set_attribute("error", "project_not_initialized")
                    return [
                        {
                            "type": "text",
                            "text": f"Error: Project not initialized - {dir_result.failure()}",
                        }
                    ]

                lattice_dir = dir_result.unwrap()

                # Determine store based on scope
                if scope == "global":
                    store_path = lattice_dir / "global.db"
                    store_result = create_global_store(store_path)
                else:
                    store_path = lattice_dir / "store.db"
                    store_result = create_store(store_path)

                if isinstance(store_result, Failure):
                    span.set_attribute("error", "store_open_failed")
                    return [
                        {
                            "type": "text",
                            "text": f"Error opening store: {store_result.failure()}",
                        }
                    ]

                conn = store_result.unwrap()

                try:
                    # Generate embedding if configured
                    query_embedding = None
                    if self.config.embedding:
                        embed_result = embed_text(self.config.embedding, query)
                        if isinstance(embed_result, Success):
                            query_embedding = embed_result.unwrap()

                    # Execute search
                    if scope == "global":
                        search_result = hybrid_search_global(
                            conn, query, query_embedding, limit
                        )
                    else:
                        search_result = hybrid_search(
                            conn, query, query_embedding, limit
                        )

                    if isinstance(search_result, Failure):
                        span.set_attribute("error", "search_failed")
                        return [
                            {
                                "type": "text",
                                "text": f"Search failed: {search_result.failure()}",
                            }
                        ]

                    results = search_result.unwrap()
                    span.set_attribute("result_count", len(results))

                    if not results:
                        return [{"type": "text", "text": "No results found."}]

                    # Format results
                    output_lines = [f"Found {len(results)} results:"]
                    for i, result in enumerate(results, 1):
                        if scope == "global":
                            # Global search returns dict
                            score = (
                                result.get("score", 0)
                                if isinstance(result, dict)
                                else 0
                            )
                            source = (
                                result.get("source_project", "unknown")
                                if isinstance(result, dict)
                                else "unknown"
                            )
                            summary = (
                                result.get("summary", "")
                                if isinstance(result, dict)
                                else ""
                            )
                            output_lines.append(f"\n[{i}] Score: {score:.4f}")
                            output_lines.append(f"    Source: {source}")
                            output_lines.append(f"    {summary[:200]}...")
                        else:
                            # Project search returns SearchResult
                            score = getattr(result, "rrf_score", 0)
                            session_id = getattr(result, "session_id", "unknown")
                            role = getattr(result, "role", None)
                            content = getattr(result, "content", "") or ""
                            output_lines.append(f"\n[{i}] Score: {score:.4f}")
                            output_lines.append(f"    Session: {session_id[:8]}...")
                            if role:
                                output_lines.append(f"    Role: {role.value}")
                            output_lines.append(f"    {content[:200]}...")

                    return [{"type": "text", "text": "\n".join(output_lines)}]

                finally:
                    conn.close()

            except Exception as e:
                logger.exception("Search failed")
                span.record_exception(e)
                return [{"type": "text", "text": f"Error: {e}"}]

    async def _handle_log_turn(self, arguments: dict[str, Any]) -> list[dict[str, str]]:
        """Handle log_turn tool call.

        Per RFC-002 §6.2: Record interaction to store.db.
        Sanitizes content, inserts log, optionally embeds.
        """
        role_str = arguments.get("role", "user")
        content = arguments.get("content", "")
        metadata = arguments.get("metadata")

        logger.debug(f"log_turn request: role={role_str}, content_len={len(content)}")

        if not content:
            return [{"type": "text", "text": "Error: content is required"}]

        # OpenTelemetry tracing for log_turn tool
        with tracer.start_as_current_span("tool.log_turn") as span:
            span.set_attribute("role", role_str)
            span.set_attribute("content.length", len(content))

            try:
                # Resolve store path
                dir_result = resolve_project_dir()
                if isinstance(dir_result, Failure):
                    span.set_attribute("error", "project_not_initialized")
                    return [
                        {
                            "type": "text",
                            "text": f"Error: Project not initialized - {dir_result.failure()}",
                        }
                    ]

                lattice_dir = dir_result.unwrap()
                store_path = lattice_dir / "store.db"

                store_result = create_store(store_path)
                if isinstance(store_result, Failure):
                    span.set_attribute("error", "store_open_failed")
                    return [
                        {
                            "type": "text",
                            "text": f"Error opening store: {store_result.failure()}",
                        }
                    ]

                conn = store_result.unwrap()

                try:
                    # Parse role
                    try:
                        role = Role(role_str.lower())
                    except ValueError:
                        role = Role.USER

                    # Sanitize content
                    secret_patterns = self.config.safety.secret_patterns
                    if secret_patterns:
                        content = sanitize(content, secret_patterns, "[REDACTED]")

                    # Generate session ID if not set
                    if not self.session_id:
                        self.session_id = generate_session_id()

                    # Insert log
                    insert_result = insert_log(
                        conn, self.session_id, role, content, metadata
                    )
                    if isinstance(insert_result, Failure):
                        span.set_attribute("error", "insert_failed")
                        return [
                            {
                                "type": "text",
                                "text": f"Error inserting log: {insert_result.failure()}",
                            }
                        ]

                    log_id = insert_result.unwrap()
                    span.set_attribute("log_id", log_id)

                    # Generate and insert embedding if configured
                    if self.config.embedding:
                        embed_result = embed_text(self.config.embedding, content)
                        if isinstance(embed_result, Success):
                            embedding = embed_result.unwrap()
                            insert_embedding(conn, log_id, embedding)

                    return [
                        {
                            "type": "text",
                            "text": f"Logged turn {log_id} in session {self.session_id[:8]}...",
                        }
                    ]

                finally:
                    conn.close()

            except Exception as e:
                logger.exception("Log turn failed")
                span.record_exception(e)
                return [{"type": "text", "text": f"Error: {e}"}]

    async def _handle_log(self, arguments: dict[str, Any]) -> list[dict[str, str]]:
        """Handle unified log tool call.

        Per RFC-002-R1 §4.4: Record event to events table.
        Unified interface for session compression.
        """
        event_type = arguments.get("type", "user")
        content = arguments.get("content", "")
        tool_input = arguments.get("input", "")
        tool_status = arguments.get("status", "")
        tool_error = arguments.get("error", "")

        if not content:
            return [{"type": "text", "text": "Error: content is required"}]

        # OpenTelemetry tracing for log tool
        with tracer.start_as_current_span("tool.log") as span:
            span.set_attribute("event_type", event_type)
            span.set_attribute("content.length", len(content))

            try:
                # Resolve store path
                dir_result = resolve_project_dir()
                if isinstance(dir_result, Failure):
                    span.set_attribute("error", "project_not_initialized")
                    return [
                        {
                            "type": "text",
                            "text": f"Error: Project not initialized - {dir_result.failure()}",
                        }
                    ]

                lattice_dir = dir_result.unwrap()
                store_path = lattice_dir / "store.db"

                store_result = create_store(store_path)
                if isinstance(store_result, Failure):
                    span.set_attribute("error", "store_open_failed")
                    return [
                        {
                            "type": "text",
                            "text": f"Error opening store: {store_result.failure()}",
                        }
                    ]

                conn = store_result.unwrap()

                try:
                    # Sanitize content for non-tool events
                    if event_type != "tool":
                        secret_patterns = self.config.safety.secret_patterns
                        if secret_patterns:
                            content = sanitize(content, secret_patterns, "[REDACTED]")

                    # Generate session ID if not set
                    if not self.session_id:
                        self.session_id = generate_session_id()

                    # Insert event
                    insert_result = insert_event(
                        conn,
                        self.session_id,
                        event_type,
                        content,
                        tool_input=tool_input if tool_input else None,
                        tool_status=tool_status if tool_status else None,
                        tool_error=tool_error if tool_error else None,
                    )
                    if isinstance(insert_result, Failure):
                        span.set_attribute("error", "insert_failed")
                        return [
                            {
                                "type": "text",
                                "text": f"Error inserting event: {insert_result.failure()}",
                            }
                        ]

                    event_id = insert_result.unwrap()
                    span.set_attribute("event_id", event_id)

                    return [
                        {
                            "type": "text",
                            "text": f"Logged {event_type} event {event_id} in session {self.session_id[:8]}...",
                        }
                    ]

                finally:
                    conn.close()

            except Exception as e:
                logger.exception("Log event failed")
                span.record_exception(e)
                return [{"type": "text", "text": f"Error: {e}"}]

    async def _handle_get_instincts(
        self, arguments: dict[str, Any]
    ) -> list[dict[str, str]]:
        """Handle get_instincts tool call.

        Per RFC-002 §6.2: Refresh System 1 rules mid-session.
        Returns merged rule text from global + project rules.
        """
        # OpenTelemetry tracing for get_instincts tool
        with tracer.start_as_current_span("tool.get_instincts"):
            # Refresh and return current instructions
            instructions = self._get_server_instructions()
            return [{"type": "text", "text": instructions}]

    async def _handle_propose_rule(
        self, arguments: dict[str, Any]
    ) -> list[dict[str, str]]:
        """Handle propose_rule tool call.

        Per RFC-002 §6.2: Submit structured rule proposal for evolution.
        Validates schema constraints and writes to drift/proposals/.
        """
        from lattice.core.types.enums import PatternType, ProposalAction
        from lattice.core.types.proposal import Proposal

        pattern = arguments.get("pattern", "")
        observation = arguments.get("observation", "")
        evidence_str = arguments.get("evidence", "")
        suggested_action = arguments.get("suggested_action", "add")

        # Validate required fields
        if not observation:
            return [{"type": "text", "text": "Error: observation is required"}]

        # Validate pattern (enum uses lowercase values)
        valid_patterns = [p.value for p in PatternType]
        if pattern.lower() not in valid_patterns:
            return [
                {
                    "type": "text",
                    "text": f"Error: pattern must be one of {valid_patterns}",
                }
            ]

        # Validate action (enum uses lowercase values)
        valid_actions = [a.value for a in ProposalAction]
        if suggested_action.lower() not in valid_actions:
            return [
                {
                    "type": "text",
                    "text": f"Error: suggested_action must be one of {valid_actions}",
                }
            ]

        # OpenTelemetry tracing for propose_rule tool
        with tracer.start_as_current_span("tool.propose_rule") as span:
            span.set_attribute("pattern", pattern)
            span.set_attribute("action", suggested_action)
            span.set_attribute("observation.length", len(observation))

            try:
                # Parse evidence session IDs
                evidence_ids = [e.strip() for e in evidence_str.split(",") if e.strip()]

                # Create proposal (use lowercase values for enum)
                proposal = Proposal(
                    proposal_id=f"mcp-{generate_session_id()[:8]}",
                    pattern=PatternType(pattern.lower()),
                    action=ProposalAction(suggested_action.lower()),
                    title=observation[:80],  # Use observation as title
                    content=observation,
                    evidence_session_ids=evidence_ids,
                    created_at=datetime.now(timezone.utc).isoformat(),
                )

                # Resolve project path
                dir_result = resolve_project_dir()
                if isinstance(dir_result, Failure):
                    span.set_attribute("error", "project_not_initialized")
                    return [
                        {
                            "type": "text",
                            "text": f"Error: Project not initialized - {dir_result.failure()}",
                        }
                    ]

                project_path = dir_result.unwrap().parent

                # Write proposal
                from lattice.shell.evolution import write_proposal

                # Create minimal CotPhases for the proposal
                from lattice.core.types.evidence import CotPhases

                phases = CotPhases(
                    triage=f"Observation: {observation}",
                    cross_ref="",
                    synthesis="",
                    review="",
                )

                write_result = write_proposal(
                    project_path, [proposal], phases, sessions_processed=0
                )
                if isinstance(write_result, Failure):
                    span.set_attribute("error", "write_failed")
                    return [
                        {
                            "type": "text",
                            "text": f"Error writing proposal: {write_result.failure()}",
                        }
                    ]

                proposal_path, trace_path = write_result.unwrap()
                span.set_attribute("proposal_path", str(proposal_path))

                return [
                    {
                        "type": "text",
                        "text": f"Proposal created: {proposal_path}\nPattern: {pattern}\nAction: {suggested_action}\nEvidence: {evidence_ids}",
                    }
                ]

            except Exception as e:
                logger.exception("Propose rule failed")
                span.record_exception(e)
                return [{"type": "text", "text": f"Error: {e}"}]

    async def _handle_get_status(
        self, arguments: dict[str, Any]
    ) -> list[dict[str, str]]:
        """Handle get_status tool call.

        Per RFC-002 §6.2: View pending proposals, token budget, sessions pending.
        """
        # OpenTelemetry tracing for get_status tool
        with tracer.start_as_current_span("tool.get_status"):
            try:
                # Resolve project path
                dir_result = resolve_project_dir()
                if isinstance(dir_result, Failure):
                    return [
                        {
                            "type": "text",
                            "text": f"Error: Project not initialized - {dir_result.failure()}",
                        }
                    ]

                lattice_dir = dir_result.unwrap()
                project_path = lattice_dir.parent

                # Get rules and token count
                rules_result = read_rules(lattice_dir / "rules")
                if isinstance(rules_result, Failure):
                    current_rules = []
                else:
                    current_rules = rules_result.unwrap()

                from lattice.core.rules import total_token_count

                token_count = total_token_count(current_rules)

                # Get pending proposals
                from lattice.shell.evolution import list_proposals

                proposals_result = list_proposals(project_path)
                if isinstance(proposals_result, Failure):
                    proposals = []
                else:
                    proposals = proposals_result.unwrap()

                # Get pending sessions count
                store_path = lattice_dir / "store.db"
                store_result = create_store(store_path)
                pending_sessions = 0
                if isinstance(store_result, Success):
                    conn = store_result.unwrap()
                    try:
                        from lattice.shell.evolution import get_pending_session_count

                        count_result = get_pending_session_count(conn)
                        if isinstance(count_result, Success):
                            pending_sessions = count_result.unwrap()
                    finally:
                        conn.close()

                # Build status report
                lines = [
                    "## Lattice Status",
                    f"\n### Rule Token Budget",
                    f"- Current tokens: {token_count}",
                    f"- Warning threshold: {self.config.thresholds.warn_tokens}",
                    f"- Alert threshold: {self.config.thresholds.alert_tokens}",
                    f"\n### Pending Sessions",
                    f"- Sessions awaiting evolution: {pending_sessions}",
                    f"\n### Pending Proposals",
                    f"- Count: {len(proposals)}",
                ]

                if proposals:
                    lines.append("\nProposals:")
                    for i, prop in enumerate(proposals, 1):
                        lines.append(f"  [{i}] {prop}")

                lines.append(f"\n### Current Rules")
                lines.append(f"- Count: {len(current_rules)} files")
                for rule in current_rules[:5]:
                    lines.append(f"  - {rule.file_path}: {rule.title}")
                if len(current_rules) > 5:
                    lines.append(f"  ... and {len(current_rules) - 5} more")

                return [{"type": "text", "text": "\n".join(lines)}]

            except Exception as e:
                logger.exception("Get status failed")
                return [{"type": "text", "text": f"Error: {e}"}]


async def run_server(capture_mode: str | None = None) -> None:
    """Run the Lattice MCP server over stdio transport.

    This is the main entry point called by `lattice serve`.

    Args:
        capture_mode: Override capture mode ("active" or "passive").
            If None, uses config.toml [capture].mode default.

    >>> import asyncio
    >>> # run_server is async, tested via serve_main
    """
    # @invar:allow shell_pure_logic: MCP stdio_server is async I/O, detected as pure
    from mcp.server.stdio import stdio_server

    from lattice.shell.telemetry import configure_tracing

    # Initialize OpenTelemetry tracing if enabled
    configure_tracing()

    lattice_server = LatticeMCPServer(capture_mode=capture_mode)

    async with stdio_server() as (read_stream, write_stream):
        await lattice_server.server.run(
            read_stream,
            write_stream,
            lattice_server.server.create_initialization_options(),
        )


def serve_main(capture_mode: str | None = None) -> None:
    """Main entry point for MCP server (sync wrapper).

    Args:
        capture_mode: Override capture mode ("active" or "passive").
            If None, uses config.toml [capture].mode default.
    """
    import asyncio

    asyncio.run(run_server(capture_mode=capture_mode))
