Определяем инструменты
Каркас есть, теперь наполним его. Инструменты — примитив, который модель вызывает сама, поэтому от качества их описания зависит, будет ли она ими пользоваться правильно. В этом уроке добавим два инструмента, разберём, что SDK делает за нас, и проверим результат из клиента.
Первый инструмент
Добавьте в docs_server.py после словаря DOCS:
from typing import Annotated
from pydantic import Field
from mcp.server.mcpserver.exceptions import ToolError
DocName = Annotated[str, Field(description="Имя файла, например brief.md")]
@mcp.tool()
def read_doc(name: DocName) -> str:
"""Прочитать документ проекта целиком."""
if name not in DOCS:
raise ToolError(f"Документа {name!r} нет. Доступны: {', '.join(DOCS)}")
return DOCS[name]Импорты поставьте в начало файла, к остальным. Декоратор @mcp.tool() регистрирует функцию как инструмент. Всё описание, которое клиент получит в ответ на tools/list, SDK собирает из самой функции:
- имя — из имени функции:
read_doc; - описание — из docstring;
- схема аргументов — из аннотаций типов.
name: strстановится обязательным строковым полем.
Аннотации здесь — не документация, а контракт. SDK проверит входящие аргументы по схеме до вызова функции: если модель пришлёт число вместо строки или забудет параметр, ошибку получит она, а ваш код даже не запустится.
Annotated[str, Field(description=...)] добавляет описание параметра в схему. Без него модель увидит только тип и имя. Мы вынесли тип DocName в переменную, потому что он понадобится ещё нескольким функциям.
Второй инструмент
@mcp.tool()
def edit_doc(
name: DocName,
old_text: Annotated[str, Field(description="Точный фрагмент, который нужно заменить")],
new_text: Annotated[str, Field(description="Текст, который встанет на его место")],
) -> str:
"""Заменить фрагмент текста в документе проекта."""
if name not in DOCS:
raise ToolError(f"Документа {name!r} нет. Доступны: {', '.join(DOCS)}")
if old_text not in DOCS[name]:
raise ToolError(f"Фрагмент {old_text!r} в документе {name!r} не найден.")
DOCS[name] = DOCS[name].replace(old_text, new_text, 1)
logger.info("edit_doc: %s", name)
return f"Документ {name} обновлён."Интерфейс «замени старый фрагмент на новый» выбран не случайно. Можно было сделать edit_doc(name, new_content) и перезаписывать документ целиком, но тогда модели пришлось бы пересылать весь текст ради правки одного слова, и она бы неизбежно что-то потеряла. Точечная замена дешевле и безопаснее. Это общий принцип: интерфейс инструмента проектируется под то, как его будет вызывать модель, а не под то, как удобнее реализовать.
Ошибки: какие видит модель, а какие нет
В обоих инструментах мы поднимаем ToolError. Это не случайное исключение. SDK различает три ситуации:
ToolError— ошибка, которую модель может исправить. Запрос завершается успешно, но в результате стоит флагis_error, а текст исключения попадает в содержимое. Модель прочитает «Документаbrif.mdнет. Доступны: brief.md, estimate.md, risks.md», поймёт, что опечаталась, и вызовет инструмент снова. Поэтому в сообщении мы перечисляем доступные документы — это подсказка модели.MCPError(изmcp) — ошибка протокола: запрос отклоняется целиком, с кодом JSON-RPC. Для случаев, когда сам запрос некорректен и повторять его бессмысленно.- Любое другое исключение — авария. Модель получит только «Error executing tool read_doc», а полный traceback уйдёт в лог сервера. Не давайте
KeyErrorвылетать наружу: модель не поймёт, что делать.
Правило выбора: «Могла бы более внимательная модель избежать этой ошибки?» Если да — ToolError с понятным сообщением.
Что возвращает инструмент
Наши инструменты возвращают str. Клиент получит его как текстовый блок в content — то, что хост отдаст модели. Заодно SDK положит то же значение в structured_content в виде {"result": "..."}.
Можно возвращать dict, list, модель Pydantic — тогда structured_content будет типизированным JSON, а в content попадёт его текстовое представление. Для инструментов, которые отдают данные для программы, а не для чтения, это удобно. Для нашего сервера строк достаточно.
Функция может быть асинхронной (async def), если внутри нужно ждать сеть или базу. Обычные def SDK выполняет в пуле потоков, чтобы не блокировать сервер.
Подсказки о поведении
Хост не знает, опасен ли инструмент, пока вы ему не скажете. Для этого есть аннотации:
from mcp.types import ToolAnnotations
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def read_doc(name: DocName) -> str:
...
@mcp.tool(annotations=ToolAnnotations(destructive_hint=False, idempotent_hint=False))
def edit_doc(...) -> str:
...read_only_hint говорит хосту, что инструмент ничего не меняет, — такие вызовы хост может разрешать без вопросов. destructive_hint=False для edit_doc означает «меняет, но не разрушает необратимо». Это подсказки, а не гарантии: хост вправе их игнорировать, а сервер — соврать, так что доверие к серверу они не заменяют. Но честные аннотации делают работу с вашим сервером заметно приятнее. Добавьте их к обоим инструментам.
Проверяем из клиента
Обновите check.py, чтобы увидеть схему и вызвать инструменты:
import asyncio
from mcp import Client
from mcp_types import TextContent
from docs_server import mcp
def text_of(result) -> str:
return "\n".join(b.text for b in result.content if isinstance(b, TextContent))
async def main() -> None:
async with Client(mcp) as client:
for tool in (await client.list_tools()).tools:
print(tool.name, "—", tool.description)
print(" схема:", tool.input_schema)
result = await client.call_tool("read_doc", {"name": "brief.md"})
print("read_doc:", text_of(result))
result = await client.call_tool("read_doc", {"name": "brif.md"})
print("ошибка?", result.is_error, "|", text_of(result))
result = await client.call_tool(
"edit_doc",
{"name": "estimate.md", "old_text": "3 недели", "new_text": "4 недели"},
)
print("edit_doc:", text_of(result))
print(text_of(await client.call_tool("read_doc", {"name": "estimate.md"})))
asyncio.run(main())call_tool возвращает объект с полями content (список блоков), structured_content и is_error. Блоки могут быть разных типов — текст, картинка, встроенный ресурс, — поэтому мы отбираем только TextContent. Пакет mcp_types ставится вместе с mcp; те же типы доступны как mcp.types.
Запустите uv run check.py. В выводе будет схема с описаниями параметров, текст брифа, сообщение об ошибке с флагом True и обновлённая оценка. Обратите внимание: ошибка read_doc не уронила ни сервер, ни клиент — она вернулась как обычный результат.
Что важно в описаниях
Описание инструмента и параметров — единственное, что модель знает о нём. Несколько правил, проверенных на практике:
- Первое предложение — что делает инструмент. Второе — когда его использовать, если это не очевидно.
- Параметры описывайте с примером значения: «Имя файла, например brief.md» лучше, чем «Имя».
- Не дублируйте в описании то, что уже есть в типе.
Literal["draft", "final"]сам превратится в перечисление в схеме. - Если инструмент опасен, скажите это в описании прямо, а не только в аннотации.
Попробуйте сами
10–15 мин на рабочем месте- Добавьте инструмент
list_docs() -> list[str], который возвращает имена документов. Проверьте вcheck.py, чтоstructured_contentсодержит список, аcontent— его текстовое представление. - Добавьте инструмент
create_doc(name, content). Решите, что делать, если документ уже существует:ToolErrorили перезапись? Аргументируйте выбор описанием инструмента. - Замените в
read_docтип параметра наLiteral["brief.md", "estimate.md", "risks.md"]и посмотрите, как изменится схема. Подумайте, почему для настоящего хранилища так делать нельзя.
Коротко
@mcp.tool()превращает функцию в инструмент: имя из функции, описание из docstring, схема из аннотаций типов.Annotated[тип, Field(description=...)]даёт параметрам описания — без них модель видит только тип.ToolError— ошибка, которую модель увидит и сможет исправить; другие исключения — авария.- Интерфейс инструмента проектируется под вызов моделью: точечная замена вместо перезаписи целиком.
ToolAnnotationsподсказывают хосту, безопасен ли инструмент; это подсказки, а не гарантии.- Проверять инструменты удобно из
Client(mcp)в том же процессе.
Видеоверсия
Сценарий озвучки · 444 слова, ≈ 3 мин
Каркас есть, теперь наполним его инструментами. Инструменты — примитив, который модель вызывает сама, поэтому от качества их описания зависит, будет ли она пользоваться ими правильно.
Первый инструмент — прочитать документ по имени. Это обычная функция на Python с одним строковым параметром и докстрингом. Декоратор «тул» регистрирует её в сервере, и всё описание для клиента SDK собирает из самой функции. Имя — из имени функции. Описание — из докстринга. Схема аргументов — из аннотаций типов. Аннотации здесь не документация, а контракт: SDK проверит входящие аргументы до вызова функции. Если модель пришлёт число вместо строки, ошибку получит она, а ваш код даже не запустится.
Есть тонкость: из аннотации типа модель узнает только тип и имя параметра. Чтобы добавить описание, параметр оборачивается в «Annotated» с полем «Field» из Pydantic. «Имя файла, например бриф-точка-эм-дэ» — так модель поймёт, что от неё хотят.
Второй инструмент — заменить фрагмент текста в документе. Три параметра: имя, старый фрагмент, новый фрагмент. Почему не «перезаписать документ целиком»? Потому что тогда модели пришлось бы пересылать весь текст ради правки одного слова, и она бы что-нибудь потеряла. Точечная замена дешевле и безопаснее. Это общий принцип: интерфейс инструмента проектируется под то, как его будет вызывать модель, а не под то, как удобнее реализовать.
Теперь ошибки. Если документа нет, мы поднимаем специальное исключение «ToolError». Оно не роняет запрос: результат возвращается с флагом ошибки, а текст исключения попадает в содержимое. Модель прочитает «документа такого нет, доступны такие-то», поймёт, что опечаталась, и вызовет инструмент снова. Поэтому в сообщении мы перечисляем доступные документы — это подсказка модели. Любое другое исключение — авария: модель увидит только «ошибка выполнения инструмента», а подробности уйдут в лог. Правило выбора простое: могла бы более внимательная модель избежать этой ошибки? Если да — «ToolError» с понятным сообщением.
Что возвращает инструмент. Наши возвращают строку, и клиент получает её как текстовый блок. Можно возвращать словарь или модель Pydantic — тогда клиент получит ещё и типизированный джейсон. Функция может быть асинхронной, если внутри нужно ждать сеть.
Ещё одна деталь — подсказки о поведении. Хост не знает, опасен ли инструмент, пока вы не скажете. Аннотация «только чтение» говорит, что инструмент ничего не меняет, и хост может разрешать такие вызовы без вопросов. Это подсказки, а не гарантии, но честные аннотации делают работу с сервером приятнее.
Проверяем из клиента в том же процессе: печатаем схему каждого инструмента, читаем бриф, специально опечатываемся в имени и смотрим, как возвращается ошибка с флагом, потом правим оценку и перечитываем её. Ошибка не уронила ни сервер, ни клиент — она вернулась как обычный результат.
И напоследок о описаниях: они — единственное, что модель знает об инструменте. Первое предложение — что делает. Второе — когда использовать. Параметры — с примером значения. Если инструмент опасен, скажите это прямо.
В следующем уроке откроем инспектор — графический инструмент, в котором сервер можно потыкать руками и увидеть каждое сообщение протокола.
