콘텐츠로 이동

캐싱 힌트

기계 번역

이 페이지는 영어 문서를 자동으로 번역한 것이며, 영어 페이지가 기준이 되는 정식 버전입니다. 어색하거나 잘못된 부분이 있다면 번역 페이지에서 제보하는 방법을 확인하세요.

2026-07-28 프로토콜에서는 서버가 tools/list, prompts/list, resources/list, resources/templates/list, resources/read, server/discover에 대해 반환하는 모든 결과에 두 필드가 실립니다. 클라이언트가 결과를 신선한 것으로 취급해도 되는 밀리초 수인 ttlMs와, 캐시된 결과를 사용자 간에 공유해도 되는지("public") 아니면 하나의 인가 컨텍스트에 속하는지("private")를 나타내는 cacheScope입니다.

서버는 아무것도 캐시하지 않습니다. 이 필드는 선언입니다. "이 도구 목록은 모두에게 동일하며 1분 동안 바뀌지 않습니다"라는 뜻입니다. 그러면 클라이언트(또는 서버 앞단의 게이트웨이)가 왕복을 생략할 수 있습니다. 힌트를 따를지는 클라이언트의 선택이고, 힌트를 내보내는 것은 서버의 일이며, 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"로 남습니다. 호출자별로 5초 동안 신선합니다. 범위와 TTL은 서로 독립적인 결정입니다.
  • "server/discover"도 유효한 키입니다. 디스커버리 결과도 다른 목록처럼 캐시할 수 있기 때문입니다.

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를 사용자별로 필터링하는 핸들러는 그 밖에는 public인 서버에서 특정 URI 하나만 cache_scope="private"로 반환할 수 있습니다.

페이지로 나뉜 목록에 관한 주의 사항이 하나 있습니다. 프로토콜은 한 목록의 모든 페이지에서 같은 cacheScope를 요구합니다. 생성자 맵은 페이지가 아니라 메서드를 키로 하므로 구조상 이를 만족합니다. 그러나 범위를 직접 재정의하는 핸들러는 그 일관성을 스스로 책임집니다. 커서가 있을 때만이 아니라 모든 페이지에서 재정의하세요. 그러지 않으면 1페이지와 2페이지가 서로 어긋납니다.

클라이언트가 보는 것

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" 항목은 캐시의 파티션(아래 참고)을 키로 하고, "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: 에포크 초 단위의 벽시계 소스입니다. 위 예제처럼 하나를 주입하면 만료 테스트에 sleep이 필요 없습니다.

파티션 = 검증된 주체

partition은 검증된 토큰의 subject 같은 검증된 자격 증명에서 도출하세요. 요청이 제공한 데이터에서 도출하지 말고, 서버 URL에서도 도출하지 마세요(서버 식별자는 별도의 키 축입니다). SDK는 자체 인증이 없는 라이브러리입니다. 신뢰의 기준점은 CacheConfig를 생성하는 쪽, 즉 테넌트가 아니라 배포입니다. 멀티테넌트 게이트웨이는 인증된 주체마다 CacheConfig를 하나씩 만듭니다.

파티션은 Client의 수명 동안 고정되기도 합니다. 연결의 인가 컨텍스트가 세션 도중 바뀌면(예를 들어 다른 주체로 재인증하는 경우) 캐시는 따라가지 않습니다. 새 주체용으로 새 Client를 생성하세요.

캐시 키에는 서버의 식별자도 담깁니다. 연결한 URL 문자열에서 user:pass@ 형태의 userinfo만 제거하고 나머지는 바이트 그대로입니다. 대소문자 접기도, 쿼리 재정렬도, 끝 슬래시 정리도 없습니다. 정규화를 덜 하면 공유 기회를 잃을 뿐이지만, 지나치게 정규화하면 두 테넌트(?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_mscache_scope를 담고 있습니다.
  • 이어지는 페이지는 캐시되지 않습니다. 커서 없는 호출만 참여합니다. 만료된 커서 때문에 거부된 이어지는 페이지는 캐시된 목록을 축출합니다. 그 아래에서 목록이 바뀌었기 때문입니다.
  • 다중 왕복 읽기는 캐시되지 않습니다. input_responses/request_state로 시작했거나 입력 라운드를 거쳐 해결되는 read_resource는 캐시에 들어가지 않습니다(명세의 MUST).
  • 알림 기반 축출에는 알림이 필요합니다. 축출은 트랜스포트의 전달 품질만큼만 동작하며, 현대적인 인프로세스 경로(기본 mode="auto"Client(server))는 현재 단독 알림을 전달하지 않습니다.
  • 축출은 즉각적이 아니라 결과적으로 일어납니다. 전송 경로 알림은 생성된 태스크에서 디스패치되므로, 알림 도착과 경합하는 호출은 축출 전 항목을 한 번 더 받을 수 있습니다. 그 구간은 디스패치 지연으로 제한되며 축출은 결국 적용됩니다.
  • stale-if-error는 없습니다. 다시 가져오기가 실패했다고 해서 만료된 항목이 제공되는 일은 없습니다. 오류가 전파됩니다.
  • 조기 재요청은 없습니다. 저장된 항목은 TTL이 만료될 때까지 제공되고, 그다음 첫 호출이 왕복 비용을 냅니다. 백그라운드에서 갱신되는 것은 없습니다.
  • 병합은 없습니다. 동시에 일어난 동일한 호출 두 개는 두 번 가져옵니다.
  • 24시간을 넘는 TTL은 없습니다. 더 큰 ttlMs는 서버가 보냈든 설정했든 저장 시점에 잘립니다(mcp.client.caching.MAX_TTL_MS). 힌트가 아무리 넉넉해도 어떤 항목이든 제공될 수 있는 기간에 상한을 둡니다.
  • 공유 스토어에서는 클라이언트끼리 경합합니다. 각 클라이언트는 진행 중인 가져오기를 축출이 추월했을 때 자기 쓰기를 버리지만, 공동 테넌트 클라이언트는 자신이 보지 못한 축출이 제거한 항목을 여전히 다시 써넣을 수 있습니다. 그리고 그 경합 관리 자체에도 한계가 있습니다. 추적 키가 4096개를 넘으면 가장 오래된 키의 가드부터 버려집니다. 두 구간 모두 허용된 것이며, 위의 TTL 상한으로 닫힙니다.
  • 프로토콜 세대를 넘어 제공하지 않습니다. 항목은 협상된 프로토콜 버전에 한정됩니다. 공유 영속 스토어에서 세션은 다른 협상 버전으로 기록된 항목을 제공하지 않습니다(SDK가 구버전 세션용으로 2026 필드를 제거하므로 같은 목록이라도 세대별로 실제로 다릅니다). 축출도 마찬가지로 현재 세대의 항목만 건드리며, 다른 세대의 항목은 TTL로 자연히 만료됩니다.

힌트를 직접 읽기

힌트는 캐시 가능한 모든 결과의 평범한 필드이기도 하므로(result.ttl_msresult.cache_scope, 이미 파싱됨), 내장 캐시 위에(또는 대신에) 자체 관리 로직을 얹고 싶을 때 쓸 수 있습니다.

구버전 서버(2026 이전 프로토콜)를 상대하면 이 필드는 전송되는 메시지에 아예 없고, 모델은 보수적인 기본값을 보여 줍니다. ttl_ms == 0cache_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(...)}(MCPServerServer 모두)는 메서드별로 서버 전역 값을 설정합니다.
  • 결과에 필드를 설정한 핸들러는 필드 단위로 맵을 재정의합니다.
  • "public"은 결과가 모든 호출자에게 동일하다는 약속입니다. 접근 제어가 아닙니다.
  • Client는 힌트를 자동으로 따릅니다. 응답 캐시는 기본으로 켜져 있고, 다시 가져오는 대신 신선한 항목을 제공하며, 힌트를 제공하지 않는 서버(또는 세션)에는 아무것도 캐시하지 않습니다.
  • 호출별로 cache_mode="refresh"는 다시 가져오고 "bypass"는 캐시를 건너뜁니다. 생성 시점의 cache=None은 캐시를 완전히 끕니다.