Skip to content

Split View: MCP가 세션을 걷어낸다 — 2026-07-28 리비전의 스테이트리스 코어 읽기

|

MCP가 세션을 걷어낸다 — 2026-07-28 리비전의 스테이트리스 코어 읽기

들어가며 — 이 글의 유통기한부터 밝히고 시작합니다

오늘은 2026년 7월 16일입니다. 그리고 MCP의 현재(Current) 사양은 여전히 2025-11-25입니다 — 공식 버저닝 문서가 그렇게 말합니다.

지금 이야기할 2026-07-28 리비전은 아직 draft이고, 릴리스 후보(RC)로 잠긴 상태입니다. RC 공지에 따르면 RC는 2026년 5월 21일에 잠겼고, 최종 사양은 2026년 7월 28일에 발행될 예정입니다. 즉 이 글을 쓰는 시점에서 12일 뒤입니다.

그래서 이 글은 "새 사양 해설"이 아니라 "RC를 미리 읽는 글"입니다. 세부는 아직 바뀔 수 있고, 실제로 바뀌면 이 글의 일부는 틀리게 됩니다. 그 전제를 깔고 읽어 주십시오. MCP가 애초에 무엇이고 도구·리소스·프롬프트가 어떻게 생겼는지는 MCP 레퍼런스 편에서 이미 정리했으니, 여기서는 무엇이 바뀌는가에만 집중합니다.

무엇이 문제였나 — 세션이 로드밸런서와 싸운다

Streamable HTTP가 원격 배포를 열어젖힌 뒤, 규모에서 문제가 드러났습니다. 2026 로드맵은 이걸 아주 직설적으로 적어 뒀습니다 — 상태를 가진 세션은 로드밸런서와 싸우고, 수평 확장에는 우회책이 필요하며, 레지스트리나 크롤러가 서버에 접속하지 않고 그 서버가 무엇을 하는지 알아낼 표준적인 방법이 없다는 것입니다.

구체적으로 무슨 뜻이냐면 이렇습니다. 기존 설계에서 클라이언트는 먼저 initialize 핸드셰이크를 하고, 서버는 Mcp-Session-Id를 발급하고, 이후 모든 요청은 그 세션에 묶입니다. 그러면 운영 쪽에는 이런 것들이 따라붙습니다.

  • 스티키 세션. 같은 세션의 요청은 같은 인스턴스로 가야 하니 라운드로빈을 못 씁니다.
  • 공유 세션 저장소. 인스턴스를 여러 개 띄우려면 세션 상태를 어딘가 공유해야 합니다.
  • 게이트웨이의 본문 검사. 이 요청이 tools/call인지 tools/list인지 알려면 JSON-RPC 본문을 뜯어봐야 합니다. HTTP 계층에서는 다 똑같은 POST니까요.

RC 공지는 이 변화의 결과를 한 문장으로 요약합니다 — 예전에 스티키 세션과 공유 세션 저장소, 게이트웨이의 심층 패킷 검사가 필요했던 원격 MCP 서버가 이제 평범한 라운드로빈 로드밸런서 뒤에서 돌 수 있다는 것입니다.

로드맵에는 솔직한 단서도 붙어 있습니다 — 이번 사이클에 공식 전송(transport)을 더 추가하지는 않고, 기존 전송을 진화시킬 뿐이라는 것. 새 아키텍처를 얹는 게 아니라 HTTP 전송을 손보는 작업입니다.

스테이트리스 코어 — 핸드셰이크와 세션이 사라진다

공식 changelog가 꼽은 주요 변경(major changes)의 앞머리가 전부 이 이야기입니다.

SEP-2575: initializenotifications/initialized 핸드셰이크를 제거합니다. 대신 모든 요청이 _meta에 자기 프로토콜 버전과 클라이언트 능력을 싣습니다 — io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities. 클라이언트는 매 요청에서 자신을 밝히는 게(io.modelcontextprotocol/clientInfo) 권장되고, 서버도 각 결과의 _meta에서 자신을 밝힙니다. 버전이 안 맞으면 UnsupportedProtocolVersionError가 돌아옵니다.

SEP-2567: Mcp-Session-Id 헤더와 프로토콜 계층의 세션 자체를 없앱니다. 그래서 목록 엔드포인트(tools/list, resources/list, prompts/list)는 더 이상 연결마다 달라지지 않습니다. 호출 간에 상태가 필요한 서버는 어떻게 하냐면 — 서버가 발행한 명시적 핸들을 평범한 도구 인자로 주고받습니다. 이게 핵심 철학입니다. 상태를 금지하는 게 아니라, 상태를 프로토콜의 암묵적 계층에서 애플리케이션의 명시적 데이터로 끌어내리는 것입니다.

server/discover 추가: 서버는 이 RPC를 반드시 구현해야 합니다(MUST). 지원하는 프로토콜 버전, 능력, 정체를 광고하는 용도입니다. 클라이언트는 다른 요청에 앞서 이걸 호출해 버전을 미리 고를 수도 있고, stdio에서는 하위 호환 탐침으로 쓸 수도 있습니다. 로드맵이 말한 "접속하지 않고 서버가 뭘 하는지 알아낼 방법"의 답이 여기 있습니다.

제거되는 것들: ping, logging/setLevel, notifications/roots/list_changed가 사라집니다. 로그 레벨은 이제 요청마다 _metaio.modelcontextprotocol/logLevel로 지정하고, 이 필드가 없는 요청에 대해 서버는 notifications/message를 내보내면 안 됩니다(MUST NOT).

구독 방식 교체: HTTP GET 엔드포인트와 resources/subscribe / resources/unsubscribesubscriptions/listen 하나로 바뀝니다 — 서버→클라이언트 변경 알림을 위한 단일 롱리브드 POST 응답 스트림입니다. 클라이언트가 원하는 종류(toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions)를 골라 옵트인합니다. 주의할 구분이 하나 있습니다 — notifications/progressnotifications/message 같은 요청 범위 알림은 여전히 그 요청의 응답 스트림으로 흐르지, subscriptions/listen 스트림으로 오지 않습니다.

그리고 여기가 진짜 비용입니다

같은 SEP-2575가 Streamable HTTP에서 SSE 스트림 재개(resumability)와 메시지 재전송을 제거합니다 — Last-Event-ID 헤더와 SSE 이벤트 ID가 없어집니다. changelog의 문장이 담백합니다: 응답 스트림이 끊기면 진행 중이던 요청은 유실되고, 클라이언트는 새 요청 ID로 새 요청을 다시 보내야 합니다(MUST).

이건 순수한 이득이 아니라 맞바꿈입니다. 재개 기능은 원래 "긴 도구 호출 중에 네트워크가 한 번 끊겨도 이어서 받는" 장치였습니다. 그게 없어졌다는 건, 오래 걸리는 작업의 신뢰성을 이제 프로토콜 코어가 아니라 다른 데서 — 뒤에 나올 Tasks 확장에서 — 구해야 한다는 뜻입니다.

MRTR — 서버가 되묻는 방식이 뒤집혔다

개인적으로 이번 리비전에서 가장 깊은 변화라고 보는 부분입니다.

지금까지 서버는 처리 도중에 클라이언트에게 되물을 수 있었습니다 — roots/list로 디렉터리를 묻거나, sampling/createMessage로 LLM을 빌려 쓰거나, elicitation/create로 사용자 입력을 받거나. 이건 서버가 요청을 붙들고 있는 동안 살아 있는 양방향 채널을 전제합니다. 즉 본질적으로 상태가 있습니다.

MRTR(Multi Round-Trip Requests) 패턴 문서는 이걸 대체합니다. 그리고 아주 분명하게 못을 박습니다 — 서버는 서버→클라이언트 요청을 반드시 MRTR 패턴으로 보내야 하고(MUST), 이전의 서버 개시 요청 패턴은 더 이상 지원되지 않으며, 이것은 파괴적 변경(breaking change)이라고요.

새 흐름은 이렇습니다.

1. 클라이언트 -> 서버 :  tools/call (id: 1, 파라미터)
2. 서버       -> 클라이언트 :  InputRequiredResult (id: 1)
                            resultType: "input_required"
                            inputRequests: { "github_login": ElicitRequest, ... }
                            requestState: "<서버만 해독하는 불투명 블롭>"
   -> 여기서 최초 요청은 종료됩니다. 서버는 아무것도 붙들지 않습니다.

3. 클라이언트: 사용자에게 물어 입력을 모음

4. 클라이언트 -> 서버 :  tools/call (id: 2, 원래 파라미터
                                   + inputResponses + requestState)
   -> 반드시 새 JSON-RPC id 여야 합니다. 독립된 요청이니까요.

5. 서버       -> 클라이언트 :  Result (id: 2, resultType: "complete")

서버가 되물을 때 돌려주는 InputRequiredResult는 대략 이런 모양입니다.

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}

문서가 강조하는 문장이 이 설계의 요점입니다 — 각 단계의 요청은 완전히 독립적이고, 재시도를 처리하는 서버는 그 재시도 요청에 직접 담긴 것 말고는 아무 정보도 필요로 하지 않습니다. 그래서 인스턴스 간 공유 저장소도, 상태 기반 로드밸런싱도 필요 없어집니다.

몇 가지 세부 규칙:

  • 모든 결과에 resultType 필드가 필수가 됩니다 — 보통 결과는 "complete", MRTR 중간 결과는 "input_required". 이전 프로토콜 서버가 이 필드를 빠뜨리면 클라이언트는 "complete"로 취급해야 합니다(MUST).
  • 서버가 InputRequiredResult를 보낼 수 있는 요청은 prompts/get, resources/read, tools/call 셋뿐입니다. 나머지에는 보내면 안 됩니다(MUST NOT).
  • 클라이언트가 능력으로 선언하지 않은 종류의 inputRequests를 서버가 보내면 안 됩니다. 클라이언트가 elicitation을 지원한다고 안 했으면 elicitation/create를 끼워 넣을 수 없습니다.
  • 서버는 클라이언트가 재시도해 줄 거라고 가정하면 안 됩니다(MUST NOT). 그냥 안 돌아올 수도 있습니다.

상태는 사라진 게 아니라 이사했습니다

여기서 정직해질 부분입니다. MRTR은 서버의 상태를 없앤 게 아니라 requestState라는 불투명 블롭에 담아 신뢰할 수 없는 클라이언트를 통과시켜 되돌려받는 방식입니다. 그리고 사양은 그 결과를 감추지 않습니다.

클라이언트 요청에 requestState가 들어 있으면, 서버는 그것을 공격자가 제어하는 입력으로 취급해야 합니다(MUST). 이 값이 인가·자원 접근·비즈니스 로직에 영향을 준다면 서버는 무결성을 보호해야 하고(HMAC이나 AEAD 등), 검증에 실패한 상태는 거부해야 합니다. 무결성 보호를 생략해도 되는 건 변조가 요청 실패 말고 아무 해도 못 끼칠 때뿐입니다.

리플레이 방지를 위해 서버는 무결성 보호된 페이로드 안에 인증된 주체(principal), 짧은 만료(TTL), 원 요청 식별자(메서드 이름과 주요 파라미터의 다이제스트 등)를 넣고 매번 검증하는 게 권장됩니다(SHOULD). 그리고 문서에 붙은 경고를 그대로 옮기면 — 이 조치들은 리플레이 창을 좁히고 사용자 간·요청 간 재사용을 막지만, 그 자체로 단 한 번만 쓰이는 것(single-use)을 보장하지는 않습니다. 일회성 상환처럼 정확히 한 번만 소비돼야 하는 경우 서버가 그 불변식을 직접 강제해야 합니다(MUST).

정리하면, 서버 작성자 입장에서 이건 새 숙제입니다. 예전엔 세션 객체를 메모리에 들고 있으면 됐던 것이, 이제 암호학을 제대로 하는 일이 됐습니다. 서명 없이 requestState에 사용자 ID를 넣는 순간 그건 인가 우회입니다. 스테이트리스는 공짜가 아니고, 그 비용의 상당 부분이 여기 있습니다.

또 하나 — 재시도는 말 그대로 원 요청의 재실행입니다. 서버가 되묻기 전까지 했던 작업이 비쌌다면, 그 작업은 재시도에서 다시 일어나거나(비용), 아니면 requestState에 실려야 합니다(크기와 복잡도).

운영자에게 실제로 바뀌는 것 — 헤더, 캐시, 트레이스

스테이트리스 코어만큼 화려하진 않지만, 게이트웨이를 굴리는 사람에게는 이쪽이 더 체감될 수 있습니다.

표준 헤더(SEP-2243). Streamable HTTP POST 요청에 Mcp-MethodMcp-Name 헤더가 필수가 됩니다. 로드밸런서·게이트웨이·레이트리미터가 본문을 뜯지 않고 HTTP 계층에서 라우팅하고 제한을 걸 수 있게 하려는 것입니다. 도구 파라미터에서 커스텀 헤더를 넘기는 x-mcp-header 지원도 함께 들어갑니다.

캐시(SEP-2549). tools/list, prompts/list, resources/list, resources/read, resources/templates/list 결과에 ttlMscacheScope가 새 CacheableResult 인터페이스로 필수가 됩니다. ttlMs는 신선도 힌트(밀리초)로 클라이언트가 캐시해 폴링을 줄이게 하고, cacheScope"public" 또는 "private"으로 공유 중간자가 캐시해도 되는지를 통제합니다. HTTP Cache-Control을 본뜬 셈이고, 기존 listChanged 알림을 대체하는 게 아니라 보완합니다.

세션이 사라져 목록이 연결마다 달라지지 않게 된 것과 이 캐시 필드가 한 세트라는 점을 눈여겨볼 만합니다. 목록이 안정적이니 캐시가 의미를 갖습니다. 같은 맥락에서 tools/list결정적인 순서로 도구를 반환하는 게 권장되는데(SHOULD), 이유가 재미있습니다 — 클라이언트 캐싱뿐 아니라 LLM 프롬프트 캐시 적중률을 올리기 위해서입니다. 프로토콜 설계가 LLM 추론 비용까지 의식하기 시작한 지점입니다.

트레이싱(SEP-414). _meta의 OpenTelemetry 트레이스 컨텍스트 전파 관례가 문서화됩니다 — traceparent, tracestate, baggage 키 이름이 고정되어, SDK와 게이트웨이를 가로질러 분산 트레이스가 상관됩니다.

에러 코드. 리소스 없음 에러가 MCP 고유의 -32002에서 JSON-RPC 표준 -32602(Invalid Params)로 바뀝니다. 리터럴 -32002로 매칭하는 클라이언트 코드가 있다면 고쳐야 합니다. 나아가 에러 코드 할당 정책이 생겨서, -32000~-32019는 구현 정의로 남기고(기존 SDK 사용은 그대로 인정) -32020~-32099를 사양이 예약합니다. 이 draft에서 도입됐던 코드들도 그에 맞춰 재번호됩니다 — HeaderMismatch-32001에서 -32020으로, MissingRequiredClientCapability-32003에서 -32021로, UnsupportedProtocolVersion-32004에서 -32022로.

인증 — OAuth/OIDC 쪽으로 더 붙는다

인증은 여러 SEP가 조금씩 조여 놓았습니다.

  • SEP-2468: 인가 서버는 RFC 9207에 따라 인가 응답에 iss 파라미터를 포함하는 게 권장되고(SHOULD), MCP 클라이언트는 iss가 있으면 인가 코드를 상환하기 전에 기록된 발급자와 대조해 검증해야 합니다(MUST). 인가 서버 믹스업 공격 완화입니다.
  • SEP-837: 클라이언트는 동적 클라이언트 등록(DCR) 시 적절한 application_type을 지정해야 합니다 — OpenID Connect 리다이렉트 URI 충돌을 피하려는 것입니다.
  • SEP-2352: 클라이언트 자격증명은 그것을 발급한 인가 서버에 묶입니다. 클라이언트는 저장한 자격증명을 발급자 식별자로 키잉해야 하고(MUST), 다른 인가 서버에 재사용하면 안 되며(MUST NOT), 인가 서버가 바뀌면 재등록해야 합니다(MUST).
  • 그리고 DCR(RFC 7591) 자체가 폐기 예정으로 들어갑니다 — Client ID Metadata Documents가 선호되는 등록 메커니즘이 됩니다. 다만 이를 지원하지 않는 인가 서버와의 하위 호환을 위해 DCR은 계속 사용 가능합니다.

없어지는 것들 — Roots, Sampling, Logging

SEP-2577이 Roots, Sampling, Logging 세 기능을 폐기 예정으로 표시합니다. 제안된 이주 경로는 이렇습니다.

기능대체
Roots도구 파라미터, 리소스 URI, 또는 서버 설정으로 디렉터리·파일 전달
SamplingLLM 제공자 API와 직접 통합
Loggingstdio에서는 stderr, 구조적 관측은 OpenTelemetry

중요한 건 지금 당장 깨지지 않는다는 점입니다. RC 공지의 표현대로, 해당 메서드·타입·능력 플래그는 이번 릴리스에서도, 그리고 이번 릴리스로부터 1년 안에 발행되는 모든 사양 버전에서도 계속 동작합니다. 이번엔 주석만 붙는 셈입니다.

그리고 이게 가능한 이유가 조용하지만 어쩌면 더 중요한 변화입니다 — SEP-2596기능 생명주기·폐기 정책을 도입합니다. 기능은 Active / Deprecated / Removed 세 상태 중 하나에 있고, 폐기와 제거 사이에는 최소 12개월 창이 보장됩니다. 창은 SEP가 Final이 된 날이 아니라 그 기능이 Deprecated로 표시된 사양 리비전이 릴리스된 시점부터 셉니다. 예외는 하나 — 게시된 보안 권고나 실제 악용이 문서화됐고 대체 완화책이 없는 활성 보안 위험일 때만 창을 줄일 수 있고, 그때도 최소 90일은 줘야 합니다.

여기에 폐기된 기능을 한곳에 모은 레지스트리가 붙습니다. 여러 리비전 changelog에 흩어진 항목을 뒤져 "뭐가 언제 없어지나"를 재구성하지 않아도 되게 하려는 것입니다. Tier 1 SDK는 해당 API 표면을 언어 고유의 방식으로(@Deprecated, [Obsolete], @deprecated JSDoc 등) 표시할 의무를 집니다.

같은 정책 아래 HTTP+SSE 전송(2025-03-26부터 폐기 상태였던)과 includeContext"thisServer" / "allServers" 값도 정식으로 Deprecated로 재분류됩니다.

솔직히 말하면, 저는 이 거버넌스 변화가 스테이트리스 코어보다 생태계에 오래 남을 거라고 봅니다. "이 프로토콜 위에 제품을 올려도 되나"라는 질문에 대한 답이 여기서 처음으로 날짜를 갖게 됐으니까요.

확장 — Tasks가 코어에서 나간다

SEP-2133이 확장(extension)을 1급 개념으로 만듭니다. 역방향 DNS 식별자를 갖고, 능력의 extensions 맵으로 협상되며, 사양과 독립적으로 버저닝되고, 자체 저장소와 위임된 관리자를 갖습니다.

이 틀 위에서 두 개의 공식 확장이 나옵니다.

  • MCP Apps (SEP-1865): 샌드박스된 iframe 안에서 서버가 렌더링한 HTML 인터페이스. 도구가 UI 템플릿을 선언하고, UI에서 나온 동작은 직접 호출과 같은 JSON-RPC 경로를 지납니다.
  • Tasks (SEP-2663): 오래 걸리는 작업. 원래 2025-11-25에 실험적으로 코어에 있던 기능인데, RC 공지의 표현을 그대로 옮기면 — 프로덕션 사용이 충분한 재설계를 불러와서, 사양보다는 확장이 이것의 올바른 집이라는 결론이 났습니다. 새 설계는 블로킹 방식의 tasks/resulttasks/get 폴링으로 바꾸고, 클라이언트→서버 입력용 tasks/update를 추가하고, tasks/list를 제거하고, 서버가 요청별 옵트인 없이 태스크 핸들을 먼저 돌려줄 수 있게 합니다.

앞서 SSE 재개가 없어졌다고 했던 대목이 여기서 이어집니다. 긴 작업의 신뢰성은 이제 스트림 재개가 아니라 태스크 핸들 + 폴링으로 푸는 게 정공법입니다. 그리고 경고 하나 — 2025-11-25의 실험적 Tasks API에 맞춰 이미 출시한 사람은 새 생명주기로 이주해야 합니다.

도구 스키마도 느슨해집니다. SEP-2106inputSchemaoutputSchema에 JSON Schema 2020-12 키워드를 전부 허용하고(oneOf, anyOf, allOf, 조건부, $ref, $defs), structuredContent가 임의의 JSON 값을 받게 합니다. 다만 구현은 외부 $ref URI를 자동으로 역참조하면 안 되고, 깊이와 검증 시간에 한계를 둬야 합니다 — 스키마가 표현력을 얻으면 그만큼 새 공격면이 열리니까요.

지금 뭘 해야 하나 — 그리고 뭘 하지 말아야 하나

2026년 6월 29일에 베타 SDK가 나왔습니다. 네 개의 Tier 1 SDK 전부입니다.

Python      mcp==2.0.0b1                       (v2, 정확한 핀 필요 — 안 하면 1.x에 머묾)
TypeScript  @modelcontextprotocol/server@beta  (v2, 서버/클라이언트 패키지 분리
            @modelcontextprotocol/client@beta   ESM 전용, Node.js 20+ / Bun / Deno)
Go          v1.7.0-pre.1                       (모듈 경로는 stable과 동일)
C#          2.0.0-preview.1                    (dotnet add package ... --prerelease)

그리고 공지가 직접 붙여 둔 경고를 그대로 옮깁니다 — 중요한 워크로드에는 stable SDK 릴리스가 여전히 권장 버전이고, 공개 API는 베타와 stable 사이에 아직 바뀔 수 있습니다. 테스트 중에는 정확한 버전을 핀하라는 이야기입니다.

하위 호환에 대해서도 분명합니다 — 기존 서버는 계속 동작하고, 공지의 표현으로는 오늘도 아무것도 깨지지 않고 7월 28일에도 아무것도 깨지지 않습니다. 새 클라이언트는 구형 서버를 만나면 레거시 핸드셰이크로 자동 폴백합니다.

지금 해 볼 만한 것

  • 코드베이스에서 리터럴 -32002 매칭을 찾아 두기. 조용히 깨질 수 있는 몇 안 되는 지점입니다.
  • 2025-11-25의 실험적 Tasks API에 걸어 뒀다면 이주 계획 세우기. 이건 확정된 숙제입니다.
  • 원격 서버를 굴린다면, 스티키 세션·공유 세션 저장소가 지금 얼마나 비용인지 계산해 보기. 그게 이번 리비전의 값어치를 재는 자입니다.
  • 베타 SDK로 테스트 환경에서 찔러 보기. 프로덕션 말고요.

서두르지 않아도 되는 것

여기가 제일 중요합니다. stdio로 로컬 서버만 돌린다면 이번 리비전의 대부분은 당신 이야기가 아닙니다. 스테이트리스 코어는 처음부터 끝까지 원격·수평확장 서사입니다. 프로세스 하나에 클라이언트 하나가 붙는 stdio 서버에게 스티키 세션은 애초에 문제가 아니었고, 라운드로빈 로드밸런서도 없습니다. 그런 서버에 돌아오는 건 이득이 아니라 이주 비용뿐입니다 — 폐기된 Roots/Sampling/Logging을 언젠가 걷어내야 하고, MRTR로 되묻기를 다시 짜야 하고, 그 대가로 얻는 건 별로 없습니다.

12개월 폐기 창은 바로 이런 사람을 위해 있는 것입니다. 급할 이유가 없으면 급하지 마십시오.

마치며

정리하면 이렇습니다. 2026-07-28은 MCP가 프로토콜 계층에서 상태를 포기하는 리비전입니다. 핸드셰이크와 Mcp-Session-Id가 사라지고, 모든 요청이 _meta로 스스로를 설명하고, server/discover가 접속 없는 발견을 열고, 표준 헤더와 캐시 힌트가 게이트웨이에 HTTP스러운 손잡이를 줍니다. 목표는 하나 — 아무 인스턴스나 아무 요청을 처리할 수 있게 하는 것.

대가도 분명합니다. 서버가 되묻는 일은 MRTR로 뒤집혀 파괴적 변경이 됐고, 상태는 사라진 게 아니라 requestState 블롭에 담겨 신뢰할 수 없는 클라이언트를 왕복하며, 그 블롭을 안전하게 만드는 암호학은 이제 서버 작성자의 책임입니다. SSE 재개가 빠진 자리는 Tasks 확장이 메워야 합니다. Roots·Sampling·Logging은 시한부에 들어갔습니다.

그리고 오늘 기준으로 이건 전부 아직 RC입니다. 현재 사양은 2025-11-25이고, 최종은 12일 뒤에 나옵니다. 스펙이 확정되면 다시 읽어야 할 문서는 블로그 글이 아니라 공식 changelog입니다 — 이 글의 목적은 그 문서를 읽으러 갈 때 무엇을 눈여겨봐야 할지 알려주는 것뿐입니다.

참고 자료

MCP Drops Sessions — Reading the Stateless Core in the 2026-07-28 Revision

Introduction — Let Me State This Post's Shelf Life Up Front

Today is July 16, 2026. And MCP's Current specification is still 2025-11-25 — that is what the official versioning document says.

The 2026-07-28 revision discussed here is still a draft, locked as a release candidate (RC). Per the RC announcement, the RC locked on May 21, 2026, and the final spec is scheduled to publish on July 28, 2026 — 12 days from when this post was written.

So this is not "an explainer of the new spec" but "a preview read of the RC." Details can still change, and if they do, parts of this post will turn out wrong. Read it with that caveat. What MCP is in the first place, and what tools, resources, and prompts look like, is already covered in the MCP reference post, so here I focus only on what is changing.

What Was the Problem — Sessions Fighting the Load Balancer

After Streamable HTTP opened the door to remote deployment, the problems showed up at scale. The 2026 roadmap puts it bluntly — stateful sessions fight load balancers, horizontal scaling needs workarounds, and there is no standard way for a registry or crawler to learn what a server does without connecting to it.

Concretely, this is what it meant. In the existing design, a client first does an initialize handshake, the server issues an Mcp-Session-Id, and every subsequent request is tied to that session. Operationally, that drags along:

  • Sticky sessions. Requests in the same session must go to the same instance, so round robin is out.
  • Shared session storage. Running multiple instances means sharing session state somewhere.
  • Deep packet inspection at the gateway. Telling whether a request is tools/call or tools/list means cracking open the JSON-RPC body — at the HTTP layer they are all identical POSTs.

The RC announcement sums up the payoff of this change in one line — a remote MCP server that used to need sticky sessions, shared session storage, and deep packet inspection at the gateway can now run behind a plain round-robin load balancer.

The roadmap also attaches an honest caveat — this cycle does not add new official transports, it only evolves the existing ones. This is not a new architecture bolted on; it is a reworking of the HTTP transport.

The Stateless Core — the Handshake and the Session Disappear

The top of the major changes listed in the official changelog is all about this.

SEP-2575: removes the initialize and notifications/initialized handshake. Instead, every request carries its own protocol version and client capabilities in _metaio.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities. Clients are recommended to identify themselves on every request (io.modelcontextprotocol/clientInfo), and servers identify themselves in the _meta of each result. A version mismatch returns UnsupportedProtocolVersionError.

SEP-2567: removes the Mcp-Session-Id header and the session itself from the protocol layer. So list endpoints (tools/list, resources/list, prompts/list) no longer vary per connection. How, then, does a server that needs state between calls handle it? It passes a server-issued, explicit handle back and forth as an ordinary tool argument. That is the central philosophy here — not banning state, but pulling state down from the protocol's implicit layer into the application's explicit data.

Addition of server/discover: servers MUST implement this RPC. It advertises which protocol versions, capabilities, and identity the server supports. Clients can call it ahead of other requests to pre-select a version, and on stdio it can serve as a backward-compatibility probe. This is the answer to what the roadmap called learning what a server does without connecting to it.

What gets removed: ping, logging/setLevel, and notifications/roots/list_changed disappear. Log level is now specified per request via io.modelcontextprotocol/logLevel in _meta, and for a request that lacks this field, servers MUST NOT emit notifications/message.

Subscription mechanism replaced: the HTTP GET endpoint and resources/subscribe / resources/unsubscribe are replaced by a single subscriptions/listen — one long-lived POST response stream for server-to-client change notifications. Clients opt in to the kinds they want (toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions). One distinction worth noting — request-scoped notifications such as notifications/progress or notifications/message still flow through that request's own response stream, not through the subscriptions/listen stream.

And Here Is Where the Real Cost Sits

That same SEP-2575 also removes SSE stream resumability and message redelivery from Streamable HTTP — the Last-Event-ID header and SSE event IDs are gone. The changelog states it plainly: if the response stream breaks, the request in flight is lost, and the client MUST send a new request with a new request ID.

This is not a pure win but a trade. Resumability used to be the mechanism that let a long tool call survive a single network drop and keep receiving. With it gone, the reliability of long-running work now has to come from somewhere else — from the Tasks extension, covered later — rather than from the protocol core.

MRTR — How Servers Ask Follow-Up Questions Gets Flipped

Personally, I think this is the deepest change in this revision.

Until now, a server could ask the client something mid-processing — request a directory via roots/list, borrow an LLM via sampling/createMessage, or collect user input via elicitation/create. That assumes a live, bidirectional channel while the server holds the request open. Which is, fundamentally, stateful.

The MRTR (Multi Round-Trip Requests) pattern document replaces this. And it states it very plainly — servers MUST send server-to-client requests using the MRTR pattern, and the previous server-initiated request pattern is no longer supported; this is a breaking change.

The new flow looks like this:

1. Client -> Server :  tools/call (id: 1, params)
2. Server -> Client :  InputRequiredResult (id: 1)
                       resultType: "input_required"
                       inputRequests: { "github_login": ElicitRequest, ... }
                       requestState: "<opaque blob only the server can decode>"
   -> The original request ends here. The server holds nothing.

3. Client: asks the user and collects the input

4. Client -> Server :  tools/call (id: 2, original params
                                   + inputResponses + requestState)
   -> Must use a new JSON-RPC id — it is an independent request.

5. Server -> Client :  Result (id: 2, resultType: "complete")

The InputRequiredResult a server returns when it asks a follow-up question looks roughly like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}

The sentence the document underlines is the point of this design — each step's request is fully independent, and a server handling a retry needs nothing beyond what is directly carried in that retry request. Which means no shared storage across instances, and no state-based load balancing, is needed.

A few detail rules:

  • A resultType field becomes mandatory on every result — ordinarily "complete", and "input_required" for an MRTR intermediate result. If a pre-existing-protocol server omits this field, clients MUST treat it as "complete".
  • Only three requests are allowed to receive an InputRequiredResult from a server: prompts/get, resources/read, and tools/call. Servers MUST NOT send one for anything else.
  • A server must not send a kind of inputRequests the client has not declared as a capability. If a client has not said it supports elicitation, the server cannot slip in elicitation/create.
  • Servers MUST NOT assume the client will retry. It may simply never come back.

The State Did Not Disappear — It Moved

Here is where honesty is required. MRTR does not eliminate server state; it stuffs it into an opaque blob called requestState, passes it through an untrusted client, and gets it back. And the spec does not hide the consequence.

If a client request carries a requestState, the server MUST treat it as attacker-controlled input. If this value affects authorization, resource access, or business logic, the server must protect its integrity (HMAC, AEAD, or similar) and must reject any state that fails validation. Skipping integrity protection is acceptable only when tampering cannot cause any harm beyond the request itself failing.

To resist replay, servers are recommended (SHOULD) to embed, inside the integrity-protected payload, an authenticated principal, a short TTL, and an identifier for the original request (such as a digest of the method name and key parameters), and to verify it every time. And to carry over the document's own warning as-is — these measures narrow the replay window and block reuse across users and requests, but they do not, by themselves, guarantee single-use. For cases that must be consumed exactly once — like a one-time redemption — the server itself must enforce that invariant (MUST).

To sum up, for server authors this is new homework. What used to be "just hold a session object in memory" has become "doing cryptography properly." The moment you put a user ID into requestState without a signature, that is an authorization bypass. Stateless is not free, and a large share of that cost lives right here.

One more thing — a retry is literally a re-execution of the original request. If the work the server did before it asked the follow-up question was expensive, that work either happens again on retry (cost), or has to ride along inside requestState (size and complexity).

What Actually Changes for Operators — Headers, Caching, Tracing

Less flashy than the stateless core, but this may be the part that people running gateways feel more.

Standard headers (SEP-2243). Mcp-Method and Mcp-Name headers become mandatory on Streamable HTTP POST requests. The goal is letting load balancers, gateways, and rate limiters route and throttle at the HTTP layer without tearing open the body. Support for x-mcp-header, which passes custom headers through tool parameters, ships alongside this.

Caching (SEP-2549). tools/list, prompts/list, resources/list, resources/read, and resources/templates/list results get ttlMs and cacheScope as a new, mandatory CacheableResult interface. ttlMs is a freshness hint in milliseconds that lets clients cache and poll less; cacheScope is "public" or "private" and controls whether a shared intermediary is allowed to cache it. It is modeled on HTTP's Cache-Control, and it does not replace the existing listChanged notifications — it complements them.

Worth noting that this caching field is a matched set with the fact that sessions are gone and lists no longer vary per connection. Stable lists are what make caching meaningful. In the same spirit, tools/list is recommended (SHOULD) to return tools in a deterministic order, and the reason is interesting — it is not just for client-side caching, but to raise the LLM prompt cache hit rate. This is the point where protocol design starts to be conscious of LLM inference cost itself.

Tracing (SEP-414). The convention for propagating OpenTelemetry trace context in _meta gets documented — the traceparent, tracestate, and baggage key names are pinned down, so distributed traces correlate across SDKs and gateways.

Error codes. The resource-not-found error moves from MCP's own -32002 to the JSON-RPC standard -32602 (Invalid Params). If you have client code matching the literal -32002, it needs fixing. Beyond that, an error-code allocation policy is now in place — -32000 through -32019 stay implementation-defined (existing SDK usage is grandfathered in), and -32020 through -32099 are reserved by the spec. Codes introduced earlier in this draft get renumbered accordingly — HeaderMismatch moves from -32001 to -32020, MissingRequiredClientCapability from -32003 to -32021, and UnsupportedProtocolVersion from -32004 to -32022.

Auth — Leaning Further Into OAuth/OIDC

Several SEPs have tightened auth incrementally.

  • SEP-2468: authorization servers are recommended (SHOULD) to include an iss parameter in the authorization response per RFC 9207, and if iss is present, MCP clients MUST validate it against the recorded issuer before redeeming the authorization code. This mitigates authorization-server mix-up attacks.
  • SEP-837: clients must specify an appropriate application_type during dynamic client registration (DCR), to avoid OpenID Connect redirect-URI collisions.
  • SEP-2352: client credentials are bound to the authorization server that issued them. Clients MUST key stored credentials by issuer identifier, MUST NOT reuse them against a different authorization server, and MUST re-register if the authorization server changes.
  • And DCR (RFC 7591) itself enters deprecation — Client ID Metadata Documents becomes the preferred registration mechanism. DCR remains usable for backward compatibility with authorization servers that do not support it yet.

What Is Going Away — Roots, Sampling, Logging

SEP-2577 marks three features — Roots, Sampling, and Logging — as deprecated. The proposed migration path is:

FeatureReplacement
Rootspass directories/files via tool parameters, resource URIs, or server config
Samplingintegrate directly with an LLM provider API
Loggingstderr on stdio; structured observability via OpenTelemetry

The important part is that nothing breaks right now. As the RC announcement puts it, the relevant methods, types, and capability flags keep working in this release, and in every spec version published within 1 year of this release. For now, this is only a comment attached.

And the reason this is even possible is a quiet but arguably more important change — SEP-2596 introduces a feature lifecycle and deprecation policy. A feature sits in one of three states — Active, Deprecated, or Removed — and a minimum 12-month window is guaranteed between deprecation and removal. The window is counted not from the day the SEP went Final, but from when the spec revision that marked the feature Deprecated was released. There is exactly one exception — the window can be shortened only for an active security risk backed by a published advisory or documented real-world exploitation with no alternative mitigation, and even then a minimum of 90 days must be given.

Attached to this is a registry that collects deprecated features in one place, so nobody has to reconstruct what disappears when by digging through changelogs scattered across revisions. Tier 1 SDKs carry an obligation to mark the corresponding API surface in each language's native way (@Deprecated, [Obsolete], @deprecated JSDoc, and so on).

Under this same policy, the HTTP+SSE transport (deprecated since 2025-03-26) and the "thisServer" / "allServers" values of includeContext are also formally reclassified as Deprecated.

Honestly, I think this governance change will outlast the stateless core in the ecosystem. The question of whether you can build a product on top of this protocol just got a date attached to its answer for the first time.

Extensions — Tasks Leaves the Core

SEP-2133 makes extensions a first-class concept. Each has a reverse-DNS identifier, is negotiated through the extensions map in capabilities, is versioned independently of the spec, and has its own repository and delegated maintainers.

Two official extensions land on top of this framework.

  • MCP Apps (SEP-1865): server-rendered HTML interfaces inside a sandboxed iframe. Tools declare UI templates, and actions from the UI take the same JSON-RPC path as a direct call.
  • Tasks (SEP-2663): for long-running work. This was originally an experimental core feature in 2025-11-25, and — to quote the RC announcement directly — production use surfaced enough of a need for redesign that an extension, rather than the spec, turned out to be its right home. The new design replaces the blocking tasks/result with tasks/get polling, adds tasks/update for client-to-server input, removes tasks/list, and lets servers return a task handle up front without requiring per-request opt-in.

This is where the earlier point about SSE resumability disappearing comes back around. The reliability of long-running work is now properly solved with task handles plus polling, not stream resumption. And one warning — anyone who already shipped against the experimental 2025-11-25 Tasks API needs to migrate to the new lifecycle.

Tool schemas loosen up too. SEP-2106 allows inputSchema and outputSchema to use the full JSON Schema 2020-12 keyword set (oneOf, anyOf, allOf, conditionals, $ref, $defs), and lets structuredContent accept arbitrary JSON values. Implementations, however, must not auto-dereference external $ref URIs, and must cap depth and validation time — more expressive schemas open a correspondingly larger attack surface.

What to Do Now — and What Not to Rush

Beta SDKs shipped on June 29, 2026 — all four Tier 1 SDKs.

Python      mcp==2.0.0b1                       (v2, needs an exact pin — otherwise you stay on 1.x)
TypeScript  @modelcontextprotocol/server@beta  (v2, server/client packages split
            @modelcontextprotocol/client@beta   ESM-only, Node.js 20+ / Bun / Deno)
Go          v1.7.0-pre.1                       (module path unchanged from stable)
C#          2.0.0-preview.1                    (dotnet add package ... --prerelease)

And to carry over the warning the announcement itself attaches — the stable SDK release is still the recommended version for important workloads, and public APIs can still change between beta and stable. Pin an exact version while testing.

On backward compatibility, it is unambiguous — existing servers keep working, and in the announcement's own words, nothing breaks today and nothing breaks on July 28 either. New clients that meet an older server fall back automatically to the legacy handshake.

Worth trying now

  • Grep your codebase for literal -32002 matches. This is one of the few spots that can break quietly.
  • If you built against the experimental 2025-11-25 Tasks API, plan the migration. This is confirmed homework.
  • If you run a remote server, work out how much sticky sessions and shared session storage actually cost you today. That is the ruler for what this revision is worth to you.
  • Poke at it with the beta SDK in a test environment. Not production.

Not worth rushing

This is the most important part. If you only run local servers over stdio, most of this revision is not about you. The stateless core is a remote, horizontal-scaling story from top to bottom. For a stdio server with one process tied to one client, sticky sessions were never a problem to begin with, and there is no round-robin load balancer either. For that kind of server, what comes back is not a benefit but pure migration cost — Roots, Sampling, and Logging eventually have to be removed, follow-up questions have to be rewritten for MRTR, and the payoff is not much.

The 12-month deprecation window exists precisely for people like this. If there is no reason to hurry, do not hurry.

Closing

To sum up: 2026-07-28 is the revision where MCP gives up state at the protocol layer. The handshake and Mcp-Session-Id disappear, every request describes itself through _meta, server/discover opens up discovery without connecting, and standard headers plus caching hints give gateways HTTP-like handles. The goal is singular — let any instance handle any request.

The cost is just as clear. How a server asks follow-up questions gets flipped by MRTR into a breaking change; state has not disappeared but now rides back and forth through an untrusted client inside a requestState blob, and the cryptography that makes that blob safe is now the server author's responsibility. The gap left by removed SSE resumability has to be filled by the Tasks extension. Roots, Sampling, and Logging are now on the clock.

And as of today, all of this is still just an RC. The current spec is 2025-11-25, and the final version arrives in 12 days. Once the spec is finalized, the document to re-read is not this blog post but the official changelog — this post's only job is to tell you what to look for when you go read that document.

References