Newer
Older
gn-synapse / app / mcp / server.py
"""Сборка MCP-сервера Synapse (docs/07).

FastMCP (stateless_http) — Starlette-приложение streamable-http; поверх —
McpTokenGuard: статический Bearer-токен из MCP_TOKEN (.env). Токен один,
выдаётся администратором (генерация `openssl rand -hex 32`) — полномочия
тулов = superadmin внутри API, так что токен держать как ключ прод-сервера.
"""

from mcp.server.fastmcp import FastMCP

from app.mcp.tools import register_tools


def build_mcp_server() -> FastMCP:
    mcp = FastMCP(
        "synapse",
        stateless_http=True,
        instructions=(
            "Управление хабом уведомлений Gnexus Synapse: источники и выдача "
            "API-ключей, типы уведомлений, цели каналов, правила маршрутизации, "
            "поток событий и доставок, настройки. Удаление = архив (restore "
            "возвращает); секреты настроек write-only."
        ),
    )
    register_tools(mcp)
    return mcp


class McpTokenGuard:
    """Чистый ASGI-гейт (не BaseHTTPMiddleware): проверяет Bearer-токен на
    каждом http-запросе. Не BaseHTTPMiddleware — чтобы не ломать SSE-стримы."""

    def __init__(self, app, token: str) -> None:
        self.app = app
        self.expected = f"Bearer {token}".encode()

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        headers = {k.lower(): v for k, v in scope.get("headers") or []}
        if headers.get(b"authorization") == self.expected:
            await self.app(scope, receive, send)
            return
        body = b'{"detail":"Invalid or missing MCP token"}'
        await send(
            {
                "type": "http.response.start",
                "status": 401,
                "headers": [
                    (b"content-type", b"application/json"),
                    (b"www-authenticate", b"Bearer"),
                    (b"content-length", str(len(body)).encode()),
                ],
            }
        )
        await send({"type": "http.response.body", "body": body})