AmigaОбучение ИИ
Модуль 2 · Ключевые возможности MCP · урок 5 из 13

Уведомления на практике

12 мин чтения▶ есть видеоверсия

Теория из прошлого урока умещается в один метод ctx.report_progress(). Но чтобы прочувствовать, как уведомления обгоняют ответ, нужно увидеть это в терминале. Построим сервер reports с инструментом, который «анализирует» задачи спринта по одной, и клиента, который печатает каждое уведомление в момент прихода. Потом запустим ту же пару через Streamable HTTP и убедимся, что поведение не изменилось.

Шаг 1. Сервер с долгим инструментом

Создайте проект (uv init reports && cd reports && uv add "mcp[cli]") и файл server.py:

python
import logging

import anyio

from mcp.server import MCPServer
from mcp.server.mcpserver import Context

logger = logging.getLogger(__name__)
mcp = MCPServer("reports")

# Заглушка вместо трекера: задачи спринта и «время анализа» каждой в секундах.
SPRINTS: dict[str, list[tuple[str, float]]] = {
    "2026-09": [
        ("WEB-301", 0.2),
        ("WEB-305", 0.4),
        ("WEB-310", 0.1),
        ("MOB-88", 0.3),
        ("MOB-91", 0.2),
        ("QA-17", 0.1),
    ],
}


async def analyze(key: str, seconds: float) -> str:
    """Имитация долгой работы: в настоящем сервере здесь запрос к трекеру и обработка."""
    await anyio.sleep(seconds)
    return f"{key}: закрыта, оценка совпала с фактом"


@mcp.tool()
async def build_report(sprint: str, ctx: Context) -> str:
    """Собрать отчёт по спринту: пройти по всем задачам и составить сводку."""
    tasks = SPRINTS.get(sprint)
    if tasks is None:
        return f"Спринт {sprint} не найден."

    logger.info("build_report: sprint=%s, tasks=%d", sprint, len(tasks))
    total = len(tasks)
    lines: list[str] = []
    for i, (key, seconds) in enumerate(tasks, start=1):
        lines.append(await analyze(key, seconds))
        await ctx.report_progress(i, total, f"Проанализирована {key}")

    logger.info("build_report: done")
    return f"Отчёт по спринту {sprint}\n" + "\n".join(lines)


if __name__ == "__main__":
    mcp.run()

Три вещи, на которые стоит обратить внимание. Инструмент асинхронный (async def): синхронные функции SDK выполняет в отдельном потоке, и await в них невозможен. ctx: Context — параметр, который SDK подставляет сам; для модели инструмент принимает только sprint. И logger.info вместо print: в stdio-сервере stdout принадлежит протоколу.

Шаг 2. Клиент, который слушает прогресс

Файл check.py:

python
import time

import anyio

from mcp import Client

from server import mcp

started = time.monotonic()


def stamp() -> str:
    return f"{time.monotonic() - started:5.2f}s"


async def on_progress(progress: float, total: float | None, message: str | None) -> None:
    print(f"[{stamp()}] прогресс {progress:g}/{total:g}: {message}")


async def main() -> None:
    async with Client(mcp) as client:
        print(f"[{stamp()}] вызываю build_report")
        result = await client.call_tool(
            "build_report",
            {"sprint": "2026-09"},
            progress_callback=on_progress,
        )
        print(f"[{stamp()}] результат получен, is_error={result.is_error}")
        print(result.content[0].text)


anyio.run(main)

Запускаем uv run python check.py. Ожидаемый вывод (метки времени будут немного отличаться):

text
[ 0.00s] вызываю build_report
[ 0.20s] прогресс 1/6: Проанализирована WEB-301
[ 0.60s] прогресс 2/6: Проанализирована WEB-305
[ 0.70s] прогресс 3/6: Проанализирована WEB-310
[ 1.00s] прогресс 4/6: Проанализирована MOB-88
[ 1.20s] прогресс 5/6: Проанализирована MOB-91
[ 1.30s] прогресс 6/6: Проанализирована QA-17
[ 1.31s] результат получен, is_error=False
Отчёт по спринту 2026-09
WEB-301: закрыта, оценка совпала с фактом
...

Метки времени — главное в этом выводе. Уведомления приходят по мере работы, а не пачкой в конце, и результат появляется только после последнего. Если убрать progress_callback из вызова, строки прогресса исчезнут, а результат придёт в ту же секунду: сервер вызывает report_progress, но без токена SDK ничего не отправляет.

В stderr при этом появятся две строки от logger.info — SDK настраивает корневой логгер на уровень INFO при создании MCPServer. Их можно отключить, передав log_level="WARNING" в конструктор.

Шаг 3. То же самое через Streamable HTTP

In-memory клиент вызывает сервер напрямую. Проверим, что через настоящий транспорт прогресс приходит так же. В одном терминале запустите сервер по HTTP:

bash
uv run mcp run server.py --transport streamable-http

Сервер будет слушать http://127.0.0.1:8000/mcp. В check.py замените Client(mcp) на Client("http://127.0.0.1:8000/mcp") и запустите во втором терминале. Вывод должен совпасть с точностью до меток времени.

Что произошло по проводу: клиент отправил POST /mcp с телом tools/call, в _meta которого лежал progressToken. Сервер ответил не одним JSON-объектом, а потоком text/event-stream: шесть событий с notifications/progress, затем событие с ответом, после чего поток закрылся. Как это устроено, разберём в уроке «Транспорт Streamable HTTP».

Шаг 4. Неизвестный total

Не всегда известно, сколько всего работы. Добавьте инструмент, который обходит задачи постранично и не знает общего числа:

python
@mcp.tool()
async def scan_overdue(ctx: Context) -> str:
    """Найти просроченные задачи, обходя трекер постранично."""
    found = 0
    page = 0
    while True:
        page += 1
        await anyio.sleep(0.2)
        keys = [f"WEB-{300 + page * 3 + i}" for i in range(3)] if page <= 3 else []
        if not keys:
            break
        found += len(keys)
        await ctx.report_progress(found, message=f"Страница {page}, найдено {found}")
    return f"Просроченных задач: {found}"

total не передаём; в колбэк клиента придёт None. Раз total в колбэке может быть None, форматирование {total:g} из шага 2 упадёт — исправьте вывод так, чтобы он это учитывал. Именно ради таких случаев спецификация требует, чтобы progress рос сам по себе: клиент может показать хотя бы «идёт работа, найдено 9».

Шаг 5. Протокольное логирование: узнать в чужом коде

Вы будете встречать серверы, которые вместо logger вызывают ctx.info(...). Чтобы понимать, что при этом происходит, добавьте в build_report одну строку после проверки спринта:

python
    await ctx.info(f"Начинаю отчёт по спринту {sprint}", logger_name="reports")

При запуске check.py вы увидите предупреждение MCPDeprecationWarning о том, что логирование через протокол устарело с 2026-07-28. Само сообщение при этом никуда не пришло: клиент не запросил логи. Чтобы получить их, нужно и колбэк, и явный уровень:

python
from mcp.types import LoggingMessageNotificationParams


async def on_log(params: LoggingMessageNotificationParams) -> None:
    print(f"[лог {params.level}] {params.data}")


async with Client(mcp, logging_callback=on_log, log_level="info") as client:
    ...

Теперь строка [лог info] Начинаю отчёт по спринту 2026-09 появится перед первым прогрессом. log_level в конструкторе — это тот самый ключ io.modelcontextprotocol/logLevel, который клиент на современном протоколе ставит в _meta каждого запроса. Без него сервер обязан молчать.

Поэкспериментировав, удалите ctx.info: в новом коде логи должны идти в logger. Оставьте этот шаг в памяти как способ прочитать чужой сервер.

Попробуйте сами

10–15 мин на рабочем месте
  1. Добавьте в build_report ограничение частоты: отправлять прогресс не чаще, чем раз в 100 миллисекунд, но последнее уведомление — всегда. Проверьте, что при задачах по 10 миллисекунд клиент не тонет в сообщениях.
  2. Сделайте так, чтобы build_report при ненайденном спринте возвращал ошибку инструмента (поднимите ToolError из mcp.server.mcpserver.exceptions или просто исключение) и проверьте, что клиент получает is_error=True, а не исключение.
  3. Запустите сервер по HTTP и подключитесь к нему клиентом с mode="legacy". Прогресс должен работать так же: убедитесь в этом и найдите в документации SDK, чем отличается доставка уведомлений на старом протоколе.

Коротко

  • Долгий инструмент: async def, параметр ctx: Context, await ctx.report_progress(i, total, message) на каждом этапе.
  • Клиент получает уведомления по мере работы, до результата; без progress_callback они не отправляются вовсе.
  • Через Streamable HTTP поведение то же: сервер отвечает SSE-потоком с уведомлениями и ответом в конце.
  • Когда общее число неизвестно, total не передаём; клиент получит None.
  • ctx.info() и родственники устарели: выдают MCPDeprecationWarning, а на современном протоколе доставляются только при log_level у клиента.
  • В новом коде логи идут в logging, прогресс — в report_progress.

Видеоверсия

Сценарий озвучки · 475 слов, ≈ 4 мин

В этом уроке строим сервер отчётов, который сообщает о прогрессе, и смотрим своими глазами, как уведомления обгоняют ответ.

Первый шаг — сервер. Инструмент build_report принимает номер спринта, находит задачи в заглушке-словаре и анализирует каждую. Анализ у нас — это просто пауза на долю секунды, в настоящем сервере здесь был бы запрос к трекеру. После каждой задачи инструмент вызывает report_progress: номер задачи, общее число, сообщение. Три вещи стоит отметить. Инструмент асинхронный, иначе await в нём невозможен. Параметр контекста SDK подставляет сам, модель его не видит. И логи идут в стандартный логгер, а не в print, потому что стандартный вывод в таком сервере принадлежит протоколу.

Второй шаг — клиент. Он подключается к серверу в том же процессе и вызывает инструмент с колбэком прогресса. Колбэк печатает каждое уведомление с меткой времени от старта. Запускаем. На экране: ноль секунд — вызываю. Две десятых — прогресс один из шести. Шесть десятых — два из шести. И так далее, до шести из шести на секунде с третью. И только после этого — результат. Метки времени — главное в этом выводе. Уведомления приходят по мере работы, не пачкой в конце. Если убрать колбэк, строки прогресса исчезнут: сервер по-прежнему вызывает report_progress, но без токена от клиента SDK ничего не отправляет.

Третий шаг — то же самое через эйч-ти-ти-пи. Запускаем сервер командой из SDK с транспортом streamable-http, он слушает локальный порт восемь тысяч. В клиенте заменяем объект сервера на адрес. Вывод совпадает. По проводу при этом произошло вот что: клиент отправил пост-запрос с вызовом инструмента и токеном прогресса, а сервер ответил не одним джейсон-объектом, а потоком событий: шесть уведомлений, потом ответ, и поток закрылся.

Четвёртый шаг — неизвестный объём работы. Инструмент обходит трекер постранично и не знает, сколько всего страниц. Тогда общее значение просто не передаём, клиент получит пустоту. Именно поэтому спецификация требует, чтобы значение прогресса росло само по себе: клиент хотя бы покажет «идёт работа, найдено девять».

Пятый шаг — про устаревшее протокольное логирование. Вы встретите серверы, где вместо логгера вызывают ctx.info. Добавьте такую строку и запустите: получите предупреждение, что возможность устарела. А само сообщение никуда не придёт, пока клиент не попросит: нужен колбэк логов и явный уровень в конструкторе клиента. Этот уровень — тот самый ключ в метаданных запроса, без которого сервер на современном протоколе обязан молчать. Поэкспериментируйте и удалите: в новом коде логи идут в логгер.

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

В следующем уроке — roots: как клиент сообщает серверу, в каких папках ему можно работать.

Отметка хранится только в вашем браузере