Привет, Хабр! Меня зовут Владимир, и это внеплановое продолжение цикла про локального MCP-агента. В первых частях мы собрали агента, научили его работать через LangGraph, добавили Gradio-интерфейс, чекпоинты и трассировку. Но как только агентом пришлось поделиться с коллегами, всплыл следующий бытовой вопрос: как понять, кто именно им пользуется, и как разнести историю, чекпоинты и трейсы по пользователям. Вычислять по IP не вышло (может не разобрался, но контейнер с приложением в Docker Desktop из-за связки Windows-WSL-Docker всегда показывал мой IP-адрес), и я решил прикрутить к Gradio аутентификацию.

В этой статье я покажу как добавить аутентификацию в Gradio-интерфейс и как использовать её для упорядочивания трейсов в Langfuse и чекпоинтов в LangGraph.

Окно входа в GUI

Начнём с паролей. В нормальной взрослой системе пользователь при регистрации вводит пароль, но сам пароль в базу не складывается. Вместо него хранится хеш: пароль пропускают через специальную функцию, обычно ещё с “солью”, а иногда и с отдельным секретным ключом. При следующем входе введённый пароль хешируется тем же способом, и приложение сравнивает уже не пароли, а получившиеся хеши. Так даже при утечке базы вражина не получает готовый список паролей в открытом виде.

Но у нас тут не серьёзный фронтенд, а миленький Gradio-интерфейс для пары коллег. У Gradio нет полноценного встроенного окна регистрации: пользователей надо заводить самому, либо подключать внешнюю аутентификацию и отдельную логику управления аккаунтами. Переделывать GUI ради регистрации мне не хотелось, поэтому для начала я выбрал самый простой вариант: хранить выданные логины и пароли в текстовом файле.

# Формат: логин:пароль (по одному на строку)
admin:admin

Теперь файл надо распарсить и передать результат аутентификации в Gradio. Вообще Gradio предполагает использование не только функции, но и реализует возможность просто передать кортеж (логин, пароль). Не знаю почему, но прямая передача паролей немного противоречит моему чувству прекрасного, поэтому сделаем функцию. Если в будущем возникнет желание заменить текстовый файл на что-то с хешем паролей, то менять GUI не придётся (хотя формально там одна строчка кода):

import secrets
from pathlib import Path
from core import settings

def get_gradio_auth():
    if not settings.service.auth_enabled:
        return None
    path = Path(settings.service.auth_users_file)
    if not path.is_file():
        raise RuntimeError(f'Файл учётных записей не найден: {path}')

    credentials = {}
    for line in path.read_text(encoding='utf-8').splitlines():
        if not line or line.startswith('#') or ':' not in line:
            continue
        user, psw = line.split(':', 1)
        if (user := user.strip()) and (psw := psw.strip()):
            credentials[user] = psw

    if not credentials:
        raise RuntimeError(f'Файл учётных записей пуст или некорректен: {path}')

    def checker(username: str, password: str) -> bool:
        stored = credentials.get(username)
        return stored is not None and secrets.compare_digest(stored, password)

    return checker

Читаем и парсим файл паролей. По соглашению с самим собой символ # в файле - это комментарий, поэтому строки, начинающиеся с # пропускаем. Также, на всякий случай, пропускаем строки в которых нет разделителя :. Для каждой валидной строки извлекаем и сохраняем пару логин-пароль. Абонентов планировалось до трёх человек, поэтому отдельное хранилище не заводил: файл читается при старте приложения, а дальше функция аутентификации работает с уже разобранным словарём.

Далее замыкание на функцию проверки учётных данных. Почему в ней не использую просто if password == credentials.get(username) - ну это же проверка паролей, должна же быть безопасность ?.

Кому интересно про `secrets.compare_digest`

Метод secrets.compare_digest обеспечивает защиту от атак по времени выполнения (timing attack) - грубо говоря, вычисление секретов по времени ответа бекенда. Сравнение if password == credentials.get(username) идёт посимвольно и возвращает False при первом несовпадении, что может быть использовано для подбора пароля. secrets.compare_digest же обеспечивает сопоставимое время сравнения для данных одинаковой длины.

Для системы, где пароли хранятся в текстовом файле - это перебор, но совсем расслабляться не стоит

Доработаем интерфейс:

class MCPCodingAgentApp:
    # Доработанный кусочек кода старого класса
    async def initialize_and_launch(self):
        await self.init_agent()
        self.build_interface()
        self.demo.launch(
            server_name=settings.service.host,
            server_port=settings.service.port,
            show_error=True,
            auth=get_gradio_auth(),  # Новая строка
            auth_message='<p>Введите выданный Вам логин и пароль</p>'  # Новая строка
        )

Вся интеграция аутентификации сводится к тому, что в метод demo.launch необходимо передать два дополнительных параметра - функцию проверки учётных данных (или, как говорил ранее, кортеж логин-паролей) и приветственное сообщение окошка ввода логина - пароля.

Для сохранения истории и фильтрации трассировок по пользователям сразу доработаем вызовы графа. Для этого необходимо передать пользователя во все методы графа. Покажу на примере метода продолжения диалога:

class MCPCodingAgentApp:
    # Доработанный кусочек кода старого класса
    async def _continue(self, history: list, request_id: str, request: gr.Request):
        phase = await self.agent.get_phase(request_id, username=request.username)

        if phase is None or phase == 'done':
            history.append({
                'role': 'assistant',
                'content': 'Нет активных задач для продолжения. Задайте новый вопрос.'
            })
            return history, request_id

        result = await self.agent.resume(
            user_messages='Продолжить', request_id=request_id, username=request.username)

Трассировка по пользователям

В предыдущей версии наш thread_id был завязан просто на request_id. Сейчас появилась новая сущность - пользователь, и его надо как-то связать с идентификацией:

class MCPAgent:
    # Новый кусочек кода старого класса
    @staticmethod
    def resolve_thread_id(username: str | None, request_id: str) -> str:
        return f'{username or "anonymous"}:{request_id}'

Если аутентификация выключена в настройках, request.username будет None и мы не получим от фронта username, поэтому используем username or "anonymous".

В коде класса MCPAgent во всех методах, которые используют request_id, необходимо добавить входной параметр username: str | None = None и вместо {'thread_id': request_id} использовать {'thread_id': self.resolve_thread_id(username, request_id)}.

Теперь осталось передать новые атрибуты для обеспечения фильтрации трейсов. Для этого используем метод для проброса атрибутов трассировки langfuse.propagate_attributes:

class MCPAgent:
    # Доработанный кусочек кода старого класса
    async def _ainvoke_with_tracing(self, data, request_id, trace_id, span_name, username, trace_input) -> list:
        user = username or 'anonymous'
        session_id = f'{user}_{request_id[:8]}'

        with settings.langfuse.client.start_as_current_observation(
                as_type='span',
                name=span_name,
                trace_context={'trace_id': trace_id},
        ) as span:
            with propagate_attributes(
                user_id=user,
                session_id=session_id,
                trace_name='MCP Agent',
                tags=['langgraph', 'mcp-agent'],
                metadata={'request_id': request_id, 'username': user},
            ):
                span.update(input={'message': trace_input})
                result = await self.graph.ainvoke(
                    data,
                    config=self._create_config(request_id, username, trace_id),
                )
                span.update(output=str(result['messages'][-1].content))
                return result['messages']

Начало похоже на старую реализацию - начинаем наблюдение типа span с конкретным trace_id. А дальше прокидываем в трейс параметры с помощью контекстного менеджера langfuse.propagate_attributes. Параметрами он принимает идентификатор пользователя, идентификатор сессии, словарик с метаданными и теги для трейса. В данном варианте в качестве идентификатора пользователя передаём его логин.

Ну и раз появилась возможность отличать диалоги разных пользователей, то перейдём от InMemorySaver к PostgreSQL-чекпоинтеру (AsyncPostgresSaver).

Checkpointer в Postgres

Для начала установим библиотеку:

uv add langgraph-checkpoint-postgres==2.0.0

Напишем метод создания чекпоинтера:

from loguru import logger
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg import AsyncConnection
from psycopg.rows import dict_row

async def create_checkpointer(db_url: str) -> AsyncPostgresSaver:
    try:
        conn = await AsyncConnection.connect(
            db_url,
            autocommit=True,
            row_factory=dict_row,
        )
        checkpointer = AsyncPostgresSaver(conn=conn)
        await checkpointer.setup()
        logger.success('Postgres checkpointer инициализирован')
        return checkpointer
    except Exception as e:
        raise RuntimeError(f'Не удалось подключиться к Postgres для checkpoint ({db_url}): {e}')

Создаём psycopg-соединение с базой данных в асинхронном режиме. В параметрах передаём адрес для подключения к БД, устанавливаем флаг автокоммита и формат возврата значений в виде dict. Следующим действием создаём и инициализируем Postgres-чекпоинтер. При первом запуске метод AsyncPostgresSaver.setup() создаст необходимые для чекпоинтера таблицы и выполнит миграции.

Заменим InMemorySaver на Postgres-чекпоинтер в агенте. Так как инициализация чекпоинтера теперь асинхронная, то в классе MCPAgent её необходимо перенести из конструктора в метод init_graph:

class MCPAgent:
    def __init__(self):
        self.llm_with_tools: ChatOpenAI | None = None
        self.tools: list[BaseTool] | None = None
        self.graph = None
        self.checkpointer: AsyncPostgresSaver | None = None  # AsyncPostgresSaver вместо InMemorySaver

    async def init_graph(self):
        try:
            self.tools = await init_tools()
        except Exception as e:
            logger.error(f'Ошибка инициализации инструментов: {e}')
            raise
        self.checkpointer = await create_checkpointer(settings.service.checkpoint_db_url)
        self.llm_with_tools = settings.llm.chat.llm.bind_tools(self.tools, parallel_tool_calls=False)
        self.graph = self._compile_graph()

Скриншотики

Это окно входа

Это я логин перепутал

А это я два раза один запрос из разных юзеров дал

А это так теперь фильтроваться можно

Итоги

В итоге у Gradio-интерфейса появилась простая аутентификация: пользователи вводят выданные логин и пароль, а приложение проверяет их через функцию, переданную в параметр auth. Главное не забыть .env файле указать AGENT__AUTH_ENABLED=true.

Этого оказалось достаточно, чтобы связать запросы с конкретными пользователями: разнести thread_id, аккуратнее хранить чекпоинты в Postgres и добавить user_id, session_id в Langfuse-трейсы. Получилось не полноценное управление пользователями, но для внутреннего инструмента - вполне себе.

Код с аутентификацией (ветка auth) тут

Комментарии (0)