Sampling: сервер обращается к модели
До сих пор поток был односторонним: хост спрашивает, сервер отвечает. Sampling переворачивает его: сервер в середине своей работы просит модель что-то написать, получает ответ и продолжает. К концу урока вы будете понимать, как устроен этот запрос, кто и что в нём контролирует, чем sampling отличается от «просто вызвать API Claude из сервера» и как он выглядит в Python SDK.
Зачем серверу модель
Представьте сервер для нашего CI: у него есть инструмент report_failure, который по номеру сборки находит упавший тест и его лог. Логи длинные и нечитаемые, а в тикет нужно положить два предложения по-человечески: что сломалось и на какой стадии. Это работа для языковой модели.
Первый вариант — положить в сервер SDK Claude и ключ API. Он работает, но у него три недостатка. Сервер начинает зависеть от конкретного провайдера. Ключ нужно где-то хранить и оплачивать отдельно. И пользователь хоста не видит, что от его имени куда-то уходят его логи.
Sampling (в спецификации так называют генерацию модели, от «взять выборку из распределения») предлагает второй вариант: сервер отправляет клиенту запрос «сгенерируй ответ на эти сообщения», клиент передаёт его модели, которая уже есть у хоста, и возвращает результат. Ключ живёт у хоста, выбор модели — у хоста, и хост может показать запрос человеку до отправки. Сервер остаётся независимым от провайдера: тот же код работает в Claude Code, в VS Code и в любом другом хосте, который поддерживает sampling.
Как выглядит запрос
Метод называется sampling/createMessage. Параметры устроены так же, как запрос к любому чат-API:
{
"method": "sampling/createMessage",
"params": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Опиши падение теста в двух предложениях для тикета:\n\n<лог>"
}
}
],
"systemPrompt": "Ты пишешь краткие технические описания багов.",
"modelPreferences": {
"hints": [{ "name": "claude" }],
"speedPriority": 0.8,
"intelligencePriority": 0.4
},
"maxTokens": 200
}
}Обязательны только messages и maxTokens. Сообщения — с ролями user и assistant, содержимое может быть текстом, картинкой или аудио. systemPrompt — пожелание, клиент вправе его изменить или проигнорировать. temperature, stopSequences и metadata — тоже необязательные, и клиент тоже вправе их не учитывать. Единственное, что клиент обязан соблюсти, — maxTokens.
Отдельного внимания заслуживает modelPreferences. Сервер не может запросить модель по точному имени: у хоста может не быть такой модели или он работает с другим провайдером. Поэтому сервер описывает, что ему важно, тремя числами от 0 до 1 — costPriority, speedPriority, intelligencePriority — и даёт подсказки hints, которые клиент сопоставляет с именами доступных моделей как подстроки. Всё это рекомендации: окончательный выбор за клиентом.
Ответ содержит роль (assistant), содержимое, имя модели, которая его сгенерировала, и stopReason — почему генерация остановилась: endTurn, maxTokens, stopSequence или toolUse.
Последнее значение относится к расширению: сервер может передать в запросе tools, и тогда модель хоста может попросить вызвать инструмент, а сервер — выполнить его и продолжить диалог. Для этого клиент должен объявить подвозможность sampling.tools. В этом курсе мы её не используем.
Кто кого контролирует
Sampling — единственный случай, когда сервер тратит деньги и внимание пользователя. Поэтому спецификация требует, чтобы в цепочке был человек: приложение должно давать возможность посмотреть запрос до отправки, отредактировать его и увидеть результат до того, как он вернётся серверу. Как именно это реализовано в интерфейсе, спецификация не диктует, но клиент вправе отклонить запрос целиком.
Для сервера это означает две вещи. Нельзя рассчитывать, что sampling вернётся быстро или вернётся вообще. И нельзя класть в запрос ничего, что не должен увидеть пользователь хоста, — он его увидит.
Ещё сервер должен проверить, умеет ли клиент это вообще. Клиент объявляет возможность sampling в своих capabilities. Если сервер попытается запросить генерацию у клиента без такой возможности, в ревизии 2026-07-28 вызов инструмента завершится ошибкой -32021 (MissingRequiredClientCapability), которую сервер обязан вернуть клиенту.
Два способа доставки
Здесь начинается самая важная часть урока. В ревизиях до 2025-11-25 включительно сервер отправлял sampling/createMessage как настоящий JSON-RPC-запрос клиенту: по обратному каналу транспорта, с собственным id, и ждал ответа. Внутри инструмента это выглядело как await: сервер приостанавливался, пока клиент не вернёт результат.
В ревизии 2026-07-28 сервер потерял право отправлять клиенту запросы. Вместо этого появился паттерн многошаговых запросов (Multi Round-Trip Requests, MRTR). Сервер отвечает на tools/call не результатом, а результатом особого типа input_required, в котором лежит список того, что ему нужно от клиента:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"resultType": "input_required",
"inputRequests": {
"summary": {
"method": "sampling/createMessage",
"params": { "messages": [ ... ], "maxTokens": 200 }
}
},
"requestState": "<непрозрачная строка>"
}
}Клиент выполняет запрошенное (здесь — просит модель) и повторяет исходный tools/call с новым id, теми же аргументами, полем inputResponses с ответом модели и полем requestState, которое возвращает серверу байт в байт. Сервер обрабатывает повтор как совершенно независимый запрос: всё, что ему нужно помнить между двумя вызовами, он упаковал в requestState.
Зачем такое усложнение? Чтобы сервер за балансировщиком не должен был держать открытым соединение и ждать. Первый и второй вызовы могут попасть на разные экземпляры сервера, и это ничего не сломает. Подробнее — в уроке «Состояние и Streamable HTTP».
Для вас как автора сервера хорошая новость: SDK прячет разницу. Один и тот же код работает и с клиентом на 2025-11-25, и с клиентом на 2026-07-28.
Как это выглядит в Python SDK
В SDK v2 sampling делается через механизм зависимостей. Вы пишете обычную функцию-резолвер, которая возвращает маркер Sample(...), и объявляете параметр инструмента через Annotated[..., Resolve(...)]:
from typing import Annotated
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Sample
from mcp.types import CreateMessageResult, SamplingMessage, TextContent
mcp = MCPServer("ci-reporter")
def summarize_failure(build_id: int) -> Sample:
log = fetch_failure_log(build_id) # ваша функция
prompt = f"Опиши падение теста в двух предложениях для тикета:\n\n{log}"
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
max_tokens=200,
system_prompt="Ты пишешь краткие технические описания багов.",
)
@mcp.tool()
async def report_failure(
build_id: int,
summary: Annotated[CreateMessageResult, Resolve(summarize_failure)],
) -> str:
"""Подготовить описание упавшей сборки для тикета."""
if summary.content.type == "text":
return summary.content.text
return "Модель вернула не текст."Резолвер получает аргументы инструмента по именам (здесь build_id), а SDK перед вызовом самого инструмента исполняет резолвер, отправляет запрос клиенту тем способом, который поддерживает подключённый клиент, и подставляет CreateMessageResult в параметр summary. Параметр summary модель не видит — в схеме инструмента его нет.
Sample принимает те же поля, что и запрос спецификации, в змеином регистре: max_tokens (обязательный), system_prompt, temperature, stop_sequences, model_preferences, metadata, include_context, а также tools и tool_choice. Одно ограничение ревизии 2026-07-28: при повторе запроса резолвер должен построить точно такой же Sample, поэтому в промпт нельзя подмешивать время или случайные значения.
В старом коде вы встретите await ctx.session.create_message(...). Этот метод остался, но выдаёт предупреждение MCPDeprecationWarning и работает только с клиентами на ревизии 2025-11-25; на современном соединении он падает с ошибкой об отсутствии обратного канала.
Статус: устарело
С ревизии 2026-07-28 sampling помечен как устаревший (SEP-2577). Он останется в спецификации минимум до июля 2027 года, SDK его поддерживает, но новым серверам рекомендуют обращаться к API провайдера напрямую. Причина в том, что с появлением MRTR и элиситации серверы получили более предсказуемые способы диалога с пользователем, а у sampling всегда была проблема с тем, что качество результата зависит от неизвестной серверу модели.
Практический совет: если вы пишете сервер для одного хоста (например, только для Claude Code) и точно хотите использовать модель хоста, sampling через Resolve/Sample всё ещё работает. Если сервер публичный и должен жить годами, кладите в него прямой вызов API и договоритесь, откуда берётся ключ.
Попробуйте сами
10–15 мин на рабочем месте- Вспомните инструмент из своего сервера, который возвращает много сырого текста (лог, дифф, выгрузку). Опишите словами, какой промпт вы бы отправили через sampling, чтобы вернуть модели уже готовую сводку, и какой
maxTokensдля этого достаточен. - Прочитайте раздел Sampling спецификации и найдите, какие поля запроса клиент обязан соблюдать, а какие может изменить. Сверьте со своим списком из первого задания: что из него на самом деле не гарантировано.
- Откройте настройки хоста, которым пользуетесь (Claude Code, VS Code), и проверьте, показывает ли он запросы sampling пользователю на подтверждение.
Коротко
- Sampling — запрос сервера к клиенту «сгенерируй ответ моделью хоста»; ключ и выбор модели остаются у хоста.
- Метод
sampling/createMessage: обязательныmessagesиmaxTokens, остальное — пожелания, которые клиент может изменить. - Модель запрашивается не по имени, а через
modelPreferences: приоритеты цены, скорости, интеллекта и подсказки-подстроки. - В цепочке должен быть человек: клиент показывает запрос и результат, может отказать.
- До
2025-11-25это был запрос сервера по обратному каналу; с2026-07-28— результатinput_requiredи повтор вызова клиентом (MRTR). - В SDK v2: резолвер возвращает
Sample(...), параметр объявляется черезAnnotated[CreateMessageResult, Resolve(...)]. - Возможность устарела с
2026-07-28; для новых публичных серверов рекомендован прямой вызов API.
Видеоверсия
Сценарий озвучки · 533 слова, ≈ 4 мин
До сих пор общение в MCP шло в одну сторону: хост спрашивает, сервер отвечает. Сегодня разберём случай, когда сервер сам просит модель что-то написать. Это называется sampling.
Начнём с задачи. У нас есть сервер для системы непрерывной интеграции. У него инструмент: по номеру сборки найти упавший тест и его лог. Лог длинный, а в тикет нужно положить два человеческих предложения — что сломалось и где. Это работа для языковой модели. Можно положить в сервер ключ от API и вызывать модель напрямую. Но тогда сервер привязан к одному провайдеру, ключ нужно хранить и оплачивать, а пользователь не видит, что его логи куда-то уходят.
Sampling предлагает другой путь. Сервер отправляет клиенту запрос: вот сообщения, сгенерируй ответ. Клиент передаёт их той модели, которая уже есть у хоста, и возвращает результат. Ключ остаётся у хоста, выбор модели — у хоста, и хост может показать запрос человеку до отправки. А сервер не зависит от провайдера и работает в любом хосте.
Как выглядит запрос. Он очень похож на обычный запрос к чат-API: список сообщений с ролями, системный промпт, максимальное число токенов. Обязательны только сообщения и лимит токенов. Всё остальное — пожелания, которые клиент вправе изменить или проигнорировать. Интересно устроен выбор модели. Сервер не может попросить модель по точному имени, потому что у хоста её может не быть. Вместо этого он говорит, что ему важно: тремя числами от нуля до единицы задаёт приоритет цены, скорости и качества, а ещё даёт подсказки, например «клод». Клиент сопоставляет подсказки с тем, что у него есть, и выбирает сам.
Очень важный момент про контроль. Sampling — единственный случай, когда сервер тратит деньги и внимание пользователя. Поэтому спецификация требует, чтобы в цепочке был человек: приложение должно показать запрос до отправки, дать его отредактировать и показать результат. Клиент может отказать. Для автора сервера это значит: нельзя рассчитывать, что ответ придёт быстро или придёт вообще, и нельзя класть в запрос ничего, что пользователь не должен увидеть.
Теперь о том, что изменилось в последней ревизии протокола. Раньше сервер отправлял запрос клиенту как настоящий запрос по обратному каналу и ждал ответа. С ревизии июля две тысячи двадцать шестого сервер больше не может отправлять запросы клиенту. Вместо этого он отвечает на вызов инструмента особым результатом: «мне нужен ввод», и вкладывает туда свой запрос к модели. Клиент выполняет запрошенное и повторяет вызов инструмента, приложив ответ модели и непрозрачную строку состояния, которую сервер сам ему выдал. Сервер обрабатывает повтор как независимый запрос. Так сделано, чтобы сервер за балансировщиком не держал открытых соединений: первый и второй вызовы могут попасть на разные экземпляры, и ничего не сломается.
Хорошая новость: SDK прячет эту разницу. В Python SDK второй версии вы пишете обычную функцию, которая возвращает объект Sample с сообщениями и лимитом токенов, и объявляете параметр инструмента как зависимость от этой функции. SDK перед вызовом инструмента исполняет функцию, отправляет запрос клиенту тем способом, который тот поддерживает, и подставляет результат модели в параметр. Модель этот параметр не видит.
И последнее. Sampling с этой же ревизии помечен как устаревший. Он останется в спецификации ещё минимум год, SDK его поддерживает, но новым серверам рекомендуют вызывать API провайдера напрямую. Мой совет: для внутреннего сервера под один хост sampling ещё вполне годится, для публичного долгоживущего — лучше прямой вызов.
В следующем уроке построим сервер с sampling руками и посмотрим, что реально ходит между клиентом и сервером.
