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/private | 5~15분, 사용자별 목록이면 private | SDK 기본 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 안쪽 차이).
서버 접근 기록에서 HTTP 요청 수를 세 보면 이유가 보여요.
| 모드 | 첫 결과까지 보낸 요청 | 순차 왕복 | 왕복 50ms 망이라면(예시 계산) |
|---|---|---|---|
| legacy | initialize → initialized 알림 → tools/list → tools/call (+ GET 스트림, 끝에 DELETE) | 4번 | 약 200ms |
| auto | server/discover → tools/list → tools/call | 3번 | 약 150ms |
| 버전 고정 | tools/list → tools/call | 2번 | 약 100ms |
| 버전 고정 + 목록 보유 | tools/call | 1번 | 약 50ms |
tools/list 가 끼는 건 SDK 클라이언트가 처음 부르는 도구의 출력 스키마를 검증하려고 목록을 한 번 받아 두기 때문이에요(같은 연결에서 세 번 부르면 요청은 4번). 서버리스 함수처럼 요청마다 연결을 새로 여는 구조라면 이 왕복 차이가 매 요청에 붙으니, 상대 서버가 새 스펙인 걸 알면 버전 고정이 가장 싸요.
auto 모드의 10초 함정
stdio 전송 스펙은 양쪽 시대를 다 지원하는 클라이언트가 server/discover 로 먼저 찔러 보고, 새 스펙 오류가 아닌 오류가 오거나 적당한 시간 안에 답이 없으면 옛 서버로 보라고 해요. 옛 서버 중에는 initialize 전에 온 모르는 요청에 아예 답하지 않는 구현이 있어요. 이 경우를 흉내 낸 서버와, 같은 상황에서 -32601 오류로 답하는 서버를 각각 만들어 SDK 로 붙여 봤어요.
| 옛 서버의 반응 | legacy 모드 연결 | auto 모드 연결 |
|---|---|---|
모르는 요청에 -32601 오류 | 124ms | 124ms |
| 모르는 요청에 침묵 | 159ms | 10,020ms |
SDK 코드의 DISCOVER_TIMEOUT_SECONDS = 10.0 이 그대로 드러난 결과예요. MCP Inspector 의 프로토콜 시대 문서도 같은 이유로 Inspector 기본값을 auto 가 아닌 legacy 로 뒀다고 적고 있어요. 그래서 데스크톱 호스트처럼 서버를 여러 개 띄우는 앱이라면 버전·호환성 문서가 권하는 대로 판정 결과를 서버 설정 단위로 저장하고(stdio 는 프로세스 수명 동안, HTTP 는 출처 단위), 저장된 판정이 틀렸을 때만 다시 탐색하는 게 맞아요.
서버는 새 스펙만 받을까, 듀얼로 받을까
서버 쪽 판단은 호환 행렬 하나로 끝나요. 버전·호환성 문서의 표를 서버 운영자 입장으로 줄이면 이래요.
| 내 서버 | 상대 클라이언트 | 결과 |
|---|---|---|
| 새 스펙만 | 새 스펙 또는 듀얼 | 동작 |
| 새 스펙만 | 옛 스펙만 | 실패 — 옛 클라이언트는 위로 올라갈 방법이 없음 |
| 듀얼 | 누구든 | 동작 — 첫 요청 모양(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 가 꼭 붙어요. 캐싱 스펙의 규칙을 정리하면 이래요.
ttlMs는 HTTPmax-age처럼 "이 시간 동안은 다시 안 물어도 된다"는 힌트예요. 보장이 아니라서 그 사이에 목록이 바뀔 수도 있어요.- 클라이언트는 TTL 을 주기적 재조회 간격으로 쓰면 안 되고, 필요할 때 신선도를 보고 지났으면 다시 받아요. 굳이 폴링한다면 지터와 백오프를 넣어야 해요.
public은 모든 사용자에게 같은 목록이라 게이트웨이가 공유 캐시해도 되는 경우,private은 토큰이 다르면 캐시를 나눠야 하는 경우예요.list_changed알림을 받으면 TTL 이 남아 있어도 바로 버려요. 새 스펙에서 이 알림은subscriptions/listen스트림을 열어 둔 클라이언트만 받아요.
SDK 의 MCPServer 를 그냥 띄우면 tools/list 응답에 ttlMs: 0, cacheScope: "private" 가 실려요. 클라이언트 캐시가 있어도 매번 다시 묻게 되는 값이에요. cache_hints 로 바꿔 보고 같은 연결에서 list_tools() 를 300번 불렀어요.
| 서버 설정 | 응답 힌트 | 목록 조회 중앙값 |
|---|---|---|
| 기본값 | ttlMs: 0, private | 5.73ms (매번 서버 왕복) |
CacheHint(ttl_ms=300000, scope="public") | ttlMs: 300000, public | 0.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")},
)
값을 고르는 기준은 도구 목록이 실제로 얼마나 자주 바뀌느냐예요.
| 목록 성격 | 추천 ttlMs | cacheScope |
|---|---|---|
| 배포 때만 바뀌는 고정 도구 | 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달러)이에요.
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.10ms | 8.94ms | 6.40ms |
| 요청별 SSE | 6.44ms | 8.83ms | 8.53ms |
새 스펙에서는 차이가 5% 남짓이라 지연으로 고를 일은 아니에요. 고르는 기준은 기능이에요.
- SSE 가 필요한 경우: 수십 초 걸리는 도구라
notifications/progress로 진행률을 보내고 싶을 때, 그리고 사용자가 취소하면 바로 멈춰야 할 때예요. 새 스펙에서는 클라이언트가 그 요청의 SSE 스트림을 닫는 것 자체가 취소 신호예요. - JSON 이 나은 경우: 조회처럼 금방 끝나는 도구가 대부분일 때예요. 프록시·API 게이트웨이·서버리스 플랫폼의 응답 버퍼링 문제를 신경 쓸 필요가 없어요.
- SSE 를 쓴다면 nginx 같은 리버스 프록시가 이벤트를 모아 두지 않게 응답에
X-Accel-Buffering: no를 넣으라고 스펙이 권해요.
스트림이 중간에 끊기면 새 스펙에서는 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 칸에 키와 메시지를 넣어 한 글자 바꿀 때 결과가 통째로 바뀌는 걸 보면 돼요.
상황별 추천
- 사내 에이전트가 사내 MCP 서버 몇 개만 쓸 때: 서버와 클라이언트를 함께 올리고 클라이언트는
"2026-07-28"고정, 서버는 새 스펙만 받아요. 왕복이 가장 적고 설정이 단순해요. - 여러 사람이 각자 서버를 붙이는 데스크톱·IDE 호스트: auto 로 처음 한 번 판정해 서버 설정 옆에 저장하고, 다음부터는 저장된 값으로 바로 붙어요. 침묵하는 옛 서버 때문에 앱 시작이 10초씩 늘어나는 걸 막는 방법이에요.
- 외부에 공개하는 원격 서버: 듀얼로 받고,
initialize오류 메시지에 지원 버전을 적어요.tools/list는ttlMs10분 이상, 사용자마다 같으면public. - 도구가 많은 범용 에이전트: 목록 순서 고정이 먼저예요. 여러 서버를 합칠 때 서버 이름·도구 이름순으로 정렬해 두면 위 계산의 2,500달러가 340달러 쪽으로 돌아와요.
- 오래 걸리는 빌드·배포 도구: SSE 로 진행률을 보내고 멱등 키를 받아요.
- 삭제·결제 전 확인이 필요한 도구: 리졸버 + MRTR,
requestState만료 5분 안쪽, 1회용이면 서버에 기록.
전환 체크리스트
- 클라이언트 모드를 설정 파일에 적었고, auto 판정 결과를 서버 설정 단위로 저장하나요?
- 새 스펙만 받는 서버라면
initialize오류 메시지에 지원 버전을 넣었나요? - 실제
tools/list응답의ttlMs가 0이 아니고, 사용자별 목록에public을 주고 있지 않나요? - 같은 목록 요청을 두 번 보내
tools배열이 바이트 단위로 같은가요? 모델 응답의 캐시 읽기 토큰이 두 번째 호출부터 0보다 큰가요? - 오래 걸리는 도구만 SSE 로 답하고 프록시 버퍼링을 껐나요? 부작용 있는 도구는 재요청돼도 한 번만 처리되나요?
ctx.elicit를 리졸버 방식으로 바꿨고, 여러 인스턴스가 같은requestState키를 쓰나요?- 게이트웨이 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 으로 두는 식으로 섞어도 돼요.
정리
- 접속 모드는 상대를 알면 버전 고정, 모르면 auto 로 한 번 판정해 저장해요. 고정이 첫 결과까지 왕복 2번으로 가장 적고, 저장 안 한 auto 는 침묵하는 옛 stdio 서버에 10초를 써요.
- SDK 서버의
tools/list기본 힌트는ttlMs: 0이에요. 몇 분 단위로 주면 목록 조회가 클라이언트 캐시로 끝나요. - 도구 순서를 고정하지 않으면 모델 쪽 캐시가 매번 깨져 캐싱을 안 쓸 때보다 비싸져요. Sonnet 5.5 예시에서 340달러가 2,500달러가 됐어요.
- 응답 형식은 지연이 아니라 진행률·취소 필요 여부로 고르고, 되묻는 도구는 리졸버 + MRTR 로 만들되
requestState를 봉인하고 만료를 짧게 둬요.
MCP 를 쓸지 말지, stdio 와 HTTP 중 무엇을 쓸지는 MCP 도입 판단표에, SDK 코드와 예제는 MCP 공식 파이썬 SDK 저장소에 있어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 MCP 동작 원리 — tools/call 흐름과 무상태 스펙에서 도식과 코드로 단계별로 풀었어요.