docs: publish translated docs in twelve languages and the tool that maintains them (#3280)

This commit is contained in:
Max 2026-08-14 17:07:34 +01:00 • committed by GitHub
commit 52ad0a8876
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 89737 additions and 54 deletions

View file

@ -0,0 +1,128 @@
---
translation:
sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240]
tool: 1
---
# Context {#the-context}
도구의 인자는 모델이 채웁니다. 그 밖의 모든 것(지금 처리 중인 요청, 도구가 속한 서버, 클라이언트에 되돌려 말을 건넬 수단)은 단 하나의 객체, **`Context`**에서 옵니다.
직접 생성하지도, 설정하지도 않습니다. 달라고 하기만 하면 됩니다.
## Context 받기 {#ask-for-it}
아무 도구에나 `Context`로 어노테이션한 매개변수를 추가하세요.
```python title="server.py" hl_lines="2 8"
--8<-- "docs_src/context/tutorial001.py"
```
* SDK는 요청마다 새 `Context`를 만들어 전달합니다.
* 매개변수 **이름은 중요하지 않습니다**. `ctx`, `context`, `c` 무엇이든 SDK는 어노테이션으로 찾아냅니다.
* 리소스와 프롬프트도 같은 방식으로 선언할 수 있습니다.
* `ctx.request_id`는 함수가 바로 지금 처리하고 있는 요청의 ID입니다.
!!! info
FastAPI를 써 봤다면 익숙한 방식입니다. 프레임워크 고유의 타입(FastAPI에서는 `Request`, 여기서는 `Context`)으로 매개변수를 선언하면 프레임워크가 값을 채워 줍니다. 등록할 것도, 설정할 것도 없습니다. 타입 어노테이션이 메커니즘의 전부입니다.
### 모델에게 보이지 않는 매개변수 {#invisible-to-the-model}
꼭 새겨 둘 부분입니다. 다음은 `tools/list`가 보고하는 `search_books`의 입력 스키마입니다.
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
속성은 하나뿐입니다. `ctx`는 인자가 아닙니다. 스키마에 나타나지 않고, 모델은 그 존재를 전혀 듣지 못하며, 어떤 클라이언트도 값을 채울 수 없습니다. 개발자와 SDK 사이의 약속일 뿐, 와이어 위에서는 보이지 않습니다.
### 직접 해 보기 {#try-it}
MCP Inspector로 서버를 실행하세요.
```console
uv run mcp dev server.py
```
`search_books` 폼에는 `query` 필드 하나만 있습니다. `dune`으로 호출해 보세요.
```text
[request 3] Found 3 books matching 'dune'.
```
숫자는 이 호출이 우연히 몇 번째 요청이었는지에 따라 정해집니다. 도구를 다시 호출하면 숫자가 바뀝니다. 요청마다 고유한 `Context`를 받기 때문입니다.
## Context가 제공하는 것 {#what-it-gives-you}
주입되는 객체는 작습니다. `request_id` 외에 다음이 있습니다.
* `await ctx.read_resource(uri)`: 도구 안에서 서버 **자신의** 리소스를 읽습니다. 다음 절에서 다룹니다.
* `await ctx.report_progress(progress, total, message)`: 오래 걸리는 호출 중에 호출자에게 진행 상황을 스트리밍합니다. 자세한 내용은 **[진행 상황](progress.md)**에서 확인하세요.
* `await ctx.elicit(message, schema)`와 `await ctx.elicit_url(...)`: 도구를 잠시 멈추고 사용자에게 질문합니다. **[엘리시테이션(elicitation)](elicitation.md)**에서 다룹니다.
* `ctx.session`: 이 클라이언트와 나누는 대화의 서버 쪽 끝입니다. 클라이언트로 보내는 알림이 여기에 있으며, 마지막 절에서 사용합니다.
* `ctx.headers`: 트랜스포트가 실어 온 요청 헤더이며, stdio에서는 `None`입니다. 사용자 정의 헤더는 `(ctx.headers or {}).get("x-...")`로 읽습니다. 헤더는 클라이언트가 제공하는 입력이므로 로캘이나 기능 플래그에는 괜찮지만, 신원으로는 절대 쓰면 안 됩니다.
* `ctx.request_context`: 요청별 원시 레코드입니다. 주로 찾게 될 필드는 `lifespan_context`로, 시작 코드가 yield한 객체입니다(**[Lifespan](lifespan.md)** 참고).
로깅은 일부러 이 목록에 넣지 않았습니다. 서버는 다른 Python 프로그램과 마찬가지로 Python의 `logging` 모듈로 로그를 남깁니다. 그 이유는 짧은 페이지 **[로깅](logging.md)**에서 설명합니다.
!!! tip
주입은 등록한 함수에만 일어납니다. 도구가 호출하는 헬퍼 함수는 자체 `Context`를 받지 않으므로 `ctx`를 일반 인자로 넘겨주세요. 다른 곳에서 가져다 쓸 수 있는 전역 "현재 컨텍스트" 같은 것은 없습니다.
## 서버 자신의 리소스 읽기 {#read-your-own-resources}
서버의 리소스는 클라이언트만을 위한 것이 아닙니다. 도구도 읽을 수 있습니다.
```python title="server.py" hl_lines="16"
--8<-- "docs_src/context/tutorial002.py"
```
`ctx.read_resource`는 `resources/read`를 처리하는 것과 같은 레지스트리를 통해 URI를 해석하므로, 도구는 클라이언트가 받는 것과 똑같은 결과를 받습니다. 콘텐츠 블록마다 하나씩 담긴 `ReadResourceContents`의 이터러블입니다. 이 URI에는 하나가 있습니다.
```python
contents.content # 'fiction, non-fiction, poetry'
contents.mime_type # 'text/plain'
```
* `content`는 `genres()`가 반환한 값 그대로입니다. 진실의 원천은 하나입니다. 클라이언트는 리소스를 둘러보고, 도구는 리소스를 사용하며, 누구도 문자열을 복사하지 않습니다.
* `describe_catalog`의 유일한 매개변수는 `Context`이므로 입력 스키마에는 **속성이 아예 없습니다**. 모델이 호출할 때 넘기는 인자는 `{}`입니다.
## 목록이 바뀌었음을 클라이언트에 알리기 {#tell-the-client-the-list-changed}
서버가 제공하는 것은 임포트 시점에 고정되지 않습니다. 런타임에 도구를 등록한 다음 클라이언트에 알리세요.
```python title="server.py" hl_lines="15-16"
--8<-- "docs_src/context/tutorial003.py"
```
* `mcp.add_tool(recommend_book)`은 평범한 함수를 도구로 등록합니다. 이름, 설명, 스키마는 `@mcp.tool()`을 썼을 때와 똑같이 도출됩니다.
* `await ctx.session.send_tool_list_changed()`는 `notifications/tools/list_changed`를 보냅니다. 이를 받은 클라이언트는 `tools/list`를 다시 호출하고 `recommend_book`을 보게 됩니다.
형제 메서드로는 `send_resource_list_changed()`, `send_prompt_list_changed()`, 그리고 특정 리소스 하나의 변경을 알리는 `send_resource_updated(uri)`가 있습니다.
2026-07-28 연결에서 클라이언트는 직접 연 `subscriptions/listen` 스트림에서만 변경 알림을 받으므로, 위의 `send_*` 메서드는 그 스트림에 닿지 않습니다. `Context`의 발행 메서드는 구독 중인 모든 스트림에 한 번에 전달합니다. `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, `await ctx.notify_resource_updated(uri)`입니다. 여러 복제본으로 확장하는 방법까지 포함한 자세한 내용은 **[구독](subscriptions.md)**에서 확인하세요.
!!! check
누군가 `enable_recommendations`를 실행하기 전까지는 약속한 도구가 존재하지 않습니다. 그래도 호출하면 모델이 읽을 수 있는 오류가 결과로 돌아옵니다.
```text
Unknown tool: recommend_book
```
`enable_recommendations`를 실행하면 똑같은 호출이 성공합니다. 도구 목록은 진짜로 동적입니다. `tools/list`는 **바로 지금** 등록되어 있는 것을 반영합니다.
## 요약 {#recap}
* (도구, 리소스, 프롬프트에서) 매개변수에 `Context` 어노테이션을 달면 SDK가 주입합니다. 이름은 마음대로 정하면 됩니다.
* 모델에게는 보이지 않습니다. 입력 스키마에는 언제나 실제 인자만 들어갑니다.
* `ctx.request_id`는 요청을 식별하고, `ctx.request_context.lifespan_context`는 시작 코드가 yield한 객체입니다.
* `await ctx.read_resource(uri)`로 도구가 서버 자신의 리소스를 읽을 수 있습니다.
* `ctx.session`은 클라이언트로 되돌아가는 채널입니다. `send_tool_list_changed()`와 형제 메서드는 바뀐 목록을 다시 가져오라고 클라이언트에 알립니다.
* 진행 상황 보고와 엘리시테이션도 `Context`에서 시작하며, 각각 별도 페이지가 있습니다.
모델은 전혀 보지 못하고 직접 작성한 함수가 채우는 매개변수는 **[의존성](dependencies.md)**입니다.

View file

@ -0,0 +1,161 @@
---
translation:
sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd]
tool: 1
---
# 의존성 {#dependencies}
도구의 인자는 모델이 제공합니다. 하지만 모델에게서 와서는 안 되는 값도 있습니다. 기록에서 조회한 가격, 사람만이 줄 수 있는 확인, 모델이 지어내면 틀릴 수 있는 모든 값이 여기에 해당합니다.
**의존성**은 직접 작성한 함수가 채우는 매개변수입니다. 매개변수에 어노테이션을 달고 함수를 지정하면, 도구가 실행되기 전에 SDK가 그 함수를 호출합니다.
## 선언하기 {#declare-one}
매개변수의 타입을 `Annotated[...]`로 감싸고 `Resolve(fn)`을 추가하세요.
```python title="server.py" hl_lines="18-19 23"
--8<-- "docs_src/dependencies/tutorial001.py"
```
* `check_stock`은 **리졸버**입니다. SDK가 `reserve_book`보다 먼저 실행하는 평범한 함수이며, 반환값이 `stock` 인자가 됩니다.
* 리졸버의 `title` 매개변수는 도구 자신의 `title` 인자이며, **이름으로** 매칭됩니다. 리졸버는 도구 본문이 보게 될 검증된 값과 정확히 같은 값을 봅니다.
* 도구 본문은 이미 존재하는 `Stock`에서 시작합니다. 도구 안에 조회 코드도 없고, "값이 없으면 어떻게 하나" 같은 사전 처리도 없습니다.
!!! info
FastAPI를 써 봤다면 이것은 `Depends`와 같습니다. 방식도 같고 이유도 같습니다. 함수가 필요한 것을
선언하면 프레임워크가 공급하고, 연결은 타입 어노테이션 안에 담깁니다.
### 모델에게는 보이지 않음 {#invisible-to-the-model}
다음은 `tools/list`가 `reserve_book`에 대해 보고하는 입력 스키마입니다.
```json
{
"type": "object",
"properties": {
"title": {"title": "Title", "type": "string"}
},
"required": ["title"],
"title": "reserve_bookArguments"
}
```
속성이 하나뿐입니다. **[Context](context.md)**의 `Context`와 마찬가지로, 리졸브된 매개변수는 작성자와 SDK 사이의 계약입니다. `stock`은 스키마에 없고, 모델은 이 매개변수를 전혀 알지 못하며, 그런데도 `stock` 값을 보내는 클라이언트가 있다면 그 값은 무시됩니다. 도구가 받을 수 있는 값은 리졸버의 값뿐입니다.
바로 이 마지막 부분이 핵심입니다. 모델이 제공할 수 없는 매개변수는 모델이 틀릴 수 없는 매개변수입니다.
### 직접 해 보기 {#try-it}
MCP Inspector로 서버를 실행하세요.
```console
uv run mcp dev server.py
```
`reserve_book` 폼에는 `title` 필드 하나만 있습니다. `stock`은 어디에도 없습니다. `Dune`으로 호출해 보세요.
```text
Reserved 'Dune' (6 copies left).
```
도구 본문은 아무것도 조회하지 않았습니다. `check_stock`이 먼저 실행되었고, 반환한 `Stock`이 인자로 도착했습니다. `Neuromancer`로 시도하면 같은 리졸버가 도구에 0을 건넵니다.
!!! tip
도구 본문에서 그냥 `check_stock(title)`을 호출해도 됩니다. 값이 헬퍼 호출 이상의 대접을 받을 만할 때
의존성으로 선언하세요. 재고가 필요한 모든 도구가 같은 매개변수를 선언하고, 몇 곳에서 선언하든 SDK는
호출당 최대 한 번만 리졸버를 실행합니다. 다음 절에서 나머지를 다룹니다. 서로 의존하는 리졸버, 그리고
사용자에게 묻는 리졸버입니다.
## 의존성의 의존성 {#dependencies-of-dependencies}
리졸버도 같은 어노테이션으로 자신의 의존성을 선언할 수 있습니다.
```python title="server.py" hl_lines="22 29-30"
--8<-- "docs_src/dependencies/tutorial002.py"
```
* `estimate_delivery`는 `check_stock`에 의존합니다. SDK는 그래프를 순서대로 실행합니다. 재고가 먼저, 그다음 배송 예상, 그다음 도구입니다.
* `stock`과 `delivery` 모두 결국 `check_stock`이 필요하지만, 이 리졸버는 **호출당 한 번** 실행됩니다. 재고 조회 한 번에 소비자 둘입니다.
* 등록할 것은 아무것도 없습니다. 어노테이션 자체가 **곧** 그래프입니다.
!!! check
호출당 한 번이라는 말을 그냥 믿지 마세요. `check_stock`에 `print`를 넣고 Inspector에서 `order_book`을
호출해 보세요. 호출마다 한 줄이 찍힙니다. 소비자는 둘, 조회는 한 번입니다.
SDK는 도구가 호출될 때가 아니라 등록될 때 그래프를 분석합니다. 분류할 수 없는 매개변수(`Context`도 아니고, `Resolve(...)`도 아니고, 도구 인자의 이름도 아닌 경우)와 리졸버의 순환은 모두 시작 시점에 `InvalidSignature`를 발생시킵니다. 서버는 클라이언트가 연결하기도 전에 실패하며, 문제가 된 매개변수나 리졸버의 이름이 오류에 표시됩니다.
리졸버의 매개변수는 도구의 매개변수와 똑같이 리졸브됩니다. 또 다른 `Resolve(...)`, 이름으로 매칭되는 도구 자신의 인자, 또는 `Context`(`ctx.headers`, lifespan 객체 등 전부)입니다.
!!! warning
HTTP 트랜스포트에서는 `Context`에 `ctx.headers`가 포함됩니다. 헤더는 여느 도구 인자와 마찬가지로
**클라이언트가 제공한 입력**입니다. 로캘이나 기능 플래그로는 괜찮지만, 신원으로는 절대 안 됩니다.
호출자가 누구인지는 누구나 설정할 수 있는 헤더가 아니라 인가 계층(**[인가](../run/authorization.md)**)에서 나옵니다.
!!! tip
**호출당 한 번**은 말 그대로입니다. 다음 `tools/call`은 `check_stock`을 다시 실행합니다. 요청보다
오래 살아야 하는 리소스(데이터베이스 풀, HTTP 클라이언트)는 **[Lifespan](lifespan.md)**에 속하며,
리졸버는 `ctx.request_context.lifespan_context`를 통해 접근할 수 있습니다.
## 꼭 필요할 때만 묻기 {#ask-when-you-must}
리졸버가 답을 꼭 알아야 하는 것은 아닙니다. `Elicit(message, Model)`을 반환하면 SDK가 사용자에게 묻습니다. **[엘리시테이션(elicitation)](elicitation.md)** 메커니즘을 대신 실행해 주는 셈입니다.
```python title="server.py" hl_lines="26-32 39"
--8<-- "docs_src/dependencies/tutorial003.py"
```
* 재고 있음: `confirm_backorder`는 `Backorder`를 바로 반환합니다. **질문도 없고, 왕복도 없습니다.** 사용자의 답이 중요할 때만 사용자를 방해합니다.
* 재고 없음: SDK가 엘리시테이션을 보내고, 답을 `Backorder`에 맞춰 검증한 뒤 주입합니다. 리졸버는 프로토콜을 전혀 건드리지 않습니다.
* 도구는 `backorder.confirm`을 여느 인자처럼 읽습니다. **아니요**라고 답하는 것도 여전히 답입니다. 엘리시테이션은 `confirm=False`로 수락되고, 도구가 실행되며, 주문은 들어가지 않습니다. 묻는 일이 도구 본문의 배관 코드가 아니라 전제 조건이 되었습니다.
그렇다면 사용자가 아예 답하지 않으면, 즉 질문을 거절하거나 취소하면 어떻게 됩니까?
!!! check
`Neuromancer`로 `order_book`을 실행하고 질문을 거절해 보세요. 어노테이션을
`Annotated[Backorder, Resolve(...)]`로 작성한 경우 도구 본문은 실행되지 않으며, 호출은 모델이
읽을 수 있는 오류 결과와 함께 실패합니다.
```text
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
```
전제 조건으로서는 이것이 올바른 기본 동작입니다. 답이 없으면 주문도 없습니다. 거절이 도구가 직접 처리하고 싶은 결과라면(예약 주문은 건너뛰되 다른 책을 추천하는 식으로) 대신 `ElicitationResult[Backorder]`로 어노테이션하세요. 그러면 도구가 수락/거절/취소 결과 전체를 받아 분기할 수 있습니다. **[엘리시테이션](elicitation.md)**에서 이 형태와 함께 묻기에 관한 나머지 모든 것, 즉 스키마 규칙, 세 가지 답, 대화의 클라이언트 쪽을 보여 줍니다.
!!! info
프레임워크는 협상된 프로토콜 버전에 따라 질문의 전송 방식을 고릅니다. 위 코드는 양쪽 모두에서
동일합니다. **2026-07-28** 및 그 이후 버전에서는 질문이 다중 왕복 `tools/call` 안에 실려 갑니다.
서버가 질문을 반환하고, 클라이언트의 `elicitation_callback`이 답하며, `Client`가 호출을 대신
재시도합니다(**[다중 왕복 요청](multi-round-trip.md)**). **2025-11-25** 및 그 이전 버전에서는 호출
도중에 보내는 동기식 엘리시테이션 요청입니다. 각 질문은 호출당 정확히 한 번만 물어봅니다. 이는
리졸버가 아니라 질문에 대한 보장입니다. 다중 왕복 형태에서는 질문 후 호출이 재개될 때마다 어떤
리졸버든 다시 실행될 수 있으므로, `return Elicit(...)` 앞의 코드는 그런 라운드마다 실행됩니다.
이때 기록된 답이 반복된 질문을 충족하므로 사용자에게 다시 묻지 않습니다. 기록된 답은 리졸버가 물을
때만 참조됩니다. `check_stock`처럼 묻지 **않고** 답하는 리졸버는 항상 스스로 계산한 값을
공급합니다. 각 답은 해당 질문에 다시 매칭되므로, 엘리시테이션을 하는 리졸버는 도구의 인자와 이전
답으로부터 질문을 결정적으로 도출해야 합니다. 호출마다 생성되는 값(`default_factory` ID, 타임스탬프)은
라운드마다 다시 만들어지므로, 답이 결합되어야 할 질문에 나타나서는 안 됩니다. 이런 변동성 데이터로
만든 질문은 기록된 모든 답을 낡은 것처럼 보이게 하므로, 클라이언트의 라운드 제한이 호출을 끝낼
때까지 서버가 라운드마다 다시 묻게 됩니다.
## 사용자가 아닌 클라이언트에게 묻기 {#ask-the-client-not-the-user}
엘리시테이션은 리졸버가 할 수 있는 세 가지 질문 중 하나이며, 다중 왕복 흐름은 그 외의 질문을 허용하지 않습니다. 나머지 둘은 사용자가 아니라 **클라이언트**에게 갑니다. 클라이언트를 통해 LLM 호출을 실행하려면(`sampling/createMessage` 요청) `Sample(...)`을, 클라이언트의 현재 루트를 가져오려면 `ListRoots()`를 반환하세요. 둘 다 수락/거절 결과가 없으므로, 소비자는 결과 타입을 직접 어노테이션합니다. `CreateMessageResult`(요청에 `tools`나 `tool_choice`가 있으면 `CreateMessageResultWithTools`) 또는 `ListRootsResult`입니다.
```python title="server.py" hl_lines="10-15 21"
--8<-- "docs_src/dependencies/tutorial004.py"
```
* 프레임워크는 이들을 `Elicit`과 똑같이 라우팅합니다. **2026-07-28**에서는 다중 왕복 `tools/call` 안에서, **2025-11-25**에서는 독립적인 서버->클라이언트 요청을 통해서입니다. 선언되지 않은 기능은 `-32021` 프로토콜 오류로 호출을 거부합니다(`sampling`, `roots`, 폼 모드 `elicitation`, 요청에 `tools`나 `tool_choice`가 있으면 `sampling.tools`).
* 위 정보 상자에서 질문에 관해 말한 모든 내용이 그대로 적용됩니다. `Sample` 요청은 정확한 렌더링으로 기록된 결과와 매칭되므로, 도구의 인자와 이전 답으로부터 결정적으로 만드세요. 그러면 클라이언트는 LLM 호출 비용을 라운드마다가 아니라 도구 호출당 한 번만 냅니다. 기록된 결과는 호출이 끝날 때까지 `request_state`에 실려 다니므로, 매우 큰 컴플리션은 남은 모든 왕복을 더 무겁게 만듭니다.
* 독립적인 샘플링 및 루트 **기능**은 2026-07-28에서 지원 중단 예정(deprecated)입니다(SEP-2577). 클라이언트의 모델이 필요한 새 서버는 이 경로를 통해 묻고, 그렇지 않은 서버는 LLM 제공자와 직접 통합해야 합니다. `"none"` 이외의 `include_context` 값 자체도 지원 중단 예정이므로 피하세요.
## 요약 {#recap}
* 도구 매개변수에 `Annotated[T, Resolve(fn)]`을 붙이면 SDK가 `fn`을 실행하고 반환값을 주입합니다.
* 리졸브된 매개변수는 모델에게 보이지 않으며 클라이언트가 제공할 수 없습니다. 모델이 지어내서는 안 되는 값(가격, 신원, 권한)은 여기에 속합니다.
* 리졸버의 매개변수도 같은 방식으로 리졸브됩니다. `Context`, 또 다른 `Resolve(...)`, 또는 이름으로 매칭되는 도구 인자입니다. 그래프는 소비자가 몇이든 각 리졸버를 라운드당 최대 한 번 실행합니다. 각 질문은 정확히 한 번만 물어보며, 질문 후 호출이 재개되면 어떤 리졸버든 다시 실행될 수 있습니다.
* 잘못된 그래프는 호출 도중이 아니라 등록 시점에 `InvalidSignature`로 실패합니다.
* 사용자에게 물으려면 `Elicit(message, Model)`을 반환하되, 꼭 필요할 때만 하세요. 감싸지 않은 어노테이션은 거절 시 중단되고, `ElicitationResult[T]`는 도구가 분기할 수 있게 해 줍니다.
* 클라이언트에게 LLM 컴플리션이나 루트 목록을 요청하려면 `Sample(...)`이나 `ListRoots()`를 반환하세요. 결과가 그대로 주입됩니다.
서버가 시작 시 한 번 구축하는 상태, 그리고 핸들러가 그 상태에 접근하는 방법은 **[Lifespan](lifespan.md)** 페이지에서 다룹니다.

View file

@ -0,0 +1,191 @@
---
translation:
sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8]
tool: 1
---
# 엘리시테이션 {#elicitation}
작업을 절반쯤 진행하다가 답 하나가 모자란 도구라고 해서 실패해야 하는 것은 아닙니다.
**엘리시테이션**(elicitation)을 사용하면 도구가 물어볼 수 있습니다. 도구 호출 도중에 사용자는 질문을 받고, 사용자의 답은 같은 함수 호출 안으로 돌아옵니다.
두 가지 모드가 있습니다.
* **폼 모드**: 값(확인, 날짜, 수량)이 필요한 경우입니다. 필드를 기술하면 클라이언트가 폼을 렌더링합니다.
* **URL 모드**: 사용자가 다른 곳(OAuth 동의 화면, 결제 페이지)으로 가야 하는 경우입니다. 사용자가 그곳에서 하는 일은 프로토콜을 전혀 거치지 않습니다.
그리고 물어보는 방법도 두 가지입니다. 먼저 손이 가야 할 것은 **리졸버**입니다. 질문을 파라미터에 걸어 두면 SDK가 대신 물어봅니다. 어떤 연결에서든, 클라이언트가 어느 시대의 프로토콜을 쓰든 상관없습니다. 직접적인 방법인 `await ctx.elicit(...)`은 **서버**가 **클라이언트**에게 보내는 요청인데, 이 채널은 레거시 연결(사양 버전 2025-11-25 이하)을 쓰는 클라이언트에게만 존재합니다. 두 방법 모두 이 페이지에서 다루며, 리졸버부터 시작하세요.
## 리졸버로 물어보기 {#ask-with-a-resolver}
도구 전체의 실행을 좌우하는 질문("정말 실행할까요?", "일치하는 계정 세 개 중 어느 것인가요?")은 도구 본문에서 꺼내 **리졸버**로 옮길 수 있으며, 그러면 프레임워크가 대신 물어봅니다.
`Annotated[T, Resolve(fn)]`로 어노테이션한 파라미터는 도구 본문보다 먼저 `fn`을 실행해 채워집니다. 리졸버는 값을 이미 알고 있으면 그대로 반환하고, 프레임워크가 물어보게 하려면 `Elicit(...)`을 반환합니다.
```python title="server.py" hl_lines="24-30 35-36"
--8<-- "docs_src/elicitation/tutorial004.py"
```
* `confirm_delete`는 도구 자신의 `path` 인자를 이름으로 읽고 폴더 내용을 나열하며, **꼭 필요할 때만 사용자에게 묻습니다**. 빈 폴더라면 클라이언트와 왕복할 필요 없이 `Confirm(ok=True)` 값으로 바로 결정됩니다.
* `delete_folder`는 `ElicitationResult[Confirm]`으로 어노테이션하므로 프레임워크가 결과 전체를 주입하고, 도구는 `match`로 모든 경우를 처리합니다. 수락 후 확인, 수락했지만 유지(`ok=False`), 거절, 취소입니다.
* `confirm` 파라미터는 도구의 입력 스키마에 전혀 나타나지 않습니다. `path`는 클라이언트가, `confirm`은 리졸버가 제공합니다.
도구가 분기할 필요가 없다면 대신 감싸지 않은 모델(`Annotated[Confirm, Resolve(confirm_delete)]`)로 어노테이션하세요. 수락하면 도구가 모델을 받고, 거절이나 취소면 호출이 오류와 함께 중단됩니다.
리졸버는 **모든** 연결에서 동작합니다. 레거시 연결을 쓰는 클라이언트에게는 SDK가 질문을 직접 보내고, **2026-07-28** 연결에서는 SDK가 호출에서 질문을 **반환**하며 클라이언트의 다음 시도에 답이 실려 옵니다. 리졸버는 그 차이를 전혀 알지 못합니다. 그 아래에서 일어나는 일은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다.
물어보는 것은 리졸버가 할 수 있는 일 중 하나일 뿐입니다. 묻지 않고 계산하는 의존성, 의존성의 의존성, 모델이 제공할 수 있는 것과 없는 것 같은 일반적인 메커니즘은 **[의존성](dependencies.md)** 페이지에서 다룹니다.
## 도구 안에서 물어보기 {#ask-from-inside-the-tool}
도구는 자기 본문 한가운데서 멈추고 물어볼 수도 있습니다.
!!! warning
`ctx.elicit()`과 `ctx.elicit_url()`은 **서버**가 **클라이언트**에게 보내는 요청이며, 이 채널은
레거시 연결(사양 버전 **2025-11-25** 이하)을 쓰는 클라이언트에게만 존재합니다.
**2026-07-28** 연결에는 서버가 시작하는 요청이 없으므로 이 호출은 실패합니다.
리졸버는 양쪽 모두에서 동작합니다. 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서
확인하세요.
`await ctx.elicit()`은 메시지와 Pydantic 모델을 받습니다.
```python title="server.py" hl_lines="9-11 20-23 25"
--8<-- "docs_src/elicitation/tutorial001.py"
```
* **`Context`** 파라미터가 있어야 `ctx.elicit`을 쓸 수 있으며, 어떤 도구든 이 파라미터를 받을 수 있습니다. 이 객체는 별도의 페이지 **[Context](context.md)**에서 다룹니다.
* `AlternativeDate`는 원하는 답의 **스키마**입니다.
* 도구는 `async def`입니다. 그래야만 합니다. 도중에 멈춰서 사람을 기다리기 때문입니다.
* 그 밖의 날짜라면 도구는 곧바로 반환합니다. 꼭 필요할 때만 묻습니다.
* 사용자가 수락한 날짜는 다시 `book_table` 자체를 거칩니다. 답도 다른 입력과 마찬가지로 입력입니다. 대안 날짜 역시 예약이 꽉 차 있다면 무작정 확정하지 않고 다시 물어봅니다.
### 클라이언트가 받는 것 {#what-the-client-receives}
클라이언트는 메시지와 함께, 모델에서 생성된 JSON Schema를 받습니다.
```json
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean"
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string"
}
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object"
}
```
이 스키마가 곧 폼입니다. `Field(description=...)`은 레이블이 되고, 기본값은 입력란을 미리 채우며 그 필드를 선택 사항으로 만듭니다. **[도구](../servers/tools.md)** 페이지가 도구 인자를 두고 설명하는, Pydantic을 JSON Schema로 변환하는 바로 그 장치입니다.
!!! warning
엘리시테이션 스키마는 도구의 입력 스키마만큼 표현력이 높지 않습니다. 평평한 원시 타입 필드만
가능합니다. `str`, `int`, `float`, `bool`, 또는 문자열 `Literal`(`enum`이 됩니다)입니다.
모델 안에 모델을 넣으면 클라이언트에 아무것도 보내기 전에 `ctx.elicit`이 예외를 일으킵니다.
```text
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
```
작업 중인 사람을 가로막고 있는 것입니다. 답에 중첩 구조가 필요하다면 애초에 도구의 인자로
받았어야 합니다.
### 세 가지 답 {#the-three-answers}
`result.action`은 사용자가 무엇을 했는지 알려 주며, 가능한 경우는 정확히 세 가지입니다.
* `"accept"`: 폼을 제출했습니다. `result.data`는 이미 검증된 `AlternativeDate` 인스턴스입니다.
* `"decline"`: 거절했습니다.
* `"cancel"`: 선택하지 않고 질문을 닫았습니다.
`result.data`는 `"accept"`일 때만 존재하며, 그래서 예제는 `result.action`을 먼저 확인합니다. 타입 체커가 이 순서를 강제합니다. `result.action == "accept"`를 확인한 뒤에는 `result.data`가 `AlternativeDate`이고, 그 전에는 `.data` 자체가 없습니다.
거절은 오류가 아닙니다. 거절이 무엇을 뜻하는지(여기서는 예약하지 않음)는 도구가 정하고, 모델에게는 평소처럼 답합니다.
!!! tip
답은 코드가 보기 전에 모델을 기준으로 검증됩니다. `bool` 자리에 `"maybe"`를 보내는 클라이언트가
예약을 망가뜨리지는 않습니다. 호출은 스키마 불일치 오류로 실패하고, `if` 문은 실행되지
않습니다.
## 사용자를 URL로 보내기 {#send-the-user-to-a-url}
모델이나 클라이언트를 거쳐서는 안 되는 것이 있습니다. 자격 증명, 카드 번호, OAuth 동의가 그렇습니다. 이런 경우에는 데이터를 요청하지 않고, 사용자에게 어딘가로 가 달라고 요청합니다.
```python title="server.py" hl_lines="10-14 23"
--8<-- "docs_src/elicitation/tutorial002.py"
```
* `ctx.elicit_url()`은 메시지, 방문할 **URL**, 그리고 직접 정하는 `elicitation_id`를 받습니다. 서버 안에서 이 엘리시테이션을 식별하는 문자열이면 무엇이든 됩니다.
* 결과에는 action만 있고 그 외에는 아무것도 없습니다. `"accept"`는 사용자가 URL을 열겠다고 동의했다는 뜻이지, 그 너머에 있는 일을 끝냈다는 뜻이 **아닙니다**.
* 결제는 대역 외로, 사용자의 브라우저와 결제 제공자 사이에서 이루어집니다. 어떤 내용도 MCP를 통해 돌아오지 않습니다.
두 번째 도구를 보세요. 서버가 대역 외 흐름이 끝났음을 알게 되면(웹훅, 폴링, 여기서는 두 번째 도구로 모델링했습니다) `ctx.session.send_elicit_complete(...)`가 같은 `elicitation_id`로 `notifications/elicitation/complete`를 보냅니다. 클라이언트는 이를 통해 *"waiting for payment..."* 표시를 멈춰도 된다는 것을 압니다. 이 알림이 없으면 클라이언트는 짐작만 할 수 있습니다.
## 클라이언트 쪽 {#the-client-side}
서버는 묻고, 클라이언트는 `Client(...)`에 **`elicitation_callback`**을 전달해 답합니다.
```python title="client.py" hl_lines="6-7 18"
--8<-- "docs_src/elicitation/tutorial003.py"
```
* 콜백 하나가 두 모드를 모두 처리합니다. `params`는 `ElicitRequestFormParams`와 `ElicitRequestURLParams`의 유니언이며, `isinstance`로 분기합니다.
* URL이면 사용자에게 `params.url`을 보여 주고 사용자가 고른 action을 반환합니다. `content`는 절대 넣지 않습니다.
* 폼이면 실제 애플리케이션은 `params.requested_schema`를 렌더링하고 사용자의 입력을 `content`로 반환합니다. 이 예제는 항상 미리 준비된 답으로 예라고 답하는데, 테스트에서 원하는 콜백이 바로 이런 것입니다.
* 콜백을 전달하는 것이 곧 **기능 선언**이기도 합니다. 서버는 이를 통해 이 클라이언트에게 물어볼 수 있다는 것을 알게 됩니다. 클라이언트가 서버에게 답해 줄 수 있는 다른 것은 **[클라이언트 콜백](../client/callbacks.md)**에 있습니다.
!!! info
엘리시테이션은 **서버**가 **클라이언트**에게 보내는 요청이며, 이런 요청은 고전적인 핸드셰이크
세션에만 존재합니다. 그래서 이 클라이언트는 `mode="legacy"`를 전달합니다.
**2026-07-28** 연결에서는 도구가 호출에서 질문을 **반환**하는 방식으로 묻습니다.
그 흐름은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다.
### 직접 해 보기 {#try-it}
`ctx.elicit` 폼 모드 `server.py`(`book_table`이 있는 것)를 Streamable HTTP로 시작하고(한 줄짜리 명령은 **[서버 실행하기](../run/index.md)**에 있습니다), 클라이언트의 `main()`을 실행해 `book_table`에 크리스마스 당일을 요청하세요.
콜백은 전달받은 질문을 출력합니다.
```text
No tables for 2 on 2025-12-25. Would you like to try another date?
```
콜백은 `{"accept_alternative": True, "date": "2025-12-27"}`로 답하고, 그동안 `await ctx.elicit(...)` 안에서 내내 기다리던 도구가 예약을 마무리합니다.
```text
Booked a table for 2 on 2025-12-27.
```
이제 URL 모드 `server.py`로 바꾸고 같은 `main()`이 `pay_deposit`을 호출하게 하세요. 같은 콜백이 다른 쪽 분기를 타서 결제 링크를 출력하고, 도구는 *"Complete the payment in your browser."*를 돌려줍니다. 호출 도중에 양방향으로 왕복 한 번이 오간 것입니다.
!!! check
이제 `Client`에서 `elicitation_callback=`을 제거하고 다시 크리스마스 당일로 `book_table`을
호출해 보세요. 호출 전체가 프로토콜 오류로 실패합니다.
```text
Elicitation not supported
```
콜백을 등록하지 않은 클라이언트는 `elicitation` 기능을 선언한 적이 없으므로 물어볼 상대가
없습니다. 도구는 `"decline"`을 받은 것이 아니라 예외를 받았습니다. 이 경우를 염두에 두고
설계하세요. 모든 엘리시테이션에는 "물어볼 수 없다면 어떻게 할 것인가?"에 대한 합리적인 답이
필요합니다.
## 요약 {#recap}
* `Annotated[T, Resolve(fn)]`로 어노테이션한 파라미터는 리졸버가 채우며, 리졸버는 물어봐야 할 때 `Elicit(...)`을 반환합니다. 모든 연결에서 동작합니다.
* 스키마는 평평한 Pydantic 모델입니다. 원시 타입 필드만 가능하며, 돌아오는 길에 검증됩니다.
* `result.action`은 `"accept"`, `"decline"`, `"cancel"` 중 하나이며, `result.data`는 accept일 때만 존재합니다.
* `await ctx.elicit(message, schema=Model)`은 도구 본문 안에서 묻고, `await ctx.elicit_url(message, url, elicitation_id)`는 모델을 거쳐서는 안 되는 모든 것을 위한 것입니다(`ctx.session.send_elicit_complete(elicitation_id)`는 대역 외 부분이 끝났음을 알립니다). 둘 다 서버가 클라이언트에게 보내는 요청이므로 클라이언트가 레거시 연결을 쓰고 있어야 합니다.
* 클라이언트는 `elicitation_callback` 하나로 답하며 params 타입에 따라 분기합니다. 콜백을 등록하는 것이 곧 기능을 선언하는 것입니다.
* 2026-07-28 연결에서는 서버가 질문을 밀어 넣는 대신 반환하며, 같은 콜백에 **[다중 왕복 요청](multi-round-trip.md)**이 질문을 공급합니다.
그 반환 아래에 있는 모든 것(재시도 루프, `requestState` 보호, 직접 구동하기)은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다.

View file

@ -0,0 +1,24 @@
---
translation:
sections: [424930166c4bc6f3]
tool: 1
---
# 핸들러 내부 {#inside-your-handler}
핸들러의 인자는 클라이언트에서 옵니다. 그 **밖에** 핸들러가 읽을 수 있는 것, 그리고 실행 중에 할 수 있는 모든 것은 여기에서 다룹니다.
읽을 수 있는 것은 다음과 같습니다.
* **[Context](context.md)**는 어떤 핸들러든 요청할 수 있는 유일한 추가 매개변수입니다. 진행 중인 요청, 요청 헤더, 세션, 그리고 진행률 보고와 변경 알림 메서드를 제공합니다.
* **[의존성](dependencies.md)**은 모델이 절대 보지 못하는 매개변수로, `Resolve`를 사용해 직접 작성한 함수가 값을 채웁니다.
* **[Lifespan](lifespan.md)**은 서버가 시작할 때 한 번 만들어 두는 상태와, 핸들러가 `Context`를 통해 그 상태에 접근하는 방법을 다룹니다.
실행 중에 할 수 있는 일은 다음과 같습니다.
* **[엘리시테이션(elicitation)](elicitation.md)**으로 사용자에게 추가 입력을 요청합니다. 이를 실어 나르는 2026-07-28 패턴은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다.
* **[샘플링과 루트](sampling-and-roots.md)**로 클라이언트에 LLM 완성이나 작업 공간 폴더를 요청합니다. 지원 중단 예정(deprecated)이지만 여전히 제공됩니다.
* 오래 걸리는 작업의 **[진행률](progress.md)**을 보고합니다.
* **[로깅](logging.md)**으로 로그를 남깁니다(서버를 운영하는 사람을 위해 표준 오류로 출력됩니다).
* **[구독](subscriptions.md)**으로 구독 중인 클라이언트에게 변경 사항을 알립니다.
아직 핸들러를 등록하지 않았다면 **[도구](../servers/tools.md)**부터 시작하세요. 이 섹션의 모든 페이지는 핸들러가 하나 있다고 가정합니다.

View file

@ -0,0 +1,107 @@
---
translation:
sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53]
tool: 1
---
# Lifespan {#lifespan}
실제 서버는 대부분 데이터베이스 풀, HTTP 클라이언트, 로드된 모델처럼 살아 있는 동안 내내 유지하는 무언가가 있습니다.
호출할 때마다 새로 만들고 싶지는 않고, 깔끔하게 닫고 싶기는 합니다. 바로 이를 위한 것이 **lifespan**입니다.
## 타입이 지정된 lifespan {#a-typed-lifespan}
lifespan은 서버를 받아 **객체 하나**를 `yield`하는 `@asynccontextmanager`입니다. yield한 객체는 서버가 실행되는 동안 모든 핸들러에서 사용할 수 있습니다.
```python title="server.py" hl_lines="25-31 34 38 40"
--8<-- "docs_src/lifespan/tutorial001.py"
```
아래에서 위로 읽어 보세요.
* `app_lifespan`은 `yield` **앞에서** `Database`에 연결하고, 그 **뒤** `finally`에서 연결을 끊습니다. 이것이 시작과 종료입니다.
* 설정한 것을 담는 평범한 dataclass인 `AppContext`를 yield합니다. 오늘은 필드 하나, 내일은 열 개입니다.
* `MCPServer("Bookshop", lifespan=app_lifespan)`이 연결 작업의 전부입니다.
* 도구 안에서 yield된 객체는 `ctx.request_context.lifespan_context`입니다.
lifespan은 **한 번** 실행됩니다. 서버가 시작될 때(첫 요청 전) 진입하고 서버가 멈출 때 빠져나옵니다. 그 사이의 모든 요청은 같은 `AppContext`를 공유합니다.
!!! info
FastAPI `lifespan`을 작성해 본 적이 있다면 이미 아는 내용입니다. 같은 데코레이터, 같은 `yield`, 같은 `finally`입니다.
### 모델에게 보이는 것 {#what-the-model-sees}
새로운 것은 없습니다. `ctx`는 **Context** 매개변수이므로 SDK가 주입하며, 입력 스키마에는 절대 들어가지 않습니다.
```json
{
"type": "object",
"properties": {
"genre": {"title": "Genre", "type": "string"}
},
"required": ["genre"],
"title": "count_booksArguments"
}
```
모델이 전달할 수 있는 인자는 `genre`뿐입니다. lifespan은 서버 내부의 일입니다.
`@mcp.resource()`와 `@mcp.prompt()` 함수도 `ctx` 매개변수를 받을 수 있는데, 다음 절에서 설명할 이유로 타입 매개변수 없는 `Context`로 씁니다. `ctx`가 담고 있는 모든 것은 **[Context](context.md)**에서 확인하세요.
### 제대로 된 타입 지정 {#it-really-is-typed}
어노테이션을 다시 보세요. `ctx: Context[AppContext]`입니다.
이 타입 매개변수 하나 덕분에 타입 검사기에게 `ctx.request_context.lifespan_context`는 **곧** `AppContext`입니다. `.db`는 자동 완성되고, `.dbb`는 서버를 실행하기도 전에 오류가 됩니다.
대신 타입 매개변수 없는 `Context`를 쓰면 `lifespan_context`의 타입은 `dict[str, Any]`가 됩니다. 타입 검사기로서는 lifespan이 무엇을 yield했는지 알 방법이 없기 때문입니다. 객체는 런타임에 여전히 존재하지만, 도움은 잃게 됩니다.
!!! warning
`Context[AppContext]`는 **도구 전용** 표기입니다. `@mcp.resource()`나
`@mcp.prompt()` 함수에 붙이면 해당 핸들러 호출은 모두 실패합니다. 클라이언트는 오류를 돌려받고,
서버 로그에 그 이유가 나타납니다.
```text
Context is not available outside of a request
```
리소스와 프롬프트에서는 타입 매개변수 없는 `ctx: Context`를 쓰세요. lifespan이 yield한 객체는
런타임에 여전히 `ctx.request_context.lifespan_context`에 있습니다. 포기하는 것은 타입 매개변수이지
객체가 아닙니다.
!!! tip
lifespan은 항상 있습니다. 전달하지 않으면 SDK의 기본 lifespan이 빈 `dict`를 yield하므로
`ctx.request_context.lifespan_context`는 `{}`이며, 절대 `None`이 아닙니다. 타입 매개변수 없는
`Context`가 이를 `dict[str, Any]`로 타입 지정하는 것도 이 기본값 때문입니다.
## 직접 확인하기 {#watch-it-happen}
"시작 코드는 첫 요청 전에 실행된다"는 말은 그냥 믿고 넘어갈 것이 아니라 직접 확인해 볼 만한 문장입니다.
서버를 생명 주기만 남도록 줄여 보세요. `Database`에 `connected` 플래그를 두고, `connect()`와 `disconnect()`에서 이를 뒤집고, 이 값을 보고하는 도구를 추가합니다.
```python title="server.py" hl_lines="11 14 17 25 44"
--8<-- "docs_src/lifespan/tutorial002.py"
```
`database`가 모듈 수준에 있는 이유는 단 하나, 서버 **바깥**에서 볼 수 있게 하기 위해서입니다.
!!! check
세 시점, 세 값입니다.
* 서버가 시작되기 전에는 `database.connected`가 `False`입니다. 모듈을 임포트해도 아무것도 연결되지 않았습니다.
* 실행 중에 `database_status`를 호출하면 결과는 `"connected"`입니다.
* 서버를 멈추면 `finally` 블록이 실행되고 `database.connected`는 다시 `False`가 됩니다.
작업은 정확히 배치한 곳, 즉 `yield` 주변에서 일어났습니다. 임포트 시점도, 요청마다도 아닙니다.
## 요약 {#recap}
* `lifespan=` 매개변수는 서버를 받아 객체 하나를 `yield`하는 `@asynccontextmanager`를 받습니다.
* `yield` 앞의 코드는 시작입니다. 뒤의 `finally`는 종료입니다.
* 요청마다 실행되는 것이 아니라 서버의 전체 수명을 감싸며 한 번 실행됩니다.
* `yield`한 것은 모든 도구, 리소스, 프롬프트에서 `ctx.request_context.lifespan_context`입니다.
* `ctx: Context[AppContext]`는 도구에서 이 접근에 완전한 타입을 부여합니다. 리소스와 프롬프트는 타입 매개변수 없는 `Context`를 받습니다.
* `lifespan=` 매개변수가 없으면 빈 `dict`이며, 절대 `None`이 아닙니다.
호출 도중 멈추고 사용자만 아는 것을 사용자에게 묻는 핸들러는 **[엘리시테이션(elicitation)](elicitation.md)**에서 다룹니다.

View file

@ -0,0 +1,87 @@
---
translation:
sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5]
tool: 1
---
# 로깅 {#logging}
도구에서 로그를 남기는 방법은 다른 Python 함수에서와 똑같습니다. 표준 라이브러리를 사용하세요.
MCP에는 프로토콜 수준의 **로깅 기능**이 있습니다. 서버가 `Context` 객체의 메서드를 통해 로그 메시지를 알림으로 클라이언트에 보낼 수 있는 기능입니다. 사양의 2026-07-28 리비전은 **이 기능을 지원 중단 예정(deprecated)으로 지정하면서 대체 수단을 제공하지 않으므로**, 이 문서에서는 다루지 않습니다. 지원 중단 예정인 항목 전체와 대신 사용할 방법은 **[지원 중단 예정 기능](../deprecated.md)**에서 확인하세요.
대신 할 일은 다른 모든 Python 프로그램에서 하는 것과 같습니다. 표준 라이브러리를 사용합니다.
## 로그를 남기는 도구 {#a-tool-that-logs}
```python title="server.py" hl_lines="1 5 13"
--8<-- "docs_src/logging/tutorial001.py"
```
* `logging.getLogger(__name__)`은 모듈 이름을 딴 로거를 돌려줍니다. 파일 맨 위에서 한 번만 만드세요.
* 도구 안에서는 다른 함수에서와 마찬가지로 `logger.info(...)`를 호출합니다. 주입할 것도, `await`할 것도, MCP에 특화된 것도 없습니다.
!!! check
도구를 호출하고 결과 전체를 살펴보세요.
```python
result.content # [TextContent(text="Found 3 books matching 'dune'.")]
result.structured_content # {'result': "Found 3 books matching 'dune'."}
```
로그 줄은 어디에도 없습니다. 로깅은 서버를 운영하는 **사람**을 위한 것입니다. 모델은
이를 절대 보지 못합니다. 모델이 읽어야 하는 내용이 있다면 `return`으로 돌려주세요.
## 출력 위치 {#where-it-goes}
**stdio** 서버에서는 이 질문이 평소보다 중요합니다. 호스트는 서버를 서브프로세스로 실행했고, 서버의 **stdout**에서 MCP 메시지를 읽고 있습니다. 표준 에러는 서버의 몫입니다.
표준 라이브러리는 이미 올바르게 동작합니다. 로그 출력은 기본적으로 `sys.stderr`로 갑니다. `logger.info(...)` 줄은 터미널(또는 호스트가 서브프로세스의 stderr를 수집하는 곳)에 도착하고, 프로토콜 스트림은 깨끗하게 유지됩니다.
!!! tip
stdio 서버에서는 `print()`를 쓰지 마세요. `print`는 **stdout**에 쓰는데, stdout은 프로토콜의 몫입니다.
서비스 중에 SDK는 실제로 **플러시된** stdout 출력을 stderr로 돌리므로 통신을 망가뜨릴 수는
없지만, 블록 버퍼링되는 프로세스에서 `print()`의 출력은 대개 플러시되지 않은 채 `sys.stdout`의
버퍼에 남아 있다가, 인터프리터가 종료 시 버퍼를 비울 때 프로토콜 스트림으로 그대로 흘러 들어갑니다.
설령 stderr로 돌려지더라도 그 줄은 레벨도, 로거 이름도, 걸러낼 방법도 없이 날것 그대로 로그 출력
사이에 섞입니다.
`logger.debug("got here")`는 똑같이 한 줄이면 되고, 올바른 곳으로 갑니다.
## 레벨 {#the-level}
`logging.basicConfig()`를 직접 호출할 필요는 없습니다. `MCPServer`를 생성하는 것만으로 이미 호출되며, 핸들러는 표준 에러를 향하고 레벨은 `log_level=`로 전달한 값을 따릅니다. 따라서 `logger.debug(...)` 줄을 보려면 `MCPServer("Bookshop", log_level="DEBUG")`만으로 충분합니다.
기본값은 `"INFO"`입니다.
`logging.basicConfig()`는 이미 존재하는 핸들러를 절대 교체하지 않습니다. 서버를 만들기 전에 로깅을 직접 설정했다면 그 설정이 우선합니다.
## 직접 해 보기 {#try-it}
MCP Inspector로 서버를 실행하세요.
```console
uv run mcp dev server.py
```
**Tools** 탭에서 `search_books`를 호출하세요. Inspector가 보여주는 결과는 반환값뿐입니다. 다음 줄은
```text
Searching for 'dune'
```
표준 에러로 갔습니다. 통신이 아니라 터미널입니다.
!!! info
정말로 원하는 것이 **트레이싱**(모든 요청, 걸린 시간, 실패 여부)이라면 로그 줄이 아니라
스팬이 필요합니다. 서버는 이미 스팬을 내보내고 있습니다. SDK는 기본적으로 모든 메시지를
OpenTelemetry로 추적합니다. **[OpenTelemetry](../run/opentelemetry.md)**를 참고하세요.
## 요약 {#recap}
* MCP 프로토콜의 로깅 기능은 2026-07-28 사양에서 지원 중단 예정으로 지정되었고 대체 수단이 없습니다. 이 기능 위에 무언가를 만들지 마세요.
* 모듈 수준에 `logger = logging.getLogger(__name__)`, 도구 안에 `logger.info(...)`. 이것이 패턴의 전부입니다.
* 로그 출력은 절대 모델에 닿지 않습니다. `return`한 값만 닿습니다.
* 표준 에러는 서버의 몫이고 stdout은 프로토콜의 몫입니다. SDK는 서비스 중 플러시된 stdout 출력을 stderr로 돌리지만, 플러시되지 않은 `print()`는 종료 시 여전히 통신으로 흘러 들어갈 수 있고, 돌려진 줄은 레이블 없이 도착합니다. 레코드마다 핸들러가 플러시하는 `logging`을 사용하세요.
* `MCPServer(..., log_level="DEBUG")`로 레벨을 설정하며, 먼저 만든 로깅 설정은 그대로 유지됩니다.
서버에서 무언가(도구 목록, 리소스)가 바뀌었음을 연결된 클라이언트에 알리는 방법은 **[구독](subscriptions.md)**에서 다룹니다.

View file

@ -0,0 +1,191 @@
---
translation:
sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b]
tool: 1
---
# 다중 왕복 요청 {#multi-round-trip-requests}
도구가 한 번의 왕복으로 끝나지 않을 때가 있습니다. 선택, 확인, 자격 증명처럼 사용자만 가진 무언가가 필요한 경우입니다.
2026-07-28 이전에는 서버가 **역방향 호출**로 이를 얻었습니다. 원래 요청을 처리하는 도중에 엘리시테이션(elicitation)이나 샘플링 호출 같은 자체 요청을 클라이언트에게 여는 방식입니다. 2026-07-28 사양은 이 백채널을 폐지합니다.
대신 서버는 **반환**합니다.
## 역방향 호출 대신 반환 {#return-dont-call-back}
서버는 `tools/call`에 `CallToolResult` 대신 **`InputRequiredResult`**로 응답합니다. 핵심은 두 필드입니다.
* **`input_requests`**: 서버에 아직 필요한 것으로, 서버가 고른 이름을 키로 하는 dict입니다. 각 값은 `ElicitRequest`, `CreateMessageRequest`, `ListRootsRequest` 중 하나입니다.
* **`request_state`**: 불투명 토큰입니다. 클라이언트는 재시도할 때 이 토큰을 그대로 되돌려 보냅니다. 이 토큰을 읽는 것은 서버뿐입니다.
클라이언트는 각 요청을 처리한 뒤, 답을 `input_responses`에, 토큰을 `request_state`에 담아 **같은 도구를 다시** 호출합니다. 이제 서버는 부족했던 것을 갖추었으므로 일반적인 `CallToolResult`를 반환합니다.
프로토콜은 이것이 전부입니다. 모든 구간은 클라이언트가 서버로 보내는 평범한 요청입니다. 반대 방향으로 흐르는 것은 아무것도 없습니다.
## 서버 측 {#the-server-side}
`@mcp.tool()`에서는 이것을 직접 만드는 일이 거의 없습니다. 사용자에게 묻거나(`Elicit`), 클라이언트의 LLM을 샘플링하거나(`Sample`), 루트를 나열하는(`ListRoots`) 의존성을 선언하면 SDK가 대신 `InputRequiredResult`를 반환합니다. 이 형태는 **[의존성](dependencies.md)** 페이지에서 다룹니다. 두 형태는 섞어 쓸 수 없습니다. 호출 하나에는 `input_responses`/`request_state` 채널이 하나뿐이므로, `Resolve(...)` 매개변수를 쓰는 도구는 본문에서 `InputRequiredResult`를 함께 반환할 수 없습니다. `InputRequiredResult` 반환을 선언하면 등록 시점에 거부되고(`InvalidSignature`), 선언하지 않고 반환하면 런타임에 호출이 실패합니다. 수동 형태는 **저수준** `Server`이며, 그 `on_call_tool` 핸들러는 두 결과 타입 중 어느 쪽이든 반환할 수 있습니다.
```python title="server.py" hl_lines="43-46"
--8<-- "docs_src/mrtr/tutorial001.py"
```
* `on_call_tool`의 타입은 `-> CallToolResult | InputRequiredResult`입니다. 두 번째를 반환하는 것이 서버 측 API의 전부입니다.
* 첫 호출에서 `params.input_responses`는 `None`이므로 가드가 작동하여 핸들러는 답하는 대신 묻습니다.
* 재시도에서는 클라이언트가 보낸 `ElicitResult`가 서버가 `input_requests`에서 사용한 것과 **같은 키**(`"region"`) 아래에 들어 있습니다.
그 파일의 나머지(명시적인 `input_schema`, 직접 만든 `CallToolResult`)는 평범한 저수준 `Server`이며 **[저수준 Server](../advanced/low-level-server.md)**에서 다룹니다. 이 페이지는 두 번째 반환 타입만 더합니다.
## 도구 외의 경우 {#beyond-tools}
`tools/call`만 특별한 것은 아닙니다. 2026-07-28에서는 서버가 `prompts/get`과 `resources/read`에도 같은 방식으로 응답할 수 있습니다. `MCPServer`에서는 `@mcp.prompt()` 함수(또는 `@mcp.resource()` **템플릿** 함수)가 직접 `InputRequiredResult`를 반환하고, 재시도의 답을 컨텍스트에서 읽습니다.
```python title="server.py" hl_lines="20 22 24"
--8<-- "docs_src/mrtr/tutorial004.py"
```
* 첫 라운드는 `InputRequiredResult`를 반환합니다. 재시도에서는 `ctx.input_responses`가 같은 키 아래에 답을 담고 있으며, 함수는 평소의 결과를 반환합니다. 여기서는 프롬프트 메시지이고, 템플릿 리소스라면 리소스 콘텐츠입니다.
* 직접 설정한 `request_state`는 서버의 다른 모든 것과 마찬가지로 전송되기 전에 봉인되고 되돌아올 때 검증됩니다. 봉인이 무엇을 보장하는지, 언제 키를 설정해야 하는지는 아래 **[`requestState` 보호](#protecting-requeststate)**에서 다룹니다.
* 의존성 형태가 맞지 않을 때는 `@mcp.tool()` 함수도 같은 방식으로 결과를 직접 반환할 수 있습니다.
* 정적 `@mcp.resource()` 함수는 참여하지 않습니다. `Context`를 받지 않으므로 재시도를 읽을 방법이 없기 때문입니다. 물을 수 있는 것은 템플릿 리소스뿐입니다.
* 아래의 프로토콜 세대 규칙은 그대로 적용됩니다. 2026 이전 세션에서 `InputRequiredResult`를 반환하면 경고에서 설명하는 것과 같은 `-32603`입니다.
## 클라이언트 측 {#the-client-side}
`Client`가 루프를 대신 돌립니다.
서버가 요청할 수 있는 콜백(`elicitation_callback`, `sampling_callback`, `list_roots_callback`)을 등록하고 도구를 호출하세요. `InputRequiredResult`가 도착하면 `Client`는 `input_requests`의 각 항목을 해당 콜백으로 보내고, 답과 되돌려 보낼 `request_state`를 담아 재시도하며, `CallToolResult`가 돌아올 때까지 계속합니다.
```python title="client.py" hl_lines="11 12"
--8<-- "docs_src/mrtr/tutorial003.py"
```
* 이 `elicitation_callback`은 2026 이전 서버의 백채널 `elicitation/create`가 호출했을 바로 그 콜백입니다. `sampling/createMessage`의 `sampling_callback`, `roots/list`의 `list_roots_callback`도 마찬가지입니다. 2026-07-28에서는 독립적인 서버->클라이언트 RPC가 사라졌지만, 동일한 `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` 페이로드가 `input_requests` 안에 실려 와서 같은 세 콜백으로 전달됩니다. 한 벌의 콜백이 두 세대를 모두 처리합니다.
* `call_tool`은 평범한 `CallToolResult`를 반환합니다. 중간 라운드는 호출자에게 보이지 않습니다.
* `get_prompt`와 `read_resource`도 같은 루프를 구동합니다.
!!! check
콜백을 빼면 루프는 첫 라운드에서 실패합니다. SDK의 대체 콜백은 모든 엘리시테이션에
오류로 답하며, `call_tool`은 *"Elicitation not supported"*라는 메시지와 함께 `MCPError`를
발생시킵니다.
루프에는 한도가 있습니다. `Client(..., input_required_max_rounds=10)`이 기본 상한이며, 이를 넘겨서도 계속 `InputRequiredResult`를 반환하는 서버는 `call_tool`에서 예외를 일으킵니다. 라운드에 `input_requests` 없이 `request_state`만 실려 있으면 `Client`는 재시도 전에 잠시 쉽니다(50ms에서 시작해 250ms 상한까지 두 배씩 늘어납니다). 그래서 그저 "아직 끝나지 않았음"을 알리는 서버를 바쁘게 폴링하지 않습니다.
### 루프 직접 구동 {#driving-the-loop-yourself}
단일 프로세스 클라이언트에는 자동 루프로 충분합니다. 다음과 같은 경우에는 루프를 직접 맡으세요.
* 클라이언트가 **분산**되어 있을 때. 사용자에게 질문을 표시하는 프로세스가 `call_tool`을 호출한 프로세스와 다르므로 다른 워커가 재시도를 보냅니다. `request_state`는 그 경계를 넘어 자체 저장소를 통해 운반하는 영속 가능한 토큰이고, `input_responses`는 반대편이 그 토큰과 함께 돌려보내는 것입니다.
* 각 라운드를 **검사**하고 싶을 때. 모든 `input_requests` 항목을 기록하거나 감사하고, 특정 종류의 요청을 거부하거나, 구간 사이에 자체 백오프를 적용합니다.
* 라운드 수가 아니라 **실제 경과 시간**으로 한도를 두고 싶을 때. `input_required_max_rounds`에 기대는 대신 자체 루프를 `anyio.fail_after(...)`로 감싸세요.
하위 세션으로 내려가면 `allow_input_required=True`가 유니언을 직접 건네줍니다.
```python title="client.py" hl_lines="12 13 19"
--8<-- "docs_src/mrtr/tutorial002.py"
```
* `client.session.call_tool(..., allow_input_required=True)`는 반환 타입을 `CallToolResult | InputRequiredResult`로 넓힙니다. 다시 좁히는 것은 `isinstance`입니다.
* 이제 `request_state`는 직접 다룹니다. 구간 사이에 기록해 두면 새 프로세스에서 대화를 재개할 수 있습니다.
* `input_requests`의 모든 항목에 대해 `input_responses`의 **같은 키** 아래에 `InputResponse`를 넣습니다. `fulfil`이 UI가 들어갈 자리이며, 이 예제는 답을 하드코딩합니다.
* 모든 구간에서 같은 도구 이름, 같은 `arguments`입니다. 재시도는 원래 호출을 다시 수행하는 것이지 새 메서드가 아닙니다.
## `requestState` 보호 {#protecting-requeststate}
위의 모든 내용은 `request_state`를 단순 에코로 취급하며, 전송 구간에서는 실제로 그것이 전부입니다. 하지만 클라이언트가 구간 사이에 이를 보관하므로(프로세스를 넘어 기록해 두는 것이 바로 앞 절에서 허용한 일입니다), 돌아오는 것은 **클라이언트가 제공한 입력**입니다. 변조되었거나, 만료되었거나, 아예 다른 호출에서 가져온 것일 수 있습니다. 사양은 상태가 인가, 리소스 접근, 비즈니스 로직에 영향을 줄 수 있는 경우 서버가 이 상태의 무결성을 보호하고 검증에 실패하면 라운드를 거부할 것을 요구합니다.
`MCPServer`는 기본적으로 이를 보호합니다. 모든 서버는 프로세스 시작 시 생성된 키로 나가는 `requestState`를 봉인하고, 리졸버 상태든 직접 만든 상태든 되돌아오는 모든 에코를 검증합니다. 아무것도 설정할 필요 없이 평문을 쓰고 평문을 읽으며, 전송 구간에는 불투명한 암호화 토큰만 오갑니다.
기본 키는 프로세스와 생사를 함께합니다. 단일 프로세스를 넘어 배포하기 전에 반드시 알아야 할 한 가지가 바로 이것입니다.
```python
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each).
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
```
* **기본값(설정 없음)**은 단일 프로세스에 적합합니다. stdio 또는 정확히 하나의 HTTP 워커입니다. 다른 워커, 로드 밸런서 뒤의 다른 인스턴스, 또는 재시작 후의 같은 서버에 도착한 재시도는 그 프로세스가 갖고 있지 않은 키로 봉인되어 있으므로, 클라이언트는 아래의 고정된 거부 응답을 받고 흐름을 처음부터 다시 시작해야 합니다.
* **`keys=[...]`** 설정은 재시도가 **다른 인스턴스**에 도달할 수 있거나(다중 워커 `uvicorn`, 로드 밸런싱된 HTTP) 재시작 후에도 살아남아야 할 때 필수입니다. 모든 인스턴스가 형제 인스턴스가 발급한 것을 검증합니다. 같은 장치이되, 생성된 비밀 대신 직접 제공한 비밀을 씁니다.
* KMS나 기존 토큰 서비스 같은 자체 암호화를 쓰려면 `keys` 대신 `RequestStateSecurity(codec=...)`를 전달하세요. 계약은 아래 **[자체 암호화 사용](#bring-your-own-crypto)**에서 다룹니다.
### 봉인이 담는 것 {#what-the-seal-carries}
기본값이든 설정했든, 전송 구간의 `requestState`는 암호화되고 인증된 토큰입니다. 코드에서는 이를 볼 일이 없습니다. 핸들러와 리졸버는 평문을 쓰고 평문을 읽으며(`ctx.request_state`), SDK가 나갈 때 봉인하고 들어올 때 검증합니다. 무결성 외에도 각 토큰은 다음에 묶입니다.
* **시간 창.** 매 라운드마다 새 만료 시각으로 다시 봉인하므로, `RequestStateSecurity(ttl=...)`(기본 600초)는 전체 흐름이 아니라 라운드별 생각할 시간을 제한합니다.
* **인증된 주체.** 요청이 SDK가 검증한 OAuth 액세스 토큰을 지니고 있으면 상태는 토큰의 클라이언트, 발급자(issuer), 사용자 식별자(subject)에 묶입니다. 한 사용자를 위해 발급된 상태는 두 사용자가 하나의 OAuth 클라이언트를 공유하더라도 다른 사용자 아래에서는 실패합니다. subject를 제공하지 않는 검증기는 바인딩을 클라이언트 ID만으로 약화시키는데, URL 기반 클라이언트 ID에서는 그 클라이언트 소프트웨어의 모든 사용자가 이를 공유합니다. 인증이 SDK 바깥(앞단 프록시)에서 종료되거나 트랜스포트가 인증되지 않은 경우에는 묶을 주체가 없으므로 이 검사는 작동하지 않습니다. 단, `RequestStateSecurity(bind_principal=...)`로 자체 ID 신호에서 주체를 제공하면 작동합니다. 토큰 검증기가 어떤 구성 요소를 제공하든 일관되게 제공해야 합니다. 어떤 요청에는 subject를 포함하고 다른 요청에는 빼는 검증기는 흐름 도중에 주체를 바꾸는 셈이고, 진행 중인 라운드는 거부됩니다.
* **원래 요청.** 메서드, 도구 또는 프롬프트 이름(또는 리소스 URI), 그리고 인수의 다이제스트입니다. 다른 도구, 다른 인수, 다른 메서드에 재사용된 토큰은 실패합니다.
* **질문한 내용 그대로.** 모든 리졸버 답은 클라이언트에게 표시된 렌더링된 질문에 고정됩니다. 답이 처음 도착한 라운드에서도, 기록된 답을 나중에 재사용할 때도 마찬가지입니다. 문구를 바꾼 메시지나 변경된 스키마로 재배포하면 서버는 오래된 답을 소비하는 대신 다시 묻습니다. 같은 고정은 반대로도 작용합니다. 메시지는 호출별 데이터가 아니라 도구의 인수에서 만드세요. 타임스탬프나 실시간 시세로 만든 메시지는 라운드마다 다르게 렌더링되므로 기록된 모든 답이 오래된 것으로 보이고, 서버는 클라이언트의 라운드 한도가 호출을 끝낼 때까지 다시 묻습니다.
이 모든 것은 SDK의 일이지 작성자의 일이 아니며, 자체 코덱을 가져오더라도 코덱의 일이 아닙니다.
### 키 교체 {#rotating-keys}
`keys[0]`이 새 상태를 봉인하고, 목록의 모든 키가 검증에 쓰입니다. 무중단 교체는 세 단계이며, 각 단계는 다음 단계로 넘어가기 전에 완전히 배포되어야 합니다.
```python
RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints
RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying
RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD
```
발급 키를 먼저 승격하지 마세요. 일부 인스턴스가 아직 검증할 수 없는 키로 발급하면 배포 도중 진행 중인 라운드가 버려집니다.
키는 하나의 서비스에 한정됩니다. 봉인된 봉투에는 서버 이름도 audience 클레임으로 담기므로, 우연히 같은 비밀을 공유하는 다른 서비스가 발급한 토큰은 어차피 거부됩니다. 클레임의 변별력은 이름만큼이므로, 명시적 정책이 주어진 서버는 실제 이름이 있거나 `RequestStateSecurity(audience=...)`를 설정해야 합니다. 이름 없는 서버는 생성 시점에 예외를 일으킵니다. `audience=`는 한 서비스가 다른 서비스가 발급한 상태를 받아들여야 하는 의도적인 다중 서비스 토폴로지에도 쓰입니다. (설정 없는 기본값은 예외입니다. 키가 프로세스를 떠나지 않으므로 audience 클레임이 더할 것이 없습니다.)
### 자체 암호화 사용 {#bring-your-own-crypto}
`RequestStateSecurity(codec=...)`에는 `seal(bytes) -> str`과 `unseal(str) -> bytes`를 갖추고 자신이 발급하지 않은 토큰에 대해 `InvalidRequestState`를 발생시키는 것이면 무엇이든 전달할 수 있습니다. 전형적인 형태는 KMS를 이용한 봉투 암호화로, 시작 시 데이터 키를 한 번 풀고 토큰별 암호화는 로컬에서 수행합니다.
```python title="server.py" hl_lines="12 26-27 34-35 38"
--8<-- "docs_src/mrtr/tutorial005.py"
```
TTL, 주체 바인딩, 요청 바인딩은 코덱의 일이 **아닙니다**. SDK가 모든 코덱에 대해 `seal` 전에 페이로드에 이를 찍어 넣고 `unseal` 후에 다시 검증합니다. 코덱의 의무는 무결성(변조되었으면 예외를 발생시킴)과, 가능하면 기밀성뿐입니다.
### 검증 실패 시 {#when-verification-fails}
들어오는 쪽의 모든 실패는 변조든, 만료든, 다른 요청이나 주체에 대한 재사용이든, 이 서버가 모르는 키로 봉인된 것이든 같은 답을 받습니다.
```json
{"code": -32602, "message": "Invalid or expired requestState"}
```
원인이 무엇이든 하나의 고정된 메시지이므로 전송 구간에서는 어떤 검사가 실패했는지 드러나지 않으며, 실제 이유는 서버 로그에 남습니다. `tools/call`, `prompts/get`, `resources/read`로 들어오는 모든 `requestState`가 검사되며, 상태를 발급한 적 없는 핸들러로 오는 것도 포함됩니다. 실제로 가장 흔한 거부는 공격자가 아니라, 기본 프로세스 로컬 키가 재시작 이전이나 다른 인스턴스에서 온 재시도를 만나는 경우입니다. 클라이언트는 흐름을 다시 시작하며, 이것이 문제가 된다면 `keys=[...]` 설정이 해결책입니다.
### 직접 만든 상태 {#hand-built-state}
직접 설정한 `request_state`(도구, 프롬프트, 리소스 템플릿 함수에서 `InputRequiredResult`를 반환하는 경우)는 코드 변경 없이 리졸버 상태와 같은 장치로 봉인되고 검증됩니다. 평문을 쓰고 평문을 읽으면 위의 모든 바인딩이 적용됩니다.
설정했더라도 SDK가 대신 고정할 수 없는 한 가지는 질문의 동일성입니다. 상태에 있는 답이 **직접 만든** 질문 중 어느 것에 속하는지 SDK는 알지 못합니다. 답을 질문별로 키를 매겨 저장한다면 자체 질문 식별자를 상태에 넣고 재시도에서 확인하세요.
저수준 `Server`는 기본 제공 기능이 없는 계층입니다. `MCPServer`와 달리 경계를 직접 덧붙이기 전까지는 아무것도 봉인되지 않으며, 그러기 전까지 `request_state`는 작성한 그대로 전송 구간을 건너갑니다. 한 줄짜리 옵트인은 **[저수준 Server](../advanced/low-level-server.md#the-other-handlers)**에 나와 있습니다.
## 2026-07-28 전용 결과 {#a-2026-07-28-result}
`InputRequiredResult`는 프로토콜 버전 **2026-07-28**에만 존재합니다. 인메모리 `Client(server)`는 이를 대신 협상하고, 네트워크를 통할 때는 `mode="auto"`가 이를 발견합니다. 연결한 뒤에는 `client.protocol_version`이 무엇을 얻었는지 알려 줍니다.
!!! warning
2026 이전 세션에는 `InputRequiredResult`를 넣을 곳이 없습니다. `mode="legacy"` 연결에서
핸들러가 이를 반환하면 러너는 협상된 버전으로 직렬화할 수 없고, 클라이언트는 `-32603`
*"Handler returned an invalid result"* 오류를 돌려받습니다. 두 세대를 모두 지원하는 서버는
이를 쓰기 전에 `ctx.protocol_version`을 확인해야 합니다.
!!! info
**URL 모드 엘리시테이션**은 2026 연결에서 바로 이 메커니즘을 탑니다. `input_requests`의
항목은 params가 `ElicitRequestURLParams`인 `ElicitRequest`입니다. 사용자가 대역 외 흐름을
마치면 클라이언트가 호출을 재시도합니다. 같은 루프이고 새 API는 없습니다. 고수준 서버 쪽
절반은 **[엘리시테이션](elicitation.md)**에 있습니다.
## 요약 {#recap}
* 2026-07-28에서 호출 도중 입력이 필요한 서버는 `InputRequiredResult`를 **반환**합니다. 클라이언트에게 요청을 여는 일은 없습니다.
* `input_requests`는 필요한 것이고, `request_state`는 서버만 읽는 불투명한 재개 토큰입니다.
* `Client`가 재시도 루프를 대신 돌립니다. `elicitation_callback` / `sampling_callback` / `list_roots_callback`을 등록하면 `call_tool`은 평범한 `CallToolResult`를 반환합니다. `input_required_max_rounds`(기본 10)가 한도입니다.
* 라운드를 검사하거나 영속화하려면 `client.session.call_tool(..., allow_input_required=True)`를 쓰고 `while isinstance(result, InputRequiredResult)` 루프를 직접 맡으세요.
* `@mcp.tool()`에서는 사용자에게 묻는 의존성이 이 결과를 대신 만들어 줍니다(**[의존성](dependencies.md)**). 수동 형태는 **저수준** `Server`입니다.
* 프롬프트와 리소스도 참여합니다. `@mcp.prompt()` 또는 템플릿 `@mcp.resource()` 함수가 직접 `InputRequiredResult`를 반환하고 재시도에서 `ctx.input_responses`를 읽습니다.
* `requestState`는 클라이언트가 제공한 입력으로 돌아오므로 `MCPServer`는 리졸버 상태든 직접 만든 상태든 기본적으로 프로세스 로컬 키로 봉인합니다. 다중 인스턴스 배포에서는 `RequestStateSecurity(keys=[...])`나 커스텀 코덱을 전달하여 모든 인스턴스가 형제 인스턴스가 발급한 것을 검증할 수 있게 합니다. 봉인은 모든 토큰을 시간 창과 원래 요청에 묶으며, 요청이 SDK가 검증한 인증을 지니거나 `bind_principal=`이 자체 ID 신호를 제공하는 경우에는 인증된 주체에도 묶습니다(**[`requestState` 보호](#protecting-requeststate)**).
이것이 서버 주도 샘플링과 그 밖의 푸시 방식 백채널을 대체하는 메커니즘입니다. **[지원 중단 예정 기능](../deprecated.md)**을 참고하세요.

View file

@ -0,0 +1,120 @@
---
translation:
sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007]
tool: 1
---
# 진행 상황 {#progress}
30초가 걸리면서 그 30초 동안 아무 말도 하지 않는 도구는 고장 난 것처럼 보입니다.
**진행 상황 알림**이 이 문제를 해결합니다. 도구는 얼마나 진행되었는지 보고하고, 클라이언트는 그 정보로 무엇을 그릴지 결정합니다. 진행 막대일 수도, 스피너일 수도, 로그 한 줄일 수도 있습니다.
## 도구에서 보고하기 {#report-it-from-the-tool}
**`Context`** 매개변수를 받고 `report_progress`를 호출하세요.
```python title="server.py" hl_lines="8 11"
--8<-- "docs_src/progress/tutorial001.py"
```
인자는 세 개이며, 각 인자의 의미는 직접 정합니다.
* `progress`: 얼마나 진행했는지입니다. 사양은 보고할 때마다 이 값이 **증가**해야 한다고 요구합니다. 같은 값을 반복하거나 뒤로 돌아가면 안 됩니다.
* `total`: 알고 있다면, 전체가 얼마나 되는지입니다. 선택 사항입니다.
* `message`: **이** 단계를 설명하는, 사람이 읽을 수 있는 한 줄입니다. 선택 사항입니다.
`ctx`는 타입 힌트 덕분에 주입되며 모델에는 전혀 보이지 않습니다. `import_catalog`의 입력 스키마에는 `urls` 속성 하나만 있습니다. **[Context](context.md)** 페이지는 이 객체를 본격적으로 다루며, 진행 상황 보고는 이 객체가 제공하는 기능 중 하나입니다.
## 클라이언트에서 수신하기 {#listen-for-it-from-the-client}
클라이언트는 **호출 단위로** 수신을 선택합니다. `call_tool`에 `progress_callback=` 인자를 전달하면 됩니다.
```python title="client.py" hl_lines="7 16"
import anyio
from mcp import Client
from server import mcp
async def show(progress: float, total: float | None, message: str | None) -> None:
print(f"{message} ({progress}/{total})")
async def main() -> None:
async with Client(mcp) as client:
result = await client.call_tool(
"import_catalog",
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
progress_callback=show,
)
print(result.structured_content)
anyio.run(main)
```
콜백은 서버가 보고한 값 그대로, 즉 `progress`, `total`, `message`를 받는 `async` 함수입니다.
!!! info
`Client(mcp)`는 서버 객체에 메모리 안에서 직접 연결하며, **[테스트](../get-started/testing.md)**
페이지의 기반이 되는 것과 같은 클라이언트입니다. `progress_callback`은 `Client`가 어떤 트랜스포트를
쓰든 같은 매개변수입니다. 다만 곧 보게 될 **타이밍**은 인메모리 연결의 타이밍입니다. 인메모리 연결은
콜백을 인라인으로 실행하므로 모든 보고가 `call_tool`이 반환되기 전에 도착합니다. 실제 트랜스포트에서는
알림과 결과가 경쟁하므로, 느린 콜백은 `call_tool`이 반환된 뒤에도 여전히 실행 중일 수 있습니다.
### 직접 해 보기 {#try-it}
`client.py`를 `server.py` 옆에 두고 실행하세요.
```console
python client.py
```
```text
Imported https://example.com/a.json (1/2)
Imported https://example.com/b.json (2/2)
{'result': 'Imported 2 records.'}
```
서버의 `await ctx.report_progress(...)` 하나하나가 클라이언트에서 `show` 호출 하나가 되었고, 순서도 그대로이며, 두 줄 모두 `call_tool`이 반환되기 **전에** 출력되었습니다. 진행 상황은 결과에 묶여 오지 않고, 도구가 아직 작업하는 동안 스트리밍됩니다.
!!! warning
`progress_callback`은 `Client`가 아니라 **호출**에 속합니다. 이를 위한 생성자 인자는 없습니다.
호출마다 원하는 콜백이 다르기 때문입니다. 어떤 호출은 다운로드 막대를 움직이고, 다음 호출은
로그 한 줄을 남깁니다.
!!! check
이제 `progress_callback=show` 부분을 지우고 다시 실행하세요.
```text
{'result': 'Imported 2 records.'}
```
오류도 경고도 없고 결과는 같습니다. `report_progress`는 **호출자가 진행 상황을 요청하지 않았으면
아무 일도 하지 않으므로**, 조건 없이 보고하면 되고 누가 듣고 있는지 신경 쓸 필요가 없습니다.
## 전체 양을 모를 때 {#when-you-dont-know-the-total}
`total`은 분모를 알 때 쓰는 값입니다. 모르는 경우도 많습니다. 피드를 비우거나, 커서를 따라가거나, 길이 헤더가 없는 무언가를 내려받을 때가 그렇습니다.
그럴 때는 생략하세요.
```python title="server.py" hl_lines="20"
--8<-- "docs_src/progress/tutorial002.py"
```
콜백은 `total=None` 값을 받습니다. 클라이언트는 여전히 **활동**("3 imported so far...")은 보여 줄 수 있지만 백분율은 보여 줄 수 없습니다. 더 보기 좋은 막대를 위해 전체 양을 지어내지 마세요.
!!! tip
`progress`가 꼭 특정한 무언가를 세어야 하는 것은 아닙니다. 바이트, 행, 페이지 중 사용자가
알아볼 단위를 고르고, 지킬 수 있는 `total`만 약속하세요.
## 요약 {#recap}
* `Context`를 받는 도구라면 어디서든 `await ctx.report_progress(progress, total=None, message=None)` 형태로 호출합니다.
* 클라이언트는 `call_tool`에 `progress_callback=` 인자를 전달합니다. 호출마다 지정하며, `Client`에는 지정하지 않습니다.
* 콜백은 `async (progress, total, message) -> None` 형태이며 도구가 아직 실행 중인 동안 호출됩니다.
* 호출에 콜백이 없으면 `report_progress`는 아무 일도 하지 않습니다. 조건 없이 보고하세요.
* `total`을 모르면 생략하세요. 콜백은 `None`을 받습니다.
진행 상황은 실행 중인 도구가 **사용자**에게 보여 주는 것입니다. 서버를 운영하는 **운영자**를 위해 도구가 남기는 로그 줄은 별개의 채널이며, **[로깅](logging.md)**에서 다룹니다.

View file

@ -0,0 +1,51 @@
---
translation:
sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9]
tool: 1
---
# 샘플링과 루트 {#sampling-and-roots}
핸들러는 연결된 클라이언트에 두 가지를 더 요청할 수 있습니다. 클라이언트가 가진 모델의 완성 결과(**샘플링**)와 클라이언트의 작업 공간 폴더(**루트**)입니다.
두 기능 모두 SDK가 지원하는 모든 프로토콜 버전에서 여전히 동작합니다. 다만 이를 중심으로 설계하기 전에 아래 경고를 먼저 읽어 보세요.
!!! warning "2026-07-28 사양에서 지원 중단 예정"
샘플링과 루트는 `2026-07-28`부터 지원 중단 예정(deprecated)입니다([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). 완전히 동작하는 상태로 유지되며 제거 대상이 되기 전까지 최소 12개월 동안 사양에 남아 있지만, 새로 구현하는 경우에는 이 기능을 기반으로 삼지 않아야 합니다. 권장하는 마이그레이션 방법은 다음과 같습니다. 샘플링 대신 LLM 제공자의 API와 직접 통합하고, 루트 대신 도구 매개변수, 리소스 URI 또는 서버 설정으로 디렉터리를 전달하세요. SDK 전체 목록은 **[지원 중단 예정 기능](../deprecated.md)**에서 확인하세요.
## 샘플링: 클라이언트의 모델 빌려 쓰기 {#sampling-borrow-the-clients-model}
리졸버가 `Sample(...)`을 반환하면 도구는 완성 결과를 받습니다. **[의존성](dependencies.md)**에서 `Elicit`를 실행하는 것과 같은 의존성 메커니즘을 거칩니다.
```python title="server.py" hl_lines="10-15 19"
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
```
* `Sample(messages, max_tokens=...)`은 `sampling/createMessage` 매개변수를 그대로 따릅니다. 주입되는 값은 클라이언트의 `CreateMessageResult`이며, `tools`나 `tool_choice`를 전달하면 대신 `CreateMessageResultWithTools`가 됩니다.
* 클라이언트는 `sampling` 기능을 선언해 두어야 합니다(`tools`나 `tool_choice`를 전달한다면 `sampling.tools`). 선언하지 않았다면 클라이언트가 처리할 수 없는 요청을 보내는 대신 `-32021` 프로토콜 오류로 호출이 실패합니다. 백채널이 없는 2026 이전 세션은 보낼 통로가 없으므로 평소와 같은 백채널 없음 오류로 실패합니다.
* `2026-07-28`에서는 요청이 다중 왕복 흐름(**[다중 왕복 요청](multi-round-trip.md)**) 안에서 전달되고, `2025-11-25`에서는 클라이언트로 가는 독립된 요청입니다. 코드는 어느 쪽이든 동일하지만 다중 왕복 규칙에 유의하세요. 요청은 재시도 라운드마다 동일하게 구성되어야 하므로 도구의 인자와 그 밖의 안정적인 데이터만으로 만들어야 합니다.
* `include_context`는 건드리지 마세요. `"none"` 이외의 값은 그 자체로 지원 중단 예정(SEP-2596)이며, 거의 어떤 클라이언트도 선언하지 않는 기능이 필요합니다.
## 루트: 어디에 두어야 할까 {#roots-where-should-this-go}
루트는 서버가 작업해도 된다고 클라이언트가 알려 주는 폴더입니다. 참고용 안내일 뿐 접근 제어 메커니즘이 아닙니다. 리졸버가 `ListRoots()`를 반환합니다.
```python title="server.py" hl_lines="10-11 15"
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
```
* 주입되는 `ListRootsResult`에는 `Root` 목록이 담깁니다. 각 항목은 `file://` URI와 선택적인 표시 이름으로 이루어집니다.
* 조건은 샘플링과 같습니다. `roots` 기능이 선언되어 있지 않으면 요청을 보내는 대신 `-32021`로 호출이 실패합니다.
연결 반대편에서 클라이언트는 이미 가지고 있는 콜백인 `sampling_callback`과 `list_roots_callback`으로 두 요청에 응답합니다. 자세한 내용은 **[클라이언트 콜백](../client/callbacks.md)**에서 확인하세요.
## 2025년대 연결에서 {#on-2025-era-connections}
세션을 직접 다루는 코드를 위해 `ctx.session.create_message(...)`와 `ctx.session.list_roots()`가 여전히 존재합니다. 백채널이 있는 곳(2025년대, 비무상태 연결)에서만 동작하며, 호출하면 지원 중단 경고가 발생합니다. 위의 리졸버 마커가 지원되는 형태입니다. 협상된 버전에 따라 전달 방식을 선택하며 경고를 내지 않습니다.
## 요약 {#recap}
* 리졸버에서 `Sample(...)`이나 `ListRoots()`를 반환하세요. 도구는 다른 의존성과 마찬가지로 `CreateMessageResult`나 `ListRootsResult`를 받습니다.
* 클라이언트는 해당하는 기능을 선언해야 하며, 그렇지 않으면 요청이 전송되는 대신 `-32021`로 호출이 실패합니다.
* 두 기능 모두 `2026-07-28`에서 지원 중단 예정입니다. 지금은 완전히 동작하지만 새 설계에는 적합하지 않습니다. 샘플링보다는 제공자 API를, 루트보다는 명시적 매개변수를 사용하세요.
느린 도구가 얼마나 진행되었는지 보고하는 방법은 **[진행 상황](progress.md)**에서 확인하세요.

View file

@ -0,0 +1,152 @@
---
translation:
sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883]
tool: 1
---
# 구독 {#subscriptions}
서버의 카탈로그는 고정되어 있지 않습니다. 도구는 런타임에 나타나고, 리소스 URI 뒤의 콘텐츠는 바뀝니다.
**구독**은 클라이언트가 이런 변화를 알게 되는 방법입니다. 클라이언트가 `subscriptions/listen` 요청을 한 번 보내면, 그 요청에 대한 응답이 **곧** 스트림입니다. 응답은 열린 채로 유지되며 클라이언트가 요청한 변경 알림을 실어 나릅니다.
## 도구에서 게시하기 {#publish-it-from-the-tool}
서버 쪽에서 할 일은 한 줄, 변경을 게시하는 것뿐입니다.
```python title="server.py" hl_lines="20 32"
--8<-- "docs_src/subscriptions/tutorial001.py"
```
* `await ctx.notify_resource_updated("board://sprint")`는 해당 URI를 구독한 열린 스트림 모두에 도달합니다. 그 외에는 아무에게도 가지 않습니다.
* `await ctx.notify_tools_changed()`는 도구 목록 변경을 요청한 모든 스트림에 도달합니다. 이를 받은 클라이언트는 `tools/list`를 다시 호출하고, 이제 `sprint_report`를 보게 됩니다.
* 형제 메서드로 `notify_prompts_changed()`와 `notify_resources_changed()`가 있습니다.
* 구독자가 없으면 할 일도 없습니다. 유휴 상태의 서버에 게시하는 것은 아무 동작도 하지 않으므로, 누가 듣고 있는지 확인할 필요가 전혀 없습니다. 무엇이 바뀌었는지만 알리면 됩니다.
`MCPServer`가 `subscriptions/listen`을 대신 처리합니다. 와이어 수준의 의무(첫 프레임으로 보내는 확인 응답, 스트림별 필터링, 모든 프레임에 붙는 구독 id)는 SDK의 몫입니다.
!!! check
와이어 위에서, 필터에 `board://sprint`를 지정한 스트림은 `complete_task`가 실행된 뒤 다음과 같이 보입니다.
```json
{"method": "notifications/subscriptions/acknowledged",
"params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
{"method": "notifications/resources/updated",
"params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
```
업데이트에 **담기지 않은** 것에 주목하세요. 보드 자체가 없습니다. 모든 프레임은 `_meta` 아래에 listen 요청의 JSON-RPC id를 담으며, 그 id가 구독 id입니다. 이 id는 클라이언트가 발급합니다. Python `Client`는 `"listen-1"` 같은 문자열을 쓰고, 다른 클라이언트는 정수를 쓰기도 합니다.
## 요청한 것만 {#only-what-was-asked-for}
필터는 계약입니다. 도구 목록 변경과 리소스 URI 하나를 요청한 스트림은 그 두 종류만 받고 다른 것은 받지 않습니다. 프롬프트 변경을 게시해도 그 스트림은 조용합니다.
`MCPServer`는 리소스 URI를 정확한 문자열로 비교하므로, `board://sprint`를 지정한 스트림은 `board://sprint/tasks/1`에 관해서는 아무것도 듣지 못합니다. 명세는 구독한 URI의 하위 리소스 변경을 서버가 보고하는 것을 허용합니다. `MCPServer`는 절대 그렇게 하지 않지만, 클라이언트는 이를 예상하도록 만들어져 있습니다.
스트림이 **아닌** 것 두 가지가 있습니다.
* **재생 로그가 아닙니다.** 끊어진 스트림은 사라지며, 아무도 연결되어 있지 않은 동안 게시된 이벤트는 대기열에 쌓이지 않습니다. 클라이언트는 다시 listen하고 다시 가져옵니다.
* **2025 방식이 아닙니다.** `resources/subscribe`를 호출한 클라이언트는 `ctx.session.send_resource_updated(uri)`로 처리됩니다. `notify_*` 메서드는 `subscriptions/listen` 스트림에만 도달합니다.
## 누가 지켜볼 수 있는지 정하기 {#deciding-who-may-watch}
기본적으로 요청된 모든 종류와 URI가 받아들여집니다. 어떤 호출자든 게시하는 모든 URI를 지켜볼 수 있습니다. 아무도 읽지 않으므로 read 핸들러는 전혀 참조되지 않습니다. `files://{name}` 핸들러가 거절할 호출자라도 `files://payroll.csv`에 스트림을 열어 그것이 바뀌었다는 사실과 언제 바뀌었는지를 알 수 있습니다. 내용은 절대 알 수 없고, 무엇이 존재하는지 탐색할 수도 없습니다. 알 수 없는 URI도 받아들여지며 단지 이벤트가 발생하지 않을 뿐이기 때문입니다. 좁지만 실재하는 문제이므로, 멀티테넌트 서버에서 사용자별 URI를 게시하기 전에 관문을 두세요.
관문은 미들웨어입니다. SDK가 확인 응답을 보내기 전에 `subscriptions/listen` 요청을 보고, 호출자가 읽어서는 안 되는 것을 하나라도 요청하면 거부합니다.
```python title="server.py" hl_lines="19-26 29"
--8<-- "docs_src/subscriptions/tutorial006.py"
```
* `ctx.params`는 원시 요청이므로, 미들웨어가 직접 `SubscriptionsListenRequestParams`로 검증하고 클라이언트가 요청한 필터를 읽습니다.
* 거부는 `call_next(ctx)` 전에 `MCPError`를 발생시키는 것입니다. 클라이언트는 그 오류를 받고 스트림은 받지 못하며, 연결은 계속 유지됩니다. 메시지는 URI를 명시하지 않고 일관되게 유지하여, 거부가 어떤 URI가 보호되는지를 확인해 주는 일이 없도록 하세요.
* 하나의 `can_access(user, uri)`가 두 질문 모두에 답합니다. 리소스 핸들러는 `resources/read`에서, 미들웨어는 `subscriptions/listen`에서 이를 묻습니다. 테이블을 데이터베이스나 RBAC 시스템으로 바꿔도 둘은 계속 보조를 맞춥니다.
* 이 결정은 스트림의 수명 동안 유지됩니다. 이벤트마다 다시 확인하지 않으므로, 호출자의 접근 권한이 스트림 도중에 만료될 수 있다면(만료되는 토큰) 그 시점에 해당 호출자의 연결을 끊으세요.
미들웨어가 그 밖에 무엇을 감싸는지, 왜 잠정적(provisional)으로 표시되어 있는지를 포함한 전체 미들웨어 계약은 **[미들웨어](../advanced/middleware.md)**에 있습니다.
## 클라이언트 쪽 {#the-client-end}
다음은 그 스트림의 반대편에서 보드를 따라가는 클라이언트입니다.
```python title="client.py" hl_lines="15"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
`client.listen(...)`에 진입하면 요청을 보내고 서버의 확인 응답을 기다리므로, 블록이 시작될 때 스트림은 이미 살아 있고, 타입이 지정된 각 이벤트는 다시 가져오라는 신호일 뿐 절대 페이로드가 아닙니다. 이것이 한 화면에 담긴 계약의 전부입니다. 주 흐름과 나란히 지켜보기, 스트림 종료, 다시 listen하기 등 클라이언트 쪽의 나머지 내용은 별도 페이지에 있습니다. **클라이언트** 아래의 **[구독](../client/subscriptions.md)**을 참고하세요.
## 단일 프로세스를 넘어 확장하기 {#scaling-past-one-process}
게시된 이벤트는 핸들러에서 열린 스트림까지 `SubscriptionBus`를 거쳐 이동합니다. 기본은 인메모리입니다. 프로세스 하나, 그 안의 모든 스트림입니다. 로드 밸런서 뒤에서 레플리카를 실행하기 전까지는 이것이 정답입니다. 그 이후에는 클라이언트의 스트림이 한 레플리카에 고정되고, 다른 레플리카에서 게시한 이벤트가 그 스트림에 도달해야 하기 때문입니다.
그 이음매는 직접 구현할 부분입니다. 사용하는 pub/sub 백엔드 위에 메서드 두 개를 만들면 됩니다.
```python
from collections.abc import Callable
from redis.asyncio import Redis
from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class
class RedisSubscriptionBus:
def __init__(self, redis: Redis) -> None:
self._redis = redis
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
async def publish(self, event: ServerEvent) -> None:
await self._redis.publish("mcp-events", encode(event)) # to every replica
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
token = object()
self._listeners[token] = listener
def unsubscribe() -> None:
self._listeners.pop(token, None)
return unsubscribe
mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
```
`encode`는 직접 작성하며, 도착하는 메시지를 디코드해 등록된 모든 리스너를 호출하는 각 레플리카의 리더 태스크도 마찬가지입니다. 리스너는 동기 함수이고, 예외를 발생시켜서는 안 되며, 서버의 이벤트 루프에서 실행됩니다.
버스는 타입이 지정된 `ServerEvent` 값, 즉 작은 데이터클래스 네 개를 실어 나르며 JSON-RPC는 절대 나르지 않습니다. 스탬핑, 필터링, 스트림 생명 주기는 SDK에 남아 있으므로 버스 구현이 프로토콜을 깨뜨릴 수는 없습니다. 프로세스 사이에서 이벤트를 옮길 수 있을 뿐입니다.
요청 바깥에서 게시하려면 참조를 보유할 수 있도록 버스를 직접 생성하세요. `MCPServer`는 아무것도 전달하지 않으면 내부적으로 하나를 만들며, 이를 노출하지 않습니다.
```python
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
bus = InMemorySubscriptionBus()
mcp = MCPServer("Sprint Board", subscriptions=bus)
async def tools_reloaded() -> None:
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
```
## 저수준 구성 {#the-low-level-composition}
저수준 `Server`에는 미리 연결된 것이 아무것도 없으며, 같은 부품이 세 줄로 조립됩니다.
```python title="server.py" hl_lines="8-9 47"
--8<-- "docs_src/subscriptions/tutorial002.py"
```
* 버스를 직접 소유하므로 버스에 직접 게시합니다. `await bus.publish(ResourceUpdated(uri=...))`. 핸들러가 닿을 수 있는 곳에 두세요. 여기서는 모듈 스코프이고, 더 큰 앱에서는 lifespan입니다.
* `ListenHandler(bus)`는 `MCPServer`가 등록하는 것과 같은 핸들러이고, `on_subscriptions_listen=`은 평범한 핸들러 슬롯입니다. 다른 의미 체계를 원하면 그 슬롯에 직접 만든 callable을 넣으세요. 그러면 명세상의 의무가 작성자에게 넘어옵니다. 먼저 확인 응답을 보내고, 모든 프레임에 구독 id를 찍고, 필터 밖의 것은 아무것도 전달하지 않아야 합니다.
* `ListenHandler.close()`는 열린 스트림을 모두 정상적으로 종료합니다. 각 스트림은 마지막 프레임으로 listen 요청의 결과를 받으며, 이는 서버가 의도적으로 구독을 끝냈음을 나타내는 명세상의 방식입니다. 이 메서드는 스트림이 플러시를 마치기 전에 반환하므로, 트랜스포트를 해체하기 전에 잠깐 여유를 주세요. 이를 호출하지 않으면 스트림은 클라이언트가 연결을 끊을 때 끝납니다.
## 요약 {#recap}
* 클라이언트는 `subscriptions/listen` 요청 하나로 참여하고, 그 응답이 스트림입니다. 이를 처리하는 기능은 내장되어 있습니다.
* `ctx.notify_*`로 게시하면 스탬핑, 필터링, 생명 주기 작업은 SDK가 처리합니다.
* 이벤트는 페이로드가 아니라 신호입니다. 양쪽 모두 다시 가져옵니다.
* 클라이언트 쪽은 `async with client.listen(...)`입니다. 자세한 내용은 **클라이언트** 아래의 **[구독](../client/subscriptions.md)**에서 확인하세요.
* 저수준 `Server`에서는 같은 부품을 직접 조립합니다. 버스, `ListenHandler(bus)`, `on_subscriptions_listen` 슬롯입니다.
* 스케일 아웃은 메서드 두 개짜리 `SubscriptionBus`를 구현하고 `MCPServer(subscriptions=...)`로 전달하는 것을 뜻합니다.
레플리카 하나 뒤에서든 스무 개 뒤에서든, 이 모든 것을 처리하는 서버를 실행하는 방법은 **[배포와 확장](../run/deploy.md)**에서 확인하세요.