# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
MCP Server — Expose Familiar Skills as MCP Servers

Turns any Familiar skill into a standards-compliant MCP server that
external clients (Claude Desktop, VS Code, Cursor, other agents)
can connect to via stdio or HTTP.

Usage (single skill):
    python -m familiar.core.mcp.server --skill nonprofit

Usage (all skills):
    python -m familiar.core.mcp.server --all

Usage (programmatic):
    from familiar.core.mcp.server import SkillMCPServer
    server = SkillMCPServer.from_skill_name("nonprofit")
    asyncio.run(server.run_stdio())

The server implements the MCP specification (2025-11-05):
    - initialize / initialized handshake
    - tools/list — exposes skill's TOOLS as MCP tools
    - tools/call — routes to skill handler functions
    - resources/list — exposes skill's data files (optional)
    - notifications/cancelled — handles client cancellation

See: https://modelcontextprotocol.io/specification/2025-11-05
"""

import asyncio
import importlib
import json
import logging
import signal
import sys
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

logger = logging.getLogger("familiar.mcp.server")

PROTOCOL_VERSION = "2025-11-05"
SERVER_NAME = "familiar"
SERVER_VERSION = "1.10.0"


# ════════════════════════════════════════════════════════════════
# JSON-RPC 2.0 Message Types
# ════════════════════════════════════════════════════════════════


def _jsonrpc_result(id: Any, result: Any) -> dict:
    return {"jsonrpc": "2.0", "id": id, "result": result}


def _jsonrpc_error(id: Any, code: int, message: str, data: Any = None) -> dict:
    error = {"code": code, "message": message}
    if data is not None:
        error["data"] = data
    return {"jsonrpc": "2.0", "id": id, "error": error}


def _jsonrpc_notification(method: str, params: dict = None) -> dict:
    msg = {"jsonrpc": "2.0", "method": method}
    if params:
        msg["params"] = params
    return msg


# JSON-RPC error codes
PARSE_ERROR = -32700
INVALID_REQUEST = -32600
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603


# ════════════════════════════════════════════════════════════════
# MCP Tool Wrapper
# ════════════════════════════════════════════════════════════════


@dataclass
class MCPToolDef:
    """An Familiar skill tool wrapped for MCP protocol."""

    name: str
    description: str
    input_schema: Dict[str, Any]
    handler: Callable
    annotations: Dict[str, Any] = field(default_factory=dict)

    def to_mcp(self) -> dict:
        """Serialize to MCP tools/list response format."""
        tool = {
            "name": self.name,
            "description": self.description,
            "inputSchema": self.input_schema,
        }
        if self.annotations:
            tool["annotations"] = self.annotations
        return tool


# ════════════════════════════════════════════════════════════════
# Skill Loader
# ════════════════════════════════════════════════════════════════


def load_skill_tools(
    skill_name: str, skill_dir: Optional[Path] = None
) -> Tuple[List[MCPToolDef], str]:
    """
    Load tools from an Familiar skill module.

    Returns:
        Tuple of (list of MCPToolDef, skill description)
    """
    # Import the skill module
    try:
        mod = importlib.import_module(f"familiar.skills.{skill_name}.skill")
    except ImportError:
        # Try direct path
        if skill_dir:
            spec = importlib.util.spec_from_file_location(
                f"familiar.skills.{skill_name}.skill", skill_dir / "skill.py"
            )
            mod = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(mod)
        else:
            raise

    raw_tools = getattr(mod, "TOOLS", None)
    if raw_tools is None:
        return [], f"Familiar {skill_name} skill (no tools)"

    mcp_tools = []
    for tool in raw_tools:
        # Infer annotations from tool properties
        annotations = {}
        name_lower = tool["name"].lower()
        if any(k in name_lower for k in ("list", "search", "get", "check", "status", "details")):
            annotations["readOnlyHint"] = True
        if any(k in name_lower for k in ("delete", "remove", "drop")):
            annotations["destructiveHint"] = True

        mcp_tools.append(
            MCPToolDef(
                name=tool["name"],
                description=tool.get("description", ""),
                input_schema=tool.get("input_schema", {"type": "object", "properties": {}}),
                handler=tool["handler"],
                annotations=annotations,
            )
        )

    # Get description from SKILL.md if available
    skill_desc = f"Familiar {skill_name} skill"
    try:
        skill_path = Path(mod.__file__).parent / "SKILL.md"
        if skill_path.exists():
            lines = skill_path.read_text().strip().split("\n")
            # First non-empty, non-heading line
            for line in lines:
                stripped = line.strip().lstrip("#").strip()
                if stripped and not stripped.startswith("---"):
                    skill_desc = stripped
                    break
    except Exception:
        pass

    return mcp_tools, skill_desc


# ════════════════════════════════════════════════════════════════
# MCP Server
# ════════════════════════════════════════════════════════════════


class SkillMCPServer:
    """
    MCP server that exposes Familiar skill tools to external clients.

    Implements the MCP specification over stdio transport.
    Compatible with Claude Desktop, VS Code, Cursor, and any MCP client.
    """

    def __init__(
        self,
        tools: List[MCPToolDef],
        server_name: str = SERVER_NAME,
        server_version: str = SERVER_VERSION,
        skill_name: str = "familiar",
        skill_description: str = "Familiar AI Agent skill",
    ):
        self.tools = {t.name: t for t in tools}
        self.server_name = server_name
        self.server_version = server_version
        self.skill_name = skill_name
        self.skill_description = skill_description

        self._initialized = False
        self._client_info: Dict[str, Any] = {}
        self._running = False

    @classmethod
    def from_skill_name(
        cls,
        skill_name: str,
        tenant_id: Optional[str] = None,
    ) -> "SkillMCPServer":
        """
        Create an MCP server from a skill name.

        Args:
            skill_name: Familiar skill directory name (e.g. "nonprofit")
            tenant_id: Optional tenant ID for data isolation
        """
        # Scope data if tenant specified
        if tenant_id:
            from familiar.core.paths import set_tenant_data_root

            set_tenant_data_root(tenant_id)

        tools, desc = load_skill_tools(skill_name)
        return cls(
            tools=tools,
            skill_name=skill_name,
            skill_description=desc,
        )

    @classmethod
    def from_all_skills(
        cls,
        tenant_id: Optional[str] = None,
        skill_names: Optional[List[str]] = None,
    ) -> "SkillMCPServer":
        """
        Create an MCP server exposing multiple skills.

        Tool names are prefixed with skill name to avoid collisions:
            nonprofit:log_donor, bookkeeping:log_income, etc.
        """
        if tenant_id:
            from familiar.core.paths import set_tenant_data_root

            set_tenant_data_root(tenant_id)

        # Discover skills
        if skill_names is None:
            skills_dir = Path(__file__).resolve().parent.parent.parent / "skills"
            skill_names = sorted(
                [d.name for d in skills_dir.iterdir() if d.is_dir() and (d / "skill.py").exists()]
            )

        all_tools = []
        descriptions = []
        for name in skill_names:
            try:
                tools, desc = load_skill_tools(name)
                for t in tools:
                    # Prefix tool names to avoid collisions
                    t.name = f"{name}:{t.name}"
                all_tools.extend(tools)
                descriptions.append(f"{name} ({len(tools)} tools)")
            except Exception as e:
                logger.warning(f"Failed to load skill '{name}': {e}")

        return cls(
            tools=all_tools,
            skill_name="familiar-all",
            skill_description=f"Familiar AI Agent — {len(all_tools)} tools from {len(descriptions)} skills",
        )

    # ── Protocol Handlers ──────────────────────────────────────

    def _handle_initialize(self, params: dict) -> dict:
        """Handle initialize request from client."""
        self._client_info = params.get("clientInfo", {})
        self._initialized = True

        logger.info(
            f"MCP client connected: "
            f"{self._client_info.get('name', 'unknown')} "
            f"v{self._client_info.get('version', '?')}"
        )

        return {
            "protocolVersion": PROTOCOL_VERSION,
            "capabilities": {
                "tools": {"listChanged": False},
                "resources": {"subscribe": False, "listChanged": False},
            },
            "serverInfo": {
                "name": f"{self.server_name}-{self.skill_name}",
                "version": self.server_version,
            },
            "instructions": self.skill_description,
        }

    def _handle_tools_list(self, params: dict) -> dict:
        """Handle tools/list request."""
        return {"tools": [t.to_mcp() for t in self.tools.values()]}

    def _handle_tools_call(self, params: dict) -> dict:
        """Handle tools/call request."""
        tool_name = params.get("name")
        arguments = params.get("arguments", {})

        if tool_name not in self.tools:
            return {
                "content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}],
                "isError": True,
            }

        tool = self.tools[tool_name]

        try:
            # Call the Familiar skill handler
            result = tool.handler(arguments)

            # Normalize result to string
            if isinstance(result, dict):
                text = json.dumps(result, indent=2, default=str)
            elif isinstance(result, (list, tuple)):
                text = json.dumps(result, indent=2, default=str)
            elif result is None:
                text = "(no output)"
            else:
                text = str(result)

            return {
                "content": [{"type": "text", "text": text}],
                "isError": False,
            }

        except Exception as e:
            logger.error(f"Tool '{tool_name}' failed: {e}")
            return {
                "content": [{"type": "text", "text": f"Error: {e}"}],
                "isError": True,
            }

    def _handle_resources_list(self, params: dict) -> dict:
        """Handle resources/list — expose skill data files."""
        return {"resources": []}

    def _handle_ping(self, params: dict) -> dict:
        """Handle ping request."""
        return {}

    # ── Request Router ─────────────────────────────────────────

    def handle_message(self, message: dict) -> Optional[dict]:
        """
        Route an incoming JSON-RPC message to the appropriate handler.

        Returns a response dict, or None for notifications.
        """
        msg_id = message.get("id")
        method = message.get("method")
        params = message.get("params", {})

        # Notifications (no id) — acknowledge silently
        if msg_id is None:
            if method == "notifications/initialized":
                logger.debug("Client sent initialized notification")
            elif method == "notifications/cancelled":
                logger.debug(f"Client cancelled request: {params}")
            return None

        # Route requests
        handlers = {
            "initialize": self._handle_initialize,
            "ping": self._handle_ping,
            "tools/list": self._handle_tools_list,
            "tools/call": self._handle_tools_call,
            "resources/list": self._handle_resources_list,
            "prompts/list": lambda p: {"prompts": []},
        }

        handler = handlers.get(method)
        if handler is None:
            return _jsonrpc_error(msg_id, METHOD_NOT_FOUND, f"Method not found: {method}")

        try:
            result = handler(params)
            return _jsonrpc_result(msg_id, result)
        except Exception as e:
            logger.error(f"Error handling {method}: {e}\n{traceback.format_exc()}")
            return _jsonrpc_error(msg_id, INTERNAL_ERROR, str(e))

    # ── Stdio Transport ────────────────────────────────────────

    async def run_stdio(self) -> None:
        """
        Run the MCP server over stdin/stdout.

        This is the standard MCP server transport. The server reads
        JSON-RPC messages from stdin (one per line) and writes
        responses to stdout.

        Usage:
            python -m familiar.core.mcp.server --skill nonprofit
        """
        self._running = True
        reader = asyncio.StreamReader()
        protocol = asyncio.StreamReaderProtocol(reader)

        loop = asyncio.get_event_loop()
        await loop.connect_read_pipe(lambda: protocol, sys.stdin)

        # Wrap stdout for async writes
        write_transport, write_protocol = await loop.connect_write_pipe(
            asyncio.streams.FlowControlMixin, sys.stdout
        )
        writer = asyncio.StreamWriter(write_transport, write_protocol, None, loop)

        logger.info(f"MCP server '{self.skill_name}' running on stdio ({len(self.tools)} tools)")

        try:
            while self._running:
                line = await reader.readline()
                if not line:
                    break  # EOF

                line_str = line.decode().strip()
                if not line_str:
                    continue

                try:
                    message = json.loads(line_str)
                except json.JSONDecodeError as e:
                    response = _jsonrpc_error(None, PARSE_ERROR, f"Parse error: {e}")
                    writer.write((json.dumps(response) + "\n").encode())
                    await writer.drain()
                    continue

                response = self.handle_message(message)

                if response is not None:
                    writer.write((json.dumps(response) + "\n").encode())
                    await writer.drain()

        except asyncio.CancelledError:
            pass
        except Exception as e:
            logger.error(f"Server error: {e}")
        finally:
            self._running = False
            logger.info("MCP server stopped")

    def stop(self) -> None:
        """Signal the server to stop."""
        self._running = False


# ════════════════════════════════════════════════════════════════
# Multi-Skill Server Registry
# ════════════════════════════════════════════════════════════════


def get_available_skills() -> List[Dict[str, Any]]:
    """
    List all available Familiar skills that can be served via MCP.

    Returns list of dicts with name, tool_count, description.
    """
    skills_dir = Path(__file__).resolve().parent.parent.parent / "skills"
    results = []

    for d in sorted(skills_dir.iterdir()):
        if not d.is_dir() or not (d / "skill.py").exists():
            continue
        try:
            tools, desc = load_skill_tools(d.name, skill_dir=d)
            results.append(
                {
                    "name": d.name,
                    "tool_count": len(tools),
                    "description": desc,
                    "tools": [t.name for t in tools],
                }
            )
        except Exception as e:
            results.append(
                {
                    "name": d.name,
                    "tool_count": 0,
                    "description": f"(failed to load: {e})",
                    "tools": [],
                }
            )

    return results


# ════════════════════════════════════════════════════════════════
# Claude Desktop Configuration Generator
# ════════════════════════════════════════════════════════════════


def generate_claude_desktop_config(
    skill_names: Optional[List[str]] = None,
    python_path: str = "python3",
) -> dict:
    """
    Generate a Claude Desktop MCP server configuration.

    This creates the JSON block you paste into Claude Desktop's
    settings to connect it to Familiar skills.

    Args:
        skill_names: List of skills to expose, or None for all
        python_path: Path to Python interpreter

    Returns:
        Dict suitable for claude_desktop_config.json mcpServers section
    """
    if skill_names is None:
        # Single server with all skills
        return {
            "familiar": {
                "command": python_path,
                "args": ["-m", "familiar.core.mcp.server", "--all"],
            }
        }

    # One server per skill
    servers = {}
    for name in skill_names:
        servers[f"familiar-{name}"] = {
            "command": python_path,
            "args": ["-m", "familiar.core.mcp.server", "--skill", name],
        }
    return servers


def generate_vscode_config(
    skill_names: Optional[List[str]] = None,
    python_path: str = "python3",
) -> dict:
    """
    Generate VS Code MCP server configuration.

    For settings.json under "mcp.servers".
    """
    if skill_names is None:
        return {
            "familiar": {
                "type": "stdio",
                "command": python_path,
                "args": ["-m", "familiar.core.mcp.server", "--all"],
            }
        }

    servers = {}
    for name in skill_names:
        servers[f"familiar-{name}"] = {
            "type": "stdio",
            "command": python_path,
            "args": ["-m", "familiar.core.mcp.server", "--skill", name],
        }
    return servers


# ════════════════════════════════════════════════════════════════
# CLI Entry Point
# ════════════════════════════════════════════════════════════════


def main():
    """CLI entry point for running the MCP server."""
    import argparse

    parser = argparse.ArgumentParser(
        description="Familiar MCP Server — expose skills to Claude Desktop, VS Code, and more",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
    # Serve a single skill
    python -m familiar.core.mcp.server --skill nonprofit

    # Serve all skills
    python -m familiar.core.mcp.server --all

    # Serve specific skills
    python -m familiar.core.mcp.server --skills nonprofit,bookkeeping,contacts

    # List available skills
    python -m familiar.core.mcp.server --list

    # Generate Claude Desktop config
    python -m familiar.core.mcp.server --generate-config claude

    # Tenant-scoped (for Reflection)
    python -m familiar.core.mcp.server --skill nonprofit --tenant abc-123
        """,
    )

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--skill", help="Serve a single skill by name")
    group.add_argument("--skills", help="Serve multiple skills (comma-separated)")
    group.add_argument("--all", action="store_true", help="Serve all available skills")
    group.add_argument("--list", action="store_true", help="List available skills and exit")
    group.add_argument(
        "--generate-config",
        choices=["claude", "vscode"],
        help="Generate client configuration and exit",
    )

    parser.add_argument("--tenant", help="Tenant ID for data isolation")
    parser.add_argument("--log-level", default="WARNING", help="Log level (default: WARNING)")
    parser.add_argument("--log-file", help="Log to file instead of stderr")

    args = parser.parse_args()

    # Configure logging (to stderr or file, NOT stdout — stdout is MCP transport)
    log_kwargs = {
        "level": getattr(logging, args.log_level.upper()),
        "format": "%(asctime)s %(name)s %(levelname)s %(message)s",
    }
    if args.log_file:
        log_kwargs["filename"] = args.log_file
    else:
        log_kwargs["stream"] = sys.stderr
    logging.basicConfig(**log_kwargs)

    # Handle --list
    if args.list:
        skills = get_available_skills()
        print(f"\nFamiliar MCP Skills ({len(skills)} available):\n")
        for s in skills:
            print(f"  {s['name']:25s} {s['tool_count']:2d} tools  {s['description']}")
            for t in s["tools"]:
                print(f"    • {t}")
        print()
        return

    # Handle --generate-config
    if args.generate_config:
        if args.generate_config == "claude":
            config = generate_claude_desktop_config()
            print('\n# Add this to claude_desktop_config.json under "mcpServers":\n')
        else:
            config = generate_vscode_config()
            print('\n# Add this to VS Code settings.json under "mcp.servers":\n')
        print(json.dumps(config, indent=2))
        print()
        return

    # Build the server
    if args.skill:
        server = SkillMCPServer.from_skill_name(args.skill, tenant_id=args.tenant)
    elif args.skills:
        names = [n.strip() for n in args.skills.split(",")]
        server = SkillMCPServer.from_all_skills(tenant_id=args.tenant, skill_names=names)
    elif args.all:
        server = SkillMCPServer.from_all_skills(tenant_id=args.tenant)

    # Handle shutdown signals
    def handle_signal(sig, frame):
        server.stop()

    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)

    # Run
    asyncio.run(server.run_stdio())


if __name__ == "__main__":
    main()
