Модуль 2
Инструменты — типы, схема и контракт с моделью
- Схема инструмента
- Типы аргументов
- Проверка данных
- Docstring как контракт
- Структурированный результат
- Параметры декоратора
- Именование и гранулярность
В первом модуле мы написали инструмент из трёх строк и убедились, что он работает. Этого достаточно, чтобы запустить сервер, но недостаточно, чтобы им кто-то пользовался. Между «инструмент работает» и «модель применяет инструмент правильно» лежит целый слой, который начинающие обычно пропускают, а затем долго не понимают, почему нейросеть вызывает не ту функцию и подставляет не те аргументы.
Этот слой называется контрактом инструмента. Контракт — это всё, что сервер сообщает о функции: её название, назначение, принимаемые аргументы, их типы, обязательность каждого из них и содержимое ответа. Модель не видит ваш код. Она видит только контракт и любое решение принимает по нему. Плохой контракт означает, что отличная функция не будет вызвана ни разу или будет вызвана с некорректными данными на входе.
В этом модуле контракт разбирается подробно: построение из обычной сигнатуры Python-функции, просмотр готового контракта, реакция на неверные данные, влияние описания на выбор инструмента и настройка параметрами декоратора. Числа и тексты ошибок в примерах — настоящие.
Отдельно отметим, что тонкую настройку валидации через Pydantic и продуманные тексты ошибок для модели мы разберём в модуле 7, ресурсы и промпты — в модуле 5, а живую модель, которая по этим контрактам выбирает инструменты, подключим в модуле 4. Пока работаем так же, как в первом модуле: с помощью своего клиента и отладчика.
#2.1 Схема инструмента: что на самом деле видит модель
Когда клиент подключается к серверу, он первым делом запрашивает список инструментов — тот самый запрос, который в истории Inspector помечен как tools/list. В ответ сервер присылает массив описаний, и каждое описание устроено одинаково.
Ниже приведён реальный ответ для инструмента menu_for_day из практики этого модуля, распечатанный целиком:
{
"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:
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, под ним — результат в схеме.
Целое число:
def portions_left(cooked: int, sold: int) -> int:В схеме: "cooked": { "type": "integer" }. Дробное значение сюда не пройдёт, об этом — следующий раздел.
Дробное число:
def find_dishes(max_price: float) -> list[dict]:В схеме: "max_price": { "type": "number" }. Целое число тоже подойдёт, потому что целое — частный случай числа.
Строка:
def dish_info(dish: str) -> dict[str, float]:В схеме: "dish": { "type": "string" }.
Булево значение:
def order_total(dishes: list[str], student_card: bool) -> dict[str, float]:В схеме: "student_card": { "type": "boolean" }.
Список однотипных значений:
def order_total(dishes: list[str]) -> dict[str, float]:В схеме появится вложенное описание элементов: "dishes": { "type": "array", "items": { "type": "string" } }. Точно так же работает list[int] и list[float]. Модель поймёт, что нужно прислать массив строк, а не строку с запятыми.
Значение по умолчанию:
def menu_for_day(day: str, meal: str = "обед") -> list[dict]:Аргумент со значением по умолчанию попадает в схему с полем default и исчезает из списка required. Это удобный способ сообщить модели, что параметр можно не заполнять, и указать, каким будет значение при его отсутствии.
Необязательный аргумент без разумного умолчания:
def find_dishes(keyword: str, max_price: float | None = None) -> list[dict]:Запись float | None означает «число или ничего». В схеме получится конструкция anyOf с двумя вариантами: number и null. Пользуйтесь ею, когда «не задано» и «задано нулём» различаются по смыслу. В нашем поиске блюд нулевая цена не имела бы смысла, а отсутствие ограничения — обычный случай.
Ограниченный набор значений:
from typing import Literal
def menu_for_day(day: Literal["понедельник", "вторник", "среда"]) -> list[dict]:Один из самых важных типов в списке. Literal перечисляет допустимые значения, и в схеме появляется enum. Эффект двойной: модель заранее знает полный список вариантов и не сочиняет своих, а библиотека отвергает всё, чего в списке нет, до входа в вашу функцию. Один Literal экономит десяток строк проверок и целый абзац объяснений в описании.
Если один и тот же набор значений нужен нескольким инструментам, дайте ему имя — это обычная переменная:
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 как контракт
Типы отвечают на вопрос «что можно прислать?». На вопрос «зачем это вызывать?» отвечает описание. И здесь начинается область, где проверок нет: библиотека не следит за качеством текста и не предупреждает о его недостатках.
Проверим на примере. Ниже приведён инструмент без строки документации:
@mcp.tool()
def do_it(x: str) -> str:
return f"сделано: {x}"Сервер запускается, инструмент регистрируется, никаких предупреждений в терминале. Распечатка схемы показывает описание нулевой длины — пустую строку. Для модели это инструмент, о котором известно только имя do_it и наличие аргумента x. Вероятность вызова в нужный момент близка к нулю, а вероятность вызова в неподходящий момент, напротив, отлична от нуля.
Без предупреждений проходят и описания вроде «Проверяет данные», «Меню», «Данные». Формально описание есть, пользы от него нет. Такой сервер собран намеренно — файл weak_tools.py в практике — и к нему написан простой скрипт-линтер, который читает схемы и выдаёт замечания к коротким описаниям. На семи инструментах он выдал одиннадцать замечаний, и все они настоящие.
#Из чего состоит хорошее описание
Хорошее описание отвечает на пять вопросов, и все пять помещаются в три-четыре предложения:
- что инструмент делает, одним предложением и глаголом в начале
- когда его стоит применять, а когда не стоит
- в каких единицах и в каком формате аргументы: рубли или копейки, метры или сантиметры, дата в формате ДД.ММ.ГГГГ
- что вернётся: не «результат», а «список блюд с ценой и калорийностью, отсортированный по возрастанию цены»
- как выглядят особые случаи: значение пустого ответа и поведение при отсутствии данных
Ниже приведено описание длиной примерно 540 символов из нашего сервера столовой:
@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 — инструмент ходит во внешний мир, например в интернет
Объявляются они так:
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» и оставит зарегистрированным первый. Ошибки при запуске не будет, а поведение окажется не тем, которого вы ожидали, — особенно если функции находятся в разных частях файла.
#Гранулярность: сколько инструментов делать
На этом вопросе ломается больше серверов, чем на любых типах. Соблазн очевиден: под каждую операцию — свой инструмент, так проще писать. Результат предсказуем: сорок инструментов, из которых модель уверенно выбирает три.
Посмотрите на фрагмент нашего плохого сервера:
@mcp.tool()
def get_menu_for_monday() -> str:
"""Меню на понедельник."""
@mcp.tool()
def get_menu_for_tuesday() -> str:
"""Меню на вторник."""
@mcp.tool()
def get_menu_for_wednesday() -> str:
"""Меню на среду."""Пять учебных дней — пять инструментов, и это только обеды. С завтраками их станет десять. Между тем достаточно одного инструмента с двумя аргументами-перечислениями:
@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:
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 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 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:
"""Нарочно плохой сервер: те же возможности столовой, но с испорченным контрактом.
Нужен для сравнения с 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:
"""Проверка контракта сервера: что модель получит вместе со списком инструментов.
Скрипт не запускает инструменты, он читает только их схемы и ищет типичные
дефекты описания: пустой или слишком короткий 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, и зафиксируйте, что скажет библиотека и в какой момент это произойдёт