快取提示
在 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 的處理函式,可以在其他部分都是 public 的伺服器上,針對某一個 URI 回傳 cache_scope="private"。
分頁清單有一點要注意:協定要求同一份清單的每一頁都要有相同的 cacheScope。建構子的對應表天生就滿足這一點,因為它以方法為鍵,而不是以頁為鍵。但自行覆寫範圍的處理函式,就得自己負責這份一致性:要在每一頁都覆寫,絕不能只在有 cursor 時才覆寫,否則第一頁和第二頁會對不上。
用戶端看到什麼
在 2026-07-28 的工作階段(session)上,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 的請求(進度 token、追蹤欄位)期待的是一個實際送上線路的請求,所以在 cache_mode="use" 下會被當成 "refresh" 處理:跳過快取讀取,而抓取回來的結果仍然會取代快取中的項目。"bypass" 和明確指定的 "refresh" 行為照舊。
要完全關掉快取,就用 Client(server, cache=None) 建構:每次呼叫又都變回一次往返,而 cache_mode 雖然仍可接受,但不會有任何作用。
範圍也會自動遵守:"private" 項目綁定在快取的分區(partition)上(見下文),而 "public" 項目則可以選擇更廣的共用。此外,對通知點名的那些項目來說,通知勝過 TTL:list_changed 通知會逐出對應的快取清單,resources/updated 則會逐出恰好存在該 URI 下的快取讀取結果,不管它們有多新鮮。在 2026-07-28 連線上,這些通知是透過你用 client.listen(...) 開啟的 subscriptions/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(先查 private 分支,再查 public 分支),所以遠端存放區的延遲預期要據此估算。自訂存放區必須搭配明確的partition。partition:授權上下文的標籤,用來避免在共用存放區中把某個主體的"private"項目提供給另一個主體。target_id:明確的伺服器身分,用於自訂傳輸和同處理程序內的伺服器(見下文)。default_ttl_ms:套用在沒有帶ttlMs提示的結果上的 TTL。預設的0讓沒有提示的結果不被快取。share_public:跨分區提供伺服器宣稱為"public"的項目(見下文)。預設關閉。clock:牆上時鐘的來源,以 epoch 秒為單位。像上面的範例那樣注入一個,過期測試就不需要 sleep。
分區 = 經過驗證的主體
partition 要從經過驗證的憑證推導出來,例如已驗證權杖的 subject。絕不要從請求提供的資料推導,也絕不要從伺服器 URL 推導(伺服器身分是另一條獨立的鍵軸)。SDK 是一個函式庫,本身沒有任何身分驗證:信任的錨點是建構 CacheConfig 的人,也就是部署方,而不是租戶。多租戶閘道要為每個已驗證的主體各建立一個 CacheConfig。
分區在 Client 的整個存活期間也是固定的。如果連線的授權上下文在工作階段中途改變(例如重新驗證成另一個主體),快取不會跟著變;請為新的主體建構一個新的 Client。
快取鍵也帶有伺服器的身分:你連線的 URL 字串,去掉任何 user:pass@ 使用者資訊,其餘逐位元組保留。不做大小寫摺疊、不重排查詢參數、不清理結尾斜線。正規化不足只會損失共用的機會,過度正規化卻可能把兩個租戶合併在一起(?tenant=a 對 ?tenant=b),所以表面上不同的 URL 就是不共用項目。沒有 URL 的時候(同處理程序內的伺服器,或 Transport 實例),用戶端會改拿到一個每個實例隨機產生的身分;設定 CacheConfig.target_id 來替伺服器命名(使用自訂存放區時這是必要的,建構時也會這麼告訴你)。身分在進入鍵的材料之前會先經過 sha256 雜湊,所以查詢字串裡帶著機密的 URL 永遠不會出現在存放區的鍵中。你自己也不要把雜湊前的形式記錄下來。
share_public 代表信任伺服器,而且是整個機群一起信任
預設情況下,即使是 "public" 項目也會留在自己的分區內。share_public=True 會把伺服器標成 cacheScope: "public" 的項目提供給使用該存放區的每一個分區,等於代替它們全體信任伺服器的分類。如果伺服器(因為 bug 或惡意)把 "public" 蓋在各租戶專屬的資料上,某個租戶的回應就會洩漏給其他租戶。這個旗標刻意只放在建構子層級:每次呼叫的 cache_mode 可以縮小快取範圍,但沒有任何每次呼叫層級的東西可以擴大共用。
快取絕不會做的事
- 工作階段層級的呼叫會繞過它。
client.session.list_tools()這一類的呼叫一定會往返;快取是掛在Client的動詞上。 server/discover不參與。 discover 結果只在連線時送達一次,永遠不會進入回應快取,即使它帶著ttlMs也一樣。如果你自己把它保存下來以跳過重新連線時的探測(prior_discover),它的新鮮度就由你自己記帳:DiscoverResult帶有已解析好的ttl_ms和cache_scope,正是為了這個用途。- 後續分頁永遠不會被快取。 只有不帶 cursor 的呼叫會參與。因為 cursor 過期而被拒絕的後續分頁倒是會逐出快取的清單,因為清單已經在底下變了。
- 多輪往返(multi-round-trip)的讀取永遠不會被快取。 以
input_responses/request_state起頭的read_resource,或是經過輸入回合才解析出結果的讀取,都永遠不會進入快取(這是規範的 MUST)。 - 靠通知逐出,就得有通知。 逐出的效果取決於傳輸能不能把通知送到,而現代的同處理程序內路徑(
Client(server)搭配預設的mode="auto")目前不會遞送獨立的通知。 - 逐出是最終發生,不是立即發生。 走線路的通知是從衍生出來的 task 分派的,所以和通知抵達搶時間的呼叫,可能會再被提供一次逐出前的項目;這個空窗受分派延遲所限,而逐出終究會生效。
- 沒有 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則會完全關掉它。