BareGit
"""Minimal newline-delimited JSON-RPC and MCP tools server."""

from __future__ import annotations

import asyncio
from enum import Enum
import io
import json
import logging
from typing import BinaryIO, Final

from .errors import CancelledError, FirefoxMcpError
from .tool_handlers import (
    InvalidArgumentsError,
    ToolHandlers,
    errorResult,
)
from .tool_schemas import TOOLS


MAX_LINE_BYTES: Final = 8 * 1024 * 1024
SUPPORTED_PROTOCOL_VERSIONS: Final = (
    "2025-11-25",
    "2025-06-18",
    "2024-11-05",
)


class ServerState(Enum):
    """Represent the MCP process lifecycle."""

    STARTING = "starting"
    INITIALIZED = "initialized"
    RUNNING = "running"
    STOPPING = "stopping"


class JsonRpcError(Exception):
    """Carry a JSON-RPC error code, message, and optional data."""

    def __init__(
        self,
        code: int,
        message: str,
        data: object | None = None,
    ) -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.data = data


class McpServer:
    """Parse MCP requests, dispatch tools, and serialize stdout responses."""

    def __init__(
        self,
        tools: ToolHandlers,
        output: BinaryIO,
        *,
        logger: logging.Logger | None = None,
    ) -> None:
        self.tools = tools
        self.output = output
        self.logger = logger or logging.getLogger("firefox_mcp.mcp")
        self.state = ServerState.STARTING
        self._output_lock = asyncio.Lock()
        self._tasks: dict[object, asyncio.Task[None]] = {}
        self._cancelled: dict[object, asyncio.Event] = {}

    async def run(self, input_stream: BinaryIO) -> None:
        """Read bounded input lines until EOF, then finish active requests."""
        buffer = bytearray()
        discarding = False
        async for chunk in self._readChunks(input_stream):
            for value in chunk:
                if discarding:
                    if value == 0x0a:
                        discarding = False
                    continue
                buffer.append(value)
                if value == 0x0a:
                    await self.processLine(bytes(buffer))
                    buffer.clear()
                    await asyncio.sleep(0)
                elif len(buffer) > MAX_LINE_BYTES:
                    buffer.clear()
                    discarding = True
                    await self._writeError(None, -32700, "Parse error")
        if buffer and not discarding:
            await self.processLine(bytes(buffer))
        if self._tasks:
            await asyncio.gather(
                *tuple(self._tasks.values()),
                return_exceptions=True,
            )
        self.state = ServerState.STOPPING

    @staticmethod
    async def _readChunks(input_stream: BinaryIO):
        try:
            input_stream.fileno()
        except (AttributeError, io.UnsupportedOperation):
            while True:
                chunk = input_stream.read(65_536)
                if not chunk:
                    return
                yield chunk
            return
        loop = asyncio.get_running_loop()
        reader = asyncio.StreamReader()
        protocol = asyncio.StreamReaderProtocol(reader)
        transport, _ = await loop.connect_read_pipe(
            lambda: protocol,
            input_stream,
        )
        try:
            while True:
                chunk = await reader.read(65_536)
                if not chunk:
                    return
                yield chunk
        finally:
            transport.close()

    async def processLine(self, raw_line: bytes) -> None:
        """Validate and schedule one newline-delimited JSON-RPC message."""
        if len(raw_line) > MAX_LINE_BYTES + 1:
            await self._writeError(None, -32700, "Parse error")
            return
        line = raw_line
        if line.endswith(b"\n"):
            line = line[:-1]
            if line.endswith(b"\r"):
                line = line[:-1]
        if not line:
            await self._writeError(None, -32700, "Parse error")
            return
        try:
            text = line.decode("utf-8", errors="strict")
            message = json.loads(text)
        except (UnicodeDecodeError, json.JSONDecodeError):
            await self._writeError(None, -32700, "Parse error")
            return
        if not isinstance(message, dict):
            await self._writeError(None, -32600, "Invalid Request")
            return
        is_notification = "id" not in message
        try:
            method, params = self._validateMessage(message)
        except JsonRpcError as error:
            if not is_notification:
                await self._writeError(
                    self._safeId(message.get("id")),
                    error.code,
                    error.message,
                    error.data,
                )
            return
        if is_notification:
            self._notification(method, params)
            return
        request_id = message.get("id")
        if not self._validId(request_id):
            await self._writeError(None, -32600, "Invalid Request")
            return
        task = asyncio.create_task(
            self._handleRequest(request_id, method, params),
            name=f"mcp-request-{request_id}",
        )
        self._tasks[request_id] = task
        task.add_done_callback(
            lambda _task, key=request_id: self._finishTask(key)
        )

    @staticmethod
    def _validId(value: object) -> bool:
        return (
            value is None
            or isinstance(value, str)
            or (isinstance(value, int) and not isinstance(value, bool))
        )

    @classmethod
    def _safeId(cls, value: object) -> object:
        return value if cls._validId(value) else None

    @staticmethod
    def _validateMessage(
        message: dict[str, object],
    ) -> tuple[str, dict[str, object]]:
        if message.get("jsonrpc") != "2.0":
            raise JsonRpcError(-32600, "Invalid Request")
        method = message.get("method")
        if not isinstance(method, str):
            raise JsonRpcError(-32600, "Invalid Request")
        params = message.get("params", {})
        if not isinstance(params, dict):
            raise JsonRpcError(-32602, "Invalid params")
        return method, params

    def _notification(
        self,
        method: str,
        params: dict[str, object],
    ) -> None:
        if method == "notifications/initialized":
            if self.state is ServerState.INITIALIZED:
                self.state = ServerState.RUNNING
            return
        if method == "notifications/cancelled":
            request_id = params.get("requestId")
            event = self._cancelled.get(request_id)
            if event is not None:
                event.set()
            return
        self.logger.debug("Ignoring unknown notification %s", method)

    def _finishTask(self, request_id: object) -> None:
        self._tasks.pop(request_id, None)
        self._cancelled.pop(request_id, None)

    async def _handleRequest(
        self,
        request_id: object,
        method: str,
        params: dict[str, object],
    ) -> None:
        try:
            result = await self._dispatch(request_id, method, params)
        except JsonRpcError as error:
            await self._writeError(
                request_id,
                error.code,
                error.message,
                error.data,
            )
        except Exception:
            self.logger.exception(
                "Unexpected MCP request failure for method %s",
                method,
            )
            await self._writeError(
                request_id,
                -32603,
                "Internal error",
            )
        else:
            await self._write({
                "jsonrpc": "2.0",
                "id": request_id,
                "result": result,
            })

    async def _dispatch(
        self,
        request_id: object,
        method: str,
        params: dict[str, object],
    ) -> object:
        if method == "initialize":
            return self._initialize(params)
        if method not in {"ping", "tools/list", "tools/call"}:
            raise JsonRpcError(-32601, "Method not found")
        if self.state not in {ServerState.INITIALIZED, ServerState.RUNNING}:
            raise JsonRpcError(-32002, "Server not initialized")
        if method == "ping":
            if params:
                raise JsonRpcError(-32602, "Invalid params")
            return {}
        if method == "tools/list":
            if set(params) - {"cursor", "_meta"}:
                raise JsonRpcError(-32602, "Invalid params")
            cursor = params.get("cursor")
            if cursor is not None and not isinstance(cursor, str):
                raise JsonRpcError(-32602, "Invalid params")
            meta = params.get("_meta")
            if meta is not None and not isinstance(meta, dict):
                raise JsonRpcError(-32602, "Invalid params")
            return {"tools": TOOLS}
        return await self._callTool(request_id, params)

    def _initialize(self, params: dict[str, object]) -> dict[str, object]:
        if self.state is not ServerState.STARTING:
            raise JsonRpcError(-32600, "Server is already initialized")
        requested = params.get("protocolVersion")
        if requested is not None and not isinstance(requested, str):
            raise JsonRpcError(-32602, "Invalid params")
        version = (
            requested
            if requested in SUPPORTED_PROTOCOL_VERSIONS
            else SUPPORTED_PROTOCOL_VERSIONS[0]
        )
        self.state = ServerState.INITIALIZED
        return {
            "protocolVersion": version,
            "capabilities": {"tools": {"listChanged": False}},
            "serverInfo": {
                "name": "firefox-rdp-mcp",
                "version": "0.1.0",
            },
        }

    async def _callTool(
        self,
        request_id: object,
        params: dict[str, object],
    ) -> dict[str, object]:
        allowed = {"name", "arguments", "_meta"}
        if set(params) - allowed:
            raise JsonRpcError(-32602, "Invalid params")
        meta = params.get("_meta")
        if meta is not None and not isinstance(meta, dict):
            raise JsonRpcError(-32602, "Invalid params")
        name = params.get("name")
        if not isinstance(name, str):
            raise JsonRpcError(-32602, "Invalid params")
        arguments = params.get("arguments", {})
        event = asyncio.Event()
        self._cancelled[request_id] = event
        try:
            return await self.tools.call(name, arguments, event.is_set)
        except InvalidArgumentsError as error:
            raise JsonRpcError(-32602, str(error)) from error
        except CancelledError as error:
            return errorResult(error)

    async def _writeError(
        self,
        request_id: object,
        code: int,
        message: str,
        data: object | None = None,
    ) -> None:
        error: dict[str, object] = {"code": code, "message": message}
        if data is not None:
            error["data"] = data
        await self._write({
            "jsonrpc": "2.0",
            "id": request_id,
            "error": error,
        })

    async def _write(self, message: dict[str, object]) -> None:
        payload = (
            json.dumps(
                message,
                ensure_ascii=False,
                separators=(",", ":"),
            ).encode("utf-8")
            + b"\n"
        )
        async with self._output_lock:
            self.output.write(payload)
            self.output.flush()


async def runServer(
    tools: ToolHandlers,
    input_stream: BinaryIO,
    output_stream: BinaryIO,
) -> None:
    """Construct and run an MCP server on supplied binary streams."""
    server = McpServer(tools, output_stream)
    await server.run(input_stream)