스테이트리스(Stateless)화된 MCP 서버에서, 어디에 남아있던 상태가 사라지는가. 로드 밸런서(Load Balancer) 설정만 고치고 끝내면, 여기서 사고가 난다.
7월 28일에 정식화 예정인 MCP 2026-07-28은, 프로토콜 계층의 initialize / initialized와 Mcp-Session-Id를 제거한다. RC 단계에서는 능력 확인은 server/discover로 옮겨지며, 각 요청이 자신의 프로토콜 정보를 가지는 형태가 된다. 일반적인 라운드 로빈(Round Robin) 하에 배치하기 쉬워지는 것은 확실하다. 다만, 장바구니나 편집 중인 문서까지 사라지는 것은 아니다.
상태를 필요로 한다면, 툴의 반환값으로 basket_id와 같은 핸들(Handle)을 반환하고, 다음 호출에서 인자(Argument)로 전달한다. 이행의 본체는 "세션을 버리는" 작업보다, 이 계약(Contract)을 겉으로 드러내는 작업이라고 생각한다.
구 구성에서는 클라이언트가 어느 서버로 왔는지와 어떤 작업의 연속인지가 Mcp-Session-Id 뒤에 숨기 쉽다. 스테이트리스화 후에도 애플리케이션의 상태는 가질 수 있지만, 툴 호출만을 보고 추적할 수 있는 형태로 만들 필요가 있다.
구 구성과 이행 후의 구성을 도식화하면, 차이점은 여기에 집약된다.
나는 이런 종류의 이행에서 먼저 Ingress의 Sticky 설정을 찾곤 했다. 하지만 먼저 조사해야 할 것은, 후속 툴이 아무런 ID도 받지 않고 동작하고 있지 않은가였다. add_item이나 checkout에 작업 대상의 인자가 없다면, 접속 대상을 아무리 깔끔하게 관리해도 상태는 승계되지 않는다.
이하는 JSON화한 툴 실행 trace에 대해, 핸들의 생성과 전달을 검사하는 최소한의 Python이다. contracts를 자신의 툴 이름과 ID 이름으로 교체하여 사용한다.
from __future__ import annotations
def audit_state_handles(calls: list[dict], contracts: dict[str, dict]) -> list[str]:
known_handles: set[str] = set()
...
Python 3.12.13에서 실행한 출력은 다음과 같았다.
[]
["#2 add_item: basket_id='b-999' 는, 이 trace에서 생성되지 않았다"]
처음에는 "필수 인자가 있는가"만을 보고 있었다. 이렇게 하면 basket_id="적당한 문자열"이 통과된다. 생성 툴의 반환값과 대조하는 검사까지 넣으면, 적어도 테스트용 trace에서 상태의 출처를 추적할 수 있다. 실운용에서는 로그에 툴 이름, 인자의 핸들, 결과의 핸들을 남기면 동일한 검사를 CI로 보낼 수 있다.
이 부분은 섞지 않는 것이 좋다. basket_id는 대상을 가리키기 위한 값이지, 액세스 권한(Access Right)이 아니다. checkout 측에서는 해당 핸들의 소유자나 테넌트(Tenant)를 인증된 주체와 대조한다. ID를 명시함으로써 다른 사용자의 ID를 전달할 수 있게 해서는 안 된다.
또 하나, 서버에서 임의의 타이밍에 통지하는 설계, 구독(Subscription), 접속에 묶인 분리가 필요하다면 스테이트리스화를 서두르지 마라. 2026-07-28 대응 SDK와 클라이언트가 혼재하는 기간은 구 프로토콜을 남겨두는 경로도 필요하다. RC는 최종 사양이 아니므로, 고정된 날짜로 구현을 단정 짓지 않는 것도 중요하다.
이번 변경으로 사라지는 것은 MCP가 관리하던 접속 단위의 상태다. 업무상의 상태까지 없앨 필요는 없다. 오히려 basket_id나 draft_id를 툴 계약으로서 드러내는 편이 trace도 테스트도 장애 조사도 쉬워진다.
이행 전에 상태를 만드는 툴, 상태를 사용하는 툴, 인가를 확인하는 곳을 한 장에 써 내려간다. 이 감사를 수행한 뒤에 Sticky Session을 제거하면, 스테이트리스화는 인프라의 모양 바꾸기로 끝나지 않고, MCP의 툴 설계를 조금 제대로 할 수 있게 된다.
참고: