Skip to content
ioob.dev
Go back

MCP 4편 — 메시지 흐름: JSON-RPC 2.0과 lifecycle

· 6분 읽기
mcp 시리즈 (4/8)
  1. MCP 1편 — MCP란 무엇인가
  2. MCP 2편 — 서버가 노출하는 세 가지: Tools, Resources, Prompts
  3. MCP 3편 — 클라이언트가 노출하는 세 가지: Sampling, Roots, Elicitation
  4. MCP 4편 — 메시지 흐름: JSON-RPC 2.0과 lifecycle
  5. MCP 5편 — 첫 MCP 서버 만들기: TypeScript SDK와 Inspector
  6. MCP 6편 — Transport: stdio와 Streamable HTTP
  7. MCP 7편 — 인증과 권한: OAuth 2.1
  8. MCP 8편 — 운영, 레지스트리, 2026 로드맵
Table of contents

Table of contents

모든 메시지는 JSON-RPC 2.0

MCP는 자체 메시지 형식을 정의하지 않는다. JSON-RPC 2.0 위에 얹는다. 메시지는 세 종류뿐이다.

// Request
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }

// Response (성공)
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [...] } }

// Response (실패)
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found" } }

// Notification
{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }

방향성이 정해져 있지 않다. 클라이언트 → 서버도, 서버 → 클라이언트도 같은 형식이다. Sampling이 서버 → 클라이언트 요청인 이유가 여기 있다 — 같은 메시지 모양이라 양방향이 자연스럽다.

UTF-8 인코딩이 필수다. 트랜스포트가 stdio라면 메시지 사이를 개행으로 구분하고, 메시지 안에는 개행이 들어갈 수 없다.

Initialize — 첫 메시지의 모양

세션이 열리고 가장 먼저 가는 메시지가 initialize다. 클라이언트가 자기 정보를 담아 보낸다.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {},
      "elicitation": { "form": {}, "url": {} }
    },
    "clientInfo": {
      "name": "ExampleClient",
      "title": "Example Client",
      "version": "1.0.0"
    }
  }
}

세 가지가 들어 있다.

서버는 자기 capability를 담아 응답한다.

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "prompts": { "listChanged": true },
      "logging": {}
    },
    "serverInfo": {
      "name": "ExampleServer",
      "version": "1.0.0"
    },
    "instructions": "Optional instructions for the client"
  }
}

응답을 받자마자 클라이언트가 준비 완료 알림을 한 번 더 보낸다.

{ "jsonrpc": "2.0", "method": "notifications/initialized" }

이 알림이 닿고 나서야 본격 통신이 열린다. 그 전엔 양쪽 다 ping 정도만 보낼 수 있다.

Capability 협상 — 무엇이 켜질지

Initialize의 핵심은 capability 교환이다. 서버가 tools capability를 안 보내면 클라이언트는 그 세션 동안 tools/list를 호출할 수 없다. 클라이언트가 sampling을 안 보내면 서버는 그 세션 동안 sampling/createMessage를 보낼 수 없다.

누가 선언Capability의미
Clientroots작업 범위(파일 경로) 제공 가능
ClientsamplingLLM 호출 빌려줌
Clientelicitation사용자에게 입력 받아옴
Servertools호출 가능한 함수 노출
Serverresources읽기 가능한 자원 노출
Serverprompts슬래시 명령 노출
Serverlogging구조화된 로그 푸시
Servercompletions인자 자동 완성

서브 옵션도 같이 협상한다. tools.listChangedtrue로 선언했으면 서버가 도구 목록 변경 시 알림을 푸시할 거라고 약속한 셈이다. resources.subscribetrue로 선언했으면 클라이언트가 자원별 구독을 걸 수 있다.

핵심 규칙은 단순하다. 협상되지 않은 기능은 쓸 수 없다. 서버가 tools를 선언했어도 prompts를 선언하지 않았다면, 클라이언트가 prompts/list를 부르면 에러를 받는다.

Version Negotiation

protocolVersion은 날짜 형식이다 — 2025-11-25처럼 spec이 발표된 날을 그대로 쓴다. 버전 협상은 단순하다.

  1. 클라이언트가 자기가 지원하는 최신 버전을 보낸다.
  2. 서버가 그 버전을 지원하면 같은 버전으로 응답한다.
  3. 지원 안 하면 서버가 지원하는 다른 버전을 응답한다 (보통 자기 최신).
  4. 클라이언트가 그 버전을 못 따라가면 연결을 끊는다.

HTTP 트랜스포트라면 MCP-Protocol-Version: 2025-11-25 헤더를 이후 모든 요청에 같이 보내야 한다. 헤더가 빠진 요청은 서버가 옛 버전(2025-03-26)으로 가정하거나 400을 돌려준다.

버전이 다르면 capability 모양도 달라진다. 그래서 initialize에서 버전이 먼저 정해지고, 그 위에서 capability를 협상하는 순서가 자연스럽다.

양방향 알림 — notifications

Operation 단계에서는 양쪽이 동등하게 메시지를 주고받는다. 알림 흐름을 보면 왜 양방향이 필요한지 잘 보인다.

sequenceDiagram
    participant Server
    participant Client
    Note over Server,Client: 도구 목록이 동적으로 바뀐 경우
    Server--)Client: notifications/tools/list_changed
    Client->>Server: tools/list
    Server-->>Client: 갱신된 도구 카탈로그

    Note over Server,Client: 자원이 변경된 경우 (subscribe된)
    Server--)Client: notifications/resources/updated<br/>(uri: file:///main.rs)
    Client->>Server: resources/read (uri: file:///main.rs)
    Server-->>Client: 새 내용

    Note over Server,Client: roots가 바뀐 경우 (역방향)
    Client--)Server: notifications/roots/list_changed
    Server->>Client: roots/list
    Client-->>Server: 갱신된 roots

양방향이라는 게 핵심이다. 도구·자원·프롬프트의 변경은 서버가 클라이언트에게 알리고, 작업 범위(roots)의 변경은 클라이언트가 서버에게 알린다. 같은 메시지 모양이라 둘이 대칭이다.

알림은 응답을 기대하지 않는다. 받은 쪽이 필요한 후속 행동을 알아서 한다 — 알림을 받자마자 보통 */list를 다시 호출해 상태를 동기화한다.

타임아웃과 취소

Operation 동안 어떤 호출이 응답 없이 매달려 있을 수 있다. 호스트가 떴다 죽었거나, LLM이 무한 루프에 빠졌거나, 서버가 외부 API를 무한정 기다리거나. spec은 두 장치를 둔다.

타임아웃 — 모든 요청에 타임아웃을 거는 게 권장이다. 진행 중 알림(notifications/progress)이 오면 타임아웃을 리셋해도 되지만, 최대 한도는 무조건 둬야 한다.

Cancellation — 클라이언트가 진행 중인 호출을 명시적으로 취소하고 싶으면 notifications/cancelled를 보낸다.

{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": { "requestId": 5 }
}

서버는 이 알림을 받으면 해당 요청 처리를 중단해야 한다. 단순한 SDK 차원의 cancel이 아니라 프로토콜 차원의 취소 신호라, 서버가 확실히 정리할 수 있다.

Shutdown

세 번째 단계는 의외로 단순하다. MCP 자체에는 shutdown 메시지가 없다. 트랜스포트 차원에서 끊으면 그게 종료다.

shutdown 메시지를 따로 정의하지 않은 이유는 분명하다. 트랜스포트가 끊기면 어차피 메시지가 안 가는데, 굳이 한 번 더 RPC를 돌릴 필요가 없다.


다음 편에서는 손을 직접 더럽혀본다. TypeScript SDK로 가장 단순한 MCP 서버를 만들고, MCP Inspector로 동작을 확인하고, Claude Desktop에 붙여 본다. 4편까지의 spec이 코드에서 어떻게 모이는지 한 번 끝까지 굴려본다.

5편: 첫 MCP 서버 만들기 — TypeScript SDK와 Inspector


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
MCP 3편 — 클라이언트가 노출하는 세 가지: Sampling, Roots, Elicitation
Next Post
MCP 5편 — 첫 MCP 서버 만들기: TypeScript SDK와 Inspector