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

Підказки щодо кешування

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

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

Кожен результат, який сервер повертає для tools/list, prompts/list, resources/list, resources/templates/list, resources/read і server/discover, у протоколі 2026-07-28 містить два поля: ttlMs — скільки мілісекунд клієнт може вважати результат свіжим, і cacheScope — чи можна ділитися закешованим результатом між користувачами ("public"), чи він належить одному контексту авторизації ("private").

Сервер нічого не кешує. Ці поля — декларація: «цей список інструментів однаковий для всіх і не зміниться протягом хвилини». Тоді клієнт (або шлюз перед вами) може пропустити звернення до сервера. Дотримуватися підказок чи ні — вибір клієнта; видавати їх — робота сервера, і SDK робить це за вас.

За замовчуванням кожен результат каже ttlMs: 0, cacheScope: "private": одразу застарілий, ніколи не спільний. Це завжди безпечно і завжди відповідає специфікації. Якщо ваші списки справді стабільні й однакові для всіх викликачів, скажіть про це під час створення:

server.py
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" — теж допустимий ключ, бо результат discovery кешується так само, як будь-який список.

Warning

cacheScope: "public" означає, що вашу закешовану відповідь можуть віддати будь-кому. Спільний шлюз охоче передасть результат одного користувача іншому, навіть якщо запит був автентифікований. Позначайте результат як "public" лише тоді, коли він однаковий для кожного викликача, і ніколи не використовуйте cacheScope як контроль доступу: це мітка, а не замок.

Перевизначення в обробнику

На низькорівневому Server обробники будують результати вручну, а ttl_ms / cache_scope — звичайні поля моделей результатів. Обробник, який задає їх явно, завжди перемагає мапу конструктора, поле за полем:

server.py
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 (одразу застарілий), тож сервер, який нічого не оголошує, бачить рівно той самий трафік виклик-за-викликом, що й завжди.

client.py
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 під час створення вимикає його повністю.