MCP 2026-07-28 마이그레이션: Stateless 요청과 Conformance CI
MCP 2026-07-28 최종 명세의 세션 제거, server/discover, MRTR, subscriptions/listen, 캐시 메타데이터와 conformance CI 전환 절차를 설명합니다.
MCP 서버를 운영하고 있다면 2026년 7월 28일 공개된 명세를 SDK 버전 업데이트로만 보면 안 됩니다. 이 명세는 initialize와 프로토콜 세션을 제거하고, 요청마다 버전과 capability를 전달하는 stateless 구조로 바꿉니다.
그렇다고 MCP 애플리케이션의 상태가 모두 사라지는 것은 아닙니다. 장기 작업, 승인, OAuth 진행 상태, 사용자별 resource에는 여전히 상태가 필요합니다. 달라지는 것은 소유권입니다. protocol이 connection이나 Mcp-Session-Id 안에 그 상태를 암묵적으로 숨겨 주지 않습니다.
핵심 판단은 간단합니다. SDK만 쓰는 애플리케이션은 버전을 고정하고 호환성 테스트부터 추가하면 됩니다. 프로토콜 수명주기나 Streamable HTTP를 직접 다루는 구현은 명세 전환 작업이 필요합니다.
이 글은 2026년 8월 5일 UTC에 2026-07-28 최종 명세와 공식 conformance 저장소를 다시 확인했습니다. SDK 지원 상태는 구현마다 다르므로 버전을 고정하고 해당 SDK의 릴리스 노트와 conformance 결과를 함께 확인하세요.
무엇이 달라지는가
| 영역 | 이전 방식 | 2026-07-28 명세 | 운영 영향 |
|---|---|---|---|
| 연결 수명주기 | initialize 후 notifications/initialized | handshake 제거 | 요청마다 독립적으로 검증해야 함 |
| 세션 | Mcp-Session-Id와 연결별 상태 | 프로토콜 세션 제거 | 상태는 명시적인 handle로 전달 |
| 버전 협상 | 초기화 시 한 번 협상 | 요청별 _meta와 server/discover | 요청마다 metadata와 version selection을 검증 |
| 서버→클라이언트 입력 요청 | 연결 안에서 server-initiated request | Multi Round-Trip Request | 원 요청을 입력과 함께 다시 전송 |
| 목록 변경 알림 | HTTP GET, subscribe/unsubscribe | subscriptions/listen | 별도 POST response stream 운영 |
| 캐시 정책 | 구현별 polling | 선택된 result의 ttlMs와 cacheScope | freshness와 isolation을 명시 |
| 재연결 | SSE event ID로 재개 가능 | resumability 제거 | 끊긴 요청은 새 request ID로 재실행 |
| 검증 | SDK별 테스트 중심 | 공식 conformance suite | CI에서 명세 호환성을 검증 가능 |
가장 큰 변화는 “서버가 상태를 가지면 안 된다”가 아닙니다. 프로토콜이 연결 세션을 암묵적으로 제공하지 않는다는 뜻입니다. 장기 작업, 사용자별 실행 상태, OAuth 진행 상태가 필요하면 서버가 직접 handle을 발급하고 일반 tool argument나 extension handle로 전달해야 합니다.
1. initialize를 요청별 메타데이터로 바꾸기
새 명세에서 각 요청은 protocol version과 client capability를 자체적으로 전달합니다. 아래 예시는 프로토콜 구조를 설명하기 위한 축약형입니다.
{
"jsonrpc": "2.0",
"id": "req-42",
"method": "tools/call",
"params": {
"name": "deploy_preview",
"arguments": {
"project": "docs"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {
"form": {},
"url": {}
}
},
"io.modelcontextprotocol/clientInfo": {
"name": "example-client",
"version": "1.4.0"
}
}
}
}
서버 응답도 identity와 resultType을 명시합니다.
{
"jsonrpc": "2.0",
"id": "req-42",
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "Preview deployment created"
}
],
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "example-deploy-server",
"version": "2.0.0"
}
}
}
}
직접 transport를 구현했다면 다음을 확인하세요.
- 초기화 결과를 전역 변수나 Redis에 저장하는 코드를 제거했는가
- 모든 요청에서 protocol version과 capability를 검증하는가
- 지원하지 않는 버전에
UnsupportedProtocolVersionError를 반환하는가 - 오래된 서버가
resultType을 생략하면 클라이언트가complete로 처리하는가 - 클라이언트와 서버 identity가 로그와 trace에 남는가
공식 SDK를 사용한다면 protocol JSON을 직접 구성하지 말고, 선택한 SDK가 문서화한 2026-07-28 호환 계층과 version-selection 경로를 사용하세요. 프로토콜 JSON을 애플리케이션 코드 곳곳에서 직접 만들면 명세 변경에 취약해집니다.
2. server/discover로 두 lifecycle을 구분하기
새 서버는 server/discover를 구현해 지원하는 protocol version, capability와 identity를 알려야 합니다. 호환 기간에는 다음 순서가 안전합니다.
1. Attempt server/discover.
2. Select a mutually supported dated version.
3. If a legacy STDIO server returns Method not found (-32601), fall back to initialize.
4. Record the selected lifecycle and protocol version in telemetry.
5. Remove the legacy path only after supported-client and supported-server traffic proves it is unused.
STDIO와 remote HTTP를 동시에 지원한다면 transport별 호환성도 따로 측정하세요. “새 SDK에서 동작한다”와 “기존 클라이언트가 계속 연결된다”는 다른 검증입니다.
3. 연결 상태를 명시적인 handle로 옮기기
이전 구현은 Mcp-Session-Id를 키로 작업 상태를 저장하기 쉬웠습니다.
Mcp-Session-Id
├─ user
├─ current repository
├─ pending approval
└─ long-running operation
새 구조에서는 상태의 소유권을 애플리케이션이 명확히 정의해야 합니다.
request
├─ authenticated subject
├─ explicit operation handle
├─ idempotency key
└─ tool arguments
└─ durable application state
이 방식은 수평 확장에 유리하지만 handle을 인증 토큰처럼 다뤄야 합니다.
- handle은 충분히 예측 불가능해야 합니다.
- 요청 주체가 해당 handle을 조회할 권한이 있는지 매번 확인해야 합니다.
- 중복 호출을 막기 위해 idempotency key를 별도로 둡니다.
- 완료된 handle은 만료시키고 감사 로그는 별도 보관합니다.
Stateless protocol은 stateless application을 의미하지 않습니다. 상태를 숨겨진 연결 정보에서 명시적인 도메인 모델로 옮기는 변화입니다.
4. Elicitation을 Multi Round-Trip Request로 바꾸기
서버가 사용자 입력을 더 필요로 할 때 연결 안에서 별도 요청을 밀어 넣는 대신, input_required 결과를 반환합니다. 이때 inputRequests 객체는 request ID를 key로 사용합니다. 클라이언트는 사용자 응답을 받은 뒤 같은 key의 inputResponses 객체와 함께 원래 요청을 다시 보냅니다.
{
"resultType": "input_required",
"inputRequests": {
"deployment_environment": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Choose a deployment environment",
"requestedSchema": {
"type": "object",
"properties": {
"environment": {
"type": "string",
"enum": ["preview", "production"]
}
},
"required": ["environment"]
}
}
}
},
"requestState": "opaque-server-minted-state"
}
서버가 requestState를 제공했다면 재시도에서 byte 단위로 그대로 돌려보내야 합니다. 아래 예시는 MRTR에 집중하기 위해 모든 요청에 필요한 _meta 필드를 생략했습니다.
{
"jsonrpc": "2.0",
"id": 43,
"method": "tools/call",
"params": {
"name": "deploy_preview",
"arguments": {
"project": "docs"
},
"inputResponses": {
"deployment_environment": {
"action": "accept",
"content": {
"environment": "preview"
}
}
},
"requestState": "opaque-server-minted-state"
}
}
입력이 준비되면 같은 업무 요청을 새 JSON-RPC request ID로 재전송합니다. 이 transport ID를 업무 operation identity로 사용해서는 안 됩니다.
보안 규칙도 분명합니다.
- form mode로 비밀번호, API key, access token, 결제 자격증명을 요구하면 안 됩니다.
- 민감한 인증이나 결제는 URL mode를 사용합니다.
- 클라이언트는 요청한 서버와 이동할 도메인을 사용자에게 보여줘야 합니다.
- 사용자는 수정, 거절, 취소할 수 있어야 합니다.
Elicitation은 편리한 입력 폼이 아니라 사용자의 데이터 통제권을 유지하기 위한 프로토콜 경계입니다.
5. 목록 캐시와 변경 알림을 함께 설계하기
tools/list, prompts/list, resources/list 등의 결과에는 ttlMs와 cacheScope가 요구됩니다. 도구 목록은 결정적인 순서로 반환하는 것이 권장됩니다.
{
"resultType": "complete",
"tools": [],
"ttlMs": 300000,
"cacheScope": "private"
}
캐시 전략은 다음 세 요소를 함께 사용합니다.
ttlMs: 다음 재검증까지의 freshness hintcacheScope: shared cache 가능 여부subscriptions/listen: 목록이 바뀌었을 때 조기 무효화
사용자 권한에 따라 도구 목록이 달라진다면 private을 사용하세요. 동일한 서버라도 인증 주체별로 결과가 다르면 public cache는 권한 정보 누출로 이어질 수 있습니다.
6. 끊긴 요청은 복구가 아니라 재실행으로 다루기
새 Streamable HTTP에서는 SSE event ID와 Last-Event-ID 기반 재개가 제거됩니다. 응답 stream이 끊기면 in-flight 요청을 잃고 새 request ID로 다시 요청해야 합니다.
따라서 부작용이 있는 도구에는 idempotency가 필수입니다.
interface DeployInput {
repository: string;
ref: string;
idempotencyKey: string;
}
async function deploy(input: DeployInput) {
const previous = await deploymentStore.findByKey(input.idempotencyKey);
if (previous) return previous;
return deploymentStore.createAndStart(input);
}
클라이언트는 모든 실패를 자동 재시도하면 안 됩니다.
| 작업 | 자동 재시도 | 조건 |
|---|---|---|
| 목록 조회 | 가능 | backoff와 timeout 적용 |
| 읽기 전용 검색 | 가능 | 동일 결과를 허용할 때 |
| 파일 수정 | 제한적 | idempotency key와 상태 확인 필요 |
| 배포·결제·삭제 | 기본 금지 | 사용자 확인 또는 operation status 조회 후 판단 |
7. 공식 conformance suite를 CI에 넣기
이번 전환에서 가장 실용적인 변화는 공식 conformance test입니다. 직접 만든 서버와 클라이언트가 지정한 protocol version의 lifecycle을 따르는지 CI에서 확인할 수 있습니다.
서버의 현재 active scenario를 실행합니다.
npx @modelcontextprotocol/conformance \
server \
--url http://localhost:3000/mcp \
--suite active \
--verbose
클라이언트는 dated protocol version을 명시할 수 있습니다.
npx @modelcontextprotocol/conformance \
client \
--command "node ./dist/client.js" \
--suite all \
--spec-version 2026-07-28
GitHub Actions에서는 공식 composite action을 사용할 수 있습니다.
name: MCP conformance
on:
pull_request:
jobs:
server-conformance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: |
npm run start:mcp -- --port 3001 &
timeout 20 bash -c 'until curl -fsS http://localhost:3001/mcp; do sleep 0.5; done'
- uses: modelcontextprotocol/conformance@v0.1.11
with:
mode: server
url: http://localhost:3001/mcp
suite: active
verbose: true
기존 실패를 baseline으로 관리할 수 있지만 scenario 전체를 무시하지 마세요. 공식 도구는 개별 check ID를 baseline으로 지정할 수 있습니다. 수정된 check가 계속 baseline에 남아 있으면 CI가 실패하므로 오래된 예외도 발견할 수 있습니다.
8. deprecated 기능을 신규 코드에 추가하지 않기
최종 명세는 Roots, Sampling, Logging, Dynamic Client Registration을 deprecated로 분류합니다. 기존 HTTP+SSE transport도 deprecated registry에 남아 있습니다. 즉시 제거되는 것은 아니며 각 항목에 earliest-removal 경계가 있지만 새 구현은 migration path를 선택해야 합니다.
| Deprecated 기능 | 권장 전환 |
|---|---|
| Roots | tool argument, resource URI, server configuration으로 파일 범위 전달 |
| Sampling | LLM provider API를 애플리케이션에서 직접 호출 |
| Logging | STDIO는 stderr, 원격 환경은 OpenTelemetry 사용 |
| Dynamic Client Registration | Client ID Metadata Documents 검토 |
| HTTP+SSE | Streamable HTTP로 이동 |
특히 Sampling을 사용해 MCP 서버가 임의의 모델 호출을 클라이언트에 위임하고 있다면 아키텍처 경계를 다시 봐야 합니다. 모델 비용, 데이터 전송, 사용 정책과 감사 로그의 소유자를 명확히 한 뒤 provider integration을 배치해야 합니다.
권장 마이그레이션 순서
애플리케이션 개발자
- 사용 중인 MCP SDK와 transport 버전을 고정합니다.
- SDK의 2026-07-28 지원 상태와 변경 로그를 확인합니다.
- 구형 서버와 신형 서버를 모두 대상으로 contract test를 추가합니다.
- fallback 경로와 선택된 protocol version을 telemetry에 기록합니다.
- canary 환경에서 먼저 올립니다.
MCP 서버 개발자
initialize와 session ID에 의존하는 코드를 목록화합니다.- 도메인 상태를 명시적 handle과 idempotency key로 이동합니다.
server/discover와 요청별_meta검증을 추가합니다.- 모든 결과에
resultType을 추가합니다. - server-initiated request를 MRTR로 전환합니다.
- 목록 결과에 cache metadata를 추가하고 deterministic ordering을 보장합니다.
- Streamable HTTP 재시도 정책을 검토합니다.
2026-07-28version을 대상으로 conformance suite를 CI에 추가합니다.
MCP 클라이언트 개발자
server/discover후 버전을 선택하고 legacy fallback을 유지합니다.- 요청마다 capability와 identity를 보냅니다.
input_required결과를 UI와 재요청 흐름에 연결합니다.- 끊긴 요청을 새 request ID로 재시도하되 부작용 도구를 구분합니다.
- URL elicitation의 도메인 표시와 사용자 동의를 구현합니다.
결론
이번 MCP 릴리스의 목적은 연결을 없애는 것이 아니라 연결에 숨어 있던 상태와 capability를 요청 단위의 명시적인 계약으로 바꾸는 것입니다. 서버는 수평 확장이 쉬워지고, 클라이언트는 요청 단위 metadata와 명시적인 version selection을 얻습니다. 대신 구현자는 상태 handle, idempotency, 사용자 입력, 재시도와 인증을 더 분명하게 설계해야 합니다.
SDK를 무작정 올리는 대신 conformance suite를 CI에 넣고 session 의존성을 목록화하세요. 구형과 신형 protocol path를 같은 contract test로 검증한 뒤 fallback telemetry를 기준으로 legacy lifecycle을 제거하는 편이 안전합니다.