Newer
Older
gn-synapse / app / mcp / context.py
"""Контекст MCP-запроса: какой пользователь за Bearer-ключом.

McpTokenGuard резолвит токен (см. server.py) и кладёт AuthenticatedUser в
ContextVar — тулы (`app/mcp/tools.py`) читают его через current_mcp_user():
полный набор тулов действуют ролью владельца ключа, админ-тулы требуют
роли admin (require_mcp_admin). ContextVar прокидывается FastMCP в
threadpool-исполнение тулов, так что sync-функции его видят.

MCP_USER — прежний синтетический superadmin: остаётся бэкдором статического
env-токена (MCP_TOKEN) и дефолтом для прямых вызовов тулов в тестах.
"""

from contextvars import ContextVar

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.auth.deps import AuthenticatedUser
from app.models import McpToken

# Синтетический superadmin: ключ статического env-токена MCP_TOKEN (docs/07).
MCP_USER = AuthenticatedUser(
    user_id="mcp", email="mcp@synapse.local", email_verified=True, system_role="superadmin"
)

_mcp_user: ContextVar[AuthenticatedUser | None] = ContextVar("mcp_user", default=None)


def set_mcp_user(user: AuthenticatedUser) -> object:
    """Для гейда: установить пользователя на время запроса, вернуть token для reset()."""
    return _mcp_user.set(user)


def reset_mcp_user(token: object) -> None:
    _mcp_user.reset(token)  # type: ignore[arg-type]


def current_mcp_user() -> AuthenticatedUser:
    """Пользователь (или роль) текущего MCP-вызова; None — прямой вызов вне запроса."""
    found = _mcp_user.get()
    if found is not None:
        return found
    return MCP_USER


def resolve_personal_token(db: Session, raw_token: str) -> AuthenticatedUser | None:
    """Bearer `mcp_...` -> владелец ключа; None — промах/отзыв.

    Вызывается гейтом на каждом запросе /mcp. last_used_at — единственный
    столбец, который MCP-путь когда-либо пишет (коммит здесь: у гейта нет
    общей транзакции роутера).
    """
    from datetime import UTC, datetime

    from app.auth.apikeys import token_hash_of

    row = db.execute(
        select(McpToken).where(
            McpToken.token_hash == token_hash_of(raw_token),
            McpToken.revoked_at.is_(None),
        )
    ).scalar_one_or_none()
    if row is None:
        return None
    row.last_used_at = datetime.now(UTC)
    db.commit()
    return AuthenticatedUser(
        user_id=row.user_id,
        email=row.user_email or "",
        email_verified=True,
        system_role=row.system_role,
    )