Add a client-side response cache honoring SEP-2549 caching hints (#3023)
This commit is contained in:
parent
67d7593df1
commit
b15b1d5f07
2 changed files with 3789 additions and 88 deletions
|
|
@ -11,27 +11,13 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final, Literal, TypeVar, get_args
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
import mcp_types as types
|
||||
from mcp_types.methods import CACHEABLE_METHODS, CacheableMethod
|
||||
|
||||
__all__ = ["CACHEABLE_METHODS", "CacheHint", "CacheableMethod", "apply_cache_hint", "validate_cache_hints"]
|
||||
|
||||
CacheableMethod = Literal[
|
||||
"prompts/list",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"resources/templates/list",
|
||||
"server/discover",
|
||||
"tools/list",
|
||||
]
|
||||
"""The methods whose results carry `ttlMs`/`cacheScope`. Closed set: the spec
|
||||
defines caching hints on exactly these six (tests pin it to which result models
|
||||
mix in `CacheableResult`)."""
|
||||
|
||||
CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(get_args(CacheableMethod))
|
||||
"""Runtime mirror of `CacheableMethod`, for callers the type checker can't see."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CacheHint:
|
||||
|
|
@ -87,7 +73,8 @@ def validate_cache_hints(cache_hints: Mapping[Any, Any] | None) -> dict[str, Cac
|
|||
"""
|
||||
if cache_hints is None:
|
||||
return {}
|
||||
unknown = sorted(method for method in cache_hints if method not in CACHEABLE_METHODS)
|
||||
# repr-format keys so a non-string key raises this ValueError, not a TypeError from sorted/join.
|
||||
unknown = sorted(repr(method) for method in cache_hints if method not in CACHEABLE_METHODS)
|
||||
if unknown:
|
||||
raise ValueError(f"cache_hints keys must be cacheable methods (see CacheableMethod); got: {', '.join(unknown)}")
|
||||
validated: dict[str, CacheHint] = {}
|
||||
|
|
|
|||
|
|
@ -198,12 +198,15 @@ class ServerRunner(Generic[LifespanT]):
|
|||
if isinstance(result, ErrorData):
|
||||
# Raise inside the chain so middleware observes the failure.
|
||||
raise MCPError.from_error_data(result)
|
||||
# Fill cache hints on the typed result, before the serialize sieve
|
||||
# Fill cache hints on the handler result, before the serialize sieve
|
||||
# decides whether the negotiated version carries the fields at all.
|
||||
# `input_required` interim results are not `CacheableResult` models,
|
||||
# so the MRTR carve-out (no hints on them) holds by shape.
|
||||
if isinstance(result, CacheableResult) and (hint := self.server.cache_hints.get(method)) is not None:
|
||||
result = apply_cache_hint(result, hint)
|
||||
# MRTR carve-out: `input_required` interim results, typed or mapping, never get hints.
|
||||
if (hint := self.server.cache_hints.get(method)) is not None:
|
||||
if isinstance(result, CacheableResult):
|
||||
result = apply_cache_hint(result, hint)
|
||||
elif isinstance(result, Mapping) and result.get("resultType") != "input_required":
|
||||
# Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
|
||||
result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
|
||||
# Dump and serialize inside the chain so the OpenTelemetry span (the
|
||||
# outermost middleware) records a failing handler return shape too.
|
||||
return self._serialize(method, version, result)
|
||||
|
|
|
|||
Loading…
Reference in a new issue