Подсказки по кэшированию
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
В протоколе 2026-07-28 каждый результат, который сервер возвращает для tools/list, prompts/list, resources/list, resources/templates/list, resources/read и server/discover, несёт два поля: ttlMs — сколько миллисекунд клиент может считать результат свежим, и cacheScope — можно ли делить закэшированный результат между пользователями ("public") или он принадлежит одному контексту авторизации ("private").
Сам сервер ничего не кэширует. Эти поля — объявление: «этот список инструментов одинаков для всех и не изменится в ближайшую минуту». Клиент (или шлюз перед вашим сервером) может тогда обойтись без обращения к серверу. Учитывать подсказки или нет — решает клиент; выдавать их — задача сервера, и SDK делает это за вас.
По умолчанию каждый результат говорит ttlMs: 0, cacheScope: "private": сразу устаревший, никому не передаётся. Это всегда безопасно и всегда соответствует спецификации. Если ваши списки действительно стабильны и одинаковы для всех вызывающих, скажите об этом при создании сервера:
from mcp.server import CacheHint, MCPServer
mcp = MCPServer(
"Weather",
cache_hints={
"tools/list": CacheHint(ttl_ms=60_000, scope="public"),
"resources/read": CacheHint(ttl_ms=5_000),
},
)
@mcp.tool()
def forecast(city: str) -> str:
return f"Sunny in {city}"
@mcp.resource("config://units")
def units() -> str:
return "metric"
- Ключи словаря — имена методов, и шесть кэшируемых методов — единственные допустимые ключи. Параметр имеет тип
Mapping[CacheableMethod, CacheHint], поэтому редактор подсказывает ключи автодополнением и отмечает опечатку ещё до запуска; всё, что проскользнёт мимо проверки типов, выбрасывает исключение при создании. - Метод, который вы не упомянули, сохраняет значения по умолчанию. Словарь — это набор переопределений, а не полный перечень.
CacheHint(ttl_ms=5_000)оставилscopeнезаданным, поэтому он остаётся"private": пять секунд свежести, отдельно для каждого вызывающего. Область и TTL — независимые решения."server/discover"— тоже допустимый ключ, поскольку результат обнаружения кэшируется так же, как любой список.
Warning
cacheScope: "public" означает, что ваш закэшированный ответ могут отдать кому угодно.
Общий шлюз охотно передаст результат одного пользователя другому, даже если запрос был
аутентифицирован. Помечайте результат как "public", только если он одинаков для каждого
вызывающего, и никогда не используйте cacheScope для управления доступом: это метка, а не замок.
Переопределение в обработчике
В низкоуровневом классе Server обработчики собирают результаты вручную, а ttl_ms и cache_scope — это просто поля моделей результата. Обработчик, который задаёт их явно, всегда берёт верх над словарём из конструктора, поле за полем:
from typing import Any
from mcp.server import CacheHint, Server, ServerRequestContext
from mcp.types import ListToolsResult, PaginatedRequestParams, Tool
TOOLS = [Tool(name="forecast", input_schema={"type": "object"})]
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=TOOLS, ttl_ms=1_000)
server = Server(
"Weather",
on_list_tools=list_tools,
cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")},
)
Обработчик указал ttl_ms=1_000 и ничего про область. В передаваемых данных: ttlMs: 1000 (значение обработчика, а не 60_000 из словаря) и cacheScope: "public" (из словаря, потому что обработчик его не задал). Явное значение важнее настроенного, а настроенное важнее значения по умолчанию. Это действует для каждого поля отдельно, так что обработчик может зафиксировать одно поле, а другое оставить общесерверной политике.
Это же и выход для динамики, о которой конструктор знать не может: обработчик, фильтрующий resources/read по пользователю, может вернуть cache_scope="private" для одного URI на сервере, где всё остальное публично.
Одна оговорка о постраничных списках: протокол требует одинакового cacheScope на каждой странице одного списка. Словарь конструктора выполняет это по построению, поскольку его ключи — методы, а не страницы. Но обработчик, переопределяющий область сам, сам же и отвечает за согласованность: переопределяйте её на каждой странице, а не только когда есть курсор, иначе первая и вторая страницы разойдутся.
Что видит клиент
В сессии 2026-07-28 Client учитывает подсказки за вас: у него есть встроенный кэш ответов, включённый по умолчанию. Результат, пришедший с ttlMs, сохраняется, и идентичный вызов в пределах этого TTL обслуживается из кэша без обращения к серверу. Результат без подсказки не кэшируется: результаты без подсказок получают CacheConfig.default_ttl_ms, по умолчанию равный 0 (сразу устаревший), так что сервер, ничего не объявляющий, видит ровно тот же трафик — вызов за вызовом, — что и всегда.
from dataclasses import dataclass
from typing import Any
from mcp import Client
from mcp.client import CacheConfig
from mcp.server import CacheHint, Server, ServerRequestContext
from mcp.types import ListToolsResult, PaginatedRequestParams, Tool
@dataclass
class DemoState:
fetches: int = 0
now: float = 1_000_000.0
state = DemoState()
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
state.fetches += 1
return ListToolsResult(tools=[Tool(name="forecast", input_schema={"type": "object"})])
server = Server(
"Weather",
on_list_tools=list_tools,
cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")},
)
async def main() -> None:
start = state.fetches
async with Client(server, cache=CacheConfig(clock=lambda: state.now)) as client:
await client.list_tools() # fetch 1
await client.list_tools() # fresh for 60s: served from the cache
state.now += 60.0
await client.list_tools() # the TTL ran out: fetch 2
await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3
print(f"4 calls, {state.fetches - start} fetches")
Четыре вызова, три обращения к серверу. Второй вызов нашёл свежую запись и до сервера не дошёл; перевод (внедрённых) часов за пределы TTL заставил третий снова обратиться к серверу; четвёртый указал cache_mode="refresh". Этот именованный аргумент есть у пяти кэширующих методов (list_tools, list_prompts, list_resources, list_resource_templates, read_resource):
"use"(по умолчанию) отдаёт свежую запись, если она есть, а если нет — запрашивает и сохраняет результат."refresh"никогда не отдаёт из кэша: запрашивает и сохраняет результат, заменяя то, что было закэшировано."bypass"обращается к серверу, вообще не трогая кэш: ни чтения, ни записи.
Над "use" стоит одно правило: вызовы с meta всегда доходят до сервера. Запрос с заданным meta (токен прогресса, поля трассировки) рассчитывает на настоящий сетевой запрос, поэтому при cache_mode="use" он обрабатывается как "refresh": чтение из кэша пропускается, а полученный результат всё равно заменяет закэшированную запись. "bypass" и явный "refresh" ведут себя как обычно.
Чтобы совсем выключить кэширование, создайте клиент как Client(server, cache=None): каждый вызов снова обращается к серверу, а cache_mode, хотя и принимается, ничего не делает.
Область тоже учитывается автоматически: записи "private" привязаны к разделу (partition) кэша (о нём ниже), тогда как записи "public" могут быть разделены шире. И уведомления важнее TTL для ровно тех записей, которые они называют: уведомление list_changed вытесняет соответствующий закэшированный список, а resources/updated вытесняет закэшированное чтение, сохранённое ровно под его URI, какими бы свежими они ни были. На подключении 2026-07-28 эти уведомления приходят по потоку subscriptions/listen, который открывается через client.listen(...), и вытеснение завершается раньше, чем наблюдатель увидит событие; подробнее — на странице Подписки.
Одна оговорка о resources/updated: вытеснение работает только по точному URI. В контракте хранилища нет операции перечисления или сканирования (как и в эталонной реализации на TypeScript), поэтому уведомление с URI подресурса не вытесняет закэшированное чтение его родителя. Если ваш сервер сигнализирует о подресурсах таким образом, перечитайте родителя с cache_mode="refresh".
Настройка: CacheConfig
from mcp.client import CacheConfig
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
store: где живут записи. По умолчанию — свежее хранилище в памяти для каждого клиента; передайте собственную реализациюResponseCacheStore(скажем, на Redis), чтобы разделять кэш между клиентами или процессами. Типы контракта (ResponseCacheStore,CacheKey,CacheEntryи стандартныйInMemoryResponseCacheStore) импортируются изmcp.client. Один поиск может выполнить до двух последовательныхgetк хранилищу (сначала приватная ветвь, затем публичная), так что рассчитывайте ожидания по задержке удалённого хранилища соответственно. Собственное хранилище требует явногоpartition.partition: метка контекста авторизации, не позволяющая отдать записи"private"одного принципала другому в общем хранилище.target_id: явная идентичность сервера, для собственных транспортов и внутрипроцессных серверов (ниже).default_ttl_ms: TTL, применяемый к результатам без подсказкиttlMs. Значение по умолчанию0оставляет результаты без подсказок незакэшированными.share_public: отдавать записи, которые сервер объявил"public", между разделами (ниже). По умолчанию выключено.clock: источник настенного времени, в секундах эпохи. Внедрите его, как в примере выше, и тестам на истечение не придётся спать.
Раздел = проверенный принципал
Выводите partition из проверенного удостоверения, например из субъекта валидированного токена. Никогда не выводите его из данных, пришедших в запросе, и никогда — из URL сервера (идентичность сервера — отдельная ось ключа). SDK — это библиотека без собственной аутентификации: якорь доверия — тот, кто создаёт CacheConfig, то есть развёртывание, а не арендатор. Мультиарендный шлюз создаёт по одному CacheConfig на каждого аутентифицированного принципала.
Раздел также фиксирован на всё время жизни Client. Если контекст авторизации подключения меняется посреди сессии (скажем, повторная аутентификация под другим принципалом), кэш за этим не следует; создайте новый Client для нового принципала.
Ключи кэша также несут идентичность сервера: строку URL, по которой вы подключились, с убранным userinfo вида user:pass@, а в остальном байт в байт. Никакого приведения регистра, никакой перестановки параметров запроса, никакой чистки завершающего слэша. Недостаточная нормализация стоит лишь совместного использования, тогда как избыточная могла бы слить двух арендаторов (?tenant=a и ?tenant=b), поэтому внешне разные URL просто не делят записи. Когда URL нет (внутрипроцессный сервер или экземпляр Transport), клиент вместо этого получает случайную идентичность на экземпляр; задайте CacheConfig.target_id, чтобы назвать сервер (с собственным хранилищем это обязательно, и создание об этом сообщит). Идентичность хешируется sha256 прежде, чем попасть в материал ключа, так что URL с секретами в строке запроса никогда не появляется в ключах хранилища. И сами не пишите в лог форму до хеширования.
share_public доверяет серверу — для всего парка клиентов
По умолчанию даже записи "public" остаются в пределах своего раздела. share_public=True отдаёт записи, которые сервер пометил cacheScope: "public", каждому разделу, использующему хранилище, доверяя классификации сервера от имени их всех. Сервер, который ставит "public" на данные отдельного арендатора (по ошибке или злонамеренно), тогда раскрывает ответ одного арендатора остальным. Флаг намеренно существует только на уровне конструктора: cache_mode для отдельного вызова может сузить кэширование, но ничто на уровне вызова не может расширить совместное использование.
Чего кэш никогда не делает
- Вызовы уровня сессии его обходят.
client.session.list_tools()и ему подобные всегда обращаются к серверу; кэш живёт в методахClient. server/discoverв него не попадает. Результат discover доставляется один раз, при подключении, и никогда не входит в кэш ответов, даже если несётttlMs. Если вы сохраняете его сами, чтобы пропустить пробу при переподключении (prior_discover), его свежесть — ваша забота:DiscoverResultнесётttl_msиcache_scope, уже разобранные, ровно для этого.- Страницы продолжения никогда не кэшируются. Участвуют только вызовы без курсора. Страница продолжения, отклонённая из-за истёкшего курсора, при этом вытесняет закэшированный список, потому что список под ней изменился.
- Многораундовые (multi-round-trip) чтения никогда не кэшируются.
read_resource, которому переданыinput_responses/request_state, или тот, что разрешается через раунды ввода, никогда не попадает в кэш (MUST в спецификации). - Вытеснению по уведомлениям нужны уведомления. Вытеснение работает настолько хорошо, насколько транспорт их доставляет, а современный внутрипроцессный путь (
Client(server)сmode="auto"по умолчанию) сегодня не доставляет самостоятельные уведомления. - Вытеснение происходит в конечном счёте, а не мгновенно. Уведомления, пришедшие по сети, диспетчеризуются из порождённых задач, поэтому вызов, состязающийся с приходом уведомления, может ещё раз получить запись до вытеснения; окно ограничено задержкой диспетчеризации, и вытеснение всё равно произойдёт.
- Нет stale-if-error. Истёкшая запись никогда не отдаётся из-за того, что повторный запрос завершился ошибкой; ошибка пробрасывается дальше.
- Нет упреждающего повторного запроса. Сохранённая запись отдаётся, пока не истечёт её TTL, и следующий вызов после этого платит обращением к серверу; ничего не обновляется в фоне.
- Нет объединения запросов. Два одновременных идентичных вызова — это два обращения к серверу.
- Нет TTL больше 24 часов. Большее
ttlMs, присланное сервером или настроенное, урезается при сохранении (mcp.client.caching.MAX_TTL_MS), что ограничивает, как долго может отдаваться любая запись, сколь бы щедрой ни была подсказка. - В общем хранилище клиенты состязаются друг с другом. Каждый клиент отбрасывает собственную запись, если вытеснение обогнало запрос в полёте, но клиент-сосед всё же может записать обратно запись, удалённую вытеснением, которого он не видел; и сам учёт этих гонок ограничен: после 4096 отслеживаемых ключей первым сбрасывается страж самого старого ключа. Оба окна приняты и закрываются ограничением TTL выше.
- Нет выдачи между поколениями протокола. Записи привязаны к согласованной версии протокола: в общем постоянном хранилище сессия никогда не отдаёт запись, сделанную при другой согласованной версии (один и тот же список действительно различается по поколениям, поскольку SDK убирает поля 2026 для более старых сессий). Вытеснение точно так же затрагивает только записи текущего поколения; записи другого поколения просто устаревают по TTL.
Чтение подсказок вручную
Подсказки — это ещё и обычные поля каждого кэшируемого результата (result.ttl_ms и result.cache_scope, уже разобранные), на случай если захочется надстроить собственный учёт поверх встроенного кэша (или вместо него).
С более старым сервером (протокол до 2026) этих полей в передаваемых данных просто нет, и модели показывают консервативные значения по умолчанию: ttl_ms == 0 и cache_scope == "private" — устаревший и неразделяемый, правильное предположение для сервера, который ничего не объявил. Кэш относится к сессии старого поколения так же: подсказки там никогда не учитываются (какие бы ключи ни появились в данных), применяется только default_ttl_ms, а его значение по умолчанию 0 ничего не кэширует, так что подключение до 2026 ведёт себя ровно так, как до появления кэша. Если нужно отличить «сервер сказал 0» от «сервер ничего не сказал», проверьте "ttl_ms" in result.model_fields_set: оно задано, только когда поле действительно пришло.
Более старые клиенты
Клиенты на версиях протокола до 2026 никогда не видят ни одного из этих полей; для таких подключений SDK убирает их при сериализации. Настройте подсказки один раз — ничего зависящего от версии писать не нужно.
Итоги
- Шесть методов несут
ttlMs/cacheScope; SDK по умолчанию ставит0/"private"— устаревший и неразделяемый, всегда безопасно. cache_hints={method: CacheHint(...)}при создании (иMCPServer, иServer) задаёт общесерверные значения по методам.- Обработчик, задающий поля в своём результате, переопределяет словарь, поле за полем.
"public"— это обещание, что результат одинаков для каждого вызывающего. Это не управление доступом.Clientучитывает подсказки автоматически: его кэш ответов включён по умолчанию, отдаёт свежие записи вместо повторного запроса и ничего не кэширует для серверов (или сессий), не дающих подсказок.- Для отдельного вызова
cache_mode="refresh"запрашивает заново, а"bypass"обходит кэш;cache=Noneпри создании выключает его совсем.