Перейти к содержанию

Зависимости

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

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

Зависимости — это параметры, которые заполняют ваши собственные функции. Вы аннотируете параметр, указываете функцию, и SDK вызывает её до запуска инструмента.

Объявление зависимости

Оберните тип параметра в Annotated[...] и добавьте Resolve(fn):

server.py
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve

mcp = MCPServer("Bookshop")

INVENTORY = {"Dune": 7, "Neuromancer": 0}


class Stock(BaseModel):
    title: str
    copies: int


async def check_stock(title: str) -> Stock:
    return Stock(title=title, copies=INVENTORY.get(title, 0))


@mcp.tool()
async def reserve_book(title: str, stock: Annotated[Stock, Resolve(check_stock)]) -> str:
    """Reserve a copy of a book."""
    if stock.copies == 0:
        return f"{title!r} is out of stock."
    return f"Reserved {title!r} ({stock.copies - 1} copies left)."
  • check_stock — это резолвер: обычная функция, которую SDK запускает перед reserve_book; её возвращаемое значение становится аргументом stock.
  • Её параметр title — это собственный аргумент title инструмента, сопоставленный по имени. Резолвер видит ровно то же проверенное значение, что увидит тело инструмента.
  • Тело инструмента начинает с уже готового Stock. Никакого кода поиска в инструменте, никакой преамбулы «а что, если его нет».

Info

Если вы работали с FastAPI, это Depends. Тот же приём по той же причине: функция объявляет, что ей нужно, фреймворк это предоставляет, а вся связка живёт в аннотации типа.

Параметр, невидимый для модели

Вот входная схема, которую tools/list сообщает для reserve_book:

{
  "type": "object",
  "properties": {
    "title": {"title": "Title", "type": "string"}
  },
  "required": ["title"],
  "title": "reserve_bookArguments"
}

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

В последнем и весь смысл. Параметр, который модель не может передать, — это параметр, в котором модель не может ошибиться.

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

Запустите сервер с MCP Inspector:

uv run mcp dev server.py

В форме для reserve_book одно поле — title. Поля stock в ней нет нигде. Вызовите инструмент с Dune:

Reserved 'Dune' (6 copies left).

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

Tip

Можно было бы просто вызвать check_stock(title) в теле инструмента. Объявляйте зависимость, когда значение заслуживает большего, чем вызов вспомогательной функции: каждый инструмент, которому нужны остатки, объявляет один и тот же параметр, а SDK запускает резолвер не более одного раза за вызов, сколько бы потребителей его ни объявляли. Следующие разделы добавят остальное: резолверы, зависящие друг от друга, и резолверы, которые спрашивают пользователя.

Зависимости зависимостей

Резолвер может объявлять собственные зависимости той же аннотацией:

server.py
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve

mcp = MCPServer("Bookshop")

INVENTORY = {"Dune": 7, "Neuromancer": 0}


class Stock(BaseModel):
    title: str
    copies: int


async def check_stock(title: str) -> Stock:
    return Stock(title=title, copies=INVENTORY.get(title, 0))


async def estimate_delivery(stock: Annotated[Stock, Resolve(check_stock)]) -> str:
    return "tomorrow" if stock.copies > 0 else "in 2-3 weeks"


@mcp.tool()
async def order_book(
    title: str,
    stock: Annotated[Stock, Resolve(check_stock)],
    delivery: Annotated[str, Resolve(estimate_delivery)],
) -> str:
    """Order a book from the shop."""
    if stock.copies == 0:
        return f"{title!r} is on backorder; it would arrive {delivery}."
    return f"Ordered {title!r}; it arrives {delivery}."
  • estimate_delivery зависит от check_stock. SDK выполняет граф по порядку: сначала остатки, затем оценка, затем инструмент.
  • И stock, и delivery в конечном счёте нуждаются в check_stock, но он выполняется один раз за вызов. Один запрос к складу, два потребителя.
  • Регистрировать ничего не нужно. Граф — это и есть аннотации.

Check

Не принимайте «один раз за вызов» на веру. Поставьте print в check_stock и вызовите order_book из Inspector: одна строка на вызов. Два потребителя, один поиск.

SDK анализирует граф при регистрации инструмента, а не при его вызове. Параметр, который не удаётся классифицировать (не Context, не Resolve(...), не имя аргумента инструмента), и цикл резолверов одинаково выбрасывают InvalidSignature при запуске. Сервер падает ещё до того, как подключится первый клиент, и в ошибке назван виновный параметр или резолвер.

Параметры резолвера разрешаются точно так же, как параметры инструмента: другой Resolve(...), собственные аргументы инструмента по имени или Contextctx.headers, объект жизненного цикла (lifespan), всё это.

Warning

На HTTP-транспортах Context включает ctx.headers. Заголовки — это входные данные от клиента, как любой аргумент инструмента: годятся для локали или флага функции, но никогда — для установления личности. Кто именно вызывает, определяет слой авторизации (Авторизация), а не заголовок, который может выставить кто угодно.

Tip

Один раз за вызов означает ровно это: следующий tools/call снова запустит check_stock. Ресурсу, который должен пережить запрос (пул соединений с базой данных, HTTP-клиент), место на странице Жизненный цикл, а резолвер может добраться до него через ctx.request_context.lifespan_context.

Вопрос пользователю, когда без него нельзя

Резолвер не обязан знать ответ. Он может вернуть Elicit(message, Model), и SDK спросит пользователя — это механизм элицитации (elicitation) со страницы Элицитация, запущенный за вас:

server.py
from typing import Annotated

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver import Elicit, Resolve

mcp = MCPServer("Bookshop")

INVENTORY = {"Dune": 7, "Neuromancer": 0}


class Stock(BaseModel):
    title: str
    copies: int


class Backorder(BaseModel):
    confirm: bool = Field(description="Order anyway and wait?")


async def check_stock(title: str) -> Stock:
    return Stock(title=title, copies=INVENTORY.get(title, 0))


async def confirm_backorder(
    title: str,
    stock: Annotated[Stock, Resolve(check_stock)],
) -> Backorder | Elicit[Backorder]:
    if stock.copies > 0:
        return Backorder(confirm=True)  # in stock: nothing to ask
    return Elicit(f"{title!r} is out of stock (2-3 weeks). Order anyway?", Backorder)


@mcp.tool()
async def order_book(
    title: str,
    stock: Annotated[Stock, Resolve(check_stock)],
    backorder: Annotated[Backorder, Resolve(confirm_backorder)],
) -> str:
    """Order a book from the shop."""
    if not backorder.confirm:
        return "No order placed."
    if stock.copies == 0:
        return f"Backordered {title!r}; it ships in 2-3 weeks."
    return f"Ordered {title!r}."
  • Есть в наличии: confirm_backorder возвращает Backorder напрямую. Нет вопроса — нет лишнего раунда обмена. Пользователя отвлекают только тогда, когда его ответ на что-то влияет.
  • Нет в наличии: SDK отправляет элицитацию, проверяет ответ по Backorder и внедряет его. Резолвер вообще не касается протокола.
  • Инструмент читает backorder.confirm как любой другой аргумент. Ответ нет — тоже ответ: элицитация принимается с confirm=False, инструмент выполняется, и заказ не оформляется. Вопрос стал предусловием, а не служебным кодом в теле инструмента.

А если пользователь вообще не станет отвечать — отклонит вопрос или отменит его?

Check

Запустите order_book для Neuromancer и отклоните вопрос. С аннотацией в виде Annotated[Backorder, Resolve(...)] тело инструмента не выполняется вовсе; вызов завершается результатом-ошибкой, который модель может прочитать:

Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline

Это правильное поведение по умолчанию для предусловия: нет ответа — нет заказа. Когда отказ — это исход, который инструмент хочет обработать (пропустить дозаказ, но всё же предложить другую книгу), укажите в аннотации ElicitationResult[Backorder], и инструмент получит полный исход accept/decline/cancel, по которому можно ветвиться. Эту форму, как и всё остальное о том, как спрашивать: правила схемы, три варианта ответа, сторону клиента в этом разговоре, — показывает страница Элицитация.

Info

Фреймворк выбирает транспорт для вопроса по согласованной версии протокола; приведённый выше код одинаков в обоих случаях. На 2026-07-28 и новее вопрос передаётся внутри многораундового (multi-round-trip) tools/call: сервер возвращает его, elicitation_callback клиента отвечает, а Client повторяет вызов за вас (Многораундовые запросы). На 2025-11-25 и старше это синхронный запрос элицитации посреди вызова. Каждый вопрос задаётся ровно один раз за вызов — это гарантия о вопросе, а не о резолвере. В многораундовой форме любой резолвер может выполниться снова всякий раз, когда вызов возобновляется после вопроса, поэтому код перед return Elicit(...) выполняется в каждом таком раунде; записанный ответ затем закрывает повторный вопрос, не спрашивая пользователя заново. К записанному ответу обращаются только тогда, когда резолвер спрашивает; резолвер, который отвечает, не спрашивая, как check_stock, всегда поставляет собственное вычисленное значение. Поскольку каждый ответ сопоставляется со своим вопросом, резолвер с элицитацией должен выводить вопрос детерминированно из аргументов инструмента и предыдущих ответов. Значение, генерируемое заново при каждом вызове (идентификатор из default_factory, метка времени), пересчитывается в каждом раунде и не должно попадать в вопрос, к которому привязывается ответ. Вопрос, построенный на таких изменчивых данных, делает любой записанный ответ устаревшим на вид, и сервер задаёт его заново в каждом раунде, пока лимит раундов на стороне клиента не завершит вызов.

Вопрос клиенту, а не пользователю

Элицитация — один из трёх вопросов, которые может задать резолвер, и многораундовый поток других не допускает. Два других адресованы клиенту, а не пользователю: верните Sample(...), чтобы выполнить вызов LLM через клиент (запрос sampling/createMessage), или ListRoots(), чтобы получить текущие корневые каталоги (roots) клиента. Ни у одного из них нет исхода accept/decline; потребитель аннотирует тип результата напрямую: CreateMessageResult (CreateMessageResultWithTools, когда запрос несёт tools или tool_choice) или ListRootsResult:

server.py
from typing import Annotated

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

mcp = MCPServer("Bookshop")


def suggest_title(genre: str) -> Sample:
    prompt = f"Suggest one {genre} book title. Answer with the title only."
    return Sample(
        [SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
        max_tokens=50,
    )


@mcp.tool()
async def recommend_book(
    genre: str,
    suggestion: Annotated[CreateMessageResult, Resolve(suggest_title)],
) -> str:
    """Recommend a book in the given genre."""
    title = suggestion.content.text if suggestion.content.type == "text" else "the classics"
    return f"Today's {genre} pick: {title}"
  • Фреймворк маршрутизирует их точно так же, как Elicit: внутри многораундового tools/call на 2026-07-28, через отдельный запрос сервер->клиент на 2025-11-25. При необъявленной возможности вызов отклоняется с протокольной ошибкой -32021 (sampling, roots, elicitation в режиме формы; sampling.tools, когда запрос несёт tools или tool_choice).
  • Всё, что сказано о вопросах в блоке info выше, применимо без изменений: запрос Sample сопоставляется с записанным результатом по точному представлению, поэтому стройте его детерминированно из аргументов инструмента и предыдущих ответов; тогда клиент платит за вызов LLM один раз за вызов инструмента, а не один раз за раунд. Записанный результат передаётся в request_state до конца вызова, так что очень большой результат генерации утяжеляет каждый оставшийся раунд обмена.
  • Отдельные возможности сэмплирования (sampling) и корневых каталогов объявлены устаревшими в 2026-07-28 (SEP-2577). Новые серверы, которым нужна модель клиента, спрашивают через этот носитель; серверам, которым она не нужна, следует интегрироваться с провайдером LLM напрямую. Значения include_context, отличные от "none", сами объявлены устаревшими; избегайте их.

Итоги

  • Annotated[T, Resolve(fn)] у параметра инструмента: SDK запускает fn и внедряет её возвращаемое значение.
  • Разрешённый параметр невидим для модели, и клиент не может его передать. Значениям, которые модель не должна выдумывать, — ценам, данным о личности, правам доступа — место здесь.
  • Параметры резолвера разрешаются так же: Context, другой Resolve(...) или аргумент инструмента по имени. Граф запускает каждый резолвер не более одного раза за раунд, сколько бы потребителей у него ни было; каждый вопрос задаётся ровно один раз, и любой резолвер может выполниться снова, когда вызов возобновляется после вопроса.
  • Плохие графы падают при регистрации с InvalidSignature, а не посреди вызова.
  • Возвращайте Elicit(message, Model), чтобы спросить пользователя, — только когда иначе нельзя. Аннотации без обёртки прерывают вызов при отказе; ElicitationResult[T] позволяет инструменту ветвиться.
  • Возвращайте Sample(...) или ListRoots(), чтобы запросить у клиента генерацию LLM или список корневых каталогов; внедряется сам результат.

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