"""Сборка 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})