Table of contents
Sampling — 서버가 호스트의 LLM을 빌린다
Sampling은 서버가 자기가 LLM을 직접 들고 있지 않은 채로 LLM 추론을 호출하는 방법이다. 서버에 OpenAI/Anthropic SDK를 박아넣지 않아도, 호스트(이미 LLM에 붙어 있는 Claude Desktop 같은 것)에게 요청해서 답을 받는다.
쉽게 말해, 서버가 호스트의 LLM을 짧게 빌려 쓰는 흐름이다. API 키 관리도, 모델 선택도 호스트에 맡긴다.
sequenceDiagram
participant Server
participant Client
participant User
participant LLM
Server->>Client: sampling/createMessage
Client->>User: 요청 검토 화면 (사람이 본다)
User-->>Client: 승인/수정
Client->>LLM: 요청 전달
LLM-->>Client: 응답
Client->>User: 응답 검토 화면
User-->>Client: 승인
Client-->>Server: 최종 응답 반환
핵심은 모든 흐름의 양 끝에 사용자가 있다는 점이다. 서버가 임의로 LLM을 부르고 임의로 응답을 가져가는 게 아니라, 사용자가 두 번 — 요청과 응답에 대해 — 검토한다.
요청 메시지는 이렇게 생겼다.
{
"method": "sampling/createMessage",
"params": {
"messages": [
{ "role": "user", "content": { "type": "text", "text": "프랑스 수도가 어디지?" } }
],
"modelPreferences": {
"hints": [{ "name": "claude-3-sonnet" }],
"intelligencePriority": 0.8,
"speedPriority": 0.5
},
"systemPrompt": "You are a helpful assistant.",
"maxTokens": 100
}
}
모델 선택은 어떻게 하나
서버가 “claude-3-sonnet 써줘”라고 직접 지정하면 호스트가 그 모델에 접근 권한이 없을 수 있다. spec은 우선순위 + 힌트로 추상화한다.
costPriority— 비용을 얼마나 신경 쓸지 (0~1)speedPriority— 응답 속도를 얼마나 신경 쓸지 (0~1)intelligencePriority— 추론 품질을 얼마나 신경 쓸지 (0~1)hints— 모델 이름의 부분 문자열. “claude-3-sonnet” 같은 식
호스트는 이 셋을 보고 자기가 가진 모델 중 가장 가까운 것을 고른다. Claude를 못 쓰면 동급 Gemini로 매핑하는 식의 호환을 호스트가 알아서 한다.
Sampling 안에서 도구를 또 쓴다
2025-11-25 spec부터 sampling 요청에 tools 배열을 같이 넣을 수 있다. 서버가 LLM을 빌리면서 그 LLM이 어떤 도구를 부를 수 있는지도 같이 알려준다. LLM은 도구를 부르고, 호스트가 그 호출을 승인받아 서버에 전달하고, 서버가 도구를 실행해 결과를 다시 sampling에 넣는 멀티턴 루프가 만들어진다.
이 패턴이 바로 spec이 말하는 agentic behavior다. 서버가 “tool 결과 → LLM → tool 결과 → LLM”을 반복해 한 작업을 끝까지 끌고 간다.
호스트는 이 루프 단계마다 사용자 동의를 얻을 수 있고, 보통 반복 횟수에 상한을 둔다.
Roots — 서버가 작업할 범위
Roots는 클라이언트가 서버에게 알려주는 작업 경계다. 파일시스템 서버에 “이 디렉터리만 봐”라고 알리는 가장 흔한 용도다.
{
"method": "roots/list"
}
응답은 단순하다.
{
"roots": [
{ "uri": "file:///home/user/projects/myproject", "name": "My Project" }
]
}
URI는 현재 spec에선 file://만 허용된다. 여러 개를 같이 노출할 수도 있다 — 모노레포의 frontend·backend 두 디렉터리를 따로 보여주는 식.
[
{ "uri": "file:///home/user/repos/frontend", "name": "Frontend Repository" },
{ "uri": "file:///home/user/repos/backend", "name": "Backend Repository" }
]
roots/list_changed
사용자가 IDE에서 워크스페이스를 바꾸면 roots도 바뀐다. listChanged: true를 capability에서 선언했으면 클라이언트가 변경 시점에 알림을 푸시한다.
{ "method": "notifications/roots/list_changed" }
서버는 이 알림을 받자마자 다시 roots/list를 호출해서 새 범위를 알아낸다.
Roots와 Tools 인자의 차이
“파일 경로를 어떻게 받느냐”의 자리에서 Roots와 Tool 인자가 헷갈릴 수 있다. 갈리는 지점은 단순하다.
- Roots: 사용자 전반의 작업 범위. 한 세션 동안 거의 안 바뀐다.
- Tool 인자의
path: 이번 호출의 대상. 매 호출마다 LLM이 정한다.
서버는 Tool 인자의 path가 Roots 안에 있는지 검증해서 path traversal을 막는다.
Elicitation — 서버가 사용자에게 묻는다
Tool을 실행하다가 사용자에게 정보가 더 필요할 수 있다. “정말 삭제할까요?”라는 확인, “GitHub 사용자명을 알려주세요” 같은 누락 인자, OAuth로 외부 서비스에 붙어야 하는 흐름 등.
Elicitation은 서버가 그 추가 정보 요청을 호스트를 통해 사용자에게 전달하는 표준 방법이다. 모드가 둘이다.
- Form 모드 — 호스트의 UI에 폼을 띄워 구조화된 데이터를 받는다. JSON Schema로 검증.
- URL 모드 — 외부 URL을 열어 MCP 클라이언트를 거치지 않고 처리한다. 자격 증명·결제·OAuth처럼 민감한 정보가 클라이언트와 LLM 컨텍스트를 통과하면 안 되는 경우.
Form 모드 — 폼으로 받는다
{
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "GitHub 사용자명을 알려주세요",
"requestedSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
호스트는 이 스키마를 받아 폼 UI를 자동 생성한다. 사용자가 입력하면 응답이 돌아온다.
{
"result": {
"action": "accept",
"content": { "name": "octocat" }
}
}
action은 셋 중 하나다.
accept— 사용자가 명시적으로 동의하고 데이터를 보냈다.content가 채워진다.decline— 사용자가 명시적으로 거부했다.cancel— 사용자가 결정하지 않고 닫았다.
서버는 셋을 다 다르게 처리해야 한다. 거부와 취소는 다르다 — 거부는 다음 흐름을 제안할 수 있고, 취소는 나중에 다시 물을 수 있다.
Form 모드에는 핵심 제약이 있다. 민감 정보는 Form 모드로 받으면 안 된다. 비밀번호·API 키·결제 정보는 URL 모드를 써야 한다.
URL 모드 — 외부 페이지로 보낸다
URL 모드는 사용자를 서버가 운영하는 외부 페이지로 보낸다. MCP 클라이언트는 그 페이지의 내용·입력을 보지 못한다.
{
"method": "elicitation/create",
"params": {
"mode": "url",
"elicitationId": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://mcp.example.com/ui/set_api_key",
"message": "API 키를 입력해주세요."
}
}
호스트는 사용자에게 URL을 명시적으로 보여주고 동의를 받은 뒤에만 연다. 응답 accept는 URL을 열겠다는 동의일 뿐, 실제 정보는 외부 페이지에서 들어간다.
sequenceDiagram
participant User
participant Browser as User Agent
participant Client
participant Server
Server->>Client: elicitation/create (mode: url)
Client->>User: URL 표시 + 동의 요청
User-->>Client: 동의
Client->>Browser: URL 열기
Client-->>Server: accept
User-->>Browser: 외부 페이지에서 입력
Browser-->>Server: 처리 완료
Server--)Client: notifications/elicitation/complete
처리가 끝나면 서버는 notifications/elicitation/complete로 클라이언트에 알린다 — 이 알림은 옵션이라, 도착하지 않을 수도 있다. 클라이언트는 알림을 기다리되, 사용자가 수동으로 재시도·취소할 수단도 같이 제공해야 한다.
URLElicitationRequiredError
서버가 도구 호출을 받았는데 처리 전에 elicitation이 필요한 상황이면, 호출 응답으로 특수 에러(-32042)를 돌려준다.
{
"error": {
"code": -32042,
"message": "이 요청은 인증이 필요합니다.",
"data": {
"elicitations": [{
"mode": "url",
"elicitationId": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://mcp.example.com/connect?elicitationId=...",
"message": "Example Co 파일에 접근하려면 인증이 필요합니다."
}]
}
}
}
클라이언트는 elicitation을 끝낸 뒤 원래 호출을 재시도한다. 외부 OAuth 흐름이 도구 호출의 전제 조건이 되는 패턴이 자연스럽게 생긴다.
모든 것의 양 끝에 사람이 있다
이번 편의 셋을 관통하는 원칙이 하나다.
클라이언트 primitive는 모두 사용자 동의 흐름 위에서 동작한다.
- Sampling — 서버가 LLM을 부를 때, 사용자가 요청과 응답을 본다.
- Roots — 서버에 작업 범위를 노출할 때, 사용자가 어떤 디렉터리를 노출할지 동의한다.
- Elicitation — 서버가 정보를 물을 때, 사용자가 내용을 보고 입력한다.
이 원칙이 깨지면 서버가 사용자 모르게 LLM을 호출하거나 임의의 디렉터리를 보거나 민감 정보를 가로챌 수 있다. 그래서 spec이 클라이언트가 capability를 선언하지 않은 모드는 서버가 보내면 안 된다는 식의 강제 조건을 붙여둔다.
다음 편에서는 한 단계 안으로 들어가 메시지 자체가 어떻게 흐르는지를 본다. JSON-RPC 2.0 위에서 lifecycle(initialize → operate → shutdown)이 어떻게 잡히고, capability 협상은 어떤 순서로 일어나며, 알림(notifications)이 양방향으로 왜 필요한지 — 1~3편에서 이름만 짚었던 메커니즘을 메시지 단위로 풀어본다.




Loading comments...