MCP가 스테이트리스가 됐다: 2026-07-28 리비전이 우리 서버에서 걷어낸 것
MCP 서버를 운영한다면 어딘가에 세션이 있습니다. 아마 테이블 하나, 어쩌면 메모리 안의 map 하나일 겁니다. 클라이언트가 접속해서 initialize를 보내고, Mcp-Session-Id를 돌려받고, 그 뒤로는 모든 요청에 그 헤더를 달고 다닙니다. 여러분은 그 행을 저장합니다. 얼마 뒤에 만료시킵니다. 요청이 그 행을 가진 인스턴스에 도착하게 만들거나, 인스턴스들 사이에서 상태를 공유합니다.
2026-07-28 리비전은 그것을 삭제했습니다. deprecated로 표시한 게 아니라, 프로토콜 코어에서 제거했습니다. 핸드셰이크는 사라졌고, 세션 헤더도 사라졌고, 이제 모든 요청이 자기 자신의 프로토콜 버전과 클라이언트 identity를 실어 나릅니다. 릴리스 글의 표현을 빌리면, "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage" — 이제 어떤 요청이든 공유 스토리지 없이도 평범한 round-robin 로드 밸런서 뒤의 어떤 서버 인스턴스에나 도착할 수 있다는 뜻입니다.
Archyl의 MCP 서버는 이제 새 리비전을 말하는 클라이언트를 처리합니다. 이 글은 그 작업에 무엇이 필요했는지, 그 뒤에 무엇을 측정했는지, 첫 번째 패스에서 틀렸던 한 가지, 그리고 우리가 하지 않은 것에 대한 글입니다. MCP 서버를 유지보수하고 있다면 흥미로운 부분은 아마 중간의 설계 결정, 새 transport를 감사하다가 옛 transport에서 드러난 버그, 그리고 여러분의 서버를 점검하러 갈 때 쓸 마지막 체크리스트일 겁니다.
리비전이 실제로 제거한 것
체인지로그에서 곧바로, 서버 구현에 닿는 부분들만 옮기면 이렇습니다.
- 프로토콜 수준의 세션과
Mcp-Session-Id헤더가 Streamable HTTP transport에서 제거됐습니다. 목록 엔드포인트는 더 이상 연결마다 달라지지 않습니다. initialize/notifications/initialized핸드셰이크가 제거됐습니다. 모든 요청이 자기 프로토콜 버전과 클라이언트 capability를_meta에 담고, Streamable HTTP에서는 같은 버전이MCP-Protocol-Version헤더로도 함께 이동합니다.server/discover가 새로 생겼고 필수입니다. 서버는 이것을 구현해야 하며(MUST), 지원하는 프로토콜 버전, capability, identity를 알립니다. 클라이언트는 다른 무엇보다 먼저 이것을 호출해도 되고(MAY), 그냥 요청을 보내고 버전 에러를 처리해도 됩니다.- **
ping,logging/setLevel,notifications/roots/list_changed**가 제거됐습니다. - 버전 불일치는
UnsupportedProtocolVersionError를 반환하며, 서버가 실제로 지원하는 버전 목록을 함께 실어 클라이언트가 재시도할 수 있게 합니다.
그 안에는 더 있습니다(Multi Round-Trip Requests, subscriptions/listen, 캐시 가능한 목록 결과, 번호를 다시 매긴 에러 코드 블록, authorization 강화). 그중 무엇을 했고 무엇을 건너뛰었는지는 뒤에서 다시 다룹니다. 위의 다섯 가지는 서버의 기능이 아니라 서버의 모양을 바꾸는 것들입니다.
정확하게 짚어둘 가치가 있는 점이 하나 있습니다. 판단이 달라지기 때문입니다. 이건 더 이상 릴리스 후보가 아닙니다. 릴리스 후보는 2026년 5월 21일에 고정됐고, SDK 메인테이너와 클라이언트 구현자를 위해 10주짜리 검증 창을 열었습니다. 그 창은 사양이 출시된 2026년 7월 28일에 닫혔고, 버저닝 페이지는 이제 2026-07-28을 "the current protocol version" — 현재의 프로토콜 버전 — 이라고 부릅니다. Tier 1 SDK 네 개(TypeScript, Python, Go, C#)는 릴리스 당일부터 이것을 말하고, Rust는 베타입니다. RC가 안정될 때까지 기다리고 있었다면, 안정됐습니다.
그것이 tool 181개짜리 서버에 의미한 것
Archyl의 MCP 서버는 C4 모델 위로 181개의 tool을 노출합니다. 프로젝트, 시스템, container, component, relationship, ADR, 문서, contract, conformance, drift, DORA, ownership. 이 변경 전에는 181개 전부가 세션 뒤에 있었습니다.
구체적으로, 우리 백엔드에서는 이랬습니다.
- 연결마다
mcp_sessions테이블에 행이 하나씩 생겼고, 만료는 24시간이었으며, 백그라운드 goroutine이 오래된 행과 만료된 행을 쓸어냈습니다. - SSE 응답 채널은 서버 struct 위의
map[string]chan *JSONRPCMessage안에 세션 ID를 키로 살아 있었고, 이것이 연결을 그 연결을 연 프로세스에 고정시켰습니다. 그 map은 그 뒤로 자리를 옮겼는데, 이유는 취향이 아니라 버그였던 것으로 드러났습니다. 아래에서 다시 다룹니다. - 핸들러 네 개(
tools/list,tools/call,resources/list,resources/read)가 똑같은 세 줄로 시작했습니다.
if !session.Initialized {
return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}
흥미로운 건 그 guard입니다. 새 프로토콜이 답할 수 없게 만들어버린 질문을 던지고 있으니까요. 이 호출자가 핸드셰이크를 완료했는가? 완료할 핸드셰이크가 없습니다.
변경을 작게 유지한 결정
솔깃한 수는 그 네 개의 핸들러에게 스테이트리스라는 개념을 가르치는 것입니다. 조건을 하나 더 붙이거나, 각 검사 앞에 session.Stateless ||를 두거나, 통째로 미들웨어로 끌어올리거나.
우리는 그중 아무것도 하지 않았습니다. guard는 손대지 않았습니다. 대신 2026-07-28을 선언한 요청은 그 한 번의 요청만을 위해 만들어진 인메모리 세션을 받고, 그 세션이 구성상 guard를 만족시킵니다.
func (s *Server) NewStatelessSession(userID, organizationID uuid.UUID, protocolVersion string) *Session {
now := time.Now()
return &Session{
Session: &mcpsession.Session{
ID: "",
UserID: userID,
OrganizationID: organizationID,
Initialized: true,
ProtocolVersion: protocolVersion,
Transport: "streamable",
LastAccessedAt: now,
CreatedAt: now,
},
Stateless: true,
}
}
아무것도 저장되지 않습니다. ID가 할당되지 않습니다. SSE 채널이 등록되지 않습니다. Initialized: true는 거짓말도 우회도 아닙니다. 이 리비전 아래에서 그 요청은 진짜로 초기화된 상태입니다. 프로토콜이 자기 버전을 직접 실어 나르고, 원래대로라면 완료했을 핸드셰이크가 더 이상 존재하지 않기 때문입니다.
이 프레이밍이 보기보다 더 중요한 이유가 있습니다. 그 네 개의 guard는 인가에 인접한 경로 위에 있습니다. 각각이 tool 호출이 실행되느냐 거부되느냐를 가르는 지점입니다. 보안의 모양을 한 질문에 답하는 호출 지점 네 곳을 수정한다는 건, 검사를 약화시킬 기회가 네 번 생긴다는 뜻이고, 그것이 리뷰어가 한 번에 머릿속에 담아야 하는 diff에 흩어져 있다는 뜻입니다. guard가 이미 기대하고 있는 객체를 만드는 쪽은 새 함수 하나로 끝나고, 기존 검사는 전부 정확히 같은 의미를 유지합니다.
게다가 안전한 방향으로 실패합니다. 우리 버전 감지가 틀려서 스테이트리스 요청을 레거시 요청으로 잘못 읽으면, 그 결과는 그 요청을 위해 세션 행이 생기는 것입니다. 이전에 통과하지 못했을 것이 통과하는 일은 없습니다. 반대 설계, 즉 guard를 느슨하게 하고 버전 문자열로 문을 지키게 하는 쪽은 반대 방향으로 실패합니다.
스테이트리스화가 테넌시 측면에서 대가를 치르게 하지도 않았습니다. 애초에 identity가 세션 행에서 온 적이 없기 때문입니다. 스테이트리스 세션은 그 요청에 제시된 API 키나 OAuth 토큰에서 해석된 사용자와 조직을 담고, 스코프는 호출마다 다시 도출되므로 키를 폐기하면 즉시 효력이 생기며, tools/call은 지금도 테넌트가 묶이지 않은 세션을 거부합니다. 훔칠 것이 하나 줄었습니다. 재생될 수 있는 저장된 세션 ID가 없습니다. 레거시 경로에서는 대응하는 검사를 그대로 뒀기 때문에, 세션 ID 하나로 어떤 자격 증명이 다른 사람의 세션에 저장된 identity로 행동하는 일은 생길 수 없습니다.
라우팅은 switch 하나
결정 전체가 HTTP 핸들러 안에, JSON-RPC 본문이 파싱되기도 전에 들어 있습니다.
switch {
case mcp.IsModernProtocolVersion(requestedVersion):
// Stateless: the request describes itself, so nothing is looked up,
// nothing is written, and no Mcp-Session-Id comes back.
session = h.mcpServer.NewStatelessSession(auth.UserID, auth.OrganizationID, requestedVersion)
case sessionID != "":
// Handshake-based client with a session: look it up, and check it
// belongs to this credential.
default:
// Legacy client that has not handshaken yet: mint a session as before.
}
거기서 놓치기 쉬운 세부가 두 개 있습니다.
IsModernProtocolVersion은 "2026-07-28"과의 문자열 비교입니다. 리비전은 YYYY-MM-DD이므로 사전순이 곧 시간순이고, 미래의 리비전은 핸드셰이크로 되돌아가는 대신 기본적으로 스테이트리스 쪽에 착지합니다. 다만 거기까지 가는 건 우리가 그 버전을 지원할 때뿐입니다. 인식되지 않는 버전은 switch에 닿기 전에 거부되며, 클라이언트가 재시도할 수 있도록 지원 목록이 에러 data에 실립니다.
그리고 응답 헤더:
if !session.Stateless {
c.Set("Mcp-Session-Id", session.ID)
}
스테이트리스 세션에는 ID가 없습니다. 빈 Mcp-Session-Id를 돌려주는 것은 존재하지 않는 것을 재사용하라고 클라이언트에게 말하는 셈이고, 이는 아예 보내지 않는 것보다 더 나쁜 버그이며, 여러분이 직접 짜지 않은 클라이언트를 상대할 때에만 드러나는 종류의 문제입니다.
체인지로그의 나머지, 제대로 읽기
버전 switch가 흥미로운 결정입니다. 리비전의 나머지는 놓치기 쉽고 확인하기는 싼 작은 요구사항들의 목록이라, 우리는 체인지로그를 한 줄씩 다시 훑었습니다. 그 패스에서 네 가지가 들어왔습니다.
모든 result에 resultType. 이 리비전은 이 필드를 필수로 만듭니다. 완결된 답에는 "complete", multi round-trip 패턴의 중간 결과에는 "input_required". 클라이언트는 더 오래된 서버에서 이 필드가 없으면 "complete"로 취급하라는 안내를 받지만, 최종 리비전을 읽는 클라이언트는 이것을 찾습니다. 우리 것들은 이제 이 필드를 담고 있습니다. 각 result 타입이 저마다 필드를 기억하는 대신, 각 result 타입에 임베드된 하나의 Result struct에서 나옵니다.
목록 결과의 ttlMs와 cacheScope. tools/list, prompts/list, resources/list, resources/read, resources/templates/list에서 새로운 CacheableResult 인터페이스를 통해 필수가 됐습니다. 우리는 그 다섯 중 셋을 호스팅하고 있고, 그것들은 60000과 private을 반환합니다. 60초는 계약이 아니라 힌트입니다. 에이전트가 매 턴마다 181개의 tool을 다시 나열하는 것을 막을 만큼은 길고, 세션 도중에 등록된 tool이 금방 나타날 만큼은 짧습니다. private은 우리가 받아들인 기본값이 아니라 결정입니다. 우리가 반환하는 모든 결과는 호출자의 조직 범위로 한정되므로, 공유되는 중간 계층이 그것을 캐시해 다른 테넌트에게 건네줘도 되는 일은 없어야 합니다.
2026-07-28을 선언하는 클라이언트의 DELETE /mcp. DELETE는 프로토콜 수준 세션을 종료시키는 것이었는데, 프로토콜 수준 세션은 더 이상 없습니다. 사양은 405로 답하라고 하고, 그래서 모던 클라이언트가 받는 것이 그것입니다. 핸드셰이크 기반 클라이언트는 예전 동작을 유지합니다.
구현되지 않은 메서드는 이제 JSON-RPC -32601을 실은 HTTP 404를 반환합니다. 상태 코드만으로는 모호합니다. 모던 엔드포인트를 아예 호스팅하지 않는 레거시 HTTP+SSE 서버도 404로 답하기 때문입니다. 둘을 구분해주는 것은 JSON-RPC 본문이고, 사양은 클라이언트가 그것으로 initialize로 되돌아갈지 재시도할지를 결정한다고 명시합니다.
그리고 첫 번째 패스에서 틀린 것 하나
우리의 지원하지 않는 버전 에러는 -32600, JSON-RPC의 일반적인 "invalid request"를 반환하고 있었습니다. 그건 정확히 이 리비전 직전까지는 변호할 수 있는 선택이었습니다. 이 리비전은 JSON-RPC의 서버 에러 범위를 쪼개는 에러 코드 할당 정책을 정의합니다. -32000부터 -32019까지는 구현 정의로 남고, -32020부터 -32099까지는 사양의 몫입니다. draft 기간에 도입된 코드들은 그 블록 안으로 번호가 다시 매겨졌습니다. HeaderMismatch는 -32001 → -32020, MissingRequiredClientCapability는 -32003 → -32021, UnsupportedProtocolVersion은 -32004 → -32022로 갔습니다.
최종 리비전을 기준으로 작성된 클라이언트는 -32022를 찾습니다. 그 클라이언트는 우리가 보내던 것을 알아보지 못했을 것이고, 그 실패 양상이야말로 이 리비전 전체가 피하려고 설계된 바로 그것입니다. 클라이언트는 "버전이 틀렸다, 내가 말하는 건 이것들이다"와 "당신 요청이 잘못된 형식이었다"를 구별하지 못하고, 따라서 재시도할 근거가 없습니다.
체인지로그를 두 번째로 읽은 것 말고는 아무것도 이것을 잡아내지 못했습니다. 그건 이 글 자신의 주장을 우리에게 되돌린 것이기도 합니다. 이 번호 재배정은 minor changes의 12번 항목으로, OpenTelemetry _meta 키와 JSON Schema 키워드에 관한 항목들 뒤에 있습니다. 눈으로 훑고 지나가기 딱 좋은 줄입니다.
이름 변경 하나는 우리에게 아무 대가도 요구하지 않았습니다. resource-not-found는 JSON-RPC의 "invalid params"에 맞추려고 -32002에서 -32602로 옮겨갔는데, resources/read는 알 수 없는 URI에 대해 이미 -32602로 답하고 있었습니다.
우리가 측정한 것
이 모든 것은 이 빌드로 돌아가는 컨테이너에 대해, 실제 API 키로 측정했습니다. Postgres의 행을 직접 셀 수 있도록 하기 위해서입니다.
| 테스트 | 결과 |
|---|---|
MCP-Protocol-Version: 2026-07-28을 붙이고 핸드셰이크 없이 보낸 tools/list |
181개 tool |
그 응답에 실려 돌아온 Mcp-Session-Id |
없음 |
tools/list와 server/discover의 resultType |
complete |
tools/list의 ttlMs / cacheScope |
60000 / private |
server/discover |
["2026-07-28", "2025-03-26"] |
| 지원하지 않는 버전 선언 | -32022, 에러 data에 지원 목록 |
2026-07-28을 선언하는 클라이언트의 DELETE /mcp |
405 |
| 알 수 없는 메서드 | -32601을 실은 404 |
레거시 initialize 핸드셰이크 |
여전히 동작 |
session id를 붙인 레거시 tools/list |
181개 tool |
스테이트리스 요청 10건이 만든 mcp_sessions 행 |
0 |
레거시 요청 3건이 만든 mcp_sessions 행 |
3 |
봐야 할 것은 마지막 두 줄입니다. 요청 열 번, 행은 없음. 레거시 요청 세 건은 각각 세션 ID 없이 도착했으므로 각각 하나씩 만들었습니다. 자기 ID를 재사용하는 얌전한 핸드셰이크 기반 클라이언트라면 호출당 하나가 아니라 세션의 수명 동안 행 하나를 갖습니다. 요점은 0입니다. 스테이트리스 경로에는 쓸 것도, 만료시킬 것도, 정리용 goroutine이 찾아낼 것도 없습니다.
그 표의 첫 줄을 만들어낸 요청을 공개 엔드포인트로 향하게 하면 이렇습니다.
curl -s https://api.archyl.com/mcp \
-H "X-API-Key: $ARCHYL_API_KEY" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
initialize 없음. 세션 없음. tool 181개.
deprecated된 transport가 감추고 있던 버그
새 transport를 감사하다 보니 옛 transport를 들여다보게 됐고, 옛 것에는 진짜 버그가 있었습니다.
2024-11-05의 HTTP+SSE transport는 하나의 대화를 두 개의 연결로 쪼갭니다. 클라이언트가 GET으로 오래 사는 스트림을 열고, 서버의 첫 이벤트가 어디로 POST해야 하는지 알려주며, 그때부터 모든 메시지는 POST로 나가고 모든 응답은 스트림으로 돌아옵니다. 그 두 연결이 같은 인스턴스에 도착해야 할 이유는 없습니다.
우리 것은 그렇다고 가정했습니다. 응답 채널은 서버 struct 위의 그 map[string]chan *JSONRPCMessage 안에 살았으므로, 인스턴스 B가 처리한 POST는 인스턴스 B에 존재하고 인스턴스 B의 누구도 읽고 있지 않은 채널에 응답을 썼습니다. 스트림은 인스턴스 A에 있었습니다. 클라이언트는 기다렸습니다.
이걸 설계상의 냄새보다 더 나쁘게 만드는 건, 아무것도 로그에 남지 않았다는 점입니다. 에러도, 경고도, 실패한 요청도 없었습니다. POST는 202 Accepted를 반환했고 그건 사실이었습니다. 메시지는 정말로 접수됐고, 답은 아무 데도 가지 않았습니다. 밖에서 보면 느린 tool 호출과 구분되지 않습니다. 이건 수평으로 스케일된 배포에서만 일어나고, 거기는 정확히 무언가를 손으로 재현할 의욕이 가장 안 나는 곳입니다.
그 map은 이제 streamrouter.go 안의 Redis pub/sub 라우터입니다. 이 프로세스가 붙들고 있는 스트림을 향한 응답은 곧바로 전달되며 왕복하지 않습니다. 다른 곳에서 붙들고 있는 스트림을 향한 응답은 mcp:stream:<sessionID>로 publish되고, 그 스트림을 붙들고 있는 인스턴스가 거기에 subscribe되어 있습니다. 어떤 인스턴스든 POST를 받을 수 있습니다. 세션 어피니티가 필요 없고, 아무도 작성한 기억이 없는 로드 밸런서 설정 안에서 sticky session 규칙을 관리할 필요도 없습니다.
여기에 대해 두 가지는 말해둘 가치가 있습니다. 라우터는 곧 의존성이니까요.
이제 Redis가 이 경로 위에 있습니다. 기동 시에 닿지 않으면 라우터는 부팅을 거부하는 대신 로컬 전용 전달로 폴백하고 경고를 로그에 남깁니다. 인스턴스가 하나뿐이라면 로컬 전용이 맞고, 두 번째가 생겨야 비로소 틀리기 때문입니다. 이 실패는 일부러 시끄럽게 만들었습니다. 대안은 우리가 방금 없앤 그 조용한 멈춤이니까요. 이걸 배포한다면 찾아야 할 기동 로그 줄은 MCP stream router: Redis connected입니다. 그게 없다는 사실이 이야기의 전부입니다.
그리고 라우터가 고치는 것은 라우팅이지 위치가 아닙니다. 스트림은 여전히 한 프로세스가 붙들고 있는 연결입니다. Redis는 응답을 그 프로세스까지 실어 나를 뿐, 스트림을 옮기지는 않습니다. 그 부분은 더 줄일 수 없습니다. 열린 연결은 어느 프로토콜에서든 열린 자리에 삽니다.
우리가 하지 않은 것
보통 발표는 여기서 끝납니다. 두 가지는 분명하게 말해둘 가치가 있습니다. 둘 다 여러분이 확인할 수 있기 때문입니다.
Archyl은 중요한 경로 위에서 2026-07-28을 말합니다. 처음부터 끝까지 스테이트리스인 것은 아닙니다.
스테이트리스 경로는 진짜로 스테이트리스입니다. 세션 조회 없음, 세션 쓰기 없음, Mcp-Session-Id 없음, 요청을 프로세스에 고정시키는 것도 없음. 그 경로는 평범한 round-robin 로드 밸런서 뒤에 놓일 수 있습니다.
우리 서버는 더 오래된 HTTP+SSE transport에도 /sse에서 여전히 응답하지만, 그것을 문서에 싣는 일은 그만두었습니다. 예전에 그 URL을 찍던 페이지는 이제 모두 /mcp를 찍고, 우리가 누구에게든 설정하라고 안내하는 엔드포인트는 그것 하나뿐입니다.
이유는 방금 우리가 추가한 그 의존성입니다. 라우터가 어피니티 요구사항을 없애주는 것은 Redis에 닿을 수 있는 곳에서뿐입니다. 닿지 않는 곳에서는 전달이 로컬 전용으로 폴백하고, 그것은 인스턴스가 하나일 때는 맞고 둘일 때는 조용히 틀립니다. 우리 프로덕션은 오늘 Redis를 돌리고 있지 않으므로, 우리가 돌리고 있는 것이 바로 그 폴백입니다. 정확성이 인스턴스 개수에 달려 있지 않은 transport로 모두를 안내하는 편이, 정확성이 거기 달려 있는 쪽을 공개하는 것보다 낫다고 봅니다.
/sse가 어디서 돌든 여전히 사실인 것은, 스트림은 단일 프로세스가 붙들고 있는 연결이고, 그 수명 동안 Postgres에 세션 행이 존재한다는 점입니다. 어피니티 요구사항을 없앤 것은 상태를 없앤 것과 같지 않습니다. 우리는 그 transport를 폐기할 날짜를 발표하지 않습니다.
다만 그 transport의 시계는 우리 것이 아니고, 우리가 가정했던 것보다 짧습니다. HTTP+SSE는 2025-03-26 리비전 이후로 deprecated였습니다. 2026-07-28이 한 일은 그것을 새로운 기능 라이프사이클 정책 아래에서 Deprecated로 재분류한 것입니다. 그 정책은 deprecation과 제거 가능 시점 사이에 최소 12개월의 창을 둡니다. Roots, Sampling, Logging이 받는 것이 그것이며, 가장 이른 제거는 "the first revision released on or after 2027-07-28" — 2027년 7월 28일 이후에 릴리스되는 첫 리비전 — 입니다. HTTP+SSE는 12개월을 받지 못합니다. 정책이 존재하기 훨씬 전부터 이미 deprecated였기 때문입니다. deprecated 기능 레지스트리는 그것의 가장 이른 제거 시점을 "Three months after SEP-2596 reaches Final" — SEP-2596이 Final에 도달한 뒤 3개월 — 로 적고 있습니다. 제거는 여전히 릴리스 준비 시점에 Core Maintainer가 내리는 결정이며 더 늦어질 수도 있지만, 어딘가에서 HTTP+SSE를 돌리고 있다면 읽어야 할 것은 그 줄입니다.
우리는 이 리비전의 형태를 구현했지, 전부를 구현하지는 않았습니다. 출시되는 것은 버전 협상, 스테이트리스 요청 경로, server/discover, 올바른 코드를 가진 지원하지 않는 버전 에러, resultType, 캐시 힌트, transport가 요구하는 405와 404, 그리고 아직 필요한 클라이언트를 위한 핸드셰이크 경로입니다. 없는 것은 이렇습니다.
Mcp-Method와Mcp-Name요청 헤더, 그리고 거기 딸린 검증. 이것이 가장 큰 공백입니다. 리비전은 POST가 자신의method와params.name또는params.uri를 헤더에 그대로 반영할 것을 요구하고, 서버가 불일치를400과-32020 HeaderMismatch로 거부할 것을 요구합니다. 이유는 정돈이 아닙니다. 사양 자신의 표현으로는, 이것이 "prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value)" — 네트워크의 서로 다른 구성 요소가 서로 다른 진실의 출처에 의존할 때(예를 들어 로드 밸런서는 헤더 값으로 라우팅하고 MCP 서버는 본문 값에 따라 실행할 때) 생길 수 있는 보안 취약점을 막아준다 — 는 것입니다. 같은 규칙이MCP-Protocol-Version에도 적용되며, 그 값은 요청의_meta에 있는 값과 반드시 일치해야 합니다(MUST). 우리는 버전을 헤더에서만 읽고_meta는 한 번도 보지 않으므로, 거부하도록 요구받은 불일치를 애초에 감지할 수 없습니다. 헤더는 본문이 파싱되기 전에 얻을 수 있고, 그래서 거기서 읽습니다. 그것이 교차 검사를 건너뛸 이유가 되지는 않습니다.subscriptions/listen, 그리고InputRequiredResult를 쓰는 Multi Round-Trip Requests. 수정이라기보다 기능 자체입니다. 우리는resources/subscribe를 구현한 적이 없으므로, 그것을 대체하는 메서드는 오늘로선 아무 비용도 아닙니다.Origin헤더 검증. 사양은 이것을 MUST로 표시하고, 잘못된 origin에는403을 반환하도록 하며, DNS 리바인딩에 대한 방어로 규정합니다. 우리는/mcp에서 이것을 하지 않습니다.- capability의
extensions, 그리고tools/list의 결정적 정렬 순서. 후자는 SHOULD이며, 클라이언트 측 캐싱과 LLM prompt cache 적중률을 겨냥한 것입니다. 우리 것은 Go의 map에서 나오므로, 순서는 그날 그 map이 주는 대로입니다. - Dynamic Client Registration. 이 리비전은 Client ID Metadata Documents를 선호하며 이것을 deprecated로 표시했지만, 우리는 여전히
POST /register를 노출합니다. 대체 방식을 지원하지 않는 authorization server를 위해 남겨두는 것이므로, 이것은 단절이 아니라 마이그레이션이며, Roots, Sampling, Logging과 같은 12개월 시계 위에 있습니다. server/discover는/mcp의 다른 모든 것과 같은 API 키 뒤에 있습니다. 익명 호출자에게는 응답하지 않습니다. 이것은 의도적인 선택이지만, 서버를 발견하려는 클라이언트가 기대하는 바는 아닙니다.
나머지는 일이고, 끝난 게 아니라 목록에 올라 있습니다.
자신의 MCP 서버를 운영하고 있다면
여러분의 서버에 돌려볼 만한 점검들입니다.
MCP-Protocol-Version: 2026-07-28을 붙이고 핸드셰이크 없이tools/list를 보내보세요. "session not initialized"가 돌아오면, 여러분의 서버는 현재 리비전을 제공하고 있지 않습니다.server/discover를 호출해보세요. 이제 필수입니다. method-not-found가 돌아온다면, 그게 메울 수 있는 가장 작은 공백입니다.- 지원하지 않는 버전을 선언해보세요. 에러가 여러분이 지원하는 버전 목록을 싣고 있는지, 그리고 그 코드가 일반적인 것이 아니라
-32022인지 확인하세요. 이건 우리가 통과하지 못한 점검입니다. - 아무 result나 읽어보세요. 전부에
resultType이 필요하고, 목록 결과에는 그 위에ttlMs와cacheScope가 필요합니다. - 스테이트리스 요청에서
Mcp-Session-Id에 무엇을 실어 보내는지 보세요. 비어 있는 것은 없는 것보다 나쁩니다. - 쓰기를 세어보세요. 스테이트리스 요청을 열 번 보내고 세션 저장소에 무언가 남았는지 확인하세요. 그 숫자가 마이그레이션이 됐는지에 대한 정직한 답입니다.
- 아직 HTTP+SSE를 제공하고 인스턴스를 둘 이상 돌린다면, 스트림을 다른 인스턴스가 붙들고 있는 상태에서 한쪽에 POST해보세요. 로그에 아무것도 없이 멈춰 있는 클라이언트가 우리가 겪은 버그입니다. 그런 다음 위의 deprecation 레지스트리 줄을 읽으세요.
"새 버전 헤더를 받아들인다"와 "실제로 스테이트리스다" 사이의 간격에 작업의 대부분이 있고, 여러분이 어느 쪽에 있는지 알려주는 건 6번 단계뿐입니다.
연결하기
엔드포인트는 그대로이고, 두 리비전 모두 여기에 동작합니다. Claude Code라면 프로젝트 루트에 .mcp.json을 두세요.
{
"mcpServers": {
"archyl": {
"type": "http",
"url": "https://api.archyl.com/mcp",
"headers": {
"X-API-Key": "YOUR_API_KEY"
}
}
}
}
리비전은 여러분의 클라이언트가 고릅니다. 2026-07-28을 말한다면 핸드셰이크 없이, 세션 없이 처리됩니다. 말하지 않는다면 그 클라이언트에게는 아무것도 달라지지 않습니다.
Claude Code, Cursor, VS Code, Codex, Warp, Windsurf, Antigravity를 위한 전체 설정과, 에이전트가 무엇을 바꿀 수 있는지 결정하는 스코프는 MCP 서버 문서에 있습니다.