AmigaОбучение ИИ
Модуль 4 · Практика: пишем сервер · урок 7 из 14

Определяем инструменты

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

Каркас есть, теперь наполним его. Инструменты — примитив, который модель вызывает сама, поэтому от качества их описания зависит, будет ли она ими пользоваться правильно. В этом уроке добавим два инструмента, разберём, что SDK делает за нас, и проверим результат из клиента.

Первый инструмент

Добавьте в docs_server.py после словаря DOCS:

python
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 в переменную, потому что он понадобится ещё нескольким функциям.

Второй инструмент

python
@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 выполняет в пуле потоков, чтобы не блокировать сервер.

Подсказки о поведении

Хост не знает, опасен ли инструмент, пока вы ему не скажете. Для этого есть аннотации:

python
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, чтобы увидеть схему и вызвать инструменты:

python
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 мин на рабочем месте
  1. Добавьте инструмент list_docs() -> list[str], который возвращает имена документов. Проверьте в check.py, что structured_content содержит список, а content — его текстовое представление.
  2. Добавьте инструмент create_doc(name, content). Решите, что делать, если документ уже существует: ToolError или перезапись? Аргументируйте выбор описанием инструмента.
  3. Замените в 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 — тогда клиент получит ещё и типизированный джейсон. Функция может быть асинхронной, если внутри нужно ждать сеть.

Ещё одна деталь — подсказки о поведении. Хост не знает, опасен ли инструмент, пока вы не скажете. Аннотация «только чтение» говорит, что инструмент ничего не меняет, и хост может разрешать такие вызовы без вопросов. Это подсказки, а не гарантии, но честные аннотации делают работу с сервером приятнее.

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

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

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

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