"""Сборка MCP-сервера Synapse (docs/07).
FastMCP (stateless_http) — Starlette-приложение streamable-http; поверх —
McpTokenGuard. Два вида Bearer-ключей:
- статический `MCP_TOKEN` из .env — суперадмин-бэкдор (совместимость с
сценариями агентов до персональных ключей; токен держать как ключ прод-сервера);
- персональный `mcp_...` — выдаётся пользователем самому себе на SPA
/mcp-keys; MCP-запрос действует ролью владельца (админ-тулы — только
для ролей admin/superadmin, остальное — личный набор).
"""
from mcp.server.fastmcp import FastMCP
from app.mcp.context import MCP_USER, reset_mcp_user, resolve_personal_token, set_mcp_user
from app.mcp.tools import register_tools
def build_mcp_server() -> FastMCP:
mcp = FastMCP(
"synapse",
stateless_http=True,
instructions=(
"Управление хабом уведомлений Gnexus Synapse: источники и выдача "
"API-ключей, типы уведомлений, цели каналов, правила маршрутизации, "
"поток событий и доставок, настройки. Персональный ключ действует "
"ролью владельца: роль user — личный набор (me, my_events_list, "
"my_push_*). Удаление = архив (restore возвращает); секреты настроек "
"write-only."
),
)
register_tools(mcp)
return mcp
class McpTokenGuard:
"""Чистый ASGI-гейт (не BaseHTTPMiddleware): резолвит Bearer-токен на
каждом http-запросе. Не BaseHTTPMiddleware — чтобы не ломать SSE-стримы.
Резолв персонального ключа — короткий indexed lookup в БД прямо в
event loop-е (миллисекунды; у гейта нет threadpool-обёртки, как у тулов).
"""
def __init__(self, app, token: str | None) -> None:
self.app = app
self.static_expected = f"Bearer {token}".encode() if token else None
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 []}
auth = headers.get(b"authorization", b"")
user = None
if self.static_expected is not None and auth == self.static_expected:
user = MCP_USER # суперадмин-бэкдор из .env
elif auth.startswith(b"Bearer mcp_"):
from app.database import SessionLocal
with SessionLocal() as db:
user = resolve_personal_token(db, auth[len("Bearer "):].strip().decode("utf-8", "replace"))
if user is None:
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})
return
ctx_token = set_mcp_user(user)
try:
await self.app(scope, receive, send)
finally:
reset_mcp_user(ctx_token)