К содержанию
Первый Коммит

Модуль 2

Инструменты — типы, схема и контракт с моделью

  • Схема инструмента
  • Типы аргументов
  • Проверка данных
  • Docstring как контракт
  • Структурированный результат
  • Параметры декоратора
  • Именование и гранулярность

В первом модуле мы написали инструмент из трёх строк и убедились, что он работает. Этого достаточно, чтобы запустить сервер, но недостаточно, чтобы им кто-то пользовался. Между «инструмент работает» и «модель применяет инструмент правильно» лежит целый слой, который начинающие обычно пропускают, а затем долго не понимают, почему нейросеть вызывает не ту функцию и подставляет не те аргументы.

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

В этом модуле контракт разбирается подробно: построение из обычной сигнатуры Python-функции, просмотр готового контракта, реакция на неверные данные, влияние описания на выбор инструмента и настройка параметрами декоратора. Числа и тексты ошибок в примерах — настоящие.

Отдельно отметим, что тонкую настройку валидации через Pydantic и продуманные тексты ошибок для модели мы разберём в модуле 7, ресурсы и промпты — в модуле 5, а живую модель, которая по этим контрактам выбирает инструменты, подключим в модуле 4. Пока работаем так же, как в первом модуле: с помощью своего клиента и отладчика.

#2.1 Схема инструмента: что на самом деле видит модель

Когда клиент подключается к серверу, он первым делом запрашивает список инструментов — тот самый запрос, который в истории Inspector помечен как tools/list. В ответ сервер присылает массив описаний, и каждое описание устроено одинаково.

Ниже приведён реальный ответ для инструмента menu_for_day из практики этого модуля, распечатанный целиком:

JSON
{
  "name": "menu_for_day",
  "description": "Вернуть меню столовой на указанный день недели.\n\n    Один вызов возвращает список блюд одного приёма пищи...",
  "inputSchema": {
    "type": "object",
    "properties": {
      "day": {
        "enum": ["понедельник", "вторник", "среда", "четверг", "пятница"],
        "title": "Day",
        "type": "string"
      },
      "meal": {
        "default": "обед",
        "enum": ["завтрак", "обед"],
        "title": "Meal",
        "type": "string"
      }
    },
    "required": ["day"],
    "title": "menu_for_dayArguments"
  },
  "outputSchema": {
    "properties": {
      "result": {
        "items": { "additionalProperties": true, "type": "object" },
        "title": "Result",
        "type": "array"
      }
    },
    "required": ["result"],
    "title": "menu_for_dayOutput",
    "type": "object"
  },
  "annotations": {
    "title": "Меню дня",
    "readOnlyHint": true,
    "openWorldHint": false
  }
}

Разберём поля, потому что каждое из них влияет на поведение модели.

Поле name — техническое имя, по которому инструмент вызывают. Модель укажет его в запросе tools/call, поэтому оно должно быть однозначным.

Поле description — текст на естественном языке. Сюда попадает строка документации вашей функции. Это единственное место, где разработчик обращается к модели словами, и от него зависит, будет ли инструмент выбран.

Поле inputSchema — описание аргументов на языке JSON Schema. Это общепринятый стандарт описания структуры данных: тип каждого поля, обязательность полей, допустимые значения. Библиотека строит его автоматически из аннотаций типов вашей функции, писать его вручную не нужно.

Поле outputSchema — описание того, что вернётся. Оно появляется не всегда; в разделе 2.6 разобрано, в каких случаях.

Поле annotations — необязательные подсказки для интерфейса хоста: например, что инструмент только читает данные и ничего не меняет. Им посвящён раздел 2.7.

Ключевая мысль: между вашей функцией и моделью стоит этот JSON, и ничего кроме него. Модель не запускает ваш код, не читает комментарии, не знает о переменной с прайсом внутри модуля. Всё, чего нет в схеме и в описании, для неё не существует.

#2.2 Смотрим схему

Схему нужно уметь читать, а не предполагать её содержимое. Способов два, и мы попробуем оба.

Примеры в разделе показаны на сервере столовой canteen_tools.py — вы напишете его в практике этого модуля, в задании 2.1. Пока его нет, пробуйте оба способа на сервере group_tools.py из практики первого модуля: инструменты там другие, но вывод устроен так же.

#Способ первый: свой клиент

Создайте файл show_schema.py:

Python
import asyncio
import importlib
import json
import sys

from mcp.client import Client

# Имя модуля сервера берём из аргумента командной строки, чтобы этим же скриптом
# смотреть на любой сервер: python show_schema.py canteen_tools
server = importlib.import_module(sys.argv[1]).mcp

async def main():
    async with Client(server) as client:
        listing = await client.list_tools()
        for tool in listing.tools:
            print("=" * 60)
            print("имя:", tool.name)
            first_line = (tool.description or "").strip().splitlines()[0:1]
            print("описание:", first_line[0] if first_line else "(описания нет!)")
            print("аргументы:", json.dumps(tool.input_schema, ensure_ascii=False, indent=2))
            print("результат:", json.dumps(tool.output_schema, ensure_ascii=False))


asyncio.run(main())

Сохраните код в файл show_schema.py в папке практики первого модуля, рядом с group_tools.py: клиент подключает сервер импортом, а Python ищет модули в папке запускаемого скрипта.

Как и check.py из задания 1.2, это клиент: сервер он поднимает в памяти сам, поэтому запускается командой python при активированном окружении, без mcp dev. Отличие от check.py одно — там сервер был вписан в код строкой from group_tools import mcp as server, а здесь его имя передаётся при запуске. Пишется оно без расширения .py, потому что это имя модуля, а не путь к файлу:

Терминал
python show_schema.py group_tools

Имя сервера в этом варианте обязательно: без него скрипт завершится ошибкой IndexError: list index out of range. В практике та же команда с именем canteen_tools покажет схемы сервера столовой.

Дальше понадобится не выводить схему целиком, а разбирать её. Схема — обычный словарь, и достаточно знать три ключа: properties перечисляет аргументы, required содержит список обязательных, а у каждого аргумента есть type. Ниже короткий разбор, который пригодится в практике.

Разбор ставится внутрь цикла for tool in listing.tools вместо строки, которая печатает аргументы целиком: переменная tool существует только внутри цикла, и за его пределами фрагмент завершится ошибкой NameError.

Код
schema = tool.input_schema
required = schema.get("required", [])
for arg_name, spec in schema.get("properties", {}).items():
    if "enum" in spec:
        kind = "одно из: " + ", ".join(str(v) for v in spec["enum"])
    elif "anyOf" in spec:
        kind = " или ".join(part.get("type", "?") for part in spec["anyOf"])
    elif spec.get("type") == "array":
        kind = "массив из " + spec.get("items", {}).get("type", "?")
    else:
        kind = spec.get("type", "?")
    mark = "обязательный" if arg_name in required else f"по умолчанию {spec.get('default')!r}"
    print(f"   {arg_name}: {kind}, {mark}")

Обратите внимание на имена полей в Python-коде: tool.input_schema и tool.output_schema — через подчёркивание. В самом JSON, который уходит по протоколу, те же поля называются inputSchema и outputSchema, в одно слово с большой буквой. Это то же расхождение, с которым мы столкнулись в первом модуле на structured_content и is_error: протокол называет поля в стиле JavaScript, Python-объекты — в стиле Python. Библиотека переводит одно в другое сама, но если код скопирован из этого курса и завершается ошибкой AttributeError, первым делом проверьте написание.

Чтобы увидеть тот же JSON, который уходит по протоколу, у любого объекта библиотеки вызывают метод model_dump и передают ему флаг псевдонимов. Строка ниже обращается к той же переменной tool, поэтому тоже ставится внутрь цикла:

Код
print(json.dumps(tool.model_dump(exclude_none=True, by_alias=True), ensure_ascii=False, indent=2))

Флаг by_alias переключает вывод на протокольные имена полей, а exclude_none убирает незаполненные поля, чтобы не читать десять строк со значением null.

#Способ второй: MCP Inspector

Отладчик из первого модуля показывает ту же схему в удобном для чтения виде. Запустите его командой mcp dev с именем файла сервера, подключитесь и откройте вкладку Tools — слева появится список инструментов, справа — детали выбранного.

Для menu_for_day Inspector показывает следующее:

  • заголовок с техническим именем инструмента menu_for_day, а рядом зелёный значок READ-ONLY — так отображается подсказка read only hint из аннотаций
  • полный текст описания из строки документации, слово в слово
  • форму ввода, построенную по схеме: поле Day помечено красной звёздочкой как обязательное, поле Meal — нет, и в нём подставлено значение по умолчанию обед
  • оба поля в виде выпадающих списков, а не текстовых, потому что в схеме у них стоит перечисление enum
  • кнопку Execute Tool, а после запуска — результат, разложенный на четыре отдельные карточки, по одной на каждое блюдо

Последний пункт важен, и к нему мы вернёмся в разделе 2.6: когда инструмент возвращает список, ответ состоит из нескольких блоков, а не из одного.

#2.3 Типы аргументов и во что они превращаются

Теперь самое главное: как из сигнатуры Python-функции получается описание аргументов. Правило одно — библиотека читает аннотации типов и переводит их в JSON Schema. Никакого отдельного языка описания учить не нужно, достаточно аккуратно расставить типы.

Разберём все типы, которые понадобятся в этом курсе. Для каждого типа сначала идёт объявление в Python, под ним — результат в схеме.

Целое число:

Python
def portions_left(cooked: int, sold: int) -> int:

В схеме: "cooked": { "type": "integer" }. Дробное значение сюда не пройдёт, об этом — следующий раздел.

Дробное число:

Python
def find_dishes(max_price: float) -> list[dict]:

В схеме: "max_price": { "type": "number" }. Целое число тоже подойдёт, потому что целое — частный случай числа.

Строка:

Python
def dish_info(dish: str) -> dict[str, float]:

В схеме: "dish": { "type": "string" }.

Булево значение:

Python
def order_total(dishes: list[str], student_card: bool) -> dict[str, float]:

В схеме: "student_card": { "type": "boolean" }.

Список однотипных значений:

Python
def order_total(dishes: list[str]) -> dict[str, float]:

В схеме появится вложенное описание элементов: "dishes": { "type": "array", "items": { "type": "string" } }. Точно так же работает list[int] и list[float]. Модель поймёт, что нужно прислать массив строк, а не строку с запятыми.

Значение по умолчанию:

Python
def menu_for_day(day: str, meal: str = "обед") -> list[dict]:

Аргумент со значением по умолчанию попадает в схему с полем default и исчезает из списка required. Это удобный способ сообщить модели, что параметр можно не заполнять, и указать, каким будет значение при его отсутствии.

Необязательный аргумент без разумного умолчания:

Python
def find_dishes(keyword: str, max_price: float | None = None) -> list[dict]:

Запись float | None означает «число или ничего». В схеме получится конструкция anyOf с двумя вариантами: number и null. Пользуйтесь ею, когда «не задано» и «задано нулём» различаются по смыслу. В нашем поиске блюд нулевая цена не имела бы смысла, а отсутствие ограничения — обычный случай.

Ограниченный набор значений:

Python
from typing import Literal

def menu_for_day(day: Literal["понедельник", "вторник", "среда"]) -> list[dict]:

Один из самых важных типов в списке. Literal перечисляет допустимые значения, и в схеме появляется enum. Эффект двойной: модель заранее знает полный список вариантов и не сочиняет своих, а библиотека отвергает всё, чего в списке нет, до входа в вашу функцию. Один Literal экономит десяток строк проверок и целый абзац объяснений в описании.

Если один и тот же набор значений нужен нескольким инструментам, дайте ему имя — это обычная переменная:

Python
Day = Literal["понедельник", "вторник", "среда", "четверг", "пятница"]


@mcp.tool()
def menu_for_day(day: Day) -> list[dict]:
    ...


@mcp.tool()
def reserve_lunch(student: str, day: Day, dishes: list[str]) -> dict[str, str]:
    ...

Схема при этом получается такой же, как при записи Literal в самой сигнатуре, зато набор дней описан в одном месте и не разойдётся между инструментами.

#2.4 Когда приходит не тот тип

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

Механизм следующий. Прежде чем вызвать вашу функцию, библиотека проверяет присланные аргументы по схеме. Проверку выполняет Pydantic — та самая библиотека, которая ставится вместе с SDK. Если проверка не прошла, ваш код не запускается, а клиенту возвращается результат с признаком ошибки и текстом, объясняющим причину.

Ниже приведены результаты реальных прогонов: два десятка вызовов к серверу столовой из практики этого модуля и столько же к серверу автосервиса из домашнего задания.

  • строка вместо числа: аргументу float отправили "70", и вызов прошёл успешно — Pydantic приводит строку к числу, если она похожа на число
  • дробное вместо целого: аргументу int отправили 2.7 и получили ошибку с текстом Input should be a valid integer, got a number with a fractional part; а значение 2.0 прошло, потому что дробная часть нулевая
  • буквы вместо числа: значение "два" даёт ошибку Input should be a valid integer, unable to parse string as an integer
  • строка вместо булева: "true" превращается в True, число 1 тоже превращается в True, а значение "да" даёт ошибку unable to interpret input — русские слова библиотека не понимает
  • пропущен обязательный аргумент: ошибка Field required с перечислением того, что прислали
  • лишний аргумент, которого нет в схеме: игнорируется без предупреждения, вызов проходит успешно
  • значение вне перечисления: для дня «суббота» получаем ошибку Input should be 'понедельник', 'вторник', 'среда', 'четверг' or 'пятница' — обратите внимание: в тексте ошибки перечислены допустимые значения, и модель может по нему исправиться
  • список, присланный строкой JSON: значение '["борщ", "компот"]' разбирается автоматически и работает как настоящий список
  • список, присланный обычной строкой: 'борщ, компот' даёт ошибку Input should be a valid list

Два пункта из этого списка заслуживают отдельного пояснения.

Первый — о строке JSON вместо списка. Это не случайность и не самостоятельное решение Pydantic: SDK специально пытается разобрать строку как JSON, если по схеме на этом месте ожидается не строка. Причина указана в исходниках SDK: некоторые клиенты с моделями присылают массивы и объекты завёрнутыми в строку. Механизм компенсирует чужую ошибку, но полагаться на него как на документированное поведение не стоит.

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

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

  • ошибка проверки схемы возникает до вашего кода, текст пишет Pydantic, и он на английском языке со ссылкой на документацию
  • ошибка внутри инструмента возникает в вашем коде, текст пишете вы, и он может быть на русском

Сравните два реальных текста из одного прогона. Первый — о несуществующем дне недели: Error executing tool menu_for_day: 1 validation error for menu_for_dayArguments day Input should be 'понедельник', 'вторник'... Второй — о несуществующем блюде, он сформирован нами: Error executing tool dish_info: Блюдо «шаурма» в меню не значится. Есть такие блюда: борщ, гречка отварная, каша овсяная...

Оба текста уйдут модели, и по обоим она может понять, что делать дальше. Но второй понятнее и содержит подсказку — список допустимых значений. Это и есть правильный стиль: сообщение об ошибке в MCP-сервере пишется для того, кто будет исправляться, а не для того, кто отлаживает код. Систематически этим займёмся в модуле 7.

#2.5 Docstring как контракт

Типы отвечают на вопрос «что можно прислать?». На вопрос «зачем это вызывать?» отвечает описание. И здесь начинается область, где проверок нет: библиотека не следит за качеством текста и не предупреждает о его недостатках.

Проверим на примере. Ниже приведён инструмент без строки документации:

Python
@mcp.tool()
def do_it(x: str) -> str:
    return f"сделано: {x}"

Сервер запускается, инструмент регистрируется, никаких предупреждений в терминале. Распечатка схемы показывает описание нулевой длины — пустую строку. Для модели это инструмент, о котором известно только имя do_it и наличие аргумента x. Вероятность вызова в нужный момент близка к нулю, а вероятность вызова в неподходящий момент, напротив, отлична от нуля.

Без предупреждений проходят и описания вроде «Проверяет данные», «Меню», «Данные». Формально описание есть, пользы от него нет. Такой сервер собран намеренно — файл weak_tools.py в практике — и к нему написан простой скрипт-линтер, который читает схемы и выдаёт замечания к коротким описаниям. На семи инструментах он выдал одиннадцать замечаний, и все они настоящие.

#Из чего состоит хорошее описание

Хорошее описание отвечает на пять вопросов, и все пять помещаются в три-четыре предложения:

  • что инструмент делает, одним предложением и глаголом в начале
  • когда его стоит применять, а когда не стоит
  • в каких единицах и в каком формате аргументы: рубли или копейки, метры или сантиметры, дата в формате ДД.ММ.ГГГГ
  • что вернётся: не «результат», а «список блюд с ценой и калорийностью, отсортированный по возрастанию цены»
  • как выглядят особые случаи: значение пустого ответа и поведение при отсутствии данных

Ниже приведено описание длиной примерно 540 символов из нашего сервера столовой:

Python
@mcp.tool()
def find_dishes(keyword: str = "", max_price: float | None = None,
                vegetarian: bool = False, limit: int = 10) -> list[dict]:
    """Найти блюда столовой по подстроке названия, цене и признаку вегетарианского.

    Все аргументы необязательны: без аргументов возвращается весь ассортимент.
    Аргумент keyword ищет подстроку в названии без учёта регистра, max_price
    ограничивает цену сверху в рублях, vegetarian равный true оставляет только
    вегетарианские блюда, limit ограничивает длину ответа (по умолчанию десять
    блюд). Возвращается список блюд с ценой и калорийностью, отсортированный по
    возрастанию цены; пустой список означает, что под условия не подошло ни одно
    блюдо.
    """

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

#Почему аргументы описываются в общем тексте

Логично было бы вынести описание каждого аргумента в отдельное поле схемы. Такая возможность есть, но она требует библиотеки Pydantic — тема модуля 7. Есть и промежуточный вариант, который начинающие пробуют первым: оформить аргументы разделом Args внутри строки документации, как принято в некоторых стилях Python-документации. Мы проверили: SDK этот раздел не разбирает, слово Args и вся разметка попадают в описание как обычный текст.

Существенного вреда в этом нет: модель читает описание целиком и понимает фразу «max_price ограничивает цену сверху в рублях». Только не рассчитывайте на то, что раздел Args превратится в структурированные подсказки на форме Inspector — он останется фрагментом текста.

#Эксперимент, который стоит поставить

Влияние описания на выбор инструмента станет наглядным в модуле 4, когда мы подключим живую модель: там можно будет спросить одно и то же у сервера с невнятными описаниями и у сервера с внятными, и сравнить, что она вызовет. Сейчас, без модели, поставьте более простой, но не менее полезный эксперимент.

Возьмите два инструмента с одинаковым смыслом и разными описаниями — например, get_data с описанием «Данные» и menu_for_day с полным описанием — и прочитайте оба так, будто вы видите этот сервер впервые и должны выбрать, что вызвать по просьбе «что сегодня на обед?». Затем ответьте себе: хватило ли информации, какие аргументы вы подставили бы в первый инструмент и откуда узнали бы формат. Если человек не может ответить по описанию, модель тоже не сможет: она читает тот же текст, только без вашей способности догадаться и переспросить.

#2.6 Что инструмент возвращает

Возвращаемое значение — вторая половина контракта. От аннотации возврата зависит, появится ли схема результата и попадёт ли ответ в поле structured_content, которое мы видели в первом модуле.

Напомним устройство ответа. В нём два представления одних и тех же данных:

  • content — массив блоков, обычно текстовых; это то, что читает модель
  • structured_content — структурированные данные, удобные для программ

Текст первого блока достают обращением result.content[0].text — эта запись понадобится в практике. Обращаться по индексу можно не всегда: блоков бывает и ноль, и несколько, а сколько именно — зависит от аннотации возврата, о чём речь ниже.

Дальше — результат при разных аннотациях. Все примеры проверены запуском.

Простой тип: str, int, float, bool. Значение оборачивается в словарь с единственным ключом result. Для функции, вернувшей строку «ИМТ = 22.9», structured_content будет равен result со значением этой строки. В content — один текстовый блок.

Словарь с указанными типами: dict[str, float] или dict[str, str]. Схема результата описывает объект с произвольными ключами нужного типа, а structured_content равен самому словарю, без обёртки result. Например, вызов dish_info вернул структуру с ключами цена, калории, белки, жиры, углеводы — в том виде, в каком её собрала функция. В content при этом находится один блок с тем же словарём, записанным как JSON с отступами.

Список: list[str] или list[dict]. Схема результата описывает массив, structured_content равен словарю с ключом result, внутри которого список. А content ведёт себя иначе, чем можно ожидать: каждый элемент списка становится отдельным блоком. Меню на среду из четырёх блюд дало четыре блока — поэтому Inspector и отобразил четыре карточки.

Пустой список — особый случай, о котором стоит знать заранее. Мы вызвали поиск блюд по слову, которого нет в меню, и получили ноль блоков в content и словарь с пустым списком в structured_content. Это не ошибка: is_error равен False. Но модель, которая читает только текстовые блоки, не получит ничего. Отсюда практический вывод: если пустой результат осмыслен, лучше вернуть не пустой список, а строку вида «Под условия не подошло ни одно блюдо» либо структуру с полем-пояснением. Отсутствие ответа модель истолкует произвольно.

Ничего не возвращает: аннотация None. Схема результата будет описывать поле result типа null. Так объявляют инструменты, которые выполняют действие, но не возвращают ответ. Лучше возвращать хотя бы подтверждение: модели нужно знать, что действие выполнено.

Код
is_error = True
Error executing tool reserve_lunch: 1 validation error for DictModel
сумма
  Input should be a valid string [type=string_type, input_value=120.0, input_type=float]

Ошибка эта коварна тем, что читается как проблема вызова: слова «Error executing tool» уводят искать причину в аргументах, а причина — в возвращённом словаре. Правило простое: если в ответе соседствуют текст и число, объявляйте dict[str, str] и приводите число к строке сами, либо опишите ответ точнее.

Если потребовать от библиотеки структурированный вывод там, где его собрать не из чего, она откажется. Параметр декоратора structured_output, равный True, на функции с голым dict завершает программу ошибкой сразу при импорте:

Код
mcp.server.mcpserver.exceptions.InvalidSignature: Function price_forced: return type <class 'dict'> is not serializable for structured output

Это лучший исход из возможных: ошибка видна в момент запуска, а не через месяц эксплуатации.

#Текст или структура: что выбрать

Разница между двумя представлениями не техническая, а смысловая. Текстовые блоки предназначены для чтения, структурированный результат — для обработки. Практические ориентиры:

  • если ответ — это готовая фраза для человека, возвращайте строку: «ИМТ = 22.9», «Заказ записан на вторник»
  • если ответ — набор чисел или полей, которые дальше пригодятся другим инструментам, возвращайте словарь с типами
  • если ответ — перечень однородных записей, возвращайте список словарей, но следите за его длиной
  • не возвращайте одно и то же дважды, склеивая числа в строку и добавляя их же отдельными полями: модель получит противоречивые сведения

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

#2.7 Параметры декоратора @mcp.tool()

До сих пор мы писали декоратор с пустыми скобками. У него есть параметры, и все они относятся к контракту. Полный список из версии 2.0.0: name, title, description, annotations, icons, meta, structured_output. Разберём те, что нужны на практике.

Параметр name задаёт техническое имя инструмента, если имя Python-функции не годится. Применяется редко: обычно проще переименовать функцию.

Параметр description задаёт описание в обход строки документации. Мы проверили: если он задан, docstring полностью игнорируется, в схему уходит текст из декоратора. Приём полезен, когда описание нужно собрать из переменных, но злоупотреблять им не стоит: описание рядом с кодом заметнее, чем описание в декораторе.

Параметр title — человекочитаемый заголовок, отдельное поле схемы. Оно не заменяет имя: имя остаётся техническим идентификатором, а title предназначен интерфейсу хоста, чтобы показать пользователю «Меню дня» вместо menu_for_day.

Параметр structured_output позволяет управлять структурированным результатом вручную. Значение False убирает схему результата, даже если аннотация возврата позволяет её построить; значение True требует её построить и завершает программу ошибкой InvalidSignature, если из аннотации это невозможно. По умолчанию параметр не задан, и библиотека решает сама по аннотации — в подавляющем большинстве случаев это правильное поведение, и менять его не нужно.

Параметр annotations — набор подсказок о характере инструмента. Он принимает объект ToolAnnotations, у которого пять полей:

  • title — заголовок для интерфейса, ещё один, независимый от параметра title у самого инструмента
  • read_only_hint — инструмент только читает и ничего не меняет
  • destructive_hint — инструмент может удалить или испортить данные
  • idempotent_hint — повторный вызов с теми же аргументами не изменит результат
  • open_world_hint — инструмент ходит во внешний мир, например в интернет

Объявляются они так:

Python
from mcp.types import ToolAnnotations


@mcp.tool(
    annotations=ToolAnnotations(title="Записать заказ", read_only_hint=False,
                                destructive_hint=False, idempotent_hint=True,
                                open_world_hint=False)
)
def reserve_lunch(student: str, day: Day, dishes: list[str]) -> dict[str, str]:
    ...

В Python поля пишутся через подчёркивание, а в JSON, который уходит по протоколу, они превращаются в readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Библиотека преобразует имена сама, достаточно писать в питоновском стиле.

Разберём, зачем это нужно. Хост читает подсказки и меняет поведение: инструмент, помеченный как читающий, можно вызывать без дополнительных вопросов, а перед изменяющим — запросить подтверждение у человека. Проверить эффект можно в Inspector: на menu_for_day с read only hint он отобразил зелёный значок READ-ONLY, а на reserve_lunch с idempotent hint — значок IDEMPOTENT. Заголовок из аннотаций при этом в шапке не появился: Inspector показывает техническое имя инструмента, а подсказки превращает в значки. Номер версии отладчика здесь не называем: отладчик выпускается отдельно от SDK и меняет облик окна с каждым обновлением, а какая версия окажется у вас — зависит от того, что вы поставили глобально в разделе 1.7 или что подтянул npx.

#2.8 Именование и гранулярность

Последний слой контракта — самый неформальный, и он чаще всего портит хорошие серверы. Модель выбирает инструмент из списка, а список видит целиком и сразу. Чем длиннее и однообразнее список, тем хуже выбор.

#Правила именования

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

Дальше — неформальные, но важные привычки:

  • имя строится как действие плюс объект: menu_for_day, order_total, reserve_lunch, find_dishes
  • стиль записи — строчные буквы через подчёркивание, как принято в Python; смешивать стили внутри одного сервера нельзя, это лишний повод для путаницы
  • имя должно читаться в отрыве от сервера: инструмент list вне контекста не значит ничего, а list_free_slots значит
  • избегайте общих слов, которые ничего не сообщают: do_it, process, handle, check
  • если серверов у пользователя несколько, а темы близкие, добавляйте различающую часть: canteen_menu и library_search перепутать труднее, чем два search

Отдельная ловушка — одинаковые имена. Если объявить два инструмента с одним именем, второй не заменит первого: библиотека выведет предупреждение «Tool already exists» и оставит зарегистрированным первый. Ошибки при запуске не будет, а поведение окажется не тем, которого вы ожидали, — особенно если функции находятся в разных частях файла.

#Гранулярность: сколько инструментов делать

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

Посмотрите на фрагмент нашего плохого сервера:

Python
@mcp.tool()
def get_menu_for_monday() -> str:
    """Меню на понедельник."""


@mcp.tool()
def get_menu_for_tuesday() -> str:
    """Меню на вторник."""


@mcp.tool()
def get_menu_for_wednesday() -> str:
    """Меню на среду."""

Пять учебных дней — пять инструментов, и это только обеды. С завтраками их станет десять. Между тем достаточно одного инструмента с двумя аргументами-перечислениями:

Python
@mcp.tool()
def menu_for_day(day: Day, meal: Literal["завтрак", "обед"] = "обед") -> list[dict]:

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

Обратная крайность тоже существует. Инструмент do_everything, который в зависимости от аргумента action с двадцатью значениями выполняет совершенно разные действия, ничем не лучше: описание такого инструмента невозможно написать внятно, а половина аргументов в каждом вызове оказывается лишней.

Ориентиры, которые работают на практике:

  • один инструмент — одна законченная операция, которую можно назвать глаголом и объяснить одним предложением
  • если разница между двумя инструментами укладывается в одно значение, это один инструмент с аргументом-перечислением
  • если у инструмента больше пяти-шести аргументов, скорее всего, внутри объединены две разные операции
  • если аргумент имеет смысл только при определённом значении другого аргумента, это тоже признак объединённых операций
  • десяток осмысленных инструментов лучше сорока мелких, как отмечалось в первом модуле; данные, которые нужны только для чтения, правильнее отдавать ресурсом, а это тема модуля 5

#2.9 Практика: сервер студенческой столовой

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

Работайте в отдельной папке, окружение создайте по инструкции раздела 1.5 первого модуля и установите mcp[cli]. Домен — столовая колледжа.

В каждом задании после шагов приведён готовый листинг файла. Наберите файл сами по шагам, а листинг откройте потом — для сверки.

#Задание 2.1 — сервер с аргументами разных типов

Шаг 1. Создайте файл canteen_tools.py, объявите сервер с именем «Столовая колледжа».

Шаг 2. Заведите два справочника обычными словарями Python: блюда с ценой, калорийностью и признаком вегетарианского, и меню по дням недели с разбивкой на завтрак и обед. Данных достаточно на пять учебных дней и десяток блюд.

Шаг 3. Объявите тип-перечисление для дня недели одной строкой: Day равно Literal с пятью учебными днями. Он понадобится двум инструментам.

Шаг 4. Реализуйте пять инструментов. Каждый — с полными аннотациями типов и описанием на три-четыре предложения по правилам раздела 2.5:

  • menu_for_day принимает день недели типа Day и приём пищи типа Literal с вариантами завтрак и обед (по умолчанию обед), возвращает list[dict] — список блюд с ценой, калорийностью и признаком вегетарианского
  • dish_info принимает название блюда строкой, возвращает dict[str, float] с ценой и пищевой ценностью; название сравнивайте без учёта регистра
  • order_total принимает список блюд типа list[str] и признак студенческого билета типа bool со значением по умолчанию False, возвращает dict[str, float] с суммой, скидкой, суммой к оплате и калорийностью
  • find_dishes принимает подстроку названия (по умолчанию пустая строка), максимальную цену типа float или None, признак вегетарианского блюда типа bool и ограничение длины ответа limit типа int со значением по умолчанию 10, возвращает отсортированный по цене list[dict]
  • reserve_lunch принимает фамилию студента, день типа Day и список блюд, записывает заказ в словарь в памяти сервера и возвращает dict[str, str] с подтверждением; если включаете в подтверждение сумму заказа, приведите её к строке — почему, объяснено в разделе 2.6

Шаг 5. В каждом инструменте проверьте бизнес-правила и бросайте ToolError с текстом на русском языке: пустой список блюд, неизвестное блюдо, пустая фамилия, неположительная максимальная цена. В текст ошибки о неизвестном блюде включите список допустимых названий — модель по нему исправится.

Шаг 6. Задайте подсказки: на четыре читающих инструмента — read only hint со значением True, на reserve_lunch — read only hint со значением False и idempotent hint со значением True, потому что повторная запись на тот же день заменяет прежнюю, а не добавляет вторую.

Готовый canteen_tools.py:

Python
from typing import Literal

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

mcp = MCPServer("Столовая колледжа")

Day = Literal["понедельник", "вторник", "среда", "четверг", "пятница"]

# Блюда: цена в рублях, калорийность порции и состав в граммах.
DISHES: dict[str, dict] = {
    "борщ": {"price": 95.0, "kcal": 210.0, "protein": 7.5, "fat": 8.0,
             "carbs": 24.0, "veg": False},
    "суп гороховый": {"price": 85.0, "kcal": 190.0, "protein": 9.0, "fat": 4.5,
                      "carbs": 28.0, "veg": True},
    "котлета куриная": {"price": 130.0, "kcal": 280.0, "protein": 22.0, "fat": 17.0,
                        "carbs": 9.0, "veg": False},
    "рыба запечённая": {"price": 160.0, "kcal": 240.0, "protein": 26.0, "fat": 12.0,
                        "carbs": 3.0, "veg": False},
    "пюре картофельное": {"price": 60.0, "kcal": 180.0, "protein": 3.0, "fat": 6.0,
                          "carbs": 27.0, "veg": True},
    "гречка отварная": {"price": 55.0, "kcal": 160.0, "protein": 6.0, "fat": 2.5,
                        "carbs": 30.0, "veg": True},
    "салат овощной": {"price": 70.0, "kcal": 90.0, "protein": 2.0, "fat": 5.0,
                      "carbs": 8.0, "veg": True},
    "сырники": {"price": 110.0, "kcal": 320.0, "protein": 15.0, "fat": 14.0,
                "carbs": 33.0, "veg": True},
    "каша овсяная": {"price": 50.0, "kcal": 150.0, "protein": 5.0, "fat": 4.0,
                     "carbs": 25.0, "veg": True},
    "компот": {"price": 35.0, "kcal": 80.0, "protein": 0.2, "fat": 0.0,
               "carbs": 20.0, "veg": True},
    "чай с лимоном": {"price": 25.0, "kcal": 30.0, "protein": 0.0, "fat": 0.0,
                      "carbs": 7.0, "veg": True},
}

MENU: dict[str, dict[str, list[str]]] = {
    "понедельник": {"завтрак": ["каша овсяная", "чай с лимоном"],
                    "обед": ["борщ", "котлета куриная", "пюре картофельное", "компот"]},
    "вторник": {"завтрак": ["сырники", "чай с лимоном"],
                "обед": ["суп гороховый", "рыба запечённая", "гречка отварная", "компот"]},
    "среда": {"завтрак": ["каша овсяная", "компот"],
              "обед": ["борщ", "рыба запечённая", "пюре картофельное", "салат овощной"]},
    "четверг": {"завтрак": ["сырники", "компот"],
                "обед": ["суп гороховый", "котлета куриная", "гречка отварная", "чай с лимоном"]},
    "пятница": {"завтрак": ["каша овсяная", "чай с лимоном"],
                "обед": ["борщ", "котлета куриная", "салат овощной", "компот"]},
}

# Предварительные заказы: (студент, день) -> список блюд. Живут до остановки сервера.
RESERVATIONS: dict[tuple[str, str], list[str]] = {}

STUDENT_DISCOUNT = 0.15


def dish_key(name: str) -> str:
    """Привести название блюда к виду, в котором оно лежит в справочнике."""
    key = name.strip().lower()
    if not key:
        raise ToolError("Название блюда не может быть пустым")
    if key not in DISHES:
        known = ", ".join(sorted(DISHES))
        raise ToolError(f"Блюдо «{name}» в меню не значится. Есть такие блюда: {known}")
    return key


@mcp.tool(
    annotations=ToolAnnotations(title="Меню дня", read_only_hint=True, open_world_hint=False)
)
def menu_for_day(day: Day, meal: Literal["завтрак", "обед"] = "обед") -> list[dict]:
    """Вернуть меню столовой на указанный день недели.

    Один вызов возвращает список блюд одного приёма пищи: для каждого блюда
    известны название, цена в рублях, калорийность порции и признак
    вегетарианского. Аргумент meal выбирает приём пищи, по умолчанию обед.
    Столовая работает с понедельника по пятницу, выходные не обслуживаются.
    """
    return [
        {
            "блюдо": name,
            "цена": DISHES[name]["price"],
            "калории": DISHES[name]["kcal"],
            "вегетарианское": DISHES[name]["veg"],
        }
        for name in MENU[day][meal]
    ]


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def dish_info(dish: str) -> dict[str, float]:
    """Вернуть цену и пищевую ценность одной порции блюда.

    Название блюда пишется так же, как в меню, регистр не важен: подойдёт и
    «борщ», и «Котлета куриная». Возвращаются цена в рублях, калорийность
    порции и содержание белков, жиров и углеводов в граммах.
    """
    card = DISHES[dish_key(dish)]
    return {
        "цена": card["price"],
        "калории": card["kcal"],
        "белки": card["protein"],
        "жиры": card["fat"],
        "углеводы": card["carbs"],
    }


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def order_total(dishes: list[str], student_card: bool = False) -> dict[str, float]:
    """Посчитать стоимость и калорийность заказа из нескольких блюд.

    Аргумент dishes — список названий блюд; одно и то же блюдо можно указать
    несколько раз, тогда оно посчитается несколько раз. Если student_card равен
    true, на сумму начисляется скидка по студенческому билету 15 процентов.
    Возвращаются сумма без скидки, размер скидки, сумма к оплате в рублях и
    суммарная калорийность заказа.
    """
    if not dishes:
        raise ToolError("Список блюд пуст: укажите хотя бы одно блюдо из меню")
    total = 0.0
    kcal = 0.0
    for name in dishes:
        card = DISHES[dish_key(name)]
        total += card["price"]
        kcal += card["kcal"]
    discount = round(total * STUDENT_DISCOUNT, 2) if student_card else 0.0
    return {
        "сумма": round(total, 2),
        "скидка": discount,
        "к_оплате": round(total - discount, 2),
        "калории": round(kcal, 1),
    }


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def find_dishes(keyword: str = "", max_price: float | None = None,
                vegetarian: bool = False, limit: int = 10) -> list[dict]:
    """Найти блюда столовой по подстроке названия, цене и признаку вегетарианского.

    Все аргументы необязательны: без аргументов возвращается весь ассортимент.
    Аргумент keyword ищет подстроку в названии без учёта регистра, max_price
    ограничивает цену сверху в рублях, vegetarian равный true оставляет только
    вегетарианские блюда, limit ограничивает длину ответа (по умолчанию десять
    блюд). Возвращается список блюд с ценой и калорийностью, отсортированный по
    возрастанию цены; пустой список означает, что под условия не подошло ни одно
    блюдо.
    """
    if max_price is not None and max_price <= 0:
        raise ToolError("Максимальная цена должна быть больше нуля")
    if limit < 1:
        raise ToolError("Ограничение на длину ответа должно быть не меньше единицы")
    needle = keyword.strip().lower()
    found = []
    for name, card in DISHES.items():
        if needle and needle not in name:
            continue
        if max_price is not None and card["price"] > max_price:
            continue
        if vegetarian and not card["veg"]:
            continue
        found.append({
            "блюдо": name,
            "цена": card["price"],
            "калории": card["kcal"],
            "вегетарианское": card["veg"],
        })
    return sorted(found, key=lambda item: item["цена"])[:limit]


@mcp.tool(
    annotations=ToolAnnotations(title="Записать заказ", read_only_hint=False,
                                destructive_hint=False, idempotent_hint=True,
                                open_world_hint=False)
)
def reserve_lunch(student: str, day: Day, dishes: list[str]) -> dict[str, str]:
    """Записать предварительный заказ студента на обед указанного дня.

    Заказ хранится в памяти сервера до его остановки. Повторная запись на тот
    же день заменяет прежний заказ, а не добавляет второй, поэтому вызов можно
    безопасно повторить. Возвращается подтверждение с суммой к оплате.
    """
    name = student.strip()
    if not name:
        raise ToolError("Укажите фамилию и имя студента")
    if not dishes:
        raise ToolError("Список блюд пуст: укажите хотя бы одно блюдо из меню")
    total = 0.0
    keys = []
    for item in dishes:
        key = dish_key(item)
        total += DISHES[key]["price"]
        keys.append(key)
    RESERVATIONS[(name, day)] = keys
    return {
        "студент": name,
        "день": day,
        "блюда": ", ".join(keys),
        "к_оплате": f"{total:.2f} руб.",
    }


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

#Задание 2.2 — увидеть схему глазами модели

Шаг 1. Создайте файл show_schema.py и напишите клиент по образцу из раздела 2.2: он подключается к серверу в памяти, получает список инструментов и печатает для каждого имя, первую строку описания, длину описания, список аргументов с типами, признаком обязательности и значением по умолчанию, а также схему результата.

Шаг 2. Для аргументов выводите тип в удобочитаемом виде: для перечисления — список допустимых значений, для массива — тип элементов, для необязательного аргумента с None — оба варианта.

Шаг 3. Запустите скрипт командой python show_schema.py canteen_tools и проверьте пять утверждений: у menu_for_day два перечисления; аргумент meal не входит в required и имеет значение по умолчанию; у order_total аргумент dishes объявлен массивом строк; у find_dishes аргумент max_price допускает null; у всех пяти инструментов есть схема результата.

Шаг 4. Допишите в конец функции main вывод полной схемы одного инструмента в протокольном виде — через model_dump с флагами exclude_none и by_alias — и сравните имена полей с теми, что вы использовали в Python-коде.

Готовый show_schema.py:

Python
"""Печатает схему каждого инструмента сервера — ровно то, что видит модель.

Запуск: python show_schema.py            (сервер столовой)
        python show_schema.py weak_tools (сервер с испорченным контрактом)
"""

import asyncio
import importlib
import json
import sys

from mcp.client import Client

module_name = sys.argv[1] if len(sys.argv) > 1 else "canteen_tools"
server = importlib.import_module(module_name).mcp


def describe_property(name: str, spec: dict, required: list[str]) -> str:
    """Собрать человекочитаемую строку про один аргумент."""
    kind = spec.get("type", "?")
    if "enum" in spec:
        kind = "одно из: " + ", ".join(str(value) for value in spec["enum"])
    if "anyOf" in spec:
        kind = " или ".join(part.get("type", "?") for part in spec["anyOf"])
    if kind == "array":
        kind = "array из " + spec.get("items", {}).get("type", "?")
    mark = "обязательный" if name in required else f"по умолчанию {spec.get('default')!r}"
    return f"    {name}: {kind} ({mark})"


async def main() -> None:
    async with Client(server) as client:
        listing = await client.list_tools()
        print(f"Инструментов на сервере: {len(listing.tools)}")
        for tool in listing.tools:
            print()
            print("=" * 72)
            print("имя:      ", tool.name)
            print("заголовок:", tool.title)
            first_line = (tool.description or "").strip().splitlines()[0:1]
            print("описание: ", first_line[0] if first_line else "(пусто!)")
            print("длина описания, символов:", len(tool.description or ""))

            schema = tool.input_schema
            required = schema.get("required", [])
            print("аргументы:")
            if not schema.get("properties"):
                print("    (нет)")
            for name, spec in schema.get("properties", {}).items():
                print(describe_property(name, spec, required))

            if tool.output_schema is None:
                print("схема результата: ОТСУТСТВУЕТ (structured_content будет пустым)")
            else:
                print("схема результата:",
                      json.dumps(tool.output_schema, ensure_ascii=False))

            if tool.annotations is not None:
                print("подсказки:",
                      json.dumps(tool.annotations.model_dump(exclude_none=True, by_alias=True),
                                 ensure_ascii=False))

        print()
        print("=" * 72)
        print("Схема menu_for_day целиком, как она уходит по протоколу:")
        for tool in listing.tools:
            if tool.name == "menu_for_day":
                print(json.dumps(tool.model_dump(exclude_none=True, by_alias=True),
                                 ensure_ascii=False, indent=2))


asyncio.run(main())

Обратите внимание: show_schema.py — не сервер, а клиент. MCPServer в нём не объявлен: скрипт импортирует canteen_tools.py и сам поднимает сервер в памяти, поэтому оба файла должны лежать в одной папке. Запускается клиент обычной командой Python из папки проекта при активированном виртуальном окружении — без mcp dev и без отладчика:

Терминал
python show_schema.py canteen_tools

Готовый вариант подставляет canteen_tools сам, если имя не указано, поэтому ему хватит и python show_schema.py. Вариант, написанный по минимальному образцу из раздела 2.2, без имени завершится ошибкой IndexError.

Чтобы посмотреть на другой сервер, передайте имя его модуля без расширения .py — это понадобится в задании 2.4:

Терминал
python show_schema.py weak_tools

Шаг 5. Запустите mcp dev canteen_tools.py, откройте вкладку Tools и выберите menu_for_day. Убедитесь, что форма построена по схеме: обязательное поле помечено звёздочкой, перечисления стали выпадающими списками, значение по умолчанию подставлено, а подсказка read only hint превратилась в значок. Вызовите инструмент и посмотрите, на сколько блоков разделился ответ.

#Задание 2.3 — проверка контракта на неверных данных

Шаг 1. Создайте файл type_probe.py со списком проверок: имя инструмента, словарь аргументов и короткий комментарий, что именно проверяется.

Шаг 2. Включите в список минимум двенадцать вызовов, среди них обязательно: корректный вызов; значение вне перечисления; пропущенный обязательный аргумент; строку вместо числа; дробное вместо целого; строку «да» вместо булева; список, присланный строкой JSON; список, присланный строкой с запятыми; лишний аргумент, которого нет в схеме; нарушение бизнес-правила внутри инструмента.

Шаг 3. Для каждого вызова печатайте признак ошибки, количество блоков в content, первый блок и структурированный результат.

Готовый type_probe.py:

Python
"""Проверка контракта: что происходит при верных и заведомо неверных аргументах.

Каждая строка отчёта — один вызов инструмента: признак ошибки, текстовый
результат и структурированный результат.

Запуск: python type_probe.py
"""

import asyncio

from mcp.client import Client

from canteen_tools import mcp as server

# Что пробуем: инструмент, аргументы, чего ждём.
PROBES = [
    ("menu_for_day", {"day": "среда"}, "по умолчанию обед"),
    ("menu_for_day", {"day": "среда", "meal": "завтрак"}, "явно указан завтрак"),
    ("menu_for_day", {"day": "суббота"}, "дня нет в наборе Literal"),
    ("menu_for_day", {}, "не передан обязательный аргумент"),
    ("dish_info", {"dish": "Борщ"}, "регистр не важен"),
    ("dish_info", {"dish": "шаурма"}, "блюда нет в справочнике"),
    ("order_total", {"dishes": ["борщ", "компот"]}, "обычный заказ"),
    ("order_total", {"dishes": ["борщ", "компот"], "student_card": True}, "со скидкой"),
    ("order_total", {"dishes": ["борщ"], "student_card": "true"}, "строка вместо булева"),
    ("order_total", {"dishes": ["борщ"], "student_card": "да"}, "непонятная строка"),
    ("order_total", {"dishes": '["борщ", "компот"]'}, "список прислан строкой JSON"),
    ("order_total", {"dishes": "борщ, компот"}, "список прислан обычной строкой"),
    ("order_total", {"dishes": []}, "пустой список"),
    ("order_total", {"dishes": ["борщ"], "student_card": True, "table": 7},
     "лишний аргумент, которого нет в схеме"),
    ("find_dishes", {}, "все аргументы по умолчанию"),
    ("find_dishes", {"max_price": "70", "vegetarian": True}, "число прислано строкой"),
    ("find_dishes", {"limit": 3}, "ограничение длины ответа"),
    ("find_dishes", {"limit": 3.5}, "дробное значение для аргумента int"),
    ("find_dishes", {"limit": 3.0}, "дробное с нулевой дробной частью для int"),
    ("find_dishes", {"limit": "два"}, "буквы вместо числа"),
    ("find_dishes", {"max_price": 0}, "нарушено правило внутри инструмента"),
    ("find_dishes", {"keyword": "шаверма"}, "ничего не найдено, но это не ошибка"),
    ("reserve_lunch", {"student": "Петров Илья", "day": "вторник",
                       "dishes": ["суп гороховый", "гречка отварная"]}, "запись заказа"),
    ("reserve_lunch", {"student": "  ", "day": "вторник", "dishes": ["борщ"]},
     "пустое имя студента"),
]


async def main() -> None:
    async with Client(server) as client:
        for name, arguments, comment in PROBES:
            result = await client.call_tool(name, arguments)
            print("-" * 72)
            print(f"{name}({arguments})  # {comment}")
            print(f"  is_error = {result.is_error}")
            print(f"  блоков в content: {len(result.content)}")
            texts = [getattr(block, "text", repr(block)) for block in result.content]
            preview = texts[0].replace("\n", " ") if texts else ""
            print(f"  content[0] = {preview[:150]}")
            print(f"  structured_content = {str(result.structured_content)[:150]}")


asyncio.run(main())

Это тоже клиент — запускается командой python, отладчик ему не нужен:

Терминал
python type_probe.py

Шаг 4. Запустите его для проверки. Попробуйте ответить на четыре вопроса: какие неверные типы библиотека исправляет без предупреждения, какие отвергает, что происходит с лишним аргументом и чем текст ошибки Pydantic отличается от вашего собственного текста.

Шаг 5. Найдите вызов, который дал ноль блоков в content, и объясните, почему для модели это плохой ответ и как его исправить.

#Задание 2.4 — плохой контракт и линтер

Шаг 1. Создайте файл weak_tools.py — второй сервер того же домена, но с намеренно испорченным контрактом: инструмент с описанием «Проверяет данные» и аргументами без аннотаций типов; инструмент без строки документации; инструмент с кириллическим именем; инструмент, возвращающий голый dict; три инструмента вида get_menu_for_monday, get_menu_for_tuesday, get_menu_for_wednesday.

Готовый weak_tools.py:

Python
"""Нарочно плохой сервер: те же возможности столовой, но с испорченным контрактом.

Нужен для сравнения с canteen_tools.py. Запускать его как сервер смысла нет —
он существует, чтобы линтер contract_lint.py нашёл в нём ошибки, а вы увидели,
как выглядит инструмент, который модель не сможет применить.
"""

from mcp.server import MCPServer

mcp = MCPServer("Столовая (плохой контракт)")


@mcp.tool()
def check(a, b):
    """Проверяет данные."""
    return f"{a} {b}"


@mcp.tool()
def get_data(day: str) -> dict:
    """Данные."""
    return {"меню": ["борщ", "котлета куриная"], "день": day}


@mcp.tool()
def do_it(x: str) -> str:
    return f"сделано: {x}"


@mcp.tool(name="меню на день")
def menu_ru(day: str) -> str:
    """Меню."""
    return f"меню на {day}"


@mcp.tool()
def get_menu_for_monday() -> str:
    """Меню на понедельник."""
    return "борщ, котлета куриная"


@mcp.tool()
def get_menu_for_tuesday() -> str:
    """Меню на вторник."""
    return "суп гороховый, рыба запечённая"


@mcp.tool()
def get_menu_for_wednesday() -> str:
    """Меню на среду."""
    return "борщ, рыба запечённая"

Этот сервер, в отличие от canteen_tools.py, не заканчивается вызовом mcp.run(): для работы его не запускают, а импортируют клиенты — линтер и скрипт из задания 2.2. Поэтому он не ждёт подключения, а печатает предупреждения библиотеки и сразу завершается — так и задумано:

Терминал
python weak_tools.py

Шаг 2. Запустите сервер и найдите в выводе терминала предупреждение библиотеки о недопустимых символах в имени. Запишите, какие символы разрешены.

Шаг 3. Посмотрите на этот сервер тем же скриптом из задания 2.2, передав ему имя модуля weak_tools. Отметьте, во что превратились аргументы без аннотаций и у каких инструментов пропала схема результата.

Шаг 4. Напишите файл contract_lint.py — скрипт, который читает только схемы (инструменты не вызывает) и печатает замечания: пустое описание, описание короче сорока символов, недопустимые символы в имени, отсутствие схемы результата, три и более инструмента с общим началом имени.

Готовый contract_lint.py:

Python
"""Проверка контракта сервера: что модель получит вместе со списком инструментов.

Скрипт не запускает инструменты, он читает только их схемы и ищет типичные
дефекты описания: пустой или слишком короткий docstring, имя не по стандарту,
аргумент без типа, отсутствующую схему результата и слишком мелкую нарезку.

Запуск: python contract_lint.py
"""

import asyncio
import string

from mcp.client import Client

import canteen_tools
import weak_tools

ALLOWED = set(string.ascii_letters + string.digits + "_-.")
MIN_DESCRIPTION = 40


async def lint(server, caption: str) -> None:
    print()
    print("#" * 72)
    print("#", caption)
    print("#" * 72)
    problems = 0
    async with Client(server) as client:
        listing = await client.list_tools()
        for tool in listing.tools:
            notes = []
            description = (tool.description or "").strip()
            if not description:
                notes.append("нет описания вообще: модель не поймёт, зачем инструмент")
            elif len(description) < MIN_DESCRIPTION:
                notes.append(f"описание короче {MIN_DESCRIPTION} символов: {description!r}")

            bad_chars = sorted({c for c in tool.name if c not in ALLOWED})
            if bad_chars:
                notes.append("недопустимые символы в имени: " + " ".join(bad_chars))

            for name, spec in tool.input_schema.get("properties", {}).items():
                if "type" not in spec and "anyOf" not in spec and "enum" not in spec:
                    notes.append(f"аргумент {name} без типа в схеме")

            if tool.output_schema is None:
                notes.append("нет схемы результата: structured_content будет пустым")

            if notes:
                problems += len(notes)
                print(f"[!] {tool.name}")
                for note in notes:
                    print("     -", note)
            else:
                print(f"[ok] {tool.name}")

        names = [tool.name for tool in listing.tools]
        prefixes = {}
        for name in names:
            head = name.rsplit("_", 1)[0]
            prefixes[head] = prefixes.get(head, 0) + 1
        for head, count in sorted(prefixes.items()):
            if count >= 3:
                problems += 1
                print(f"[!] {count} инструмента(ов) начинаются с «{head}_»: "
                      "похоже, это один инструмент с аргументом, а не три разных")
        if len(names) > 15:
            print(f"[!] инструментов {len(names)}: длинный список ухудшает выбор модели")

    print(f"Итого замечаний: {problems}")


async def main() -> None:
    await lint(canteen_tools.mcp, "canteen_tools.py — рабочий сервер")
    await lint(weak_tools.mcp, "weak_tools.py — сервер с испорченным контрактом")


asyncio.run(main())

Линтер — тоже клиент: он импортирует оба сервера и читает их схемы, поэтому запускается командой python из той же папки, где лежат canteen_tools.py и weak_tools.py:

Терминал
python contract_lint.py

Шаг 5. Запустите линтер на обоих серверах. На правильном сервере замечаний быть не должно; на плохом их будет около десятка.

Шаг 6. Исправьте weak_tools.py по замечаниям линтера: расставьте аннотации, напишите описания, переименуйте инструмент латиницей, укажите параметры типа у dict, а три инструмента с днями недели сверните в один с аргументом-перечислением. Добейтесь нуля замечаний и объясните письменно, какая из правок сильнее всего повлияет на поведение модели.

#Итоги модуля 2

  • Контракт инструмента — это всё, что сервер сообщает о функции: имя, описание, схема аргументов, схема результата и подсказки; модель не видит ваш код и принимает решения только по контракту
  • Ответ на запрос списка инструментов содержит поля name, description, inputSchema, outputSchema и annotations; схема аргументов записана на языке JSON Schema и строится автоматически из аннотаций типов функции
  • Схему нужно просматривать: своим клиентом через поля tool.input_schema и tool.output_schema либо в Inspector, который строит по ней форму ввода; в Python поля пишутся через подчёркивание, а в протокольном JSON — в стиле inputSchema, и метод model_dump с флагом by_alias показывает второй вариант
  • Типы аргументов переводятся напрямую: int даёт integer, float даёт number, str даёт string, bool даёт boolean, list[str] даёт массив строк, значение по умолчанию убирает аргумент из списка обязательных, запись с None даёт вариант anyOf
  • Literal превращается в перечисление enum: модель заранее знает допустимые значения, а библиотека отвергает всё остальное до входа в вашу функцию — это самый простой способ убрать половину проверок
  • Аргумент без аннотации типа объявляется в схеме строкой, но не проверяется: внутрь функции попадёт что угодно, поэтому аннотации обязательны всегда
  • Аргументы проверяются до вызова вашего кода: строка «70» станет числом, значение 2.7 для int будет отвергнуто, пропущенный обязательный аргумент даст ошибку, а лишний аргумент будет проигнорирован без предупреждения — последнее особенно опасно в отладке
  • Список, присланный строкой JSON, библиотека разбирает автоматически, потому что некоторые клиенты с моделями присылают массивы завёрнутыми в строку
  • Ошибки бывают двух видов: проверка схемы даёт английский текст от Pydantic, а исключение внутри инструмента — ваш собственный текст; оба уходят модели, поэтому свои сообщения пишут понятными и с подсказкой, что делать
  • Строка документации становится описанием инструмента слово в слово; пустой docstring даёт пустое описание без единого предупреждения, и такой инструмент модель не выберет
  • Хорошее описание отвечает на пять вопросов: что делает, когда применять, в каких единицах аргументы, что вернётся и что означает особый случай вроде пустого ответа
  • Тип возвращаемого значения определяет структурированный результат: простой тип оборачивается в поле result, dict с указанными типами отдаётся как есть, список даёт по отдельному текстовому блоку на элемент, а голый dict без параметров типа не даёт ни схемы результата, ни structured_content
  • Параметры декоратора name, title, description, annotations и structured_output настраивают контракт; annotations принимает ToolAnnotations с подсказками read only, destructive, idempotent и open world, которые Inspector показывает значками, но которые ничего не запрещают
  • Имя инструмента пишется латиницей по правилам спецификации, строится как действие плюс объект и должно быть понятно вне контекста; несколько инструментов с общим началом имени почти всегда означают один инструмент с пропущенным аргументом-перечислением

#Домашнее задание

Задания выполняются в папке, отдельной от практики. Сквозная тема — сервер записи в автосервис: работы, нормо-часы, межсервисный интервал и свободные окна на постах.

  • Создайте проект car-service с виртуальным окружением и установленным mcp[cli], объявите сервер с именем «Автосервис» и реализуйте инструмент work_price: принимает вид работы из ограниченного набора (замена масла, шиномонтаж, диагностика, замена колодок) и класс автомобиля (легковой по умолчанию, кроссовер, минивэн), возвращает словарь с нормо-часами, ставкой и стоимостью работы в рублях; убедитесь по распечатке схемы, что в ней появились два перечисления и один аргумент со значением по умолчанию
  • Реализуйте инструмент next_service: принимает текущий пробег в километрах, пробег на прошлом обслуживании (ноль по умолчанию) и межсервисный интервал (десять тысяч по умолчанию), возвращает пройденное после прошлого ТО расстояние, остаток до следующего и величину просрочки; на отрицательный пробег и на текущий пробег меньше прошлого бросайте ToolError с текстом, из которого понятно, как исправить вызов
  • Реализуйте инструмент estimate_repair: принимает список работ, класс автомобиля, стоимость запчастей (ноль по умолчанию) и признак дисконтной карты, возвращает смету со стоимостью работ, запчастей, скидкой и итогом; проследите, чтобы аннотация возврата давала схему результата, и проверьте это распечаткой
  • Реализуйте инструмент find_slots, возвращающий список свободных окон записи на указанный день недели, и инструмент book_slot, который занимает конкретное окно и хранит записи в памяти сервера; задайте читающим инструментам подсказку read only hint, а инструменту book_slot — подсказки, точно описывающие изменение данных, и объясните в файле README, почему подсказкам нельзя доверять как защите
  • Напишите клиент check.py, который печатает схемы всех инструментов в компактном виде (имя, аргументы с типами и умолчаниями, наличие схемы результата), а затем вызывает каждый инструмент минимум трижды: корректно, с неверным значением перечисления и с нарушением бизнес-правила; отдельной парой вызовов покажите, что после успешной записи занятое окно исчезает из списка свободных
  • (со звёздочкой) Проведите три эксперимента и запишите наблюдения в README: уберите у одного аргумента аннотацию типа и выясните, каким он станет в схеме и какие значения после этого дойдут до функции; замените у одного инструмента возврат dict с указанными типами на голый dict и посмотрите, что станет со схемой результата и с полем structured_content; добавьте тому же инструменту параметр декоратора structured_output, равный True, и зафиксируйте, что скажет библиотека и в какой момент это произойдёт

Войдите, чтобы отмечать пройденные модули и видеть прогресс по курсу.

Введите запрос: название модуля, тема или раздел.

Стрелки — выбор, Enter — открыть. Ищет по названиям модулей, темам и разделам.