광고
~ / posts / developer
smith@lab:~/posts$ cat developer/mcp-stateless-spec-client-mode-cache-mrtr-guide.md

MCP 무상태 스펙 전환, 접속 모드·ttlMs·MRTR 설정 고르는 법

MCP 서버나 호스트를 2026-07-28 스펙으로 옮기려는데 "클라이언트는 auto 로 둘까 버전을 고정할까, tools/list 의 ttlMs 는 몇으로 줄까, 사용자에게 되묻는 도구는 어떻게 만들까"에서 막히는 경우가 많아요. 결론부터 말하면 상대 서버가 전부 새 스펙이면 버전 고정, 섞여 있으면 auto 로 한 번 판정해 결과를 저장하고, 도구 목록은 ttlMs 를 몇 분 단위로 주고 순서를 고정하고, 되묻기는 SDK 의 MRTR 경로에 맡기되 requestState 를 반드시 봉인하는 게 기본값이에요. 이 중 가장 비싼 실수는 도구 순서가 바뀌어 모델 쪽 프롬프트 캐시가 매번 깨지는 경우로, 아래 계산에서 캐싱을 아예 안 쓸 때보다 돈이 더 나갔어요.

이 글은 프로토콜이 어떻게 흐르는지 대신 설정값을 고를 때 필요한 숫자만 다뤄요. 공식 파이썬 SDK mcp 2.3.0 으로 Apple M1·파이썬 3.13 에서 직접 쟀고, 비용은 2026-11-03 에 연 Anthropic 가격표로 계산했어요. 절대 시간은 기기마다 달라서 비율과 자릿수로 읽어 주세요.

광고

MCP 2026-07-28 전환에서 정할 것 다섯 가지

2026-07-28 변경 기록을 운영 관점으로 다시 묶으면 직접 값을 골라야 하는 칸은 다섯 개예요.

정할 것선택지기본 추천이유
클라이언트 접속 모드legacy · auto · 버전 고정상대가 확실하면 고정, 아니면 auto + 결과 저장고정이 왕복이 가장 적고, auto 는 옛 서버도 붙음
서버가 받을 시대새 스펙만 · 듀얼전환 기간엔 듀얼옛 클라이언트는 새 서버로 올라갈 방법이 없음
목록 캐시 힌트ttlMs 값 · public/private5~15분, 사용자별 목록이면 privateSDK 기본 0이라 매번 다시 받음
HTTP 응답 형식JSON 하나 · 요청별 SSE짧은 도구는 JSON, 오래 걸리면 SSE진행률 알림과 취소가 필요할 때만 SSE 의미
되묻기 상태봉인 방식 · 만료AES-GCM, 만료 5~10분스펙이 조작 가능한 입력으로 다루라고 요구

다섯 가지 모두 "서버가 연결별 상태를 기억하지 않는다"는 변화에서 나와요. 도입 여부 자체나 stdio·HTTP 중 무엇을 쓸지는 MCP 도입 판단표에서 다뤘으니, 여기서는 이미 쓰기로 정한 뒤의 설정만 봐요.

클라이언트 접속 모드 — legacy·auto·버전 고정 지연 비교

파이썬 SDK 의 Client(..., mode=...) 는 세 값을 받아요. legacy 는 예전처럼 initialize 로 시작하고, auto(기본값)는 server/discover 를 먼저 보내 보고 새 서버가 아니면 옛 방식으로 내려가요. "2026-07-28" 처럼 버전 문자열을 주면 탐색 없이 그 버전으로 바로 요청해요.

같은 서버를 로컬 127.0.0.1 HTTP 로 띄우고 모드마다 15번 연결해 첫 tools/call 결과가 올 때까지의 중앙값을 쟀어요. 같은 측정을 두 번 했고 두 번째 값을 적었어요(첫 번째와 3ms 안쪽 차이).

MCP 클라이언트 모드별 첫 도구 결과까지 시간 (로컬 HTTP)
legacylegacy · 41.9ms41.9msautoauto · 27.6ms27.6ms버전 고정버전 고정 · 19.9ms19.9ms

연결이 열린 뒤 호출 한 번은 세 모드 모두 6.1~6.4ms 로 거의 같았어요. 차이는 연결 직후에만 생겨요

단위: ms · 자료: 직접 측정 (2026-11-03, mcp 2.3.0, Apple M1, 15회 중앙값)
표로 보기
구분첫 결과
legacy41.9
auto27.6
버전 고정19.9

서버 접근 기록에서 HTTP 요청 수를 세 보면 이유가 보여요.

모드첫 결과까지 보낸 요청순차 왕복왕복 50ms 망이라면(예시 계산)
legacyinitialize → initialized 알림 → tools/list → tools/call (+ GET 스트림, 끝에 DELETE)4번약 200ms
autoserver/discover → tools/list → tools/call3번약 150ms
버전 고정tools/list → tools/call2번약 100ms
버전 고정 + 목록 보유tools/call1번약 50ms

tools/list 가 끼는 건 SDK 클라이언트가 처음 부르는 도구의 출력 스키마를 검증하려고 목록을 한 번 받아 두기 때문이에요(같은 연결에서 세 번 부르면 요청은 4번). 서버리스 함수처럼 요청마다 연결을 새로 여는 구조라면 이 왕복 차이가 매 요청에 붙으니, 상대 서버가 새 스펙인 걸 알면 버전 고정이 가장 싸요.

auto 모드의 10초 함정

stdio 전송 스펙은 양쪽 시대를 다 지원하는 클라이언트가 server/discover 로 먼저 찔러 보고, 새 스펙 오류가 아닌 오류가 오거나 적당한 시간 안에 답이 없으면 옛 서버로 보라고 해요. 옛 서버 중에는 initialize 전에 온 모르는 요청에 아예 답하지 않는 구현이 있어요. 이 경우를 흉내 낸 서버와, 같은 상황에서 -32601 오류로 답하는 서버를 각각 만들어 SDK 로 붙여 봤어요.

옛 서버의 반응legacy 모드 연결auto 모드 연결
모르는 요청에 -32601 오류124ms124ms
모르는 요청에 침묵159ms10,020ms

SDK 코드의 DISCOVER_TIMEOUT_SECONDS = 10.0 이 그대로 드러난 결과예요. MCP Inspector 의 프로토콜 시대 문서도 같은 이유로 Inspector 기본값을 auto 가 아닌 legacy 로 뒀다고 적고 있어요. 그래서 데스크톱 호스트처럼 서버를 여러 개 띄우는 앱이라면 버전·호환성 문서가 권하는 대로 판정 결과를 서버 설정 단위로 저장하고(stdio 는 프로세스 수명 동안, HTTP 는 출처 단위), 저장된 판정이 틀렸을 때만 다시 탐색하는 게 맞아요.

붙일 서버가 모두 새 스펙인지, 서버별 시대를 설정에 적을 수 있는지, 전송이 stdio 인지 차례로 물어 버전 고정·서버별 기록·auto 와 결과 캐시·HTTP auto 중 하나로 가는 순서도
HTTP 는 400 응답 본문으로 바로 판정돼 10초 함정이 없어요

서버는 새 스펙만 받을까, 듀얼로 받을까

서버 쪽 판단은 호환 행렬 하나로 끝나요. 버전·호환성 문서의 표를 서버 운영자 입장으로 줄이면 이래요.

내 서버상대 클라이언트결과
새 스펙만새 스펙 또는 듀얼동작
새 스펙만옛 스펙만실패 — 옛 클라이언트는 위로 올라갈 방법이 없음
듀얼누구든동작 — 첫 요청 모양(initialize 냐 _meta 냐)으로 갈림

판단 기준은 내가 클라이언트를 통제하느냐예요. 사내 에이전트처럼 클라이언트 버전을 같이 올릴 수 있으면 새 스펙만 받아도 돼요. 외부 사용자가 각자 쓰는 IDE·데스크톱 앱에서 붙는 공개 서버라면, 옛 클라이언트가 남아 있는 동안 듀얼로 두는 게 안전해요. 새 스펙만 받기로 했다면 initialize 요청에 돌려주는 오류 메시지에 지원 버전을 적으라고 스펙이 권해요. 옛 클라이언트가 사용자에게 보여 줄 수 있는 단서가 그것뿐이라서예요.

실측한 SDK 서버는 버전을 모르는 요청에 HTTP 400 과 -32022 오류, data.supported: ["2026-07-28"] 를 돌려줬어요. 필수 _meta 키가 빠지면 400 과 -32602, Mcp-Name 헤더가 본문의 도구 이름과 다르면 400 과 -32020 이었어요. 게이트웨이 로그에서 400 이 갑자기 늘면 이 세 코드를 먼저 나눠 보면 원인이 바로 갈려요. 오류 본문 JSON 이 길면 JSON 정렬 도구에 붙여 error.code 와 data 만 보면 돼요.

참고로 매 요청에 버전·클라이언트 정보·능력을 싣는 비용은 작아요. 인자 두 개짜리 tools/call 본문이 96바이트에서 _meta 를 넣고 304바이트가 됐어요. 요청당 약 200바이트라 압축이나 대역폭을 걱정할 수준은 아니에요.

tools/list 캐시 — ttlMs·cacheScope 값 고르기

새 스펙에서는 tools/list·resources/list·prompts/list·resources/read·server/discover 결과에 ttlMs 와 cacheScope 가 꼭 붙어요. 캐싱 스펙의 규칙을 정리하면 이래요.

SDK 의 MCPServer 를 그냥 띄우면 tools/list 응답에 ttlMs: 0, cacheScope: "private" 가 실려요. 클라이언트 캐시가 있어도 매번 다시 묻게 되는 값이에요. cache_hints 로 바꿔 보고 같은 연결에서 list_tools() 를 300번 불렀어요.

서버 설정응답 힌트목록 조회 중앙값
기본값ttlMs: 0, private5.73ms (매번 서버 왕복)
CacheHint(ttl_ms=300000, scope="public")ttlMs: 300000, public0.35ms (클라이언트 캐시)
from mcp.server import MCPServer
from mcp.server.caching import CacheHint

mcp = MCPServer(
    "repo-tools",
    cache_hints={"tools/list": CacheHint(ttl_ms=10 * 60 * 1000, scope="public")},
)

값을 고르는 기준은 도구 목록이 실제로 얼마나 자주 바뀌느냐예요.

목록 성격추천 ttlMscacheScope
배포 때만 바뀌는 고정 도구10분~1시간모두 같으면 public
권한·요금제에 따라 사용자마다 다른 목록5~15분private (공유하면 남의 도구가 보임)
기능 플래그로 수시로 켜고 끄는 도구1분 안팎 + list_changed 알림상황에 따라

SDK 클라이언트는 받은 TTL 을 최대 24시간으로 잘라요. 그보다 길게 줘도 효과는 같아요. 그리고 public 은 "같은 인가 맥락이 아니어도 공유된다"는 뜻이라, 캐시 범위를 접근 통제 수단으로 쓰면 안 된다고 스펙이 따로 경고해요. 도구 호출 자체의 권한 검사는 매 호출에서 해야 해요.

도구 순서가 바뀌면 드는 돈 — 프롬프트 캐시 계산

목록 캐시보다 돈이 더 크게 걸린 곳은 모델 API 쪽이에요. 호스트는 MCP 에서 받은 도구 정의를 모델 요청의 tools 배열로 넣는데, 모델 제공자의 프롬프트 캐시는 요청 앞부분이 한 글자라도 다르면 맞지 않아요. 그래서 변경 기록에 도구 목록을 결정적인 순서로 돌려주라는 권고가 들어갔고, 클라이언트 모범 사례 문서도 대화 중간에 도구를 넣고 빼면 캐시가 깨진다고 짚어요.

순서가 흔들리는 흔한 원인은 서버가 집합(set)이나 해시 맵 순서로 목록을 만들 때, 그리고 호스트가 여러 서버 목록을 도착한 순서대로 합칠 때예요. 짧은 ttlMs 로 목록을 자주 다시 받을수록 순서가 흔들릴 기회도 많아져요.

계산 조건은 이래요. 시스템 프롬프트와 도구 정의를 합친 앞부분 1만 토큰, 작업 하나에 모델 호출 10번, 월 1만 작업. 캐싱은 5분 캐시를 쓰고, 순서가 고정이면 작업마다 첫 호출 한 번 쓰기·나머지 아홉 번 읽기, 순서가 매번 바뀌면 열 번 모두 쓰기로 봤어요. 단가는 가격표의 기본 입력·5분 캐시 쓰기·캐시 읽기 값(Sonnet 5.5 기준 100만 토큰당 2달러·2.50달러·0.10달러)이에요.

도구 순서와 캐싱에 따른 앞부분 1만 토큰의 월 비용
캐싱 안 씀순서 고정 + 캐싱순서 흔들림 + 캐싱
01,0002,0003,0004,0005,000Haiku 5.5 · 캐싱 안 씀 100달러Haiku 5.5 · 순서 고정 + 캐싱 21.5달러Haiku 5.5 · 순서 흔들림 + 캐싱 125달러Haiku 5.5Sonnet 5.5 · 캐싱 안 씀 2,000달러Sonnet 5.5 · 순서 고정 + 캐싱 340달러Sonnet 5.5 · 순서 흔들림 + 캐싱 2,500달러Sonnet 5.5Opus 5.5 · 캐싱 안 씀 4,000달러Opus 5.5 · 순서 고정 + 캐싱 680달러Opus 5.5 · 순서 흔들림 + 캐싱 5,000달러Opus 5.5

출력 토큰과 대화 기록 토큰은 뺀 앞부분 몫만이에요

단위: 달러 · 자료: Anthropic 공식 가격표(2026-11-03 확인) 기준 직접 계산, 월 1만 작업 × 작업당 10회 호출
표로 보기
구분캐싱 안 씀순서 고정 + 캐싱순서 흔들림 + 캐싱
Haiku 5.510021.5125
Sonnet 5.52,0003402,500
Opus 5.54,0006805,000

Sonnet 5.5 로 풀면 순서 고정은 작업당 1만 × (2.50 + 9 × 0.10) ÷ 100만 = 0.034달러, 월 340달러예요. 순서가 흔들리면 1만 × 10 × 2.50 ÷ 100만 = 0.25달러, 월 2,500달러로 캐싱을 아예 끈 2,000달러보다 25% 비싸요. 캐시 쓰기가 기본 입력보다 1.25배 비싼데 읽기는 한 번도 못 하기 때문이에요. 캐시가 실제로 맞는지는 모델 응답의 cache_read_input_tokens 가 0보다 큰지로 확인해요. TTL 과 캐시 쓰기 단가의 손익은 프롬프트 캐싱 비용 계산에서 따로 다뤘어요.

응답 방식 — JSON 하나 vs 요청별 SSE

Streamable HTTP 스펙에서 서버는 요청마다 JSON 하나로 답할지, 그 요청 전용 SSE 스트림을 열지 고를 수 있고 클라이언트는 둘 다 받아야 해요. 파이썬 SDK 는 mcp.run("streamable-http", json_response=True) 로 JSON, 기본값(False)으로 SSE 를 써요. 연결을 연 뒤 덧셈 도구를 500번 불렀어요.

응답 형식버전 고정 중앙값99퍼센타일legacy 모드 중앙값
JSON 하나6.10ms8.94ms6.40ms
요청별 SSE6.44ms8.83ms8.53ms

새 스펙에서는 차이가 5% 남짓이라 지연으로 고를 일은 아니에요. 고르는 기준은 기능이에요.

스트림이 중간에 끊기면 새 스펙에서는 Last-Event-ID 로 이어받을 수 없고 클라이언트가 새 id 로 다시 보내요. 결제·이슈 생성처럼 두 번 실행되면 안 되는 도구는 인자에 멱등 키를 받는 쪽으로 설계해야 해요.

광고
광고

되묻는 도구 — MRTR 비용과 requestState 봉인

도구가 실행 중에 사용자 확인을 받아야 할 때, 새 스펙은 서버가 클라이언트에게 요청을 거꾸로 보내는 대신 resultType: "input_required" 로 일단 끝내고 클라이언트가 답을 붙여 원래 요청을 다시 보내게 해요(MRTR 패턴 문서).

처음에 예전 방식대로 도구 안에서 await ctx.elicit(...) 를 부르게 짰더니, 버전 고정 HTTP 연결에서 Cannot send 'elicitation/create': this transport context has no back-channel 오류가 났어요. 새 스펙 연결에는 서버가 거꾸로 말할 통로가 없어서예요. SDK 2.3.0 에서는 질문을 리졸버로 빼서 선언하면, 연결이 새 스펙이면 MRTR 로, 옛 스펙이면 예전 방식으로 알아서 바꿔 줘요.

from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import Resolve, Elicit
from mcp.server.request_state import RequestStateSecurity

mcp = MCPServer("repo-tools", request_state_security=RequestStateSecurity.ephemeral(ttl=300))

class Confirm(BaseModel):
    ok: bool

def ask(name: str):
    return Elicit(f"{name} 저장소를 지울까요?", Confirm)

@mcp.tool()
async def delete_repo(name: str, confirm: Annotated[Confirm, Resolve(ask)]) -> str:
    """저장소를 지우기 전에 확인을 받아요."""
    return "deleted" if confirm.ok else "kept"

같은 연결에서 확인이 필요 없는 도구와 이 도구를 번갈아 300번 불렀어요. 클라이언트의 확인 콜백은 바로 승인하게 해서 사람 응답 시간은 뺐어요.

측정값
일반 도구 호출 중앙값4.99ms
MRTR 도구 호출 중앙값 (요청 2번)12.73ms
첫 응답의 requestState 길이338자 (첫 응답 본문 762바이트)
봉인 값을 3글자 바꿔 다시 보냄HTTP 400, -32602 Invalid or expired requestState
AES-GCM 봉인+해제 / HMAC 서명+검증 1회10µs / 16µs

MRTR 은 왕복이 하나 더 생기지만 사람이 답하는 데 초 단위가 걸리니 이 7~8ms 는 의미가 없어요. 따져야 할 건 보안이에요. 스펙은 requestState 를 공격자가 조작할 수 있는 입력으로 보고, 권한이나 업무 로직에 영향을 주면 무결성 보호를 반드시 하라고 해요. 그리고 봉투 안에 요청한 사용자, 짧은 만료, 원래 요청 식별값을 넣어 받을 때 확인하라고 권해요. 위 실측에서 SDK 는 봉인 값을 한 글자만 바꿔도 거절했어요. HMAC 이 어떤 값인지 감을 잡고 싶다면 해시 생성기의 HMAC 칸에 키와 메시지를 넣어 한 글자 바꿀 때 결과가 통째로 바뀌는 걸 보면 돼요.

requestState 봉투 안에 넣을 사용자·만료·원래 요청 다이제스트·받은 답, 가운데 AES-GCM 또는 HMAC 봉인, 오른쪽 재요청 때 거절할 네 경우를 나란히 놓은 그림
봉인은 재사용 범위를 좁힐 뿐 한 번만 쓰게 하지는 못해요

상황별 추천

전환 체크리스트

  1. 클라이언트 모드를 설정 파일에 적었고, auto 판정 결과를 서버 설정 단위로 저장하나요?
  2. 새 스펙만 받는 서버라면 initialize 오류 메시지에 지원 버전을 넣었나요?
  3. 실제 tools/list 응답의 ttlMs 가 0이 아니고, 사용자별 목록에 public 을 주고 있지 않나요?
  4. 같은 목록 요청을 두 번 보내 tools 배열이 바이트 단위로 같은가요? 모델 응답의 캐시 읽기 토큰이 두 번째 호출부터 0보다 큰가요?
  5. 오래 걸리는 도구만 SSE 로 답하고 프록시 버퍼링을 껐나요? 부작용 있는 도구는 재요청돼도 한 번만 처리되나요?
  6. ctx.elicit 를 리졸버 방식으로 바꿨고, 여러 인스턴스가 같은 requestState 키를 쓰나요?
  7. 게이트웨이 400 오류를 -32022·-32602·-32020 별로 나눠 보고 있나요?
광고
광고

자주 묻는 질문

2026-07-28 이전 서버는 이제 못 쓰나요?

쓸 수 있어요. 양쪽을 다 지원하는 클라이언트(파이썬 SDK 의 auto 모드 등)는 옛 서버를 만나면 initialize 로 내려가요. 못 붙는 조합은 새 스펙만 아는 클라이언트와 옛 서버, 그리고 옛 클라이언트와 새 스펙만 받는 서버예요.

auto 모드로 두면 손해 보는 건 없나요?

새 스펙 서버에는 탐색 왕복이 한 번 더 붙고(로컬 실측 19.9ms → 27.6ms), 침묵하는 옛 stdio 서버에는 10초를 기다려요. 판정 결과를 저장하면 둘 다 처음 한 번으로 끝나요.

ttlMs 를 길게 주면 도구를 추가해도 안 보이지 않나요?

TTL 이 끝날 때까지는 안 보일 수 있어요. 바로 반영돼야 하면 서버가 listChanged 능력을 알리고 목록이 바뀔 때 알림을 보내면, 구독 중인 클라이언트는 TTL 과 상관없이 캐시를 버려요. 구독하지 않는 클라이언트는 도구 호출이 "없는 도구" 오류로 실패할 때 다시 받아도 된다고 스펙에 적혀 있어요.

requestState 는 서버 세션과 뭐가 다른가요?

상태를 서버 메모리가 아니라 응답에 실어 클라이언트에게 맡긴다는 점이 달라요. 그래서 어느 인스턴스가 재요청을 받아도 처리할 수 있어요. 대신 클라이언트를 거쳐 오므로 봉인과 만료 확인이 필수이고, 인스턴스끼리 봉인 키를 공유해야 해요.

JSON 응답으로 정하면 진행률은 못 보내나요?

요청 하나에 JSON 하나로 답하면 중간 알림을 끼울 자리가 없어요. 서버는 요청마다 형식을 고를 수 있으니, 오래 걸리는 도구만 SSE 로 답하고 나머지는 JSON 으로 두는 식으로 섞어도 돼요.

정리

MCP 를 쓸지 말지, stdio 와 HTTP 중 무엇을 쓸지는 MCP 도입 판단표에, SDK 코드와 예제는 MCP 공식 파이썬 SDK 저장소에 있어요.

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 MCP 동작 원리 — tools/call 흐름과 무상태 스펙에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#MCP 2026-07-28#MCP 무상태#MCP 레거시 호환#server/discover#ttlMs cacheScope#MRTR requestState#Streamable HTTP SSE#MCP 파이썬 SDK
← 이전 글JPA 애그리거트 루트 버전 안 오를 때, 강제 증가 판단표와 비용다음 글 →NestJS durable 프로바이더 언제 쓰나, 테넌트 메모리와 점검표
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →