New MCP Spec : The 2026-07-28 Specification
새로운 MCP Spec 을 유즈케이스를 포함하여 이해하기 쉽게 정리. 물론 내가 이해하기 쉽게 gemini 의 도움을 받았다.
2026년 7월 28일에 발표된 Model Context Protocol(MCP) 사양 업데이트의 핵심은 '상태 유지(Stateful) 양방향 프로토콜에서 상태 비저장(Stateless) 요청/응답 프로토콜로의 전환'이다.
https://blog.modelcontextprotocol.io/posts/2026-07-28/ 기사에서 설명된 주요 변경 사항들을 바탕으로, 각 케이스별 이전 방식과 바뀐 방식을 유즈케이스로 정리하였다.
1. 핸드셰이크 및 세션 제거 (No handshake or sessions)
이번 업데이트의 가장 큰 변화로, 연결을 유지하는 세션 개념이 완전히 사라졌다.
- 이전 방식 (Stateful): 클라이언트는 서버와 통신하기 전 반드시 initialize / initialized 핸드셰이크 과정을 거쳐야 했으며, 이후 모든 요청에 Mcp-Session-Id 헤더를 포함하여 세션을 유지해야 했다. 로드밸런서 환경에서는 특정 클라이언트가 동일한 서버 인스턴스에만 붙도록 'Sticky Session' 설정이 필수였다.
- 바뀐 방식 (Stateless): 세션과 초기화 과정이 폐지되었다. 대신 모든 단일 요청이 독립적이며, 프로토콜 버전, 클라이언트 신원, 권한 등을 _meta 필드와 HTTP 헤더에 담아 보낸다. 아무 로드밸런서(Round-robin)나 거쳐 아무 서버 인스턴스에 요청이 도달해도 정상 처리된다. 서버가 상태를 유지해야 한다면 도구(Tool) 실행 결과로 명시적인 핸들(Handle) 값을 반환하여 클라이언트가 다음 요청 시 전달하게 한다.
// 바뀐 방식의 HTTP 요청 예시
POST /mcp HTTP/1.1
Host: api.example.com
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search",
"arguments": {
"q": "2026 company OKRs"
},
"_meta": {
"io.modelcontextprotocol/clientInfo": {
"name": "my-enterprise-agent",
"version": "1.0"
}
}
}
}
Mcp-Session-Id에 어떤 정보들이 저장되었고, 왜 그것이 필요했었는지 이해가 안 가는 것은 지극히 정상이다. 특히 클라우드 네이티브와 쿠버네티스 환경에 익숙하다면, '왜 굳이 HTTP 통신에 세션을 유지해서 상태(Stateful)를 만들었지?' 하고 의문을 가지시는 것이 당연하다.
이전의 MCP(Model Context Protocol)는 주로 LLM(AI 에이전트)과 서버 간의 지속적인 대화형 상호작용을 전제로 설계되었기 때문에, 서버 메모리에 특정 세션(현재 AI와의 대화 흐름)에 종속된 임시 상태들을 유지하려 했다.
Mcp-Session-Id를 키(Key)로 삼아 서버 메모리(또는 Redis 등)에 저장해두었던 '특별한 정보'들의 대표적인 예시는 다음과 같다.
유즈케이스 예제:
1. 리소스 구독 및 변경 알림 (Resource Subscriptions)
가장 대표적인 세션 사용 사례이다. AI 에이전트가 특정 파일이나 데이터베이스의 실시간 변경 사항을 추적해야 할 때 사용되었다.
- 저장된 정보 예: Session-Id: 1234는 현재 github/repo/issue#5 리소스를 구독 중임.
- 어떻게 썼나: 서버는 백그라운드에서 이슈 업데이트를 모니터링하다가 변경이 발생하면, 메모리에서 해당 리소스를 구독 중인 Mcp-Session-Id를 찾아 열려있는 Streamable HTTP 스트림을 통해 AI에게 알림을 쏴주었다.
- 문제점: 쿠버네티스 환경에서 파드(Pod)가 재시작되거나 오토스케일링으로 인해 트래픽이 다른 노드로 라우팅되면, 구독 정보(세션)가 날아가서 AI가 더 이상 알림을 받지 못하는 치명적인 단점이 있었다.
2. 다중 단계 작업의 임시 컨텍스트 (Multi-step Temporary Workspace)
AI가 도구를 여러 번 호출하여 복잡한 작업을 수행할 때, 이전 작업의 중간 결과물을 서버가 기억해두는 용도이다.
- 저장된 정보 예: Session-Id: 5678의 임시 작업 공간(Temp Directory 경로, 또는 인메모리 데이터프레임).
- 어떻게 썼나:
- AI가 "대용량 CSV 파일 다운로드해서 필터링해줘" (데이터 MCP 서버의 도구 A 호출)
- 서버는 다운로드 후 필터링한 데이터를 Session-Id: 5678 공간에 임시 저장하고 성공 여부만 반환.
- AI가 "그 데이터로 차트 그려줘" (데이터 MCP 서버의 도구 B 호출)
- 서버는 요청 헤더의 Session-Id: 5678를 보고 앞서 저장해둔 임시 데이터를 꺼내어 차트를 생성.
- 상태 비저장(Stateless)으로의 변화: 이제 세션이 없으므로, 도구 A는 결과를 임시 저장한 뒤 handle_id (예: "temp_file_999")를 반환해야 한다. AI는 도구 B를 호출할 때 인자(Argument)로 handle_id: "temp_file_999"를 명시적으로 넘겨주는 방식으로 바뀌었다.
- Tool Schema (도구 정의 예시)
{
"name": "draw_chart",
"description": "이전 작업에서 가공한 데이터로 차트를 그린다.",
"inputSchema": {
"type": "object",
"properties": {
"workspace_token": { // 개발자가 임의로 정의한 핸들 이름
"type": "string",
"description": "이전 데이터 전처리 작업에서 반환받은 임시 토큰값을 넣는다."
}
},
"required": ["workspace_token"]
}
}
3. 페이지네이션 커서 및 순차적 조회 상태 (Pagination & Cursor)
대량의 리소스(예: 수천 개의 로그 라인, 수백 개의 프롬프트 목록)를 여러 번에 걸쳐 가져올 때, '어디까지 읽었는지'를 기억하는 용도이다.
- 저장된 정보 예: Session-Id: 9012는 logs/error.log의 '1,500번째 라인'까지 읽었음.
- 어떻게 썼나: AI가 "다음 목록 줘"라고 요청 바디 없이 호출해도, 서버는 세션 ID를 조회하여 1,501번째 라인부터 응답해 주었다.
- 문제점: 전형적인 안티 패턴이다. RESTful API처럼 커서 토큰(Cursor Token)이나 Offset을 클라이언트가 명시적으로 보내는 것이 맞다. 이번 업데이트로 이 역시 클라이언트가 토큰을 관리하도록 변경되었다.
4. 진행 중인 트랜잭션 관리 (Active Transactions)
데이터베이스의 Transaction이나, 인프라 프로비저닝 시 Lock을 걸어두는 행위를 세션과 묶어 관리했다.
- 저장된 정보 예: Session-Id: 3456이 DB에 BEGIN TRANSACTION을 걸어두었음.
- 어떻게 썼나: AI가 특정 데이터를 수정하고 최종 확인(Commit)을 하기 전까지, 세션 ID를 기반으로 해당 DB 커넥션이나 Lock을 유지했다. 만약 클라이언트와의 세션 연결이 끊어지면(네트워크 단절 등), 서버가 이를 감지하고 트랜잭션을 롤백(Rollback)하는 용도로 사용되었다.
클라우드 및 쿠버네티스 인프라를 다루시는 입장에서는 "왜 L7 로드밸런서에서 Sticky Session(Session Affinity)을 강제해야만 했는가?"에 대한 해답이 바로 이 Mcp-Session-Id 때문이었다.
이전 스펙에서는 위와 같은 상태들(구독 정보, 임시 컨텍스트 등)이 특정 서버 파드(Pod)의 메모리에 결합되어 있었기 때문에, 라운드 로빈(Round-robin)으로 다른 파드에 요청이 들어가면 "존재하지 않는 세션"이라며 에러가 발생했다. 이를 해결하려면 별도의 Redis 같은 외부 저장소를 강제해야만 했다.
이번 2026년 7월 28일 업데이트로 이런 암묵적인 세션 의존성을 모두 걷어내고, 상태 관리를 클라이언트에게 명시적으로 위임하거나 응답 데이터 자체에 포함(Self-describing)시킴으로써, 어떤 로드밸런서를 거쳐 어떤 파드에 요청이 떨어져도 동작하는 진정한 클라우드 네이티브 아키텍처로 진화했다고 볼 수 있다.
2. 다중 왕복 요청 (Multi Round-Trip Requests, MRTR)
서버가 클라이언트(또는 사용자)에게 역으로 정보를 요청해야 할 때 사용하는 방식이 변경되었다.
- 이전 방식 (Streams): 서버가 사용자에게 확인을 받거나(Elicitation) 추가 입력을 받아야 할 때, 항상 열려 있는 양방향 스트림(Streamable HTTP 또는 WebSocket)을 통해서만 서버발 요청(elicitation/create 등)을 보낼 수 있었다.
- 바뀐 방식 (MRTR): Stateless 환경에 맞게 스트림이 불필요해졌다. 서버가 추가 입력이 필요하면 응답으로 resultType: "input_required"를 반환한다. 클라이언트는 사용자에게 입력을 받은 뒤, 원래 보냈던 요청 바디에 inputResponses를 추가하여 재시도(Retry)한다.
유즈케이스 예제: 데이터베이스 테이블 삭제 권한 확인 (Elicitation)
// 1. 클라이언트의 최초 요청 (테이블 삭제)
{
"method": "tools/call",
"params": { "name": "drop_table", "arguments": { "table": "users" } }
}
// 2. 서버의 응답 (바뀐 방식: 확인 필요)
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"requests": [
{
"id": "req-123",
"type": "confirmation",
"message": "정말로 'users' 테이블을 삭제하시겠습니까?"
}
]
}
}
// 3. 클라이언트의 재시도 요청 (사용자 승인 완료)
{
"method": "tools/call",
"params": {
"name": "drop_table",
"arguments": { "table": "users" },
"inputResponses": {
"req-123": { "approved": true }
}
}
}
3. 헤더 기반 라우팅 (Header-based routing)
API 게이트웨이나 방화벽(WAF)에서의 라우팅 및 제어 방식이 개선되었다.
- 이전 방식: 라우터나 게이트웨이가 특정 도구(Tool)에 대해 트래픽을 제어(예: 과금, 속도 제한)하려면, 무거운 JSON 바디를 직접 파싱하여 method나 name 값을 찾아내야 했다.
- 바뀐 방식: 모든 HTTP 요청 헤더에 Mcp-Method 와 Mcp-Name 이 필수로 포함된다. 게이트웨이는 바디를 열어볼 필요 없이 헤더만 보고 라우팅이나 Rate Limiting을 수행할 수 있다.
유즈케이스 예제: 리소스 소모가 큰 이미지 생성 도구에 대한 API 게이트웨이 속도 제한
// 게이트웨이는 아래 헤더만 읽고 "image_generator" 도구에 대해 초당 10회 제한 룰을 즉각 적용할 수 있음
POST /mcp HTTP/1.1
Mcp-Method: tools/call
Mcp-Name: image_generator
4. 리스트 결과 캐싱 지원 (List results are cacheable)
목록을 불러오는 API의 효율성이 극대화되었다.
- 이전 방식: tools/list, prompts/list 등을 호출할 때 캐시 기준이 없어, 클라이언트는 서버에 재연결할 때마다 도구 목록을 처음부터 다시 받아오거나 임의로 자체 캐싱을 해야 했다.
- 바뀐 방식: 목록 반환 응답에 명시적으로 ttlMs(캐시 유지 시간)와 cacheScope가 포함된다. 클라이언트는 이를 바탕으로 불필요한 재요청을 줄일 수 있다.
유즈케이스 예제: 툴 카탈로그 캐싱
// 서버의 응답 예시
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{ "name": "calculator", "description": "기본 계산기" },
{ "name": "weather", "description": "날씨 조회" }
],
"ttlMs": 3600000, // 1시간 동안 캐시 유효
"cacheScope": "public"
}
}
5. 인가(Authorization) 및 보안 강화
보안 취약점 패치 및 OAuth 호환성이 강화되었다.
- 이전 방식: DCR(Dynamic Client Registration)을 주로 사용했으며, 데스크탑 앱이나 CLI 환경에서 OAuth 진행 시 redirect_uri 오류가 자주 발생했다. 또한 인가 서버 혼동 공격에 취약할 수 있었다.
- 바뀐 방식: RFC 9207에 따라 iss 파라미터를 반환 및 검증하며, CLI 환경의 리다이렉트 에러 해결을 위해 application_type을 설정한다. 궁극적으로 DCR은 더 이상 권장되지 않으며(Deprecated), 정적인 CIMD(Client ID Metadata Documents) 체계로 전환된다.
엔터프라이즈 환경에서 인증/인가 아키텍처를 설계하실 때 가장 까다로운 부분 중 하나가 표준 OAuth 2.0 스펙과의 호환성 및 보안 취약점 방어이다. 이번 MCP 2026-07-28 업데이트의 인가(Authorization) 섹션은 바로 이 부분을 글로벌 표준 스펙(RFC)에 맞게 끌어올린 것이다.
기사에서 언급된 핵심 변경 사항인 1) 네이티브 앱(CLI/Desktop) 리다이렉트 호환성 강화와 2) 인가 서버 혼동 공격(Mix-up Attack) 방어(RFC 9207)를 중심으로, 이전과 바뀐 방식을 상세히 비교했다. (유즈케이스 포함)
강화 포인트 1: CLI/데스크탑 환경의 OAuth 리다이렉트 문제 해결 (application_type)
로컬 환경에서 실행되는 AI 에이전트(CLI나 데스크탑 앱)가 서버의 리소스에 접근하려면 OAuth 2.0 인가 코드(Authorization Code) 흐름을 타야 한다. 이때 코드를 돌려받기 위해 로컬호스트([http://127.0.0.1](http://127.0.0.1))를 리다이렉트 URI로 사용하게 되는데, 엄격한 보안을 적용하는 엔터프라이즈 인가 서버들은 HTTPS가 아니라는 이유로 이를 거부하는 문제가 있었다.
❌ 이전 방식 흐름 (에러 발생)
인가 서버가 해당 클라이언트가 '웹 서버'인지 '로컬 앱'인지 구분하지 못해 발생하는 문제이다.
- [AI 에이전트] ➔ [인가 서버]: 동적 클라이언트 등록(DCR) 요청
- Payload: {"redirect_uris": ["[http://127.0.0.1:8080/callback](http://127.0.0.1:8080/callback)"]}
- [인가 서버] ➔ [AI 에이전트]: 🔴 등록 거부 (Error)
- 사유: "보안 정책 상 redirect_uri는 반드시 https://로 시작해야 합니다."
- 결과: 터미널에서 실행되는 AI 에이전트는 OAuth 로그인을 시도조차 할 수 없고 연동이 실패합니다.
✅ 바뀐 방식 흐름 (application_type: "native" 도입)
표준 RFC 8252 (OAuth 2.0 for Native Apps)를 준수하여 클라이언트의 '유형'을 명시한다.
- [AI 에이전트] ➔ [인가 서버]: 동적 클라이언트 등록(DCR) 요청
- Payload: {"redirect_uris": ["[http://127.0.0.1:8080/callback](http://127.0.0.1:8080/callback)"], "application_type": "native"}
- [인가 서버] ➔ [AI 에이전트]: 🟢 등록 승인 (Success)
- 판단 로직: "아, 이 클라이언트는 웹 서버가 아니라 네이티브 앱이구나. 네이티브 앱은 로컬 루프백 주소(127.0.0.1)를 사용하는 것이 표준이므로 허용해 주자."
- [사용자 브라우저] ➔ [인가 서버]: 로그인 및 권한 승인 진행
- [인가 서버] ➔ [AI 에이전트 (로컬 8080포트)]: 리다이렉트 성공
- [http://127.0.0.1:8080/callback?code=SplxlOBeZQQYbYS6WxSbIA](http://127.0.0.1:8080/callback?code=SplxlOBeZQQYbYS6WxSbIA)
- 결과: AI 에이전트가 코드를 정상적으로 수신하고 엑세스 토큰으로 교환한다.
강화 포인트 2: 인가 서버 혼동 공격 방어 (RFC 9207 iss 검증)
클라이언트(AI 에이전트)가 여러 개의 인가 서버(예: 사내 SSO 서버, 외부 파트너사 SSO 등)와 통신할 수 있을 때 발생할 수 있는 보안 취약점(Mix-up Attack)을 원천 차단하는 스펙이다.
악의적인 공격자가 클라이언트를 속여, 클라이언트가 정상적인 서버(A)에 로그인하려고 했으나 실제로는 공격자의 서버(B)로 코드를 보내게 만드는 공격이다.
❌ 이전 방식 흐름 (취약점 존재)
리다이렉트 시 돌아오는 응답에 이 코드를 '누가' 발급했는지에 대한 정보가 없다.
- [AI 에이전트] ➔ [사용자 브라우저]: 정상 인가 서버(Trust-IdP)로 로그인 요청 시도. (단, 네트워크나 설정 변조로 인해 악성 서버(Evil-IdP)를 경유하게 됨)
- [Evil-IdP] ➔ [Trust-IdP]: 공격자가 클라이언트인 척 Trust-IdP로 요청을 토스함.
- [Trust-IdP] ➔ [사용자 브라우저]: 사용자 로그인 완료 후 브라우저를 통해 리다이렉트 시킴.
- [http://127.0.0.1:8080/callback?code=AuthCode_1234](http://127.0.0.1:8080/callback?code=AuthCode_1234)
- [AI 에이전트]: 코드를 수신함. 하지만 이 코드가 Trust-IdP에서 온 것인지, Evil-IdP에서 온 것인지 구분할 단서가 없음.
- [AI 에이전트] ➔ [Evil-IdP]: 에이전트는 설정 변조된 대로 악성 서버(Evil-IdP)의 토큰 엔드포인트로 code=AuthCode_1234를 전송함.
- 결과: 🔴 공격자(Evil-IdP)가 정상적인 인가 코드를 탈취하여 토큰을 훔쳐냄.
✅ 바뀐 방식 흐름 (RFC 9207 적용)
인가 서버는 리다이렉트 시 반드시 iss (Issuer, 발급자 식별자) 파라미터를 포함해야 하고, 클라이언트는 이를 엄격히 검증한다.
- [AI 에이전트] ➔ [사용자 브라우저]: 정상 인가 서버(Trust-IdP)로 로그인 요청 시도. (공격 개입)
- [Trust-IdP] ➔ [사용자 브라우저]: 사용자 로그인 완료 후 리다이렉트 시킴. (이때 발급자 정보 iss를 반드시 포함함)
- [http://127.0.0.1:8080/callback?code=AuthCode_1234](http://127.0.0.1:8080/callback?code=AuthCode_1234)&iss=[https://trust-idp.example.com](https://trust-idp.example.com)
- [AI 에이전트]: 코드와 함께 iss 값을 수신함.
- [AI 에이전트의 내부 검증 로직 가동]:
- "내가 애초에 이 로그인을 시작할 때 설정했던 인가 서버 주소가 어디였지?" ➔ [https://evil-idp.com](https://evil-idp.com) (공격자에 의해 변조된 주소)
- "지금 코드를 발급했다고 주장하는 곳(iss)은 어디지?" ➔ [https://trust-idp.example.com](https://trust-idp.example.com)
- "두 주소가 다르네? 누군가 중간에 장난을 쳤구나!"
- 결과: 🟢 에이전트는 토큰 교환을 즉시 중단(Abort)하고 인가 코드를 폐기하여 탈취를 방지한다.
부가 변경 사항: DCR 퇴출과 CIMD 도입
이러한 OAuth 연동을 위해 클라이언트를 서버에 등록하는 과정 자체도 바뀐다. 기존의 동적 클라이언트 등록(DCR)은 클라이언트가 실행될 때마다 인가 서버에 자신을 등록하는 API를 호출해야 했다.
새로운 스펙에서는 CIMD (Client ID Metadata Documents)를 표준으로 삼는다. 이는 쿠버네티스에서 CRD나 ConfigMap으로 상태를 선언(Declarative)해두는 것과 유사한 접근이다. 클라이언트의 메타데이터(퍼블릭 키, 리다이렉트 URI 등)를 정적인 JSON 문서 형태로 호스팅하고, 인가 서버는 해당 문서를 읽어 신뢰 관계를 구축한다. 이는 인가 서버 측의 상태 관리 부담을 줄이고 인프라적 안정성을 높이는 방향이다.
이번 변경 사항인 DCR(동적 클라이언트 등록) 퇴출과 CIMD(클라이언트 ID 메타데이터 문서) 도입은, 인가 서버(Authorization Server)마저도 상태 비저장(Stateless)과 선언적(Declarative) 아키텍처로 전환하려는 클라우드 네이티브의 철학이 깊게 반영된 조치이다.
특히 쿠버네티스 환경의 GitOps(선언적 상태 관리) 익숙하시다면, 이 변화가 '명령형(Imperative) API 호출을 통한 상태 생성'에서 '선언형(Declarative) 문서 호스팅을 통한 상태 증명'으로 넘어가는 과정이라는 점을 쉽게 체감하실 수 있을 것이다.
이전의 DCR 방식과 새로운 CIMD 방식을 상세히 비교했다.
❌ 이전 방식: DCR (Dynamic Client Registration)
DCR은 클라이언트(AI 에이전트)가 실행될 때마다 인가 서버의 /register API를 호출하여 자신을 동적으로 등록하고, 인가 서버로부터 고유한 client_id를 발급받는 방식이다.
1. DCR 순서 흐름 (Stateful & Imperative)
- [AI 에이전트] ➔ [인가 서버]: 최초 실행 시 등록 API 호출
- POST /register
- Body: {"client_name": "My Agent", "redirect_uris": ["http://localhost:8080/cb"]}
- [인가 서버 내부 로직]: 전달받은 정보를 자신의 데이터베이스(RDBMS 등)에 저장(Stateful)하고, 새 client_id와 client_secret을 생성.
- [인가 서버] ➔ [AI 에이전트]: 발급된 자격 증명 반환
- Body: {"client_id": "auto-gen-uuid-1234", "client_secret": "..."}
- [AI 에이전트] ➔ [인가 서버]: 사용자 로그인을 위해 인가 요청 시작
- GET /authorize?client_id=auto-gen-uuid-1234&...
- [인가 서버 내부 로직]: DB에서 auto-gen-uuid-1234를 조회하여 등록된 redirect_uri가 맞는지 검증 후 로그인 진행.
🚨 DCR의 문제점 (엔터프라이즈 환경)
- 상태 관리의 부담: 수많은 에이전트 파드(Pod)가 뜰 때마다 등록을 요청하면 인가 서버의 DB에 쓰레기 데이터(Orphaned Clients)가 무한정 쌓인다.
- 보안 취약점: 아무나 /register 엔드포인트를 찔러 클라이언트를 무단으로 등록할 수 있어, 엔터프라이즈 환경에서는 보통 이 기능을 닫아두거나 관리에 골머리를 앓았다.
✅ 바뀐 방식: CIMD (Client ID Metadata Document)
CIMD 체계에서는 client_id 자체가 하나의 URL 주소가 된다. 클라이언트는 인가 서버에 자신을 '등록'해 달라고 부탁하는 대신, 자신의 정보(리다이렉트 주소, 공개 키 등)가 담긴 정적인 JSON 문서를 특정 URL에 퍼블리싱(호스팅)해 둔다.
1. CIMD 순서 흐름 (Stateless & Declarative)
- [사전 준비 (GitOps/배포 단계)]: 개발자나 파이프라인이 AI 에이전트의 메타데이터 JSON 파일을 사내 웹 서버(또는 S3 등)에 정적으로 배포한다.
- URL: [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)
- [AI 에이전트] ➔ [인가 서버]: 에이전트가 인가 서버에 등록하는 과정을 생략하고, 즉시 로그인(인가) 요청을 시작한다. 이때 client_id로 자신의 문서 URL을 보낸다.
- GET /authorize?client_id=[https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)&...
- [인가 서버] ➔ [해당 URL]: 인가 서버는 전달받은 client_id가 URL 형태인 것을 보고, 해당 주소로 메타데이터 문서를 Fetch(조회) 한다.
- GET [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)
- [인가 서버 내부 로직]: 받아온 정적 JSON 문서의 내용(리다이렉트 주소, 암호화 퍼블릭 키 등)과 현재 요청을 대조하여 검증한다. (DB 조회 불필요)
- [인가 서버] ➔ [사용자 브라우저]: 검증 성공 시 사용자 로그인 화면 노출 및 이후 OAuth 흐름 정상 진행.
1) 무엇과 무엇을 대조하는가?
인가 서버는 현재 들어온 API 요청이 유효한지 확인하기 위해, 요청 파라미터를 JSON 문서(클라이언트의 메타데이터나 JWKS 등)의 내용과 대조한다.
- 리다이렉트 주소(Redirect URI) 검증:
- 요청 값: 클라이언트가 인가 코드(Auth Code)를 받기 위해 파라미터로 보낸 redirect_uri (예: [https://app.example.com/callback](https://app.example.com/callback))
- JSON 값: 정적 JSON 문서에 명시된 허용 가능한 redirect_uris 배열.
- 대조 로직: 요청받은 주소가 JSON 문서의 배열 안에 존재하는지 확인합니다. 일치하지 않으면 인가 서버는 탈취된 주소로 간주하고 에러를 반환한다.
- 암호화 퍼블릭 키(Public Key) 검증:
- 요청 값: 클라이언트가 보낸 서명된 요청(Signed Request Object) 또는 클라이언트 인증용 JWT.
- JSON 값: 정적 JSON 문서(보통 JWKS 형태)에 포함된 퍼블릭 키(Public Key).
- 대조 로직: JSON 문서에서 추출한 퍼블릭 키를 사용하여 클라이언트가 보낸 서명의 유효성을 수학적으로 검증합니다. 서명이 일치하면 위변조되지 않은 요청임이 증명된다.
2) 왜 인가 서버의 DB 조회가 필요 없는가?
인가 서버의 DB를 조회해야 한다는 것은 상태 유지(Stateful)를 전제로 하는 전통적인 방식이다. 클라이언트를 사전에 등록하고 그 정보를 DB에 저장해 두어야 하기 때문이다.
하지만 설명에 나온 아키텍처에서는 정적 JSON 문서 자체가 DB를 대체하는 '신뢰의 원천(Source of Truth)' 역할을 한다.
- 신뢰 체인(Trust Chain)의 이동: 인가 서버는 사전에 약속된 안전한 URL(예: /.well-known/openid-configuration 또는 클라이언트의 신뢰할 수 있는 호스트)에서 JSON 문서를 가져온다. HTTPS/TLS 등을 통해 이 문서를 안전하게 가져왔거나 문서 자체가 서명되어 있다면, 인가 서버는 그 문서 안의 데이터를 100% 신뢰할 수 있다.
- DB 부하 감소 및 유연성 확보: 클라이언트의 설정이 바뀔 때마다 인가 서버의 DB를 업데이트할 필요가 없다. 클라이언트 측에서 자신의 정적 JSON 문서만 업데이트하면, 인가 서버는 요청이 들어올 때마다 최신 JSON을 읽어와(또는 캐싱하여) 즉시 검증할 수 있다.
- 무상태(Stateless) 검증: 중앙 DB에 접근하기 위한 네트워크 비용이나 트랜잭션을 일으키지 않고, 메모리 상에서 가져온 JSON 객체와 Request 객체만 비교하면 되므로 처리 속도가 빠르고 병목 현상이 줄어든다.
유즈케이스 예제: CIMD HTTP/JSON 예제
1) 호스팅된 클라이언트 메타데이터 문서 (CIMD JSON) 웹 서버가 정적으로 서빙하는 [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json) 파일의 내용이다. 에이전트의 스펙이 선언적으로 명시되어 있다.
{
"client_name": "kt cloud Internal Enterprise Agent",
"client_uri": "https://my-agent.ktcloud.com",
"redirect_uris": [
"http://127.0.0.1:8080/callback"
],
"application_type": "native",
"token_endpoint_auth_method": "private_key_jwt",
"jwks_uri": "https://my-agent.ktcloud.com/jwks.json"
}
2) AI 에이전트의 최초 인가(로그인) HTTP 요청 에이전트는 인가 서버에 동적 등록을 할 필요 없이 바로 브라우저를 띄워 아래 주소로 보낸다.
GET /oauth2/authorize?
response_type=code
&client_id=https%3A%2F%2Fmy-agent.ktcloud.com%2Fclient.json <-- URL 자체가 클라이언트 ID
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback
&scope=mcp_tools
&state=xyz123 HTTP/1.1
Host: auth.ktcloud.com
6. Tasks(백그라운드 작업) 정식 확장 편입
오래 걸리는 작업(Long-running tasks)을 처리하는 방식이 정비되었다.
- 이전 방식: 코어 기능에서 실험적으로 제공되었고, 알림을 받으려면 각기 다른 HTTP GET 엔드포인트를 사용해야 했다.
- 바뀐 방식: io.modelcontextprotocol/tasks라는 정식 확장(Extension) 프레임워크로 분리되었습니다. 폴링 방식의 tasks/get과 상태 업데이트를 위한 tasks/update가 도입되었으며, 모든 변경 알림은 단일 subscriptions/listen 스트림으로 통합되었다.
유즈케이스: 10GB 대용량 로그 데이터 분석 (소요 시간 10분)
AI 에이전트(클라이언트)가 서버에게 "최근 한 달 치 10GB 웹 서버 로그에서 에러 패턴을 분석해 줘"라고 요청한다. 10분이 걸리는 작업이므로 기존 동기식(Synchronous) HTTP 요청이었다면 무조건 타임아웃(Timeout)이 발생했을 것이다.
에이전트가 "10GB 로그 분석"을 요청하면 즉각적인 결과 대신 Task ID를 받는다. 이후 단일 구독 스트림(subscriptions/listen)을 통해 진행도(30% -> 70% -> 완료) 알림을 효율적으로 수신할 수 있다.
이를 Tasks 확장과 subscriptions/listen을 활용해 비동기식(Asynchronous)으로 처리하는 흐름이다.
1. 순서도 (Sequence Flow)
- [구독 연결] 에이전트 ➔ 서버: 먼저 subscriptions/listen 스트림(Streamable HTTP)을 열어 알림 채널을 유지한다.
- [작업 지시] 에이전트 ➔ 서버: 도구(Tool)를 호출하여 데이터 분석을 지시한다.
- [즉시 응답] 서버 ➔ 에이전트: 작업을 큐(Queue)에 넣고, 즉시 Task ID를 반환한다. (HTTP 연결 종료)
- [백그라운드 실행] 서버: 백그라운드 워커가 10GB 로그 분석을 시작한다.
- [상태 변경 알림] 서버 ➔ 에이전트: 분석이 50% 진행되었을 때, 1번에서 열어둔 스트림으로 "상태 변경됨" 알림을 푸시(Push)한다.
- [상세 조회] 에이전트 ➔ 서버: 알림을 받은 에이전트는 tasks/get을 호출해 진행도를 확인한다. (응답: "50% 진행 중")
- [완료 알림] 서버 ➔ 에이전트: 작업이 100% 완료되면 스트림으로 다시 완료 알림을 푸시(Push)한다.
- [결과 조회] 에이전트 ➔ 서버: 에이전트가 마지막으로 tasks/get을 호출하여 최종 분석 리포트를 받아온다.
- 에이전트가 "10GB 로그 분석"을 요청하면 즉각적인 결과 대신 Task ID를 받는다. 이후 단일 구독 스트림(subscriptions/listen)을 통해 진행도(30% -> 70% -> 완료) 알림을 효율적으로 수신할 수 있다.
2. HTTP / JSON-RPC 호출 예제 시나리오
Step 1: 스트림 구독 (Client ➔ Server) 클라이언트가 먼저 서버의 알림을 받기 위해 단일 스트림 채널을 연다. 이 연결은 계속 유지된.
POST /mcp/subscriptions/listen HTTP/1.1
Host: api.ktcloud.com
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"method": "subscriptions/listen",
"params": {
"topics": ["io.modelcontextprotocol/tasks"] // Tasks 관련 알림만 구독하겠다고 선언
}
}
Step 2: 작업 지시 (Client ➔ Server) 분석 툴을 호출한다. (이 요청은 일반적인 HTTP 요청이므로 금방 끊어진다.)
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "analyze_large_logs",
"arguments": { "target_size": "10GB" }
}
}
Step 3: 즉시 응답 (Server ➔ Client) 서버는 데이터를 처리하기 전에 예약표(taskId)만 먼저 던져준다.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"status": "accepted",
"taskId": "task-log-9991"
}
}
Step 4 & 5: 백그라운드 진행 및 푸시 알림 (Server ➔ Client) 진행 상태가 변하면, 아까 열어둔 subscriptions/listen 스트림을 통해 서버가 이벤트를 쏩니다.
// (스트림을 통해 서버가 일방적으로 내려보내는 데이터)
{
"jsonrpc": "2.0",
"method": "notifications/tasks/updated",
"params": {
"taskId": "task-log-9991"
}
}
💡 주의: 알림에는 "50% 진행됨" 같은 무거운 데이터가 들어있지 않습니다. 오직 "이 Task에 변화가 생겼다"는 신호만 간다.
Step 6: 상세 조회 (Client ➔ Server) 알림을 받은 에이전트가 "뭐가 변했지?" 하고 단발성 API(tasks/get)로 찔러본다. (Polling)
// 요청
{
"jsonrpc": "2.0",
"id": 3,
"method": "tasks/get",
"params": { "taskId": "task-log-9991" }
}
// 응답
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"taskId": "task-log-9991",
"status": "running",
"progress": 50,
"message": "파싱 중 (5GB/10GB)"
}
}
Step 7 & 8: 완료 알림 및 최종 결과 수령 작업이 끝나면 다시 스트림으로 알림이 오고, 클라이언트가 다시 tasks/get을 호출하면 최종 결과를 준다.
// 최종 응답
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"taskId": "task-log-9991",
"status": "completed",
"result_data": {
"error_count": 402,
"top_error": "ConnectionTimeout",
"report_url": "https://api.ktcloud.com/reports/log-9991.pdf"
}
}
}
왜 이렇게 설계했을까요?
만약 스트림(Push)에 10GB 분석 결과를 통째로 담아서 보낸다면 스트림 트래픽이 폭주하고 네트워크가 불안정해질 수 있다. 반대로 스트림 없이 클라이언트가 1초마다 무지성으로 "다 됐어?" 하고 물어보는 방식(순수 Polling)은 서버에 엄청난 HTTP 부하(DDos와 유사)를 일으킨다.
새로운 MCP의 subscriptions/listen + tasks/get 방식은 가벼운 신호(Signal)는 스트림으로 Push하고, 무거운 데이터(Payload)는 필요할 때만 HTTP로 Fetch(가져오기) 하는 클라우드 네이티브의 이벤트 주도(Event-driven) 아키텍처 베스트 프랙티스를 그대로 프로토콜 스펙으로 가져온 것이다.
Node.js (TypeScript/Express)로 구현한 슈도 코드
import express from 'express';
const app = express();
app.use(express.json());
// 구독 중인 클라이언트들의 HTTP 응답(Response) 스트림 객체를 담아둘 Map
// 세션 ID(Mcp-Session-Id)가 아니라, 순수하게 현재 열려있는 TCP 연결 객체를 관리합니다.
const activeStreams = new Set<express.Response>();
/**
* 1. 단일 MCP 엔드포인트 및 헤더 기반 라우팅
*/
app.post('/mcp', (req, res) => {
// 바뀐 SPEC: JSON 바디를 열어보지 않고 헤더만으로 라우팅 (API 게이트웨이 친화적)
const mcpMethod = req.headers['mcp-method'];
const mcpName = req.headers['mcp-name'];
// --- A. 구독(스트림) 연결 요청 처리 ---
if (mcpMethod === 'subscriptions/listen') {
// SSE(text/event-stream)가 아닌 일반 NDJSON 스트림 설정
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Transfer-Encoding', 'chunked'); // HTTP/1.1 청크 스트리밍 핵심
res.setHeader('Connection', 'keep-alive');
// 연결된 응답 객체를 Set에 저장 (알림을 쏠 타겟)
activeStreams.add(res);
// 클라이언트가 네트워크를 끊으면 Set에서 제거하여 메모리 누수 방지
req.on('close', () => {
activeStreams.delete(res);
});
// 구독 성공 즉시 응답 (스트림을 닫지 않기 위해 res.end()를 호출하지 않음)
const successResponse = {
jsonrpc: "2.0",
id: req.body.id,
result: { status: "subscribed" }
};
// JSON 객체를 String으로 변환 후 개행문자(\n)를 붙여서 전송
res.write(JSON.stringify(successResponse) + '\n');
return; // 스트림 열어둔 채로 대기
}
// --- B. 일반 도구 호출 처리 ---
if (mcpMethod === 'tools/call' && mcpName === 'analyze_large_logs') {
const taskId = `task-${Date.now()}`;
// 백그라운드 워커에 작업 위임 (비동기)
startBackgroundWorker(taskId, req.body.params.arguments);
// 클라이언트에게는 HTTP 즉시 종료 (200 OK) 및 Task ID 반환
res.json({
jsonrpc: "2.0",
id: req.body.id,
result: { status: "accepted", taskId: taskId }
});
return;
}
res.status(404).send('Not Found');
});
/**
* 2. 백그라운드 작업 상태 변경 시 클라이언트(들)에게 알림 푸시
*/
function notifyTaskUpdate(taskId: string) {
// 서버가 클라이언트에게 쏘는 순수 JSON-RPC Notification 형식 (응답 ID가 없음)
const notification = {
jsonrpc: "2.0",
method: "notifications/tasks/updated",
params: {
taskId: taskId
}
};
const payload = JSON.stringify(notification) + '\n';
// 현재 열려있는 모든 스트림에 데이터를 밀어 넣음 (Write)
// (실제 프로덕션 환경에서는 해당 Task를 요청한 특정 사용자/채널의 스트림으로 필터링하는 로직이 추가됩니다)
for (const streamRes of activeStreams) {
// SSE의 event: / data: 래핑 없이 순수 JSON 텍스트만 전송
streamRes.write(payload);
}
}
// 가상의 백그라운드 워커 시뮬레이션
function startBackgroundWorker(taskId: string, args: any) {
setTimeout(() => {
// 5초 뒤 진행 상태 변경 알림 발생
notifyTaskUpdate(taskId);
}, 5000);
}
app.listen(3000, () => console.log('MCP Stateless Server running on port 3000'));