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

Sampling: сервер обращается к модели

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

До сих пор поток был односторонним: хост спрашивает, сервер отвечает. Sampling переворачивает его: сервер в середине своей работы просит модель что-то написать, получает ответ и продолжает. К концу урока вы будете понимать, как устроен этот запрос, кто и что в нём контролирует, чем sampling отличается от «просто вызвать API Claude из сервера» и как он выглядит в Python SDK.

Зачем серверу модель

Представьте сервер для нашего CI: у него есть инструмент report_failure, который по номеру сборки находит упавший тест и его лог. Логи длинные и нечитаемые, а в тикет нужно положить два предложения по-человечески: что сломалось и на какой стадии. Это работа для языковой модели.

Первый вариант — положить в сервер SDK Claude и ключ API. Он работает, но у него три недостатка. Сервер начинает зависеть от конкретного провайдера. Ключ нужно где-то хранить и оплачивать отдельно. И пользователь хоста не видит, что от его имени куда-то уходят его логи.

Sampling (в спецификации так называют генерацию модели, от «взять выборку из распределения») предлагает второй вариант: сервер отправляет клиенту запрос «сгенерируй ответ на эти сообщения», клиент передаёт его модели, которая уже есть у хоста, и возвращает результат. Ключ живёт у хоста, выбор модели — у хоста, и хост может показать запрос человеку до отправки. Сервер остаётся независимым от провайдера: тот же код работает в Claude Code, в VS Code и в любом другом хосте, который поддерживает sampling.

Как выглядит запрос

Метод называется sampling/createMessage. Параметры устроены так же, как запрос к любому чат-API:

json
{
  "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, в котором лежит список того, что ему нужно от клиента:

json
{
  "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(...)]:

python
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 мин на рабочем месте
  1. Вспомните инструмент из своего сервера, который возвращает много сырого текста (лог, дифф, выгрузку). Опишите словами, какой промпт вы бы отправили через sampling, чтобы вернуть модели уже готовую сводку, и какой maxTokens для этого достаточен.
  2. Прочитайте раздел Sampling спецификации и найдите, какие поля запроса клиент обязан соблюдать, а какие может изменить. Сверьте со своим списком из первого задания: что из него на самом деле не гарантировано.
  3. Откройте настройки хоста, которым пользуетесь (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 руками и посмотрим, что реально ходит между клиентом и сервером.

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