# @invar:allow file_size: MCP server module (1125 lines). Split analysis: Tool handlers
# (7 handlers, ~450 lines) could move to server_handlers.py, but LatticeMCPServer class
# depends heavily on instance state. Split would require passing self/context, increasing
# coupling. Single file maintains clear request→handler flow. Tracked: tech-debt-6.
"""
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, 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
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
from lattice.shell.telemetry import get_tracer, is_tracing_enabled

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


# @shell_orchestration: Tool definition builders for MCP protocol
# @invar:allow shell_result: Pure builders that return MCP Tool objects, cannot fail
def _build_search_tool() -> Tool:
    """Build the lattice_search tool definition."""
    return 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"],
        },
    )


# @shell_orchestration: Tool definition builder for MCP protocol
# @invar:allow shell_result: Pure builder for log tool with 5 inputSchema properties, cannot fail
def _build_log_tool() -> Tool:
    """Build the log tool definition."""
    return 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"],
        },
    )


# @shell_orchestration: Tool definition builder for MCP protocol
# @invar:allow shell_result: Pure builder for get_instincts tool with empty inputSchema, cannot fail
def _build_get_instincts_tool() -> Tool:
    """Build the get_instincts tool definition."""
    return Tool(
        name="get_instincts",
        description="Refresh System 1 rules mid-session. Call for long sessions (30+ min).",
        inputSchema={
            "type": "object",
            "properties": {},
        },
    )


# @shell_orchestration: Tool definition builder for MCP protocol
# @invar:allow shell_result: Pure builder for propose_rule tool with 4 required fields, cannot fail
def _build_propose_rule_tool() -> Tool:
    """Build the propose_rule tool definition."""
    return 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",
            ],
        },
    )


# @shell_orchestration: Tool definition builder for MCP protocol
# @invar:allow shell_result: Pure builder for get_status tool with empty inputSchema, cannot fail
def _build_get_status_tool() -> Tool:
    """Build the get_status tool definition."""
    return Tool(
        name="get_status",
        description="View pending proposals, token budget, sessions pending.",
        inputSchema={
            "type": "object",
            "properties": {},
        },
    )


# @shell_orchestration: Validation helper for propose_rule tool
def _validate_proposal_arguments(
    pattern: str, observation: str, suggested_action: str
) -> Result[tuple[str, str, str], str]:
    """Validate propose_rule tool arguments.

    Args:
        pattern: The pattern type string.
        observation: The observation text.
        suggested_action: The action to take.

    Returns:
        Success with validated (pattern, observation, action) tuple, or Failure with error.
    """
    from lattice.core.types.enums import PatternType, ProposalAction

    if not observation:
        return Failure("observation is required")

    valid_patterns = [p.value for p in PatternType]
    if pattern.lower() not in valid_patterns:
        return Failure(f"pattern must be one of {valid_patterns}")

    valid_actions = [a.value for a in ProposalAction]
    if suggested_action.lower() not in valid_actions:
        return Failure(f"suggested_action must be one of {valid_actions}")

    return Success((pattern.lower(), observation, suggested_action.lower()))


# @shell_orchestration: Formatting helper for global search results
# @invar:allow shell_result: Pure formatting function, cannot fail
def _format_global_search_result(result: Any, index: int) -> list[str]:
    """Format a global search result for output.

    Args:
        result: A dict with score, source_project, and summary keys.
        index: 1-based index for display.

    Returns:
        List of output lines for this result.
    """
    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 ""
    return [
        f"\n[{index}] Score: {score:.4f}",
        f"    Source: {source}",
        f"    {summary[:200]}...",
    ]


# @shell_orchestration: Formatting helper for project search results
# @invar:allow shell_result: Pure formatting function, cannot fail
def _format_project_search_result(result: Any, index: int) -> list[str]:
    """Format a project search result for output.

    Args:
        result: A SearchResult with rrf_score, session_id, role, and content attributes.
        index: 1-based index for display.

    Returns:
        List of output lines for this result.
    """
    score = getattr(result, "rrf_score", 0)
    session_id = getattr(result, "session_id", "unknown")
    role = getattr(result, "role", None)
    content = getattr(result, "content", "") or ""
    lines = [f"\n[{index}] Score: {score:.4f}", f"    Session: {session_id[:8]}..."]
    if role:
        lines.append(f"    Role: {role.value}")
    lines.append(f"    {content[:200]}...")
    return lines


# @shell_orchestration: Formatting dispatch helper for search results
# @invar:allow shell_result: Pure formatting dispatch, cannot fail
def _format_search_result(result: Any, index: int, scope: str) -> list[str]:
    """Format a single search result for output.

    Args:
        result: A search result (dict for global, SearchResult for project).
        index: 1-based index for display.
        scope: Either "global" or "project".

    Returns:
        List of output lines for this result.
    """
    if scope == "global":
        return _format_global_search_result(result, index)
    return _format_project_search_result(result, index)


# @shell_orchestration: Store creation helper for search
# @invar:allow shell_result: Returns sqlite3.Connection or error via Result pattern
def _open_search_store(lattice_dir: Path, scope: str) -> Result[Any, str]:
    """Open the appropriate store connection for search.

    Args:
        lattice_dir: Path to the .lattice directory.
        scope: Either "global" or "project".

    Returns:
        Success with sqlite3.Connection, or Failure with error message.
    """
    if scope == "global":
        store_path = lattice_dir / "global.db"
        return create_global_store(store_path)
    else:
        store_path = lattice_dir / "store.db"
        return create_store(store_path)


# @shell_orchestration: Proposal creation helper
# @invar:allow shell_result: Pure builder returning Proposal object, cannot fail
def _create_proposal_from_args(
    pattern: str, action: str, observation: str, evidence_ids: list[str]
) -> Any:
    """Create a Proposal object from validated arguments.

    Args:
        pattern: Validated pattern type (lowercase).
        action: Validated action type (lowercase).
        observation: Observation text.
        evidence_ids: List of evidence session IDs.

    Returns:
        Proposal object ready for writing.
    """
    from lattice.core.types.enums import PatternType, ProposalAction
    from lattice.core.types.proposal import Proposal

    return Proposal(
        proposal_id=f"mcp-{generate_session_id()[:8]}",
        pattern=PatternType(pattern),
        action=ProposalAction(action),
        title=observation[:80],
        content=observation,
        evidence_session_ids=evidence_ids,
        created_at=datetime.now(timezone.utc).isoformat(),
    )


# @shell_orchestration: CotPhases creation helper
# @invar:allow shell_result: Pure builder returning CotPhases, cannot fail
def _create_minimal_phases(observation: str) -> Any:
    """Create minimal CotPhases for a proposal.

    Args:
        observation: The observation text.

    Returns:
        CotPhases object with minimal content.
    """
    from lattice.core.types.evidence import CotPhases

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


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: Record event (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 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`: Record event to memory
- `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 event).
"""
        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).
"""

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

        log is only registered when capture mode is active.
        """
        tools = [_build_search_tool()]

        # Only include log when capture mode is active
        if self.config.capture.is_active():
            tools.append(_build_log_tool())

        tools.extend(
            [
                _build_get_instincts_tool(),
                _build_propose_rule_tool(),
                _build_get_status_tool(),
            ]
        )

        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 is 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 == "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()

                # Open appropriate store
                store_result = _open_search_store(lattice_dir, scope)
                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):
                        output_lines.extend(_format_search_result(result, i, scope))

                    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(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/.
        """
        pattern = arguments.get("pattern", "")
        observation = arguments.get("observation", "")
        evidence_str = arguments.get("evidence", "")
        suggested_action = arguments.get("suggested_action", "add")

        # Validate arguments
        validation_result = _validate_proposal_arguments(
            pattern, observation, suggested_action
        )
        if isinstance(validation_result, Failure):
            return [{"type": "text", "text": f"Error: {validation_result.failure()}"}]

        validated_pattern, validated_observation, validated_action = (
            validation_result.unwrap()
        )

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

            try:
                # Parse evidence session IDs and create proposal
                evidence_ids = [e.strip() for e in evidence_str.split(",") if e.strip()]
                proposal = _create_proposal_from_args(
                    validated_pattern,
                    validated_action,
                    validated_observation,
                    evidence_ids,
                )

                # 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

                phases = _create_minimal_phases(validated_observation)
                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: {validated_pattern}\nAction: {validated_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}"}]


# @shell_orchestration: MCP server entry point that binds stdio transport and runs async server loop - fundamentally I/O orchestration
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
    """
    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(),
        )


# @shell_orchestration: Server entry point wrapper using asyncio.run() to manage event loop - I/O orchestration
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))
