05. 초기화 핸드셰이크와 capability 협상

🎯 이 장의 목표
  • 핸드셰이크의 세 단계(initialize → result → initialized)를 순서대로 설명할 수 있다
  • capability(능력) 협상이 왜 필요한지, 누가 무엇을 광고하는지 안다
  • "협상되지 않은 기능은 쓰지 않는다"는 규칙의 의미를 이해한다

통화 첫머리 비유

처음 보는 사람과 영상통화를 한다고 해봅시다. 시작하자마자 본론으로 들어가지 않습니다. "들리세요? 화면 보이세요? 저는 한국어 됩니다, 그쪽은요?" 이렇게 서로 무엇이 되는지 맞춰 본 뒤에 대화를 시작하죠.

MCP의 핸드셰이크가 정확히 그렇습니다. 본격적인 도구 호출에 앞서, 클라이언트와 서버는 (1) 프로토콜 버전이 맞는지, (2) 서로 어떤 기능을 지원하는지를 합의합니다. MCP는 적절한 capability 협상과 상태 관리를 보장하기 위해 클라이언트-서버 연결에 대한 엄격한 생명주기를 정의하며, 초기화 단계가 그 첫 상호작용입니다.

3단계 핸드셰이크

sequenceDiagram
    participant C as Client
    participant S as Server
    Note over C,S: 1단계 — 클라이언트가 시작
    C->>S: initialize (protocolVersion, capabilities, clientInfo)
    Note over C,S: 2단계 — 서버가 응답
    S-->>C: InitializeResult (protocolVersion, capabilities, serverInfo, instructions?)
    Note over C,S: 3단계 — 클라이언트가 완료 신호
    C->>S: notifications/initialized
    Note over C,S: 이제 정상 동작 단계 시작

초기화 단계는 반드시 클라이언트와 서버 간 첫 상호작용이어야 합니다.

1단계 — 클라이언트의 initialize 요청

클라이언트가 자신의 프로토콜 버전, 지원 기능, 식별 정보를 담아 먼저 보냅니다.

JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {},
      "elicitation": {}
    },
    "clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
  }
}
⚠️ 흔한 실수
흔한 실수: initialize를 JSON-RPC 배치(batch) 안에 넣는 것. initialize 요청은 배치에 포함되면 안 됩니다 — 초기화가 끝나기 전에는 다른 요청·알림이 불가능하기 때문이며, 이는 배치를 명시적으로 지원하지 않는 구(舊) 버전과의 호환도 보장합니다.

2단계 — 서버의 InitializeResult

서버가 자신의 버전·기능·정보로 답합니다. 선택적으로 instructions(이 서버를 어떻게 쓰면 좋은지에 대한 안내문)를 담을 수 있습니다.

JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "prompts": { "listChanged": false }
    },
    "serverInfo": { "name": "FileSystemServer", "version": "2.5.1" },
    "instructions": "이 서버는 작업 디렉터리의 파일을 읽고 검색합니다."
  }
}

3단계 — 클라이언트의 notifications/initialized

클라이언트가 "준비 끝, 정상 동작 시작합시다"라는 알림을 보냅니다. 이건 알림이므로 id가 없고, 답장도 없습니다(HTTP에서는 서버가 202 Accepted로만 응답).

⚠️ 흔한 실수
순서 엄수: 클라이언트는 initialize에 대한 서버 응답이 오기 전에는 (ping 외의) 요청을 보내면 안 되고, 서버는 initialized 알림을 받기 전에는 (ping·로깅 외의) 요청을 보내면 안 됩니다. 한 실제 버그 사례에서는 서버가 initialize를 처리하기도 전에 초기화 알림을 먼저 보내, 클라이언트가 스키마 검증 오류로 거부한 일이 있었습니다. 핸드셰이크가 끝나기 전엔 침묵이 원칙입니다.

capability 협상 — 누가 무엇을 광고하나

capability(능력)는 핸드셰이크에서 클라이언트나 서버가 광고하는 기능입니다. 서로 자신이 지원하는 것을 선언하고, 이후엔 양쪽이 합의한 기능만 씁니다.

대략적인 분담은 다음과 같습니다(세부는 스펙 리비전에 따라 다를 수 있음):

capability주로 선언하는 쪽의미
tools서버도구를 노출함. listChanged로 목록 변경 알림 지원 여부
resources서버리소스를 노출함. subscribe(구독), listChanged 하위 플래그
prompts서버프롬프트 템플릿을 노출함. listChanged
roots클라이언트파일 루트(작업 경로)를 서버에 알려줄 수 있음
sampling클라이언트서버가 호스트의 LLM에 추론을 요청(sampling)할 수 있게 허용
elicitation클라이언트서버가 사용자에게 추가 입력을 요청할 수 있게 허용

tools: {}처럼 빈 객체는 "이 기능은 지원하지만 하위 옵션은 없음"을 뜻하고, tools: { "listChanged": true }는 "도구 목록이 바뀌면 알림을 보낼 수 있음"을 추가로 광고합니다.

flowchart LR
    subgraph CLIENT["Client가 광고"]
        ROOTS["roots"]
        SAMP["sampling"]
        ELIC["elicitation"]
    end
    subgraph SERVER["Server가 광고"]
        TOOLS["tools"]
        RES["resources"]
        PROMPTS["prompts"]
    end
    CLIENT <-->|"교집합만 사용"| SERVER
    classDef c fill:#fde68a,stroke:#b45309,color:#000;
    classDef s fill:#5eead4,stroke:#0f766e,color:#000;
    class ROOTS,SAMP,ELIC c;
    class TOOLS,RES,PROMPTS s;

"협상되지 않은 기능은 쓰지 않는다"

이게 capability 협상의 핵심 규칙입니다. 정상 동작 단계에서는 양쪽 모두 합의된 capability에 해당하지 않는 기능을 호출하면 안 됩니다. 예를 들어 서버가 resources.subscribe를 광고하지 않았다면, 클라이언트는 resources/subscribe를 보내선 안 됩니다. 그래서 똑똑한 클라이언트는 기능을 쓰기 전에 서버 capability를 먼저 확인합니다.

PYTHON
# (의사코드) capability 확인 후 사용
if server_capabilities.get("resources", {}).get("subscribe"):
    await session.subscribe_resource(uri)
else:
    raise RuntimeError("이 서버는 리소스 구독을 지원하지 않음")
💡 팁
왜 이렇게까지? 합의되지 않은 기능을 함부로 부르면, 서버가 모르는 메서드라며 -32601을 던지거나, 더 나쁘게는 예측 불가한 상태에 빠집니다. 협상은 "서로 할 수 있는 것만 약속하고, 약속한 것만 한다"로 대화를 예측 가능하게 만듭니다.
🔒 보안 관점: capability는 기능 노출 범위이기도 합니다. 서버가 굳이 필요 없는 기능(예: 구독)을 광고하지 않으면 그만큼 공격 표면이 줍니다. 최소 권한 원칙은 capability 선언에서부터 시작됩니다.

이 장에서 배운 것

  • 핸드셰이크는 3단계: 클라이언트 initialize → 서버 InitializeResult → 클라이언트 notifications/initialized.
  • initialize는 첫 상호작용이어야 하고 배치에 넣으면 안 된다. 핸드셰이크 완료 전엔 양쪽 다 (ping 등 예외 외) 침묵.
  • capability 협상으로 서로 지원 기능을 광고한다. 서버는 보통 tools·resources·prompts, 클라이언트는 roots·sampling·elicitation.
  • 정상 동작 단계에서는 합의된 capability만 사용한다.

✍️ 확인 문제

  1. 핸드셰이크 3단계를 메시지 이름으로 순서대로 적어 보자. 셋 중 id가 없는 것은?
  2. 서버 InitializeResultresources: { "subscribe": false }가 왔다. 클라이언트가 resources/subscribe를 보내도 될까? 안 된다면 왜?
  3. samplingelicitation은 보통 클라이언트가 광고한다. 이 둘이 "서버 → 사용자/모델 방향"의 요청을 가능하게 한다는 점에서, 왜 클라이언트의 허락이 필요한지 추론해 보자.
다음 장: 06. 버전(날짜 리비전)과 버전 협상, 생명주기