キャッシュヒント
2026-07-28 プロトコルでは、サーバーが tools/list、prompts/list、resources/list、resources/templates/list、resources/read、server/discover に対して返す結果はすべて、2 つのフィールドを持ちます。ttlMs はクライアントがその結果を新鮮なものとして扱ってよいミリ秒数、cacheScope はキャッシュした結果をユーザー間で共有してよいか("public")、それとも 1 つの認可コンテキストに属するか("private")を表します。
サーバーは何もキャッシュしません。これらのフィールドは「宣言」です。つまり「このツール一覧は全員にとって同じで、1 分間は変わりません」という意思表示です。それを受けてクライアント(または手前にあるゲートウェイ)はラウンドトリップを省略できます。ヒントに従うかどうかはクライアントの判断で、ヒントを出すのがサーバーの仕事です。そしてその仕事は 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"
- このマップのキーはメソッド名で、キャッシュ可能な 6 つのメソッドだけが有効なキーです。パラメーターの型は
Mapping[CacheableMethod, CacheHint]なので、エディターがキーを補完し、実行前にタイプミスを指摘します。型チェッカーをすり抜けたものは構築時に例外を送出します。 - 記載しなかったメソッドはデフォルトのままです。このマップは上書きの集合であって、一覧表ではありません。
CacheHint(ttl_ms=5_000)はscopeを設定していないので、"private"のままです。呼び出し側ごとに 5 秒間新鮮、という意味です。スコープと 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 をユーザーごとにフィルターするハンドラーは、それ以外は公開であるサーバーでも、特定の URI については cache_scope="private" を返せます。
ページ分割された一覧について 1 つ注意点があります。プロトコルは、1 つの一覧のすべてのページで同じ cacheScope を要求します。コンストラクターのマップはページではなくメソッドをキーにしているため、この条件を構造上満たします。しかしスコープを自分で上書きするハンドラーは、その一貫性に自ら責任を持ちます。カーソルがあるときだけではなく「すべての」ページで上書きしてください。そうしないと 1 ページ目と 2 ページ目で食い違います。
クライアントから見えるもの
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")
呼び出し 4 回、取得 3 回です。2 回目の呼び出しは新鮮なエントリーを見つけ、サーバーに到達しませんでした。(注入した)クロックを TTL の先へ進めたことで、3 回目は再び取得しました。4 回目は cache_mode="refresh" を指定しています。このキーワード引数はキャッシュ対象の 5 つのメソッド(list_tools、list_prompts、list_resources、list_resource_templates、read_resource)にあります。
"use"(デフォルト)は、新鮮なエントリーがあればそれを返し、なければ取得して保存します。"refresh"はキャッシュから返しません。取得して結果を保存し、キャッシュにあったものを置き換えます。"bypass"はキャッシュに一切触れずにラウンドトリップします。読み込みも書き込みもしません。
"use" より上位にルールが 1 つあります。meta を持つ呼び出しは必ずサーバーに到達します。meta を設定したリクエスト(進捗トークンやトレーシング用フィールドなど)は実際のリクエスト送信を前提にしているため、cache_mode="use" では "refresh" として扱われます。キャッシュの読み込みは省略され、取得した結果は引き続きキャッシュのエントリーを置き換えます。"bypass" と明示的な "refresh" はいつもどおりに動作します。
キャッシュを完全に無効にするには、Client(server, cache=None) で構築してください。すべての呼び出しが再びラウンドトリップになり、cache_mode は受け付けられるものの何もしません。
スコープも自動的に尊重されます。"private" のエントリーはキャッシュの「パーティション」(後述)をキーにし、"public" のエントリーはより広い共有を選べます。そして、名指しされたエントリーについては通知が TTL に勝ちます。list_changed 通知は対応するキャッシュ済みの一覧を破棄し、resources/updated はその URI と完全に一致するキーで保存されたキャッシュ済みの読み込み結果を、どれだけ新鮮でも破棄します。2026-07-28 の接続では、これらの通知は client.listen(...) で開く subscriptions/listen ストリームに届き、破棄はウォッチャーがイベントを見る前に完了します。詳しくは サブスクリプション を参照してください。
resources/updated について 1 つ注意点があります。破棄は 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からインポートできます。1 回の検索で、ストアのgetが最大 2 回(private 側、次に public 側)順に発行されることがあるので、リモートストアのレイテンシー要件はそれに合わせて見積もってください。カスタムストアには明示的なpartitionが必須です。partition:認可コンテキストのラベルです。共有ストア内で、あるプリンシパルの"private"エントリーが別のプリンシパルに返されないようにします。target_id:明示的なサーバーの識別子です。カスタムトランスポートやインプロセスサーバー用です(後述)。default_ttl_ms:ttlMsヒントを持たない結果に適用する TTL です。デフォルトの0では、ヒントのない結果はキャッシュされません。share_public:サーバーが"public"と主張したエントリーをパーティションをまたいで返します(後述)。デフォルトでは無効です。clock:エポック秒単位の時刻ソースです。上の例のように注入すれば、有効期限のテストでスリープする必要がありません。
パーティション = 検証済みプリンシパル
partition は、検証済みトークンの subject など、検証済みの資格情報から導出してください。リクエストで渡されたデータから導出してはいけませんし、サーバーの URL からも導出してはいけません(サーバーの識別子は別のキー軸です)。SDK はライブラリであり、独自の認証を持ちません。信頼の起点は CacheConfig を構築する主体、つまりテナントではなくデプロイメントです。マルチテナントのゲートウェイは、認証済みプリンシパルごとに 1 つの CacheConfig を作ります。
パーティションは Client の寿命の間、固定でもあります。接続の認可コンテキストがセッション途中で変わった場合(たとえば別のプリンシパルとして再認証した場合)、キャッシュは追従しません。新しいプリンシパルには新しい Client を構築してください。
キャッシュキーにはサーバーの識別子も含まれます。接続先として指定した URL 文字列から user:pass@ のユーザー情報を取り除いたもので、それ以外はバイト単位で完全一致です。大文字小文字の畳み込みも、クエリの並べ替えも、末尾スラッシュの整理もしません。正規化が足りない場合は共有の機会を失うだけですが、正規化しすぎると 2 つのテナント(?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は対象外です。ディスカバリーの結果は接続時に一度だけ届き、ttlMsを持っていてもレスポンスキャッシュには入りません。再接続時のプローブを省略するために自分で永続化する場合(prior_discover)、その鮮度は自分で管理します。DiscoverResultはまさにその目的で、解析済みのttl_msとcache_scopeを持っています。- 継続ページは決してキャッシュされません。カーソルなしの呼び出しだけが対象です。期限切れのカーソルで拒否された継続ページは、キャッシュ済みの一覧を「破棄」します。一覧がその下で変わったからです。
- マルチラウンドトリップ(multi-round-trip)の読み込みは決してキャッシュされません。
input_responses/request_stateを与えたread_resource、または入力ラウンドを経て解決されるものは、決してキャッシュに入りません(仕様上の MUST です)。 - 通知による破棄には通知が必要です。破棄の確実さはトランスポートの配信に依存します。現在、モダンなインプロセスの経路(デフォルトの
mode="auto"でのClient(server))は単独の通知を配信しません。 - 破棄は結果整合であり、即時ではありません。通信経由の通知は生成されたタスクから配送されるため、通知の到着と競合した呼び出しには、破棄前のエントリーがもう一度返されることがあります。その時間幅は配送のレイテンシーで抑えられ、破棄自体は必ず行われます。
- stale-if-error はありません。再取得が失敗したからといって期限切れのエントリーが返されることはありません。エラーはそのまま伝播します。
- 早期の再取得はありません。保存されたエントリーは TTL が切れるまで返され、その後の最初の呼び出しがラウンドトリップの費用を負担します。バックグラウンドでの更新はありません。
- 集約はありません。同時に行われた同一の呼び出し 2 つは、取得 2 回です。
- 24 時間を超える TTL はありません。サーバーが送ったものでも設定したものでも、それより大きな
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 がそれらの接続ではシリアライズ時に取り除きます。ヒントは一度設定するだけで、バージョン固有に書くものは何もありません。
まとめ
- 6 つのメソッドが
ttlMs/cacheScopeを持ちます。SDK のデフォルトは0/"private"で、古く、共有されず、常に安全です。 - 構築時の
cache_hints={method: CacheHint(...)}(MCPServerとServerの両方)で、メソッドごとにサーバー全体の値を設定します。 - 結果にフィールドを設定したハンドラーは、フィールド単位でマップを上書きします。
"public"は、結果がすべての呼び出し側に対して同一であるという約束です。アクセス制御ではありません。Clientはヒントに自動で従います。レスポンスキャッシュはデフォルトで有効で、再取得の代わりに新鮮なエントリーを返し、ヒントを提供しないサーバー(またはセッション)については何もキャッシュしません。- 呼び出しごとに、
cache_mode="refresh"は再取得し、"bypass"はキャッシュを飛ばします。構築時のcache=Noneでキャッシュを完全に無効にできます。