Table of contents
- 모든 메시지는 JSON-RPC 2.0
- Initialize — 첫 메시지의 모양
- Capability 협상 — 무엇이 켜질지
- Version Negotiation
- 양방향 알림 — notifications
- 타임아웃과 취소
- Shutdown
모든 메시지는 JSON-RPC 2.0
MCP는 자체 메시지 형식을 정의하지 않는다. JSON-RPC 2.0 위에 얹는다. 메시지는 세 종류뿐이다.
- Request —
id가 있고, 응답을 기대한다. - Response —
id가 같은 응답.result(성공) 또는error(실패). - Notification —
id가 없다. 응답이 오지 않는다.
// 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"
}
}
}
세 가지가 들어 있다.
- protocolVersion — 클라이언트가 지원하는 spec 버전. 최신을 보내는 게 권장.
- capabilities — 클라이언트가 서버에게 제공할 수 있는 것.
roots,sampling,elicitation등. 빈 객체{}도 허용 — 옵션이 없는 capability일 뿐 지원함을 의미한다. - clientInfo — 호스트 이름·버전 같은 식별 정보.
서버는 자기 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 | 의미 |
|---|---|---|
| Client | roots | 작업 범위(파일 경로) 제공 가능 |
| Client | sampling | LLM 호출 빌려줌 |
| Client | elicitation | 사용자에게 입력 받아옴 |
| Server | tools | 호출 가능한 함수 노출 |
| Server | resources | 읽기 가능한 자원 노출 |
| Server | prompts | 슬래시 명령 노출 |
| Server | logging | 구조화된 로그 푸시 |
| Server | completions | 인자 자동 완성 |
서브 옵션도 같이 협상한다. tools.listChanged를 true로 선언했으면 서버가 도구 목록 변경 시 알림을 푸시할 거라고 약속한 셈이다. resources.subscribe를 true로 선언했으면 클라이언트가 자원별 구독을 걸 수 있다.
핵심 규칙은 단순하다. 협상되지 않은 기능은 쓸 수 없다. 서버가 tools를 선언했어도 prompts를 선언하지 않았다면, 클라이언트가 prompts/list를 부르면 에러를 받는다.
Version Negotiation
protocolVersion은 날짜 형식이다 — 2025-11-25처럼 spec이 발표된 날을 그대로 쓴다. 버전 협상은 단순하다.
- 클라이언트가 자기가 지원하는 최신 버전을 보낸다.
- 서버가 그 버전을 지원하면 같은 버전으로 응답한다.
- 지원 안 하면 서버가 지원하는 다른 버전을 응답한다 (보통 자기 최신).
- 클라이언트가 그 버전을 못 따라가면 연결을 끊는다.
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 메시지가 없다. 트랜스포트 차원에서 끊으면 그게 종료다.
- stdio — 클라이언트가 서버 stdin을 닫고, 서버가 안 죽으면 SIGTERM, 그래도 안 죽으면 SIGKILL.
- HTTP — HTTP 연결을 닫는다. 세션 ID로 잡힌 세션이 있으면
DELETE요청으로 명시 종료할 수 있다.
shutdown 메시지를 따로 정의하지 않은 이유는 분명하다. 트랜스포트가 끊기면 어차피 메시지가 안 가는데, 굳이 한 번 더 RPC를 돌릴 필요가 없다.
다음 편에서는 손을 직접 더럽혀본다. TypeScript SDK로 가장 단순한 MCP 서버를 만들고, MCP Inspector로 동작을 확인하고, Claude Desktop에 붙여 본다. 4편까지의 spec이 코드에서 어떻게 모이는지 한 번 끝까지 굴려본다.




Loading comments...