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

Sampling на практике

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

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

Шаг 1. Проект

Создайте папку и установите SDK:

bash
mkdir ci-reporter && cd ci-reporter
uv init
uv add "mcp[cli]"

Проверьте, что установилась вторая версия: uv run mcp version должно вывести 2.x. Если видите 1.x, в pyproject.toml стоит ограничение <2 — уберите его.

Шаг 2. Сервер без sampling

Сначала напишем сервер, который просто отдаёт лог. Так мы убедимся, что каркас работает, до того как добавим сложную часть. Файл server.py:

python
import logging

from mcp.server import MCPServer

logger = logging.getLogger(__name__)
mcp = MCPServer("ci-reporter")

# Вместо реального CI — заглушка. В настоящем сервере здесь будет вызов API.
FAILURES: dict[int, str] = {
    4812: (
        "FAILED tests/test_checkout.py::test_apply_promo\n"
        "AssertionError: expected total 900, got 1000\n"
        "  at checkout/pricing.py:57 in apply_promo\n"
        "  promo 'SPRING10' loaded, discount=0"
    ),
    4813: (
        "FAILED tests/test_export.py::test_csv_header\n"
        "UnicodeEncodeError: 'ascii' codec can't encode character '\\u0451'\n"
        "  at export/csv_writer.py:23 in write_header"
    ),
}


def fetch_failure_log(build_id: int) -> str:
    log = FAILURES.get(build_id)
    if log is None:
        return "Сборка не найдена или завершилась успешно."
    return log


@mcp.tool()
def failure_log(build_id: int) -> str:
    """Вернуть сырой лог упавшего теста по номеру сборки."""
    logger.info("failure_log for build %s", build_id)
    return fetch_failure_log(build_id)


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

Запустите uv run mcp dev server.py, откройте Inspector в браузере и вызовите failure_log с build_id 4812. Должен вернуться лог. Обратите внимание: логгер пишет в stderr, а не в stdout, — почему это важно, разберём в уроке «Транспорт STDIO».

Шаг 3. Резолвер с Sample

Теперь добавим инструмент, который просит модель. Допишите в server.py импорты и второй инструмент:

python
from typing import Annotated

from mcp.server.mcpserver import Resolve, Sample
from mcp.types import CreateMessageResult, SamplingMessage, TextContent


def summarize_failure(build_id: int) -> Sample:
    """Резолвер: собирает запрос к модели из лога сборки."""
    log = fetch_failure_log(build_id)
    prompt = (
        "Ниже лог упавшего теста. Опиши проблему для тикета в двух предложениях: "
        "что сломалось и в каком файле. Без вступлений.\n\n"
        f"{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 "Модель вернула не текст, описание не составлено."
    return f"Сборка {build_id}. {summary.content.text}"

Что здесь происходит. Резолвер summarize_failure получает build_id — SDK сопоставляет параметры резолвера с аргументами инструмента по имени. Резолвер возвращает не значение, а маркер Sample: «мне нужно, чтобы клиент сгенерировал ответ на эти сообщения». SDK отправляет запрос клиенту, дожидается результата и кладёт его в summary. Сам инструмент получает уже готовый CreateMessageResult.

Если посмотреть схему инструмента через tools/list, в ней будет только build_id. Параметр summary для модели не существует.

Шаг 4. Клиент с подменой модели

Реальный хост будет спрашивать настоящую модель, а для проверки нам нужна предсказуемость. Напишем клиента, который подключается к серверу в том же процессе и отвечает на sampling заранее заготовленным текстом. Файл check.py:

python
import anyio

from mcp import Client
from mcp.client.session import ClientRequestContext
from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent

from server import mcp


async def fake_model(
    context: ClientRequestContext,
    params: CreateMessageRequestParams,
) -> CreateMessageResult:
    """Играет роль модели хоста: печатает, что попросил сервер, и отвечает заглушкой."""
    first = params.messages[0].content
    print("--- сервер попросил модель ---")
    print("system_prompt:", params.system_prompt)
    print("max_tokens:", params.max_tokens)
    if first.type == "text":
        print("prompt начинается с:", first.text[:60].replace("\n", " "), "...")
    return CreateMessageResult(
        role="assistant",
        content=TextContent(
            type="text",
            text=(
                "Тест test_apply_promo падает: промокод SPRING10 загружается, "
                "но скидка не применяется, итог 1000 вместо 900. "
                "Проблема в checkout/pricing.py, функция apply_promo."
            ),
        ),
        model="fake-model",
    )


async def main() -> None:
    async with Client(mcp, sampling_callback=fake_model) as client:
        tools = await client.list_tools()
        print("инструменты:", [t.name for t in tools.tools])
        print("схема report_failure:", tools.tools[1].input_schema["properties"])

        result = await client.call_tool("report_failure", {"build_id": 4812})
        print("--- результат инструмента ---")
        print(result.content[0].text)


anyio.run(main)

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

text
инструменты: ['failure_log', 'report_failure']
схема report_failure: {'build_id': {'title': 'Build Id', 'type': 'integer'}}
--- сервер попросил модель ---
system_prompt: Ты пишешь краткие технические описания багов на русском языке.
max_tokens: 200
prompt начинается с: Ниже лог упавшего теста. Опиши проблему для тикета в двух ...
--- результат инструмента ---
Сборка 4812. Тест test_apply_promo падает: промокод SPRING10 загружается, но скидка не применяется, итог 1000 вместо 900. Проблема в checkout/pricing.py, функция apply_promo.

Точный вид схемы (title, порядок ключей) может отличаться от версии к версии SDK; важно, что в ней один параметр — build_id.

Обратите внимание на порядок: сначала клиент получил запрос к модели, и только потом — результат инструмента. Функция report_failure не начала выполняться, пока SDK не получил ответ от fake_model.

Шаг 5. Что ушло по проводу

In-memory клиент вызывает сервер напрямую, без JSON-RPC-обёртки, поэтому «провода» здесь нет. Но полезно знать, как тот же обмен выглядит, когда клиент и сервер разделены транспортом.

С клиентом на ревизии 2026-07-28 первый tools/call вернёт результат типа input_required:

json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "summary": {
        "method": "sampling/createMessage",
        "params": {
          "messages": [{ "role": "user", "content": { "type": "text", "text": "Ниже лог..." } }],
          "systemPrompt": "Ты пишешь краткие технические описания багов на русском языке.",
          "maxTokens": 200
        }
      }
    },
    "requestState": "gAAAAB...<непрозрачно>"
  }
}

Клиент вызовет sampling_callback, а затем отправит второй tools/call с новым id, теми же аргументами и двумя дополнительными полями — inputResponses с результатом модели и requestState без изменений. Второй вызов сервер обрабатывает заново: резолвер выполняется ещё раз, строит тот же Sample, SDK видит, что ответ на него уже есть в inputResponses, и подставляет его. Именно поэтому резолвер обязан быть детерминированным: если бы промпт содержал текущее время, второй Sample не совпал бы с первым.

С клиентом на 2025-11-25 всё проще и старомоднее: сервер отправит клиенту запрос sampling/createMessage с собственным id по обратному каналу, дождётся ответа и завершит tools/call одним результатом. Ваш код одинаков в обоих случаях.

Шаг 6. Отсутствие возможности у клиента

Уберите sampling_callback=fake_model из конструктора Client и запустите снова. Вызов завершится ошибкой: клиент не объявил возможность sampling, и сервер отвечает JSON-RPC-ошибкой -32021. Обратите внимание на разницу с ошибкой инструмента: исключение внутри инструмента вернулось бы обычным результатом с is_error=True, а здесь ошибка протокольная, и клиент SDK поднимает исключение MCPError с этим кодом. Это нормальное поведение: сервер обязан проверить возможности клиента до того, как что-то у него просить. Если хотите, чтобы инструмент в таком случае возвращал сырой лог вместо ошибки, сделайте резолвер условным: пусть он принимает ctx: Context, проверяет ctx.client_capabilities и возвращает готовый CreateMessageResult с текстом лога вместо маркера Sample.

Шаг 7. Подключение к хосту

Чтобы попробовать с настоящей моделью, зарегистрируйте сервер в хосте, который поддерживает sampling (проверьте это в документации хоста — поддерживают не все). Для запуска хост будет использовать команду вроде uv run --directory /полный/путь/ci-reporter server.py. После подключения попросите ассистента: «Подготовь описание для тикета по сборке 4812». Хост должен показать вам запрос к модели на подтверждение — это та самая проверка человеком, о которой шла речь в прошлом уроке.

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

10–15 мин на рабочем месте
  1. Добавьте в Sample параметр model_preferences с подсказкой claude и высоким speed_priority. В fake_model распечатайте params.model_preferences, чтобы увидеть, как предпочтения доходят до клиента.
  2. Сделайте резолвер условным, как описано в шаге 6: без возможности sampling у клиента инструмент должен возвращать сырой лог с пометкой «описание не составлено».
  3. Напишите тест на pytest по образцу из документации SDK (фикстура с Client(mcp, raise_exceptions=True)), который проверяет, что результат report_failure начинается со слова «Сборка».

Коротко

  • Sampling в SDK v2 — это резолвер, возвращающий Sample(...), и параметр инструмента через Annotated[CreateMessageResult, Resolve(...)].
  • Резолвер получает аргументы инструмента по имени; его параметр в схему инструмента не попадает.
  • Для проверки используйте in-memory Client(mcp, sampling_callback=...) — колбэк играет роль модели хоста.
  • Порядок событий: запрос к модели, потом тело инструмента.
  • На современном протоколе резолвер выполняется дважды (первый вызов и повтор) и должен строить одинаковый Sample.
  • Без возможности sampling у клиента инструмент возвращает ошибку -32021; сервер должен это учитывать.

Видеоверсия

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

В этом уроке строим сервер с sampling руками. Идея простая: инструмент получает номер сборки в системе непрерывной интеграции, достаёт лог упавшего теста и просит модель хоста написать два предложения для тикета. Проверять будем без реального хоста, чтобы видеть каждый шаг.

Первый шаг — каркас. Создаём папку, ставим SDK второй версии и пишем сервер с одним простым инструментом, который просто возвращает лог по номеру сборки. Логи у нас в заглушке-словаре: две сборки, два падения. Запускаем сервер под Inspector, вызываем инструмент, видим лог. Каркас работает.

Второй шаг — добавляем sampling. Пишем функцию-резолвер. Она получает номер сборки, берёт лог, собирает промпт: «опиши проблему для тикета в двух предложениях» — и возвращает объект Sample с этим сообщением, лимитом в двести токенов и системным промптом. Потом объявляем инструмент, у которого два параметра: номер сборки и summary. Второй параметр помечен как зависимость от нашего резолвера. SDK перед вызовом инструмента исполнит резолвер, отправит запрос клиенту и подставит ответ модели в summary. Модель хоста этот параметр не видит: если посмотреть схему инструмента, там только номер сборки.

Третий шаг — проверка. Настоящий хост спросит настоящую модель, а нам нужна предсказуемость. Пишем клиента, который подключается к серверу прямо в том же процессе, а роль модели играет наша функция. Она печатает, что попросил сервер — системный промпт, лимит токенов, начало промпта — и возвращает заранее заготовленный текст описания. Запускаем. Сначала на экране появляется блок «сервер попросил модель» с параметрами запроса, потом — результат инструмента с нашим текстом. Порядок важен: тело инструмента не начало выполняться, пока клиент не ответил.

Четвёртый шаг — понять, что ходит по проводу. Клиент в одном процессе вызывает сервер напрямую, но если бы между ними был транспорт, обмен выглядел бы так. На современном протоколе первый вызов инструмента возвращает результат типа «нужен ввод», внутри которого лежит запрос к модели и непрозрачная строка состояния. Клиент вызывает нашу функцию-модель и повторяет вызов инструмента с новым идентификатором, теми же аргументами, ответом модели и той же строкой состояния. Сервер обрабатывает повтор заново: резолвер выполняется второй раз, строит такой же запрос, SDK видит, что ответ уже есть, и подставляет его. Отсюда правило: резолвер должен быть детерминированным. Никакого текущего времени в промпте. Со старым клиентом всё проще: сервер отправляет запрос по обратному каналу и ждёт. Ваш код одинаков в обоих случаях.

Пятый шаг — что будет, если клиент не умеет sampling. Убираем колбэк из клиента, запускаем снова. Инструмент возвращает ошибку с кодом минус тридцать две тысячи двадцать один: у клиента нет нужной возможности. Это правильное поведение, сервер обязан проверять возможности клиента. Если хотите деградировать мягко, сделайте резолвер условным: пусть он проверяет возможности клиента через контекст и без sampling возвращает сырой лог.

И последний шаг — подключение к настоящему хосту, который поддерживает sampling. Регистрируете сервер, просите ассистента подготовить описание по сборке, и хост показывает вам запрос к модели на подтверждение. Это та самая проверка человеком из прошлого урока.

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

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