Уведомления на практике
Теория из прошлого урока умещается в один метод ctx.report_progress(). Но чтобы прочувствовать, как уведомления обгоняют ответ, нужно увидеть это в терминале. Построим сервер reports с инструментом, который «анализирует» задачи спринта по одной, и клиента, который печатает каждое уведомление в момент прихода. Потом запустим ту же пару через Streamable HTTP и убедимся, что поведение не изменилось.
Шаг 1. Сервер с долгим инструментом
Создайте проект (uv init reports && cd reports && uv add "mcp[cli]") и файл server.py:
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:
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. Ожидаемый вывод (метки времени будут немного отличаться):
[ 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:
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
Не всегда известно, сколько всего работы. Добавьте инструмент, который обходит задачи постранично и не знает общего числа:
@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 одну строку после проверки спринта:
await ctx.info(f"Начинаю отчёт по спринту {sprint}", logger_name="reports")При запуске check.py вы увидите предупреждение MCPDeprecationWarning о том, что логирование через протокол устарело с 2026-07-28. Само сообщение при этом никуда не пришло: клиент не запросил логи. Чтобы получить их, нужно и колбэк, и явный уровень:
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 мин на рабочем месте- Добавьте в
build_reportограничение частоты: отправлять прогресс не чаще, чем раз в 100 миллисекунд, но последнее уведомление — всегда. Проверьте, что при задачах по 10 миллисекунд клиент не тонет в сообщениях. - Сделайте так, чтобы
build_reportпри ненайденном спринте возвращал ошибку инструмента (поднимитеToolErrorизmcp.server.mcpserver.exceptionsили просто исключение) и проверьте, что клиент получаетis_error=True, а не исключение. - Запустите сервер по 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: как клиент сообщает серверу, в каких папках ему можно работать.
