"""
MCP Adapter — Wraps any MCP (Model Context Protocol) server as a Brynq agent.

MCP servers expose tools via JSON-RPC 2.0 (initialize, tools/list, tools/call).
This adapter translates the Brynq agent protocol (POST /execute, GET /health,
GET /info) into MCP JSON-RPC calls, making every MCP server a first-class
Brynq agent.

Two transport modes:
  - **stdio**: Spawns the MCP server as a subprocess. JSON-RPC messages flow
    over stdin/stdout. Best for local/CLI MCP servers.
  - **SSE/HTTP**: Sends JSON-RPC via HTTP POST to a running MCP server.
    Best for remote or already-running servers.

All stdlib except FastAPI for the optional ``as_fastapi_app()`` wrapper.
"""

from __future__ import annotations

import json
import logging
import re
import subprocess
import threading
import time
import urllib.error
import urllib.request
from typing import Any, Optional

log = logging.getLogger("brynq.runtime.mcp_adapter")

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

MCP_PROTOCOL_VERSION = "2024-11-05"
BRYNQ_CLIENT_INFO = {"name": "brynq", "version": "0.1.0"}
AGENT_PROTOCOL_VERSION = 1

_STDIO_READ_TIMEOUT = 30.0  # seconds waiting for a single JSON-RPC response
_HTTP_TIMEOUT = 30  # seconds for HTTP transport requests
_CONNECT_TIMEOUT = 15.0  # seconds for initial MCP handshake


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class MCPError(Exception):
    """Raised when MCP communication fails."""


class MCPTimeoutError(MCPError):
    """Raised when an MCP operation times out."""


class MCPTransportError(MCPError):
    """Raised when the underlying transport (stdio/HTTP) fails."""


# ---------------------------------------------------------------------------
# MCPServerConnection
# ---------------------------------------------------------------------------


class MCPServerConnection:
    """Connects to an MCP server via stdio (subprocess) or SSE (HTTP).

    Args:
        command: Shell command to spawn the MCP server (stdio mode).
                 Example: ``["npx", "-y", "@modelcontextprotocol/server-filesystem"]``
        url:     HTTP URL of a running MCP server (SSE/HTTP mode).
                 Example: ``"http://localhost:3001/rpc"``
        sandbox: If True (default), wraps the command in a secure Docker container
                 to prevent Remote Code Execution on the host machine.

    Exactly one of ``command`` or ``url`` must be provided.
    """

    def __init__(
        self,
        command: list[str] | None = None,
        url: str | None = None,
        sandbox: bool = True,
    ) -> None:
        if command and url:
            raise ValueError("Provide either command (stdio) or url (HTTP), not both")
        if not command and not url:
            raise ValueError("Provide either command (stdio) or url (HTTP)")

        self._command = command
        self._url = url
        self._mode: str = "stdio" if command else "http"
        self._sandbox = sandbox

        # stdio state
        self._proc: Optional[subprocess.Popen] = None
        self._lock = threading.Lock()
        self._request_id = 0

        # server capabilities from initialize
        self._server_info: dict[str, Any] = {}
        self._capabilities: dict[str, Any] = {}
        self._connected = False

    # -- Public API ---------------------------------------------------------

    def connect(self) -> dict[str, Any]:
        """Send ``initialize`` and ``notifications/initialized``, return server info.

        Returns:
            Dict with ``serverInfo`` and ``capabilities`` from the server.

        Raises:
            MCPError: If the handshake fails.
        """
        if self._mode == "stdio":
            self._start_process()

        # Send initialize with 10s timeout
        try:
            result = self._send_request("initialize", {
                "protocolVersion": MCP_PROTOCOL_VERSION,
                "capabilities": {},
                "clientInfo": BRYNQ_CLIENT_INFO,
            }, timeout=10.0)
        except MCPTimeoutError:
            raise MCPTimeoutError("MCP server failed to respond to initialize within 10 seconds")

        self._server_info = result.get("serverInfo", {})
        self._capabilities = result.get("capabilities", {})

        # Send initialized notification (no response expected)
        self._send_notification("notifications/initialized", {})

        self._connected = True
        log.info(
            "MCP connected (%s): server=%s capabilities=%s",
            self._mode,
            self._server_info.get("name", "unknown"),
            list(self._capabilities.keys()),
        )
        return result

    def list_tools(self) -> list[dict[str, Any]]:
        """Call ``tools/list`` and return the tool descriptors.

        Returns:
            List of dicts, each with ``name``, ``description``, ``inputSchema``.

        Raises:
            MCPError: If the server does not support tools or the call fails.
        """
        self._ensure_connected()
        result = self._send_request("tools/list", {})
        tools = result.get("tools", [])
        log.info("MCP server exposes %d tools", len(tools))
        return tools

    def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
        """Call ``tools/call`` with the given tool name and arguments.

        Args:
            name:      Tool name as returned by ``list_tools()``.
            arguments: Key-value arguments matching the tool's inputSchema.

        Returns:
            The tool result content. Returns the text content if the result
            contains text content items, otherwise returns the raw result.

        Raises:
            MCPError: If the tool call fails or returns an error.
        """
        self._ensure_connected()
        result = self._send_request("tools/call", {
            "name": name,
            "arguments": arguments,
        })

        # MCP tool results have a "content" list with type/text entries
        content_items = result.get("content", [])
        if isinstance(content_items, list) and content_items:
            texts = []
            for item in content_items:
                if isinstance(item, dict) and item.get("type") == "text":
                    texts.append(item.get("text", ""))
                elif isinstance(item, dict) and item.get("type") == "image":
                    texts.append(f"[image: {item.get('mimeType', 'unknown')}]")
                elif isinstance(item, str):
                    texts.append(item)
            if texts:
                return "\n".join(texts)

        # Check for isError flag
        if result.get("isError"):
            error_text = ""
            for item in content_items:
                if isinstance(item, dict) and item.get("type") == "text":
                    error_text += item.get("text", "")
            raise MCPError(f"MCP tool '{name}' returned error: {error_text or result}")

        # Fallback: return raw result
        return json.dumps(result, default=str) if result else ""

    def close(self) -> None:
        """Shut down the connection. Terminates subprocess if stdio mode."""
        self._connected = False
        if self._proc is not None:
            log.info("Shutting down MCP stdio subprocess (pid=%d)", self._proc.pid)
            try:
                self._proc.stdin.close()  # type: ignore[union-attr]
            except Exception:
                pass
            try:
                self._proc.terminate()
                self._proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                log.warning("MCP subprocess did not terminate gracefully, sending SIGKILL")
                self._proc.kill()
                self._proc.wait(timeout=3)
            except Exception as exc:
                log.warning("Error closing MCP subprocess: %s", exc)
            finally:
                self._proc = None

    def __enter__(self) -> MCPServerConnection:
        self.connect()
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()

    # -- Internal: transport dispatch ---------------------------------------

    def _ensure_connected(self) -> None:
        if not self._connected:
            raise MCPError("Not connected. Call connect() first.")

    def _next_id(self) -> int:
        with self._lock:
            self._request_id += 1
            return self._request_id

    def _send_request(self, method: str, params: dict[str, Any], timeout: float | None = None) -> dict[str, Any]:
        """Send a JSON-RPC request and return the result."""
        msg_id = self._next_id()
        message = {
            "jsonrpc": "2.0",
            "id": msg_id,
            "method": method,
            "params": params,
        }

        if self._mode == "stdio":
            return self._stdio_request(message, msg_id, timeout=timeout)
        else:
            return self._http_request(message, timeout=timeout)

    def _send_notification(self, method: str, params: dict[str, Any]) -> None:
        """Send a JSON-RPC notification (no id, no response expected)."""
        message = {
            "jsonrpc": "2.0",
            "method": method,
            "params": params,
        }

        if self._mode == "stdio":
            self._stdio_send(message)
        else:
            self._http_send_notification(message)

    # -- Internal: stdio transport ------------------------------------------

    def _start_process(self) -> None:
        """Start the MCP server subprocess."""
        if self._proc is not None:
            return

        assert self._command is not None
        
        exec_command = self._command
        if self._sandbox:
            # Map common runtimes to secure base images
            base_image = "ubuntu:22.04"
            if self._command[0] in ("npx", "node"):
                base_image = "node:20-slim"
            elif self._command[0] in ("python", "python3"):
                base_image = "python:3.11-slim"
            
            # Build docker wrapper for secure execution
            exec_command = [
                "docker", "run", "-i", "--rm",
                # Limit memory and CPU to prevent resource exhaustion attacks
                "--memory=512m", "--cpus=1.0",
                # Drop privileges
                "--cap-drop", "ALL",
                "--security-opt", "no-new-privileges",
                # Network isolation - can be changed to 'none' for fully air-gapped agents
                "--network", "bridge", 
                base_image
            ] + self._command
            log.info("Sandboxing MCP server in Docker image %s", base_image)

        log.info("Starting MCP server via stdio: %s", " ".join(exec_command))
        try:
            self._proc = subprocess.Popen(
                exec_command,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                bufsize=1,  # line-buffered
            )
        except FileNotFoundError as exc:
            raise MCPTransportError(
                f"MCP server command not found: {exec_command[0]}"
            ) from exc
        except OSError as exc:
            raise MCPTransportError(
                f"Failed to start MCP server: {exc}"
            ) from exc

        log.info("MCP subprocess started (pid=%d)", self._proc.pid)

    def _stdio_send(self, message: dict[str, Any]) -> None:
        """Write a JSON-RPC message to the subprocess stdin."""
        if self._proc is None or self._proc.stdin is None:
            raise MCPTransportError("MCP subprocess is not running")

        if self._proc.poll() is not None:
            stderr_out = ""
            if self._proc.stderr:
                try:
                    stderr_out = self._proc.stderr.read()
                except Exception:
                    pass
            raise MCPTransportError(
                f"MCP subprocess exited with code {self._proc.returncode}"
                + (f": {stderr_out}" if stderr_out else "")
            )

        raw = json.dumps(message)
        try:
            self._proc.stdin.write(raw + "\n")
            self._proc.stdin.flush()
        except (BrokenPipeError, OSError) as exc:
            raise MCPTransportError(f"Failed to write to MCP subprocess: {exc}") from exc

    def _stdio_read_response(self, expected_id: int, timeout: float | None = None) -> dict[str, Any]:
        """Read a JSON-RPC response from the subprocess stdout.

        Skips notifications and log messages, waits for the response
        matching ``expected_id``.
        """
        if self._proc is None or self._proc.stdout is None:
            raise MCPTransportError("MCP subprocess is not running")

        t = timeout if timeout is not None else _STDIO_READ_TIMEOUT
        deadline = time.monotonic() + t
        while time.monotonic() < deadline:
            # Check if process died
            if self._proc.poll() is not None:
                stderr_out = ""
                if self._proc.stderr:
                    try:
                        stderr_out = self._proc.stderr.read()
                    except Exception:
                        pass
                raise MCPTransportError(
                    f"MCP subprocess exited unexpectedly (code={self._proc.returncode})"
                    + (f": {stderr_out}" if stderr_out else "")
                )

            line = self._proc.stdout.readline()
            if not line:
                # EOF — process likely exited
                continue

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

            try:
                parsed = json.loads(line)
            except json.JSONDecodeError:
                log.debug("MCP stdio: ignoring non-JSON line: %s", line[:200])
                continue

            # Skip notifications (no id)
            if "id" not in parsed:
                log.debug("MCP stdio: received notification: %s", parsed.get("method", "?"))
                continue

            if parsed.get("id") != expected_id:
                log.debug(
                    "MCP stdio: skipping response id=%s (waiting for %d)",
                    parsed.get("id"), expected_id,
                )
                continue

            return parsed

        raise MCPTimeoutError(
            f"Timed out waiting for MCP response (id={expected_id}) "
            f"after {t}s"
        )

    def _stdio_request(self, message: dict[str, Any], msg_id: int, timeout: float | None = None) -> dict[str, Any]:
        """Send a request via stdio and return the result dict."""
        self._stdio_send(message)
        response = self._stdio_read_response(msg_id, timeout=timeout)

        if "error" in response:
            err = response["error"]
            code = err.get("code", -1)
            errmsg = err.get("message", "Unknown error")
            raise MCPError(f"MCP JSON-RPC error {code}: {errmsg}")

        return response.get("result", {})

    # -- Internal: HTTP transport -------------------------------------------

    def _http_request(self, message: dict[str, Any], timeout: float | None = None) -> dict[str, Any]:
        """Send a JSON-RPC request via HTTP POST and return the result."""
        assert self._url is not None
        data = json.dumps(message).encode("utf-8")
        req = urllib.request.Request(
            self._url,
            data=data,
            method="POST",
            headers={
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
        )

        t = timeout if timeout is not None else _HTTP_TIMEOUT
        try:
            with urllib.request.urlopen(req, timeout=t) as resp:
                raw = resp.read().decode("utf-8")
                parsed = json.loads(raw) if raw.strip() else {}
        except urllib.error.HTTPError as exc:
            detail = ""
            try:
                detail = exc.read().decode("utf-8", errors="replace")
            except Exception:
                pass
            raise MCPTransportError(
                f"MCP HTTP error {exc.code}: {detail}"
            ) from exc
        except urllib.error.URLError as exc:
            if "timeout" in str(exc).lower():
                raise MCPTimeoutError(f"MCP HTTP request timed out after {t}s") from exc
            raise MCPTransportError(
                f"Cannot reach MCP server at {self._url}: {exc.reason}"
            ) from exc
        except json.JSONDecodeError as exc:
            raise MCPTransportError(
                f"MCP server returned invalid JSON: {exc}"
            ) from exc

        if "error" in parsed:
            err = parsed["error"]
            code = err.get("code", -1)
            errmsg = err.get("message", "Unknown error")
            raise MCPError(f"MCP JSON-RPC error {code}: {errmsg}")

        return parsed.get("result", {})

    def _http_send_notification(self, message: dict[str, Any]) -> None:
        """Send a JSON-RPC notification via HTTP POST (ignore response)."""
        assert self._url is not None
        data = json.dumps(message).encode("utf-8")
        req = urllib.request.Request(
            self._url,
            data=data,
            method="POST",
            headers={
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
        )
        try:
            with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT):
                pass
        except Exception as exc:
            log.debug("MCP notification send failed (non-fatal): %s", exc)


# ---------------------------------------------------------------------------
# Tool matching + argument extraction
# ---------------------------------------------------------------------------


def _tokenize(text: str) -> set[str]:
    """Split text into lowercase word tokens for keyword matching."""
    return set(re.findall(r"[a-z0-9]+", text.lower()))


def match_tool(prompt: str, tools: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
    """Find the best matching tool for a natural language prompt.

    Uses keyword overlap between the prompt and each tool's name +
    description. Returns the tool with the highest overlap, or None
    if no tool has any keyword match.

    Args:
        prompt: The user's natural language prompt.
        tools:  List of tool descriptors from ``list_tools()``.

    Returns:
        The best matching tool dict, or None if no match found.
    """
    prompt_tokens = _tokenize(prompt)
    if not prompt_tokens:
        return None

    best_tool: Optional[dict[str, Any]] = None
    best_score = 0

    for tool in tools:
        name = tool.get("name", "")
        description = tool.get("description", "")

        # Build tool tokens from name (split on underscores/hyphens too) + description
        tool_text = f"{name} {name.replace('_', ' ').replace('-', ' ')} {description}"
        tool_tokens = _tokenize(tool_text)

        overlap = len(prompt_tokens & tool_tokens)

        # Boost score if the tool name appears directly in the prompt
        if name.lower() in prompt.lower():
            overlap += 5

        if overlap > best_score:
            best_score = overlap
            best_tool = tool

    return best_tool if best_score > 0 else None


def extract_arguments(
    prompt: str,
    input_schema: dict[str, Any],
) -> dict[str, Any]:
    """Extract tool arguments from a prompt based on the tool's inputSchema.

    Uses heuristics for simple schemas:
    - If the schema has a single string field, use the full prompt.
    - If the schema has a generic "input", "query", "text", or "prompt"
      field, map the prompt to it.
    - For multi-field schemas, attempt regex extraction for quoted strings
      and key=value patterns.

    Args:
        prompt:       The user's natural language prompt.
        input_schema: JSON Schema for the tool's input.

    Returns:
        Dict of extracted arguments. May be incomplete for complex schemas.
    """
    properties = input_schema.get("properties", {})
    required = set(input_schema.get("required", []))
    args: dict[str, Any] = {}

    if not properties:
        return args

    # --- Phase 1: Extract explicit key=value patterns first ----------------
    # These are the most precise signals from the user.

    # key="quoted value"
    kv_pattern = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
    for match in kv_pattern.finditer(prompt):
        key, value = match.group(1), match.group(2)
        if key in properties:
            args[key] = value

    # key=unquoted_value (single word, no quotes)
    kv_simple = re.compile(r'(\w+)\s*=\s*(\S+)')
    for match in kv_simple.finditer(prompt):
        key, value = match.group(1), match.group(2)
        if key in properties and key not in args:
            # Strip surrounding quotes if the simple regex caught them
            value = value.strip('"').strip("'")
            prop_type = properties[key].get("type", "string")
            if prop_type == "integer":
                try:
                    args[key] = int(value)
                except ValueError:
                    pass
            elif prop_type == "number":
                try:
                    args[key] = float(value)
                except ValueError:
                    pass
            elif prop_type == "boolean":
                args[key] = value.lower() in ("true", "1", "yes")
            else:
                args[key] = value

    # --- Phase 2: Extract quoted strings for remaining required fields -----
    quoted_strings = re.findall(r'"([^"]*)"', prompt)
    quote_idx = 0
    for field_name in required:
        if field_name not in args and field_name in properties:
            prop_type = properties[field_name].get("type", "string")
            if prop_type == "string" and quote_idx < len(quoted_strings):
                args[field_name] = quoted_strings[quote_idx]
                quote_idx += 1

    # --- Phase 3: Fallback — use the full prompt for generic/single fields -
    # Only applies to fields that were NOT already filled by explicit extraction.

    # Generic fields that should receive the raw prompt
    generic_fields = {"input", "query", "text", "prompt", "message", "question", "content"}

    for field_name in properties:
        if field_name.lower() in generic_fields and field_name not in args:
            args[field_name] = prompt
            break

    # If schema has exactly one string property and it's still empty, use prompt
    string_props = [
        name for name, schema in properties.items()
        if schema.get("type") == "string"
    ]
    if len(string_props) == 1 and string_props[0] not in args:
        args[string_props[0]] = prompt

    # Last resort: if there are still missing required string fields,
    # assign the raw prompt to the first one
    for field_name in required:
        if field_name not in args and field_name in properties:
            prop_type = properties[field_name].get("type", "string")
            if prop_type == "string":
                args[field_name] = prompt
                break  # only assign prompt once

    return args


# ---------------------------------------------------------------------------
# MCPAgentAdapter
# ---------------------------------------------------------------------------


class MCPAgentAdapter:
    """Wraps an MCPServerConnection as a standard Brynq agent.

    Translates the Brynq agent protocol (execute / health / info) into
    MCP JSON-RPC tool calls.

    Args:
        server:   A connected ``MCPServerConnection``.
        agent_id: Unique identifier for this agent in the Brynq registry.
        name:     Human-readable name for the agent.
    """

    def __init__(
        self,
        server: MCPServerConnection,
        agent_id: str,
        name: str,
    ) -> None:
        self._server = server
        self._agent_id = agent_id
        self._name = name
        self._start_time = time.monotonic()
        self._tools: list[dict[str, Any]] = []
        self._cache: dict[str, tuple[float, dict[str, Any]]] = {}

    def _ensure_tools(self) -> list[dict[str, Any]]:
        """Lazy-load tool list from the MCP server."""
        if not self._tools:
            try:
                self._tools = self._server.list_tools()
            except MCPError as exc:
                log.warning("Failed to list MCP tools: %s", exc)
                self._tools = []
        return self._tools

    def execute(
        self,
        prompt: str,
        context: dict[str, Any] | None = None,
        config: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Execute a task by matching the prompt to an MCP tool.

        If a matching tool is found, extracts arguments from the prompt
        and calls the tool. If no tool matches, returns a help message
        listing all available tools.

        Args:
            prompt:  Natural language task description.
            context: Upstream outputs from earlier chain steps (unused by
                     most MCP tools, but available for future use).
            config:  Optional config. Supports ``"tool"`` key to force a
                     specific tool name, and ``"arguments"`` to pass
                     explicit arguments.

        Returns:
            Dict with ``output``, ``tokens_used``, ``elapsed_ms``, ``metadata``.
        """
        context = context or {}
        config = config or {}
        t0 = time.monotonic()
        
        # Check cache
        cache_key = f"{prompt}:{config.get('tool', '')}:{str(config.get('arguments', {}))}"
        if cache_key in self._cache:
            t, cached_result = self._cache[cache_key]
            if time.monotonic() - t < 30.0:
                log.info("Returning cached MCP response for '%s'", cache_key)
                return cached_result

        tools = self._ensure_tools()

        try:
            # Allow explicit tool selection via config
            forced_tool_name = config.get("tool")
            forced_args = config.get("arguments")

            if forced_tool_name:
                tool = next(
                    (t for t in tools if t["name"] == forced_tool_name),
                    None,
                )
                if tool is None:
                    available = [t["name"] for t in tools]
                    elapsed = (time.monotonic() - t0) * 1000
                    return {
                        "output": (
                            f"Tool '{forced_tool_name}' not found. "
                            f"Available tools: {', '.join(available)}"
                        ),
                        "tokens_used": 0,
                        "elapsed_ms": elapsed,
                        "metadata": {"tool": None, "mcp_server": self._agent_id},
                    }
            else:
                tool = match_tool(prompt, tools)

            # No match — return help
            if tool is None:
                elapsed = (time.monotonic() - t0) * 1000
                help_lines = ["No matching tool found. Available tools:"]
                for t in tools:
                    desc = t.get("description", "No description")
                    help_lines.append(f"  - {t['name']}: {desc}")
                if not tools:
                    help_lines.append("  (no tools available)")
                return {
                    "output": "\n".join(help_lines),
                    "tokens_used": 0,
                    "elapsed_ms": elapsed,
                    "metadata": {"tool": None, "mcp_server": self._agent_id},
                }

            # Build arguments
            if forced_args and isinstance(forced_args, dict):
                arguments = forced_args
            else:
                input_schema = tool.get("inputSchema", {})
                arguments = extract_arguments(prompt, input_schema)

            # Call the tool
            tool_name = tool["name"]
            log.info(
                "MCP adapter calling tool '%s' with args: %s",
                tool_name, list(arguments.keys()),
            )
            result = self._server.call_tool(tool_name, arguments)
            elapsed = (time.monotonic() - t0) * 1000

            output = str(result) if result is not None else ""
            res = {
                "output": output,
                "tokens_used": 0,
                "elapsed_ms": elapsed,
                "metadata": {
                    "tool": tool_name,
                    "mcp_server": self._agent_id,
                    "arguments": arguments,
                },
            }
            
            # Save to cache
            self._cache[cache_key] = (time.monotonic(), res)
            return res

        except MCPError as exc:
            elapsed = (time.monotonic() - t0) * 1000
            log.error("MCP tool call failed: %s", exc)
            return {
                "output": f"MCP error: {exc}",
                "tokens_used": 0,
                "elapsed_ms": elapsed,
                "metadata": {"tool": None, "mcp_server": self._agent_id, "error": str(exc)},
            }

        except Exception as exc:
            elapsed = (time.monotonic() - t0) * 1000
            log.error("Unexpected error in MCP adapter: %s", exc, exc_info=True)
            return {
                "output": f"Unexpected error: {exc}",
                "tokens_used": 0,
                "elapsed_ms": elapsed,
                "metadata": {"tool": None, "mcp_server": self._agent_id, "error": str(exc)},
            }

    def health(self) -> dict[str, Any]:
        """Return health status of the MCP agent.

        Returns:
            Dict with ``status`` ("ok" or "degraded") and ``uptime_seconds``.
        """
        uptime = time.monotonic() - self._start_time
        try:
            # Verify the server is still responsive by listing tools
            self._server.list_tools()
            return {"status": "ok", "uptime_seconds": round(uptime, 2)}
        except MCPError:
            return {"status": "degraded", "uptime_seconds": round(uptime, 2)}

    def info(self) -> dict[str, Any]:
        """Return agent info with MCP tools listed as capabilities.

        Returns:
            Dict with ``name``, ``version``, ``capabilities``, ``protocol_version``.
        """
        tools = self._ensure_tools()
        capabilities = [t.get("name", "unknown") for t in tools]
        server_info = self._server._server_info

        return {
            "name": self._name,
            "version": server_info.get("version", "0.1.0"),
            "capabilities": capabilities,
            "protocol_version": AGENT_PROTOCOL_VERSION,
        }

    def as_fastapi_app(self) -> Any:
        """Return a FastAPI app exposing /execute, /health, /info endpoints.

        The app conforms to the Brynq agent protocol so the runtime can
        call this adapter exactly like any other local agent.

        Returns:
            A FastAPI application instance.

        Raises:
            ImportError: If FastAPI is not installed.
        """
        try:
            from fastapi import FastAPI
            from fastapi.responses import JSONResponse
        except ImportError:
            raise ImportError(
                "FastAPI is required for as_fastapi_app(). "
                "Install it with: pip install fastapi"
            )

        app = FastAPI(
            title=f"Brynq MCP Agent: {self._name}",
            description=f"MCP adapter for {self._agent_id}",
            version="0.1.0",
        )
        adapter = self

        @app.post("/execute")
        async def execute(body: dict) -> JSONResponse:
            prompt = body.get("prompt", "")
            context = body.get("context", {})
            config = body.get("config", {})
            result = adapter.execute(prompt, context, config)
            return JSONResponse(content=result)

        @app.get("/health")
        async def health() -> JSONResponse:
            return JSONResponse(content=adapter.health())

        @app.get("/info")
        async def info() -> JSONResponse:
            return JSONResponse(content=adapter.info())

        return app


# ---------------------------------------------------------------------------
# Helper: one-liner to wrap an MCP server
# ---------------------------------------------------------------------------


def wrap_mcp_server(
    command: list[str] | None = None,
    url: str | None = None,
    agent_id: str | None = None,
    name: str | None = None,
    port: int | None = None,
    sandbox: bool = True,
) -> Any:
    """Wrap an MCP server as a Brynq agent FastAPI app in one call.

    Connects to the MCP server, auto-discovers tools, creates the adapter,
    and returns a FastAPI app ready to serve.

    Args:
        command:  Shell command to spawn the MCP server (stdio mode).
        url:      HTTP URL of a running MCP server (HTTP mode).
        agent_id: Agent identifier. Auto-generated from server info if omitted.
        name:     Human-readable agent name. Defaults to MCP server name.
        port:     Unused here (caller can pass to uvicorn). Kept for API symmetry.

    Returns:
        A FastAPI application exposing /execute, /health, /info.

    Example::

        # Wrap a filesystem MCP server
        app = wrap_mcp_server(
            command=["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
            agent_id="mcp-filesystem",
        )

        # Run it
        import uvicorn
        uvicorn.run(app, port=9200)
    """
    server = MCPServerConnection(command=command, url=url, sandbox=sandbox)
    server.connect()

    # Auto-derive agent_id and name from server info
    server_name = server._server_info.get("name", "mcp-server")
    if agent_id is None:
        agent_id = f"mcp-{server_name}".lower().replace(" ", "-")
    if name is None:
        name = f"MCP: {server_name}"

    adapter = MCPAgentAdapter(server=server, agent_id=agent_id, name=name)

    log.info(
        "MCP server wrapped as Brynq agent: id=%s name=%s tools=%s port=%s",
        agent_id, name, [t["name"] for t in adapter._ensure_tools()], port,
    )

    return adapter.as_fastapi_app()
