Перейти до змісту

Залежності

Машинний переклад

Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.

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

Залежності — це параметри, які заповнюють ваші власні функції. Ви анотуєте параметр, указуєте функцію, а 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).
  • Усе, що сказано про запитання в інформаційному блоці вище, застосовується без змін: запит 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 або список кореневих каталогів; впроваджується сам результат.

Про стан, який сервер будує один раз під час запуску, і про те, як обробник до нього дістається, — сторінка Життєвий цикл.