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
53 changed files with 89737 additions and 54 deletions

205
i18n/ko/glossary.json Normal file
View file

@ -0,0 +1,205 @@
{
"keep": [
"MCP",
"Model Context Protocol",
"MCPServer",
"FastMCP",
"ClientSession",
"Context",
"ctx",
"stdio",
"Streamable HTTP",
"SSE",
"JSON-RPC",
"JSON",
"JSON Schema",
"OAuth",
"PKCE",
"JWT",
"CIMD",
"HTTP",
"HTTPS",
"TLS",
"CORS",
"URI",
"URL",
"ASGI",
"WebSocket",
"API",
"SDK",
"CLI",
"IDE",
"LLM",
"SEP",
"RFC",
"Python",
"TypeScript",
"Node.js",
"PyPI",
"Pydantic",
"Starlette",
"FastAPI",
"uvicorn",
"httpx",
"anyio",
"asyncio",
"trio",
"pytest",
"OpenTelemetry",
"Inspector",
"Claude",
"GitHub",
"VS Code",
"Windows",
"macOS",
"Linux",
"llms.txt",
"2026-07-28",
"2025-11-25",
"2025-06-18",
"2025-03-26"
],
"terms": [
{
"source": "tool",
"target": "도구",
"note": "MCP primitive: an action the model chooses and calls. The wire identifiers `tools/call` and `tools/list` and any code identifiers stay in Latin script inside code font; not the colloquial 툴. Provisional pending native review."
},
{
"source": "resource",
"target": "리소스",
"note": "MCP primitive: read-only data the application reads. `resources/read` and other identifiers stay Latin in code font. Provisional pending native review; 자원 is the general system-resources word and is avoided here."
},
{
"source": "prompt",
"target": "프롬프트",
"note": "Both the MCP primitive (a message template a person invokes) and the general LLM sense; 프롬프트 in both. Provisional pending native review."
},
{
"source": "sampling",
"target": "샘플링",
"note": "MCP feature where the server asks the client's model for a completion; not the statistics sense (표본 추출). Provisional pending native review.",
"avoid": ["표본 추출", "표집"]
},
{
"source": "roots",
"target": "루트",
"note": "Filesystem locations the client exposes. No plural marker (write 루트, not 루트들); the `roots/list` identifier stays Latin. Target provisional pending native review; 뿌리 (the botanical root) is never correct here.",
"avoid": ["뿌리"]
},
{
"source": "elicitation",
"target": "엘리시테이션",
"note": "Transliteration; write 엘리시테이션(elicitation) at the first occurrence on a page, 엘리시테이션 alone afterwards. The verb \"elicit\" in prose is 사용자에게 입력을 요청하다; `ctx.elicit(...)` stays code. Provisional pending native review — the least settled term in this file; 유도 is the candidate alternative to confirm against, so it is noted here rather than banned; 도출 is not a candidate."
},
{
"source": "capability",
"target": "기능",
"note": "A negotiated protocol feature (\"capability negotiation\" → 기능 협상), not 역량 or 능력; the `capabilities` wire field and `client.server_capabilities` stay Latin in code. Provisional pending native review — 기능 doubles as \"feature\"; native review should confirm the collision is acceptable."
},
{
"source": "transport",
"target": "트랜스포트",
"note": "The countable, named connection mechanism (stdio 트랜스포트, 인메모리 트랜스포트) and the `Transport` protocol name in code. Prose describing the generic idea of how messages are carried may say 전송 방식 without implying a separate term. Provisional pending native review; 운송/수송 (freight transport) are never correct.",
"avoid": ["운송", "수송"]
},
{
"source": "session",
"target": "세션",
"note": "Standard loanword; `ctx.session` and other identifiers stay code. Provisional pending native review."
},
{
"source": "handler",
"target": "핸들러",
"note": "The function registered for a tool, resource or prompt. 핸들러 rather than 처리기, to match modern Korean developer docs and the code vocabulary. Provisional pending native review."
},
{
"source": "dependency",
"target": "의존성",
"note": "Dependency-injection sense (\"dependency injection\" → 의존성 주입); a package dependency in installation contexts is also 의존성. Provisional pending native review; 종속성 is the competing house style."
},
{
"source": "client",
"target": "클라이언트",
"note": "The protocol role. The `Client` and `ClientSession` class names stay Latin in code font (see keep list). Provisional pending native review; 고객 means a customer and is never correct here.",
"avoid": ["고객"]
},
{
"source": "server",
"target": "서버",
"note": "The protocol role. The `MCPServer` and `Server` class names stay Latin in code font (see keep list). Provisional pending native review."
},
{
"source": "host",
"target": "호스트",
"note": "\"MCP host\" is the application that embeds a client (a desktop assistant, an editor); the corpus also says \"ASGI host\" for the app that serves an ASGI app — 호스트 covers both. Provisional pending native review.",
"avoid": ["주최자"]
},
{
"source": "request",
"target": "요청",
"note": "A JSON-RPC or HTTP request; the verb \"to request\" is 요청하다. Not the transliteration 리퀘스트. Provisional pending native review."
},
{
"source": "response",
"target": "응답",
"note": "A JSON-RPC or HTTP response; not the transliteration 리스폰스. Provisional pending native review."
},
{
"source": "context",
"target": "컨텍스트",
"note": "The \"context provided to a model\" sense (as in Model Context Protocol). The `Context` class and `ctx` are identifiers and stay Latin (see keep list). The everyday idiom \"in this context\" is not this term and may be recast (이 경우, 여기서는). Provisional pending native review; 맥락 is the competing rendering for the general word."
},
{
"source": "notification",
"target": "알림",
"note": "Protocol notifications (`notifications/...` identifiers stay Latin); \"send a notification\" → 알림을 보내다; not 통지. Provisional pending native review."
},
{
"source": "resolver",
"target": "리졸버",
"note": "The SDK's `Resolve(...)`-annotated parameter resolvers. Provisional pending native review; 해석기 (parser/interpreter) and 해결자 are avoided.",
"avoid": ["해결자"]
},
{
"source": "authorization",
"target": "인가",
"note": "Security sense: authorization → 인가 (인가 서버 for authorization server, 인가 코드 for authorization code), distinct from authentication → 인증. The `Authorization` HTTP header and code identifiers stay Latin. Provisional pending native review — 승인 and 권한 부여 are competing renderings to confirm against."
},
{
"source": "deprecated",
"target": "지원 중단 예정",
"note": "Advisory status: still works but discouraged. First occurrence on a page may add the original: 지원 중단 예정(deprecated). \"Removed\" is a different word (제거됨); the corpus contrasts the two, so never render deprecated as 폐기됨 or 제거됨. Provisional pending native review."
},
{
"source": "round-trip",
"target": "왕복",
"note": "A network round trip; \"multi-round-trip requests\" → 다중 왕복 요청; not the transliteration 라운드 트립. Provisional pending native review."
},
{
"source": "lifespan",
"target": "lifespan",
"note": "Keep the English word in Korean prose (lifespan 함수, 호스트 앱의 lifespan), matching the `lifespan=` parameter it names. Provisional pending native review — 수명 주기 was rejected because it collides with \"lifecycle\" (생명 주기)."
},
{
"source": "Get started",
"target": "시작하기",
"note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (첫걸음), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review. 첫 단계 is the alternative for the page."
},
{
"source": "First steps",
"target": "첫걸음",
"note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review."
},
{
"source": "Recap",
"target": "요약",
"note": "Recurring section heading that closes most pages; one rendering everywhere, not 요약 on some pages and 정리 on others. Provisional pending native review."
},
{
"source": "Try it",
"target": "직접 해 보기",
"note": "Recurring section heading above a runnable example; one rendering everywhere, with this spacing (해 보기), not 실행해 보기 or 사용해 보기 on some pages. Provisional pending native review."
}
]
}

143
i18n/ko/instructions.md Normal file
View file

@ -0,0 +1,143 @@
# Korean (ko) — translation instructions
Target language: Korean (한국어), directory and URL code `ko`, page language
tag `ko`. This file is sent verbatim with every translation request for this
language, on top of the shared rules in `../general-prompt.md`. The termbase
in `glossary.json` is sent alongside it and wins any terminology conflict with
this file.
## 1. Register
Write 합쇼체 (formal-polite, sentence endings in -습니다 / -ㅂ니다) as the one
register for the whole page.
- Body prose, list items, table cells and admonition bodies end in -습니다 /
-ㅂ니다: "The SDK does the rest." → SDK가 나머지를 처리합니다.
- Short imperatives (steps, instructions, calls to action) use -세요:
"Create a file `server.py`" → `server.py` 파일을 만드세요. Never -십시오, never
the bare 해요체 (-어요 / -예요 / -해요), and never plain-style -다 endings.
- Headings are noun phrases where the English heading is a noun phrase
("Installation" → 설치). An English heading phrased as a sentence or a
question becomes a noun phrase too: "What's new in v2" → v2에서 달라진 점.
Do not write -나요? or -습니까? headings.
- Never address the reader with 당신, 여러분 or 우리. Korean drops the
subject: "you can pass a URL" → URL을 전달할 수 있습니다. Where a subject is
unavoidable, name the role — 클라이언트, 서버, 사용자 — never a pronoun.
"Your server" is 서버 or, when the contrast matters, 작성한 서버.
- One page, one register. Mixing -습니다 with -어요, or -세요 with -십시오, is
wrong even when each sentence is correct on its own.
## 2. Voice
Warm, direct and considerate: the reader is a capable developer being
guided by a colleague, not lectured by a manual.
- Keep the source's directness and its short payoff sentences. "That's a
complete MCP server." → 이것으로 완전한 MCP 서버가 완성됩니다. Do not pad the
translation with hedges the English does not have.
- A brief friendly aside is welcome in Korean too — 참고로, 다행히, a plain
환영합니다 — as long as it stays in 합쇼체.
- Prefer verbs over noun stacks. "Configuration of the transport" is 트랜스포트를
설정하는 방법, not 트랜스포트의 설정.
- Avoid translationese (번역체):
- no double passives: -되어지다 → -되다; no -할 것입니다 chains where -합니다
says the same thing;
- no pronoun crutches: drop 그것, 그들, 이것들 — repeat the noun or restructure;
- do not stack a conditional marker on top of -면: drop 만약 when -(으)면
already carries the condition;
- mark plurals sparingly: Korean rarely needs -들 ("the tools" → 도구);
- no honorific inflation: 살펴보시면 ✗ → 살펴보면 ✓ (-세요 endings are the
only place -시- appears);
- do not overuse -에 대해 / -에 대하여 where a plain object particle works.
Example — English: "A **host** is the LLM application: Claude, an IDE, an
agent runtime. It's the thing the user is talking to."
- Wrong (translationese): **호스트**는 LLM 애플리케이션입니다: Claude, IDE,
에이전트 런타임. 그것은 사용자가 그것에게 이야기하는 것입니다.
- Right: **호스트**는 LLM 애플리케이션입니다. Claude, IDE, 에이전트 런타임이 여기에
해당하며, 사용자가 대화하는 상대가 바로 호스트입니다.
## 3. Humour and idioms
Translate the information, not the joke.
- Idioms, puns and light asides are recast into a plain friendly 합쇼체
sentence that carries the same fact, never translated word for word: "Out
of the box the app answers **only** requests addressed to localhost." →
기본적으로 이 앱은 localhost로 오는 요청**만** 받습니다. — not 상자에서 꺼내자마자.
- Recurring English tags get fixed renderings: "**[X](…)** has the whole
story" / "The whole story is in **[X](…)**" → 자세한 내용은 **[X](…)**에서
확인하세요.; "That's the whole API." / "That's the whole protocol." → 이것이
API의 전부입니다. / 프로토콜은 이것이 전부입니다.; "That's it. It's just Python."
→ 이게 전부입니다. 평범한 Python일 뿐입니다.
- Exclamation marks: keep one only where the English is genuinely
emphatic; a routine sentence ends with 온점 even if the source ends in "!".
- Emoji: the source's only emoji are two ✨ closing payoff lines, and they
are dropped in Korean; the friendliness moves into the wording. "You get
`3` back. ✨" → `3`이 돌아옵니다. Emoji shortcodes (`:smile:`) are syntax and
stay untouched.
- If a light aside has no natural Korean equivalent, replace it with a
neutral sentence stating the underlying point — never leave a gap and
never add a translator's note explaining the joke.
## 4. Typography
- Punctuation is ASCII: `. , ? ! ( )`. Never 。 、 「」 or full-width forms.
Every sentence, including -세요 imperatives, ends with 온점 `.`.
- No sentence-final colon or dash before a code block or list: "Try this:"
→ 다음을 시도해 보세요. An English em-dash aside becomes a comma, a
parenthesis, or its own sentence — no ` — ` in Korean prose.
- Straight quotes only. No italics on Hangul: where the source italicises a
word that becomes Korean, use `**굵게**` or nothing; italics may stay
around Latin-script words.
- Spacing follows 한글 맞춤법: words are separated by spaces, but a
particle (조사) attaches to the word before it — also after Latin words and
code spans, with no space in between: Python은, MCP를, `add`를 호출합니다,
`Client`가 연결을 맺습니다. Latin words otherwise sit in the sentence like
Korean words, with normal spacing on each side.
- Choose the particle after a Latin word or code span by how the term is
read aloud: Python은 (파이썬), stdio는, MCP는 (엠씨피), `list_tools`를,
Streamable HTTP를. When the reading is unclear (symbols, mixed digits),
restructure so a Korean noun carries the particle — `x` 값을, `--port`
옵션은. Never write the double form 은(는) / 을(를) / 이(가).
- Digits are ASCII; a unit or counter follows a numeral without a space:
3개, 30초, 8000번 포트, 5MB. Version numbers and the protocol's date-shaped
revision strings are identifiers and are copied byte-for-byte (they are in
the glossary's keep list). A calendar date written out in prose, if any,
becomes 2026년 7월 28일.
- Parenthetical originals use ASCII parentheses with no space before them:
엘리시테이션(elicitation).
## 5. Terminology pointer
The glossary (`glossary.json`) is injected separately and overrides this
file on every term it covers. These conventions apply to everything the
glossary does not pin:
- Loanword spellings follow the standard 외래어 표기법: 서버, 클라이언트,
콜백 (not 콜빽), 프롬프트, 세션, 토큰, 스키마, 데코레이터, 미들웨어. Where an
ICT term is not in the glossary, prefer the rendering that mainstream
Korean developer documentation uses; treat 국립국어원 and TTA usage as the
tie-breaker.
- Three strategies coexist and the glossary decides which applies per term:
transliterate established loanwords (스트림, 서버), translate into the common
Sino-Korean word where that is the mainstream (요청, 응답, 알림, 도구, 인가,
의존성), and keep in Latin script anything that is an identifier or a
proper name — class and function names, wire method names such as
`tools/call`, package names, protocol and product names.
- Text quoted from what the example code prints or displays (an output
line, a log message, a UI label) stays exactly as the code emits it,
usually English.
- 한글(English) 병기: the glossary marks a few MCP-specific nouns for a
parenthetical original on first mention only — 엘리시테이션(elicitation) once,
then 엘리시테이션. Class names never get a Hangul gloss.
- One term, one rendering, throughout the page. Do not alternate between
객체 and 오브젝트, or between 컨텍스트 and 맥락, for the same source term.
- Abbreviations stay Latin and lose the English plural "s": "the APIs" → API.
## 6. Provisional note
Every decision in this file is provisional pending review by native Korean
speakers. To propose a change, edit this file (or `glossary.json`) in a pull
request — never edit the generated pages under `pages/`.

20
i18n/ko/notices.md Normal file
View file

@ -0,0 +1,20 @@
---
translation:
sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc]
tool: 1
---
# 번역 안내 {#translation-notices}
번역된 문서 사이트의 모든 페이지 상단에는 아래 안내문 중 하나가 표시됩니다.
## 기계 번역 {#translated}
이 페이지는 영어 문서를 자동으로 번역한 것이며, [영어 페이지](ENGLISH_PAGE)가 기준이 되는 정식 버전입니다. 어색하거나 잘못된 부분이 있다면 [번역](TRANSLATIONS_PAGE) 페이지에서 제보하는 방법을 확인하세요.
## 영어 페이지보다 오래된 번역 {#outdated}
이 번역이 만들어진 뒤 영어 페이지가 변경되어 일부 내용이 최신이 아닐 수 있습니다. 확실하지 않을 때는 [영어 페이지](ENGLISH_PAGE)를 읽으세요. 번역 문서가 어떻게 운영되는지는 [번역](TRANSLATIONS_PAGE) 페이지에서 설명합니다.
## 영어로 표시됨 {#english}
이 페이지는 현재 번역본이 없어 영어로 표시됩니다. 번역 문서가 어떻게 운영되는지는 [번역](TRANSLATIONS_PAGE) 페이지에서 설명합니다.

View file

@ -0,0 +1,123 @@
---
translation:
sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
**MCP App**은 얼굴을 가진 도구입니다. 도구가 데이터와 함께 HTML 문서를 가리키면, 호스트는 이 문서를 상호작용 가능한 화면으로 렌더링합니다.
두 부분으로 이루어지며, 언제나 두 부분입니다.
1. 다른 도구와 마찬가지로 작업을 수행하고 데이터를 반환하는 **도구**.
2. 호스트가 도구를 위해 보여 줄 HTML을 담은 **`ui://` 리소스**.
도구는 리소스를 가리키는 `_meta.ui.resourceUri` 참조를 지닙니다. 호스트는 `resources/read`로 리소스를 가져와 **샌드박스 처리된 iframe**에 렌더링하고, 도구의 결과를 `postMessage`로 그 iframe에 전달합니다. 서버는 어떤 `ui/*` 메시지도 주고받지 않습니다. 그 트래픽은 호스트와 iframe 사이의 일입니다. 서버는 도구와 HTML 문서를 제공할 뿐이고, 나머지 연출은 호스트가 맡습니다.
SDK는 이를 내장 `Apps` 확장(`io.modelcontextprotocol/ui`)으로 제공합니다. [확장](extensions.md)이 처음이라면 먼저 그 페이지를 훑어보세요. 1분이면 충분하니 읽고 돌아오면 됩니다.
## 얼굴을 가진 시계 {#a-clock-with-a-face}
```python title="server.py" hl_lines="19 22 30 32"
--8<-- "docs_src/apps/tutorial001.py"
```
네 가지 단계입니다.
* `Apps()`: 인스턴스 하나가 UI에 연결된 도구와 그 리소스를 모두 담습니다.
* `@apps.tool(resource_uri="ui://clock/app.html")`: 일반 도구에 `_meta.ui.resourceUri` 표시가 더해집니다. `@mcp.tool()`이 받는 모든 것(name, title, description, ...)이 그대로 전달됩니다.
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: 짝이 되는 리소스이며 `text/html;profile=mcp-app`으로 제공됩니다. 바로 이 MIME 타입이 호스트에게 "이것은 앱이니 렌더링하라"고 알려 줍니다.
* `MCPServer("clock", extensions=[apps])`: 옵트인입니다. 이제 서버는 `capabilities.extensions` 아래에 `io.modelcontextprotocol/ui`를 알립니다.
HTML 자체는 호스트의 `postMessage`를 수신하고 결과를 표시합니다. 실제 앱에서는 HTML 안에서 공식 [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) 브라우저 SDK를 사용하세요. 원시 메시지 이벤트 대신 `ontoolresult`, `callServerTool`, `getHostContext`, `onhostcontextchanged`를 제공합니다.
## 우아한 성능 저하 {#graceful-degradation}
모든 클라이언트가 앱을 렌더링하지는 않습니다. 이것이 서버에 어떤 의미인지 사양은 분명하게 말합니다.
> 도구는 UI를 사용할 수 있는 경우에도 의미 있는 `content` 배열을 반환해야 **합니다(MUST)**.
모델은 `content`를 읽고, iframe은 사람을 위한 것입니다. UI를 지원하는 호스트도 여전히 텍스트 결과를 모델에 전달하며, 텍스트 전용 클라이언트는 **오직** 그 텍스트만 받습니다. 따라서 표준 패턴은 도구 하나에 답 둘입니다. `get_time`을 다시 살펴보세요.
```python title="server.py" hl_lines="23-27"
--8<-- "docs_src/apps/tutorial001.py"
```
`client_supports_apps(ctx)`는 클라이언트가 `io.modelcontextprotocol/ui` 확장을 선언했고 **동시에** `mimeTypes` 설정에 `text/html;profile=mcp-app`을 나열했을 때만 `True`입니다. 이 필드는 필수이므로 생략한 클라이언트는 해당하지 않습니다. 같은 파일의 `main()`이 선언하는 것이 바로 이것입니다. 협상의 클라이언트 쪽 절반을 선언하면 풍부한 답이 돌아옵니다.
!!! warning
`"[Rendered UI]"` 같은 자리 표시자를 유일한 content로 반환하지 마세요. 대체 텍스트가 쓸모없다면, 그 도구는 모든 텍스트 전용 클라이언트와 모델 자체에 쓸모없는 도구가 됩니다. 제대로 된 문장을 작성하세요.
## iframe 잠그기 {#locking-the-iframe-down}
리소스 쪽이 보안 메타데이터를 지닙니다. iframe이 무엇을 로드할 수 있는지, 어떤 브라우저 권한을 원하는지, 어떻게 프레임에 담기기를 원하는지를 담습니다.
```python title="server.py" hl_lines="9 19-22"
--8<-- "docs_src/apps/tutorial002.py"
```
`csp`와 `permissions`는 서버의 동작이 아니라 **호스트에 대한 요청**입니다. 호스트는 이를 바탕으로 iframe의 Content-Security-Policy와 Permissions-Policy를 구성하며, 거부할 수도 있습니다. 허가되었다고 가정하지 말고 JS에서 기능 탐지를 하세요.
`ResourceCsp`를 필드별로 살펴보면 다음과 같습니다(Python 이름, 와이어 키, 호스트가 이것으로 하는 일).
| Python | 와이어 (`_meta.ui.csp`) | 제어 대상 |
|---|---|---|
| `connect_domains` | `connectDomains` | `connect-src`: `fetch`/XHR이 갈 수 있는 곳 |
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: 정적 자산 |
| `frame_domains` | `frameDomains` | `frame-src`: 중첩 iframe |
| `base_uri_domains` | `baseUriDomains` | `base-uri`: `<base>`가 가리킬 수 있는 곳 |
`ResourcePermissions`: 각 필드는 iframe을 위한 브라우저 권한 하나를 요청합니다.
| Python | 와이어 (`_meta.ui.permissions`) |
|---|---|
| `camera` | `camera` |
| `microphone` | `microphone` |
| `geolocation` | `geolocation` |
| `clipboard_write` | `clipboardWrite` |
!!! note
CSP와 권한은 **리소스**에 있으며, 도구에는 절대 두지 않습니다. 사양의 도구 메타데이터에는 이를 위한 자리가 없고, 호스트는 그곳에 있는 값을 무시합니다. SDK는 이 실수를 아예 표현할 수 없게 만듭니다. `@apps.tool()`에는 `csp` 매개변수가 없습니다.
### 가시성 {#visibility}
도구의 `visibility=["app"]`은 "이것은 모델이 아니라 iframe을 위해 존재한다"는 뜻입니다.
* `"model"`: 모델이 호출할 수 있습니다.
* `"app"`: iframe이 호출할 수 있습니다(`callServerTool`을 통해).
* 생략: 둘 다이며, 이것이 기본값입니다.
필터링은 **호스트**의 일입니다. 서버는 앱 전용 도구를 다른 도구와 마찬가지로 `tools/list`에 나열하고, 호스트가 이를 모델에게서 숨깁니다. 서버 쪽에서 필터링하지 마세요.
## SDK가 강제하는 규칙 {#the-rules-the-sdk-enforces}
모두 프로덕션이 아니라 시작 시점에 실패합니다.
* `ui://...`가 아닌 `resource_uri`나 리소스 URI는 데코레이션/등록 시점에 `ValueError`입니다.
* **짝이 되는 등록된 리소스가 없는** URI에 연결된 도구는 `MCPServer(extensions=[apps])`가 확장을 소비할 때 `ValueError`입니다. `resources/read`에서 404가 나는 HTML을 알리는 도구는 잘못된 설정이므로, 생성 자체를 거부합니다.
* `@apps.tool()`의 `meta={"ui": ...}`는 `ValueError`입니다. `_meta["ui"]`는 데코레이터의 소유이니 `resource_uri=`와 `visibility=`로 표현하세요. 다른 `meta=` 키는 문제없이 함께 병합됩니다.
TypeScript ext-apps SDK도 FastMCP도 현재는 이 중 어느 것도 잡아내지 못합니다. 호스트가 발견하기 전에 먼저 알게 되는 편이 낫다고 생각합니다.
## 인라인 HTML 너머 {#beyond-inline-html}
`add_html_resource`는 흔한 경우, 즉 HTML 문자열을 다룹니다. 그 밖의 경우, 디스크에 있는 HTML이나 생성된 콘텐츠라면 리소스를 직접 만들어 넘기세요.
```python title="server.py" hl_lines="12 18"
--8<-- "docs_src/apps/tutorial003.py"
```
`add_resource`는 리소스가 MIME 타입을 명시적으로 설정하지 않았을 때 `text/html;profile=mcp-app`을 채워 넣고, 명시적으로 불일치하는 값은 거부합니다. 다른 MIME 타입의 `ui://` 리소스는 어떤 호스트도 렌더링하지 않기 때문입니다.
!!! tip
지원 중단 예정(deprecated)인 평면 키 `_meta["ui/resourceUri"]`를 여전히 읽는 GA 이전 호스트를 대상으로 하나요? 직접 병합하세요.
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
중첩된 `ui` 객체가 사양의 형태이며, 평면 키는 사라지는 중입니다.
## 실행해 보기 {#see-it-run}
`examples/stories/`의 `apps` 스토리는 이 페이지를 실행 가능한 한 쌍으로 만든 것입니다. UI에 연결된 시계 도구를 갖춘 서버, 그리고 Apps를 협상하고 도구의 `_meta.ui.resourceUri`를 읽고 HTML을 가져와 도구를 호출하는 클라이언트입니다.
```bash
uv run python -m stories.apps.client
```

View file

@ -0,0 +1,172 @@
---
translation:
sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff]
tool: 1
---
# 확장 {#extensions}
**확장**은 하나의 식별자 아래에 묶어 두고 원할 때만 켜서 쓰는 MCP 동작의 묶음입니다.
서버에서는 도구, 리소스, 새로운 요청 메서드를 제공할 수 있고 `tools/call`을 감쌀 수 있습니다. 클라이언트에서는 추가적인 `tools/call` 결과 형태를 클레임하고 벤더 알림을 관찰할 수 있습니다. 양쪽 모두 각자의 `capabilities.extensions` 아래에 이를 광고하며, 요청하지 않은 쪽에는 아무것도 달라지지 않습니다. 이것이 계약([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))이고, 황금률은 단 하나입니다. **확장은 기본적으로 꺼져 있습니다**.
## 확장 사용하기 {#using-an-extension}
생성할 때 인스턴스를 전달하세요.
```python title="server.py"
--8<-- "docs_src/extensions/tutorial001.py"
```
끝입니다. 이제 서버는 `capabilities.extensions` 아래에 `io.modelcontextprotocol/ui`를 광고하고 확장이 제공하는 모든 것을 서비스합니다.
`Apps`는 내장된 참조 확장이며 별도의 페이지에서 다룹니다. **[MCP Apps](apps.md)**를 참고하세요.
!!! note
확장은 생성 시점에 고정됩니다. 나중에 호출할 `add_extension`은 없습니다. 클라이언트가 연결되어 있는 동안 서버의 기능 맵이 바뀌어서는 안 되기 때문입니다.
기능 맵은 `server/discover`에 실려 전달되는데, 이는 **2026-07-28** 경로입니다. 레거시 `initialize` 핸드셰이크에는 이를 담을 자리가 없으므로 레거시 클라이언트는 확장을 아예 보지 못합니다. 이를 고려해 설계하세요. 확장은 서버를 **보강**하는 것이지, 서버를 사용할 수 있는 유일한 방법이 되어서는 안 됩니다.
## 직접 작성하기 {#writing-your-own}
`Extension`을 서브클래싱하고 필요한 것만 재정의하세요. 모든 메서드에는 기본 구현이 있습니다.
### 식별자 {#the-identifier}
```python
--8<-- "docs_src/extensions/tutorial002.py"
```
식별자는 사양의 `_meta` 키 문법을 따르는 `vendor-prefix/name` 문자열입니다. 점으로 구분된 레이블(각각 문자로 시작하고 문자 또는 숫자로 끝남), 슬래시, 그리고 이름 순서입니다. **클래스가 정의될 때** 검증되므로 오타가 서버 부팅까지 숨어 있지 않습니다.
```text
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
(reverse-DNS prefix required), got 'stamps'
```
직접 관리하는 도메인을 접두사로 사용하세요. `io.modelcontextprotocol/*`는 MCP 프로젝트 자체가 규정하는 확장을 위한 것입니다.
### 도구 제공하기 {#contributing-tools}
쓸모 있는 가장 작은 확장은 도구 하나와 설정 맵 하나입니다.
```python title="server.py" hl_lines="17 19-20 22-23 26"
--8<-- "docs_src/extensions/tutorial003.py"
```
* `tools()`는 `ToolBinding`을 반환합니다. 서버는 각각을 직접 `mcp.add_tool(...)`을 호출한 것과 똑같이 등록합니다. 스키마 생성도, `Context` 주입도, 나머지도 모두 같습니다.
* `settings()`는 `capabilities.extensions["com.example/stamps"]`에 광고되는 값입니다. 설정 없이 확장을 광고하려면 `{}`(기본값)을 반환하세요.
* 확장은 서버를 절대 전달받지 않습니다. 기여할 내용을 데이터로 선언하고, `MCPServer`가 이를 소비합니다. 변경할 `self.server` 같은 것은 없습니다.
그리고 `main()`이 그 증거입니다. `mcp`에 바로 연결하는 인메모리 클라이언트입니다.
```python title="server.py" hl_lines="29-34"
--8<-- "docs_src/extensions/tutorial003.py"
```
### 자체 메서드 제공하기 {#serving-your-own-methods}
확장은 **새로운 요청 메서드**를 등록할 수 있습니다. 사양의 동사 옆에서 함께 서비스되는 자체 동사입니다.
```python title="server.py" hl_lines="16-22 31 40-48"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `SearchParams`는 `RequestParams`를 서브클래싱하므로 2026 `_meta` 봉투가 일관되게 파싱되고, 핸들러는 원시 dict가 아니라 검증된 params를 받습니다. 클라이언트가 제어하는 값에는 한계를 두세요. `Field(ge=1, le=100)`은 코드가 무언가를 할당하기 전에 터무니없는 `limit`을 거부합니다.
* `require_client_extension(ctx, EXTENSION_ID)`가 관문입니다. 확장을 선언하지 않은 클라이언트는 사양이 요구하는 기계 판독 가능한 `requiredCapabilities` 페이로드와 함께 `-32021`(필수 클라이언트 기능 누락) 오류를 받습니다.
* `protocol_versions=frozenset({"2026-07-28"})`은 메서드를 하나의 와이어 버전에 고정합니다. 다른 버전에서는 클라이언트가 `METHOD_NOT_FOUND`를 받는데, 그 버전에 메서드가 존재하지 않는 것과 똑같습니다. 그 클라이언트에게는 실제로 존재하지 않는 셈입니다.
메서드는 **엄격하게 추가만 가능**합니다. SDK는 이를 런타임이 아니라 생성 시점에 강제합니다.
* 사양에 정의된 메서드(`tools/list`, `completion/complete`, ...)에 대한 `MethodBinding`은 바인딩이 생성될 때 `ValueError`를 일으킵니다. 핵심 동사는 서버의 것입니다.
* 두 확장이 같은 메서드를 바인딩하면 두 번째가 등록될 때 오류가 납니다. 마지막 쓰기가 이기는 방식은 플러그인이 서로를 망가뜨리는 원인이므로 그렇게 하지 않습니다.
* 빈 `protocol_versions` 집합도 오류를 일으킵니다. 절대 서비스될 수 없는 메서드는 설정이 아니라 버그입니다.
### 클라이언트 측 {#the-client-side}
같은 파일의 `main()`이 클라이언트 쪽 이야기의 전부이며, 두 부분을 모두 담고 있습니다.
```python title="server.py" hl_lines="54-58"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `Client(..., extensions=[advertise(EXTENSION_ID)])`가 확장을 선언합니다. 선언은 `ClientCapabilities.extensions`가 됩니다. 2026-07-28 연결에서는 이 맵이 요청별 `_meta` 봉투에 실려 이동하므로 서버는 **모든** 요청에서 이를 봅니다. 레거시 연결에서는 `initialize` 핸드셰이크에 실립니다. 서버 코드는 어느 쪽인지 신경 쓰지 않습니다. `require_client_extension(ctx, ...)`와 `ctx.session.check_client_capability(...)`는 두 경로 모두에서 올바른 출처를 읽습니다.
* 벤더 메서드는 한 계층 아래인 `client.session.send_request(...)`로 내려갑니다. `Client`는 사양 동사에 대해서만 일급 메서드를 갖춥니다. `send_request`는 모든 `Request` 서브클래스를 받으므로 벤더 요청은 그대로 통과합니다.
### `tools/call` 가로채기 {#intercepting-toolscall}
유일하게 개입하는 훅입니다. 도구 호출을 관찰하거나, 단락시키거나, 거부하려면 `intercept_tool_call`을 재정의하세요.
```python title="server.py" hl_lines="17-24"
--8<-- "docs_src/extensions/tutorial005.py"
```
* `params`는 검증된 `CallToolRequestParams`입니다. 원시 JSON을 건드리지 않고 `params.name`과 `params.arguments`를 얻습니다. 어느 도구 호출이 실행될지 결정하는 것도 바로 이것입니다. 다시 작성한 컨텍스트를 `call_next`에 넘기면 핸들러가 `ctx`에서 관찰하는 내용이 바뀔 뿐 도구 호출 자체는 바뀌지 않습니다. 와이어 수준의 요청 재작성은 [미들웨어](middleware.md)의 몫입니다.
* `call_next(ctx)`는 체인의 나머지를 실행하고 핸들러의 결과를 반환합니다. 그대로 반환하거나(관찰), 다른 것을 반환하거나(대체), `MCPError`를 일으키세요(거부). 무엇을 반환하든 2026 계열의 `serverInfo` 신원 스탬프를 포함해 다른 핸들러 결과와 똑같이 직렬화되므로, 단락시키는 인터셉터가 익명이거나 스키마에 어긋나는 응답을 만들어 내는 일은 없습니다.
* 확장이 여럿이면 인터셉터는 등록 순서대로 중첩됩니다. `extensions=[...]`의 첫 번째 확장이 가장 바깥쪽입니다.
* 기본 구현은 그대로 통과시키며, 확장이 이 훅을 재정의하지 않는 서버는 원래의 `tools/call` 핸들러를 그대로 유지합니다. 쓰지 않는 것에는 비용을 치르지 않습니다.
이 훅은 `tools/call`만 감싸고 다른 것은 감싸지 않습니다. 모든 메시지에 걸친 관심사에는 [미들웨어](middleware.md)를 사용하세요. 미들웨어는 바로 그런 용도입니다.
## 클라이언트 확장 사용하기 {#using-a-client-extension}
**클라이언트 확장**은 소비하는 쪽에서 본 같은 계약으로, 하나의 식별자 아래 묶인 클라이언트 측 동작의 묶음입니다. `Client(extensions=[...])`에 인스턴스를 전달하고 평소처럼 도구를 호출하세요.
```python title="client.py" hl_lines="66-68"
--8<-- "docs_src/extensions/tutorial006.py"
```
`call_tool("buy", ...)`은 다른 모든 호출처럼 평범한 `CallToolResult`를 반환합니다. 확장이 바꾼 것은 이렇습니다. 이제 서버는 `buy`에 최종 결과 대신 `receipt` **결과 형태**로 응답할 수 있고, `call_tool`이 반환하기 전에 `Receipts`가 이를 마무리합니다(여기서는 후속 호출로 영수증을 정산합니다). 호출 지점에서는 아무것도 달라지지 않습니다.
확장을 빼면 이 중 어떤 것도 존재하지 않습니다. 서버의 관문은 확장을 선언하지 않은 클라이언트를 거부하고(오류 -32021), 관문을 건너뛰는 서버가 보낸 클레임된 형태는 인식되지 않은 `resultType`에 대해 사양이 요구하는 그대로 검증에 실패합니다. 와이어 양 끝 모두에서 기본적으로 꺼져 있습니다.
클라이언트 측 동작이 **전혀 없는** 식별자를 광고하려면(위의 검색 클라이언트처럼 서버는 기능을 기준으로 관문을 두고 클라이언트는 아무것도 하지 않는 경우) `advertise()`를 사용하세요.
```python
from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
```
## 클라이언트 확장 작성하기 {#writing-a-client-extension}
`ClientExtension`을 서브클래싱하고 필요한 것만 재정의하세요. 기여 종류는 세 가지이며 각각 기본 구현이 있습니다. `settings()`, `claims()`, `notifications()`입니다.
```python title="client.py" hl_lines="17-18 43-44 46-47"
--8<-- "docs_src/extensions/tutorial006.py"
```
* 식별자는 서버의 것과 같은 문법을 따르며 클래스가 정의될 때 검증됩니다.
* `claims()`는 `ResultClaim`을 반환합니다. 와이어 태그, 이를 파싱하는 모델, 마무리하는 리졸버로 구성됩니다. 모델은 `result_type: Literal["receipt"]`으로 태그를 고정해야 하며 해당 동사의 핵심 결과 타입을 서브클래싱해서는 안 됩니다. 둘 다 클레임이 생성될 때 강제됩니다. `receipt_token` 같은 벤더 필드는 와이어에 그대로 실립니다. 대체된 형태는 클라이언트에 원문 그대로 도달합니다.
* 리졸버는 파싱된 모델과 `ClaimContext`를 받습니다. `ctx.session`은 `client.session`과 같은 공개 핸들이므로 후속 작업은 평범한 세션 호출입니다. 리졸버는 해당 동사의 일반적인 `CallToolResult`를 반환합니다.
* `settings()`는 `ClientCapabilities.extensions[identifier]`에 광고되는 값이며 `Client` 생성 시 한 번 읽힙니다.
`notifications()`는 관찰할 벤더 서버 알림을 선언합니다.
```python
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
```
핸들러는 검증된 params를 디스패치 순서대로 하나씩 받습니다. 관찰만 할 뿐, 거부하거나 응답할 수는 없습니다.
조용한 규칙이 두 가지 있습니다. 클레임은 2026-07-28 연결에서만 활성화되며 기능 광고도 이를 따릅니다. 레거시 연결에서는 클레임이 사라지고 식별자도 함께 광고에서 빠지므로, 클라이언트가 스스로 거부할 형태의 확장을 광고하는 일은 없습니다. 그리고 리졸버 대신 클레임된 형태를 직접 받고 싶다면 `client.session.call_tool(..., allow_claimed=True)`를 호출하세요. 이 플래그가 없으면 세션 계층 호출자에게 도달한 클레임된 형태는 `UnexpectedClaimedResult`를 일으킵니다.
### 확장 동사 {#extension-verbs}
확장의 자체 요청 메서드에는 클라이언트 측 등록이 필요 없습니다. 벤더 요청 타입은 `mcp.types.Request`를 서브클래싱하고, [자체 메서드 제공하기](#serving-your-own-methods)에서처럼 `client.session.send_request`를 거칩니다. 한 가지가 더 있습니다. params 키가 `Mcp-Name` 헤더에 실려야 할 때(tasks 같은 확장 사양은 자신의 동사에 이를 요구합니다) 요청 타입이 `name_param`을 선언합니다.
```python title="client.py" hl_lines="22-25 46-47"
--8<-- "docs_src/extensions/tutorial007.py"
```
세션은 모든 전송 경로에서 `params["jobId"]`를 `Mcp-Name`에 반영하며, 값이 없으면 필수 헤더를 조용히 빠뜨리는 대신 명시적으로 실패합니다.
## 확장이 할 수 없는 것 {#what-an-extension-cannot-do}
기여 범위는 의도적으로 **닫혀** 있습니다. 서버에서는 설정, 도구, 리소스, 메서드, `tools/call` 인터셉터 하나입니다. 클라이언트에서는 설정, 결과 클레임, 알림 바인딩입니다. 확장은 다음을 할 수 없습니다.
* **호스트 내부에 손대기.** 데이터를 선언할 뿐, 서버나 클라이언트 참조를 쥐지 않습니다.
* **핵심 동작 바꾸기.** 사양 메서드와 핵심 결과 태그는 생성 시점에 거부되며(`initialize`는 러너가 아예 예약해 둡니다), 핵심 어휘에 가려지는 알림 바인딩은 대신 경고와 함께 조용해집니다.
* **늦게 등록하기.** `MCPServer(...)`나 `Client(...)`가 반환된 뒤에는 확장 집합이 그대로 확정됩니다.
이 벽과 씨름하고 있다면 확장을 작성하는 것이 아니라 포크를 작성하는 것입니다. 벽이 곧 기능입니다. `extensions=[Apps(), Stamps()]`라는 코드를 읽는 사용자는 그 둘이 건드렸을 수 있는 **모든 것**을 압니다.

View file

@ -0,0 +1,22 @@
---
translation:
sections: [ca6988b7503cd2d3]
tool: 1
---
# 고급 {#advanced}
일반적인 서버나 클라이언트에 필요한 모든 것은 위의 섹션에서 주제별로 다룹니다. 이 섹션은 `MCPServer`의 편의 계층이 오히려 방해가 될 때 꺼내 쓰는 비상구입니다.
* **[저수준 Server](low-level-server.md)**: `MCPServer`가 기반으로 삼는 클래스입니다. 손으로 작성하는 스키마, `on_*` 핸들러, 아무것도 대신 검사해 주지 않는 구조, 그리고 직접 정의하는 커스텀 JSON-RPC 메서드를 다룹니다.
* **[페이지네이션](pagination.md)**과 **[미들웨어](middleware.md)**: 저수준 `Server`에서**만** 할 수 있는 두 가지입니다.
* **[확장](extensions.md)**과 **[MCP Apps](apps.md)**: 프로토콜의 확장 지점입니다. 확장 패키지를 서버에 조합해 넣거나 직접 작성할 수 있습니다.
여기서 찾을 법한 몇 가지 항목은 실제로 사용하는 곳에 배치되어 있습니다.
* **인가**는 **[서버 실행하기](../run/index.md)** 아래에 있습니다. 서버는 배포하는 곳에서 보호하기 때문입니다.
* **OAuth**, **신원 어설션**, **여러 서버**에 연결하기, 응답 **캐시**는 모두 **[클라이언트](../client/index.md)** 아래에 있습니다.
* **다중 왕복 요청**과 **구독**은 **[핸들러 내부](../handlers/index.md)** 아래에 있습니다. 둘 다 핸들러가 **수행하는** 일이기 때문입니다.
* **URI 템플릿**은 **[서버](../servers/index.md)** 아래, 리소스 옆에 있습니다.
* **[프로토콜 버전](../protocol-versions.md)**과 **[지원 중단 예정 기능](../deprecated.md)**은 각각 별도의 최상위 페이지가 있습니다.
이 섹션이 필요한지 확신이 서지 않는다면, 필요하지 않은 것입니다.

View file

@ -0,0 +1,207 @@
---
translation:
sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e]
tool: 1
---
# 저수준 Server {#the-low-level-server}
`@mcp.tool()`은 하나의 계층입니다. 그 아래에는 두 번째 서버 클래스인 `Server`가 있으며, 이 클래스는 MCP를 날것 그대로 다룹니다. 프로토콜 객체를 넘기면 변경 없이 그대로 와이어에 실어 보냅니다.
`MCPServer`는 그 위에 만들어져 있습니다. 편의 계층이 방해가 될 때 저수준으로 내려갑니다.
* Python 시그니처에서 도출한 스키마가 아니라 **정확히 그대로의** 스키마(파일에서 읽어 오거나 데이터베이스에서 생성한 스키마)를 내보내야 할 때.
* 결과를 완전히 제어해야 할 때: `_meta`, `is_error`, `structured_content`의 모든 키.
* MCP가 정의하지 않은 메서드를 처리해야 할 때.
그 밖의 모든 경우에는 `MCPServer`를 계속 사용하세요.
## 같은 도구를 직접 작성하기 {#the-same-tool-by-hand}
다음은 **[도구](../servers/tools.md)**에서 `@mcp.tool()` 아홉 줄로 작성한 `search_books` 도구에서 문법적 편의를 걷어 낸 모습입니다.
```python title="server.py" hl_lines="22 26 32"
--8<-- "docs_src/lowlevel/tutorial001.py"
```
세 가지가 바뀌었고, 이 세 가지가 저수준 API의 전부입니다.
* **핸들러는 생성자 매개변수입니다.** `on_list_tools=`와 `on_call_tool=`은 `Server(...)`에 들어갑니다. 여기에는 데코레이터가 없으며, 모든 핸들러의 형태가 `async (ctx, params) -> result`로 동일합니다.
* **입력 스키마를 직접 작성합니다.** `Tool.input_schema`는 평범한 JSON Schema `dict`입니다. 타입 힌트에서 대신 도출해 주는 곳이 없습니다. 도출할 타입 힌트 자체가 없기 때문입니다.
* **결과를 직접 만듭니다.** `CallToolResult(content=[TextContent(...)])`를 손으로 작성합니다. 감싸거나 변환하거나 반환 어노테이션에서 추론하는 것은 아무것도 없습니다.
`params`는 파싱된 요청입니다. `CallToolRequestParams`에는 `.name`과 `.arguments`가 있습니다. `ctx`는 `ServerRequestContext`입니다. 클라이언트에 다시 말을 거는 데 쓰는 `ctx.session`, 그리고 `ctx.lifespan_context`, `ctx.request_id`, 요청에 실려 들어온 `_meta`인 `ctx.meta`가 있습니다.
!!! info
FastAPI를 써 봤다면 이 관계를 이미 알고 있습니다. `MCPServer`는 데코레이터와 타입 힌트로 이루어진 계층이고, `Server`는 그 아래의 Starlette에 해당합니다. 둘은 경쟁 관계가 아닙니다. `MCPServer`는 `Server`를 생성하고 그 위에 바로 이런 핸들러를 등록합니다.
### 직접 해 보기 {#try-it}
이번에는 Inspector를 쓸 수 없습니다. `mcp dev`와 `mcp run`은 `MCPServer`만 받습니다. 인메모리 `Client`는 상관하지 않으며, `MCPServer`를 받는 것과 똑같이 저수준 `Server`도 받습니다.
```python title="main.py"
import asyncio
from mcp import Client
from server import server
async def main() -> None:
async with Client(server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
asyncio.run(main())
```
```text
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
```
`@mcp.tool()` 버전이 만들어 낸 것과 같은 텍스트입니다. 숨김없이 말하면 차이점이 두 가지 있습니다.
* `result.structured_content`가 `None`입니다. 고수준 서버는 `-> str` 반환값을 `{"result": ...}`로 감싸 주지만, 여기서는 직접 만들지 않은 것을 대신 만들어 주는 곳이 없습니다.
* `list_tools`는 **직접** 입력한 스키마를 글자 하나까지 그대로 반환합니다. 고수준 버전에는 모든 속성에 `"title": "Query"`가, 루트에 `"title": "search_booksArguments"`가 있었습니다. Pydantic이 남긴 흔적입니다. 여기서는 와이어에 실린 것이라면 전부 직접 넣은 것입니다.
## 자동 검증 없음 {#nothing-is-checked-for-you}
`MCPServer`는 함수가 실행되기도 전에, 생성한 스키마에 호출을 대조해 검증하여 잘못된 인수를 거부합니다(**[도구](../servers/tools.md)**).
`Server`는 그렇게 하지 않습니다. `input_schema`는 클라이언트에 **알려지기만** 할 뿐, `params.arguments`에 **적용되는** 일은 없습니다.
!!! check
`limit` 없이 `search_books`를 호출하면 `args["limit"]`에서 `KeyError`가 발생합니다. 클라이언트가 보는 것은 다음과 같습니다.
```text
MCPError: Internal server error
```
코드 `-32603`의 JSON-RPC 오류이며, 메시지는 일부러 포괄적으로 되어 있습니다. SDK는 트레이스백을 원격 호출자에게 새어 나가게 하지 않습니다. 모델은 무엇을 잘못했는지 끝내 알지 못하므로 재시도할 수 없습니다. (테스트에서는 `raise_exceptions=True`로 실제 예외를 대신 드러낼 수 있습니다. **[테스트](../get-started/testing.md)**를 참고하세요.)
이것은 일반적인 규칙입니다. 저수준 핸들러에서 발생한 예외는 **언제나** 프로토콜 오류이며, 결코 `is_error=True` 도구 결과가 되지 않습니다. 모델이 실패 내용을 읽고 복구하기를 원한다면 `params.arguments`를 직접 검증하고 `CallToolResult(content=[TextContent(...)], is_error=True)`를 반환하세요. 이 두 가지 실패는 **[오류 처리](../servers/handling-errors.md)**에서 다룹니다.
## 도구 두 개, 핸들러 하나 {#two-tools-one-handler}
`on_call_tool`은 서버에 있는 모든 도구의 단일 진입점입니다. `params.name`으로 분기합니다.
```python title="server.py" hl_lines="38-43"
--8<-- "docs_src/lowlevel/tutorial002.py"
```
* `list_tools`는 둘 다 알립니다. `call_tool`은 이름에 따라 디스패치합니다.
* `else` 분기가 중요합니다. `Server`는 목록에 올린 적 없는 이름의 `tools/call`도 기꺼이 핸들러로 그대로 전달합니다. 거기서 예외를 일으키면 위와 같은 `-32603`이 됩니다.
## 구조화된 출력 직접 작성하기 {#structured-output-by-hand}
`Tool`에 `output_schema`를 선언하고 결과에 `structured_content`를 넣습니다. 둘 다 직접 작성합니다.
```python title="server.py" hl_lines="19-23 36"
--8<-- "docs_src/lowlevel/tutorial003.py"
```
호출하면 결과에 두 가지 표현이 모두 실립니다.
```json
{
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
}
```
`_meta` 블록은 서버의 신원 도장입니다. SDK는 2026 계열 프로토콜의 모든 결과에 이것을 추가하며, `version`은 생성자에서 가져옵니다(버전을 설정하지 않은 서버는 빈 문자열을 보고합니다). 자신을 드러내서는 안 되는 서버는 미들웨어로 이 키를 제거할 수 있습니다. 미들웨어는 자신이 반환하는 결과를 소유하기 때문입니다.
서버는 두 필드를 비교하지 않습니다. 이 SDK의 `Client`는 비교합니다. 선언한 `output_schema`를 만족하지 않는 `structured_content`를 반환하면 `call_tool`이 `RuntimeError`를 일으키는데, 메시지는 `Invalid structured content returned by tool search_books`로 시작해 `jsonschema` 실패 내용을 인용합니다. 스키마를 약속하기는 쉽지만, 지키는 것은 작성자의 몫입니다. 반환 타입과 스키마의 전체 단계는 **[구조화된 출력](../servers/structured-output.md)**에서 확인하세요.
## `_meta`: 모델이 아닌 애플리케이션을 위한 데이터 {#\_meta-for-the-application-not-the-model}
`content`는 답변 중 모델이 읽는 부분입니다. `structured_content`는 같은 답변을 타입이 있는 데이터로 나타낸 것입니다. `_meta`는 세 번째 채널입니다. 답변의 일부가 전혀 아니면서 결과에 함께 실려 **클라이언트 애플리케이션**으로 가는 데이터입니다.
레코드 ID, 트레이스 ID처럼 UI에는 필요하고 프롬프트에는 필요 없는 것이라면 무엇이든 여기에 넣으세요.
```python title="server.py" hl_lines="37"
--8<-- "docs_src/lowlevel/tutorial004.py"
```
* 구성할 때는 와이어 이름인 `_meta=`로 씁니다. 클라이언트는 `result.meta`로 읽습니다.
* 키에 네임스페이스를 붙이세요(`bookshop/record_ids`). `io.modelcontextprotocol/*` 키는 프로토콜이 예약해 두었습니다.
!!! warning
`_meta`는 작성자와 클라이언트 애플리케이션 사이의 관례이지, 무엇이 모델에 도달하는지에 관한 보장이
아닙니다. 무엇을 렌더링할지는 호스트가 결정합니다. 도구 결과의 어느 부분에도 절대 비밀 값을 넣지 마세요.
## 핸들러에 따라 결정되는 기능 {#capabilities-follow-your-handlers}
`Server`는 핸들러를 제공한 메서드 군만 정확히 알립니다. 위의 `Bookshop`은 `on_list_tools`와 `on_call_tool`만 전달하고 다른 것은 전달하지 않으므로, 여기에 연결하는 클라이언트가 보는 것은 다음과 같습니다.
```json
{"tools": {"listChanged": false}}
```
`resources`도 `prompts`도 없습니다. 뒷받침할 것이 없기 때문입니다. `on_list_prompts`를 전달하면 `prompts`가 나타나고, `on_completion`을 전달하면 `completions`가 나타납니다.
`MCPServer`는 등록한 것이 있든 없든 항상 도구, 리소스, 프롬프트를 알립니다. 관리자 객체가 항상 존재하기 때문입니다. 여기서는 선언이 **곧** 생성자 호출입니다.
## lifespan 제네릭 {#the-lifespan-generic}
`Server`는 lifespan이 yield하는 타입에 대해 제네릭입니다. 어노테이션을 한 번 달면 그 객체가 나타나는 모든 곳에서 타입이 지정됩니다.
```python title="server.py" hl_lines="24-26 44-45 50"
--8<-- "docs_src/lowlevel/tutorial005.py"
```
* lifespan은 `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]` 형태이며, `async` 제너레이터에 `@asynccontextmanager`를 붙이면 정확히 이것이 됩니다.
* `yield`한 것은 무엇이든 `ctx.lifespan_context`가 되고, 핸들러에 `ServerRequestContext[Catalog]` 어노테이션이 달려 있으므로 `.search(...)`가 자동 완성되고 타입 검사를 통과합니다.
* 서버가 시작할 때 한 번 진입하고 멈출 때 한 번 빠져나옵니다. 시작, 정리, 그리고 같은 개념의 `MCPServer` 버전은 **[Lifespan](../handlers/lifespan.md)**에서 확인하세요.
`lifespan=` 인수가 없으면 `ctx.lifespan_context`는 빈 `dict`입니다.
## 직접 정의하는 메서드 {#a-method-of-your-own}
생성자는 MCP가 정의한 메서드를 다룹니다. 그 밖의 모든 것은 `add_request_handler`가 다룹니다.
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
--8<-- "docs_src/lowlevel/tutorial006.py"
```
* 첫 번째 인수는 메서드 문자열입니다. 알림에는 짝이 되는 `add_notification_handler`가 있습니다.
* `params_type`은 핸들러가 실행되기 **전에** 들어오는 `params`를 검증하는 기준 모델입니다. 따라서 커스텀 메서드는 도구가 받지 못하는 검증을 **받습니다**. `_meta` 필드가 다른 모든 메서드처럼 파싱되도록 `RequestParams`를 상속하세요.
* 핸들러는 `BaseModel`, `dict`, `None` 중 하나를 반환합니다. SDK가 이를 JSON-RPC 결과로 직렬화합니다.
솔직한 단서 하나가 있습니다. 고수준 `Client`에는 MCP가 정의한 메서드용 동사만 있으므로 `client.reindex()`는 없습니다. 벤더 메서드는 그 메서드의 존재를 이미 아는 상대를 위한 것입니다. 함께 배포하는 클라이언트나, JSON-RPC를 말하는 자체 서비스가 여기에 해당합니다.
차지할 수 없는 메서드가 하나 있습니다.
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
핸드셰이크는 러너의 소유입니다. `server/discover`, `ping`, 그 밖의 모든 내장 메서드는 자유롭게 대체할 수 있습니다.
!!! tip
오류 메시지에 언급된 `Server.middleware`는 `initialize`를 포함해 들어오는 **모든** 메시지를 감쌉니다. 새 메서드에 응답하는 것이 아니라 트래픽을 관찰하거나 다시 쓰고 싶다면 **[미들웨어](middleware.md)**부터 시작하세요.
## 나머지 핸들러 {#the-other-handlers}
다음은 각각 이제 이해할 어휘를 갖춘 개념 하나씩이며, 각각 별도의 페이지가 있습니다.
* `on_call_tool`, `on_get_prompt`, `on_read_resource`는 호출을 일시 중지하고 클라이언트에 입력을 요청하기 위해 평소의 결과 대신 `InputRequiredResult`를 반환할 수 있습니다. **[다중 왕복 요청](../handlers/multi-round-trip.md)**을 참고하세요. 이 계층답게 대신 설치해 주는 것은 없습니다. `MCPServer`가 기본적으로 `requestState`를 봉인하는 반면, 여기서는 설정한 `request_state`가 `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`로 명시적으로 켜기 전까지 쓴 그대로 와이어를 건너갑니다. 이 한 줄(두 이름 모두 `mcp.server.request_state`에서 임포트합니다)이면 `MCPServer`가 수행하는 것과 동일한 봉인과 검증이 이루어집니다(**[`requestState` 보호하기](../handlers/multi-round-trip.md#protecting-requeststate)**).
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion`은 나머지 프리미티브용으로 같은 `(ctx, params) -> result` 형태입니다.
* `on_subscriptions_listen`은 2026-07-28의 `subscriptions/listen` 스트림을 제공합니다. `SubscriptionBus` 위에 만든 `ListenHandler`를 전달하고 다른 핸들러에서 버스로 이벤트를 발행하세요. 전체 구성은 **[구독](../handlers/subscriptions.md)**에서 확인하세요.
* `server.streamable_http_app()`은 `MCPServer`의 것과 같은 Starlette 앱을 반환합니다. **[서버 실행하기](../run/index.md)**에서 다른 ASGI 앱을 배포하는 방식 그대로 배포하세요. 여기에는 `server.run(transport=...)` 같은 것이 없습니다. `server.run(read_stream, write_stream, server.create_initialization_options())` 호출이 스트림 한 쌍 위에서 연결 하나를 구동하며, 이 한 줄이 전부입니다.
## 요약 {#recap}
* 저수준 `Server`는 핸들러를 `on_*` **생성자 매개변수**로 받으며, 모든 핸들러는 `async (ctx, params) -> result`입니다.
* `input_schema` dict를 직접 작성하고 `CallToolResult`를 직접 만듭니다. 대신 도출하거나 감싸거나 검증해 주는 것은 없습니다.
* 핸들러의 예외는 `-32603` 프로토콜 오류입니다. 모델이 읽을 수 있는 도구 오류는 `is_error=True`인 `CallToolResult`이며 **직접** 반환해야 합니다.
* 결과의 `_meta`는 모델이 아니라 클라이언트 애플리케이션에 보내는 것입니다.
* `Server[T]`는 lifespan이 yield하는 것에 대해 제네릭이며, `ctx.lifespan_context`는 타입이 지정된 `T`입니다.
* `add_request_handler(method, params_type, handler)`는 어떤 메서드든 제공합니다. `initialize`는 예약되어 있습니다.
* `Server`가 알리는 기능은 등록한 핸들러에서 도출됩니다.
`Client(server)`가 두 서버를 똑같이 다룬 것은 둘이 **같은** 프로토콜이기 때문이며, 바로 그 점이 핵심입니다. 그다음 아래 계층은 클래스가 아닙니다. 바로 **[미들웨어](middleware.md)**입니다.

View file

@ -0,0 +1,125 @@
---
translation:
sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c]
tool: 1
---
# 미들웨어 {#middleware}
**미들웨어**는 서버가 받는 모든 메시지를 감싸는 하나의 async 함수입니다.
`async (ctx, call_next)` 형태로 작성해서 `server.middleware`에 추가하면 됩니다. 이것이 API의 전부입니다.
!!! warning
미들웨어 목록은 소스에서 **잠정적**(provisional)으로 표시되어 있습니다. 시그니처와 동작 의미는
2.x 마이너 릴리스에서 바뀔 수 있습니다. 메시지를 **관찰**(시간 측정, 로깅, 트레이싱)하고
**거부**하는 데 사용하세요. 서버가 딛고 서는 기반으로 삼지는 마세요.
`MCPServer`는 생성 시 목록을 받아(`MCPServer(name, middleware=[...])`) `mcp.middleware`로 노출하고,
저수준 `Server`는 같은 목록을 `server.middleware`로 노출합니다. 아래 예제는 저수준 `Server`를
사용합니다. `Server(name, on_call_tool=...)`가 처음이라면
**[저수준 Server](low-level-server.md)**를 먼저 읽으세요.
## 시간을 재는 미들웨어 {#a-timing-middleware}
서버 하나, 도구 하나, 그리고 메시지마다 걸린 시간을 로그로 남기는 미들웨어 하나입니다.
```python title="server.py" hl_lines="39-45 49"
--8<-- "docs_src/middleware/tutorial001.py"
```
* `ctx`는 핸들러가 받는 것과 같은 `ServerRequestContext`입니다. `ctx.method`는 원시 메서드
문자열이고, `ctx.params`는 어떤 검증도 거치기 **전**의 원시 params입니다.
* `call_next(ctx)`는 체인의 나머지, 즉 검증, 핸들러 조회, 작성한 핸들러를 실행합니다.
반환된 값을 그대로 반환하면 응답은 손대지 않은 채로 나갑니다.
* `try`/`finally`는 의도적인 선택입니다. 예외를 일으키는 핸들러도 시간이 측정됩니다. 실패는
`call_next`에서 빠져나오는 예외로 미들웨어에 도달하기 때문입니다.
* `server.middleware.append(...)`로 등록합니다. 목록은 바깥쪽부터 실행되므로
`middleware[0]`이 와이어에 가장 가까운 미들웨어입니다.
### 직접 해 보기 {#try-it}
클라이언트를 연결하고, 도구 목록을 조회하고, 하나를 호출해 보세요. 로그에는 **세** 줄이 남습니다.
```text
server/discover took 18.3 ms
tools/list took 0.1 ms
tools/call took 0.1 ms
```
호출은 두 번 했는데 줄은 세 개입니다. 첫 번째 줄은 `server/discover`로, 아무것도 요청하기 전에
클라이언트가 연결을 설정하려고 보낸 요청입니다.
바로 이것이 핵심입니다. 미들웨어는 들어오는 **모든** 메시지를 감쌉니다.
* 연결 설정. `server/discover`이거나, 레거시 세션에서는 `initialize`와
`notifications/initialized`입니다.
* 모든 요청과 모든 알림. 알림의 경우 `ctx.request_id is None`이고,
`call_next(ctx)`는 `None`을 반환하며, 무엇을 반환하든 버려집니다.
* 서버에 핸들러가 없는 메서드까지도 포함됩니다. `call_next`가
`MCPError(-32601, "Method not found")`를 일으키고, 이 예외는 클라이언트로 가는 길에
미들웨어를 **통과합니다**.
## 미들웨어 안에서 할 수 있는 일 {#what-you-can-do-inside-one}
망설임이 적게 필요한 것부터 순서대로 나열합니다.
* **관찰.** 시간을 재고, 횟수를 세고, 로그를 남기세요. 위의 예제가 이에 해당합니다.
* **거부.** `call_next(ctx)`를 호출하는 **대신** `MCPError`를 일으키면 그 메시지 하나에
JSON-RPC 오류로 응답합니다. 연결은 유지되고 다음 메시지는 그대로 통과합니다. 서버가
호출자별로 `subscriptions/listen`을 제한하는 방법이 바로 이것입니다. 구독 페이지의
**[누가 지켜볼 수 있는지 정하기](../handlers/subscriptions.md#deciding-who-may-watch)**에서
단계별로 설명합니다.
* **재작성.** `ctx`는 데이터클래스입니다. `await call_next(dataclasses.replace(ctx, params=...))`는
체인의 나머지에 클라이언트가 보낸 것과 다른 params를 넘깁니다. `initialize`에는 절대 이렇게
하지 마세요. 클라이언트가 돌려받는 결과는 재작성한 params로 만들어지지만, 서버는 원래 와이어
params를 기준으로 연결 상태를 확정합니다. 양쪽이 무엇을 협상했는지 서로 다르게 이해한 채로
핸드셰이크를 마칠 수 있습니다.
* **응답.** `call_next(ctx)`를 호출하지 않고 결과를 반환하면 그 결과가 응답으로 클라이언트에
전달됩니다. `call_next`는 완성된 와이어 형식을 넘겨주고, 파이프라인은 반환한 값을 손보지
않으므로 봉투 전체가 미들웨어의 몫입니다. 2026년 세대의 연결에서는 `serverInfo` `_meta`
스탬프가 여기에 포함되는데, SDK는 이를 핸들러 결과에는 추가하지만 미들웨어가 반환한 결과에는
추가하지 않습니다.
!!! check
`initialize`도 미들웨어가 감싸는 대상 중 하나이며, 미들웨어는 이에 대해 얻을 수
있는 **유일한** 훅입니다. `add_request_handler`로 가로채려고 하면 SDK가 거부합니다.
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
!!! warning
`initialize`는 인라인으로 처리됩니다. 미들웨어 체인이 반환할 때까지 서버는 들어오는 메시지를
더 읽지 않습니다. 따라서 `initialize`를 처리하는 동안 서버에서 클라이언트로 가는 요청
(`ctx.session.send_request(...)`, 엘리시테이션(elicitation))을 await하면 **연결이 교착 상태에
빠집니다**. 기다리는 응답은 결코 읽힐 수 없기 때문입니다. 보내고 잊는 방식의 알림은 괜찮습니다.
## 기본으로 켜져 있는 단 하나의 미들웨어 {#the-one-middleware-that-ships-on-by-default}
SDK에는 미들웨어가 정확히 하나 포함되어 있으며, 이미 서버의 목록에 들어 있습니다. 모든 메시지마다
OpenTelemetry 스팬을 내보내는 미들웨어입니다. 직접 추가할 필요가 없고, 대부분의 경우 신경 쓸
필요도 없습니다. 익스포터를 설치하기 전까지는 아무 일도 하지 않으며, 별도의 페이지가 있습니다.
**[OpenTelemetry](../run/opentelemetry.md)**를 참고하세요.
!!! info
ASGI 미들웨어를 작성해 본 적이 있다면 이 형태가 이미 익숙할 것입니다. Starlette의
`(scope, receive, send)`가 `(ctx, call_next)`가 되었고, 트랜스포트 **이후에**, 원시 HTTP 요청이
아니라 디코딩된 메시지를 대상으로 실행됩니다. 둘은 함께 조합됩니다. `streamable_http_app()`에
붙인 Starlette 미들웨어는 HTTP를 보고, 이 미들웨어는 MCP를 봅니다.
## 요약 {#recap}
* 미들웨어는 `async (ctx, call_next) -> result` 형태이며, `MCPServer(middleware=[...])`로
전달하거나(또는 `mcp.middleware`에 추가하거나) 저수준 `Server`에서는 `server.middleware`에
추가합니다.
* 들어오는 **모든** 메시지(`server/discover`, `initialize`, 요청, 알림, 알 수 없는 메서드)를
감싸며 바깥쪽부터 실행됩니다.
* `ctx.request_id is None`으로 알림과 요청을 구분합니다.
* `call_next`를 호출하는 대신 예외를 일으키면 메시지 하나를 거부합니다. 연결은 유지됩니다.
* SDK 자체의 OpenTelemetry 트레이싱도 미들웨어이며, 이미 목록에 있습니다.
**[OpenTelemetry](../run/opentelemetry.md)**를 참고하세요.
* 이 표면 전체가 잠정적입니다. 관찰하는 데 사용하고, 그 위에 무언가를 쌓지는 마세요.
요청을 감싸는 것은 이것이 전부입니다. 요청이 애초에 실행될 수 있는지를 결정하는 것은
**[인가](../run/authorization.md)**입니다.

View file

@ -0,0 +1,85 @@
---
translation:
sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9]
tool: 1
---
# 페이지네이션 {#pagination}
대부분의 서버에는 필요 없는 기능입니다.
`MCPServer`는 모든 `list_*` 요청에 가진 것을 전부 한 페이지에 담아 `next_cursor=None`으로 응답합니다. 도구, 리소스, 프롬프트가 수십 개 정도라면 이것이 올바른 동작이며, 설정할 것은 아무것도 없습니다.
페이지네이션은 리소스 목록이 사실상 데이터베이스인 서버를 위한 것입니다. 한 번의 응답으로 직렬화하기에는 무리인 수천 개의 행이 있는 경우입니다. 프로토콜의 해법은 **커서**입니다. 서버는 페이지 하나와 불투명한 토큰을 함께 반환하고, 클라이언트는 그 토큰을 다시 보내 다음 페이지를 받습니다.
`@mcp.resource()`에는 이를 위한 훅이 없습니다. 페이지를 나누려면 **[저수준 Server](low-level-server.md)**에서 목록 핸들러를 직접 작성해야 합니다.
## 페이지를 나누는 서버 {#a-server-that-pages}
```python title="server.py" hl_lines="12 15-16"
--8<-- "docs_src/pagination/tutorial001.py"
```
* 저수준 `Server`에서 핸들러는 데코레이터가 아니라 생성자 인자입니다. `on_list_resources`가 모든 `resources/list` 요청에 응답하며, 연결 작업은 이것이 전부입니다.
* 페이지를 나누는 핸들러는 모두 `params: PaginatedRequestParams | None` 타입을 받으며, 예제는 두 경우를 모두 처리합니다. 다만 실제 연결에서는 SDK가 `None`을 넘기는 일이 없습니다(`params` 멤버가 없는 요청은 기본값이 채워진 모델로 핸들러에 도달합니다). 따라서 의미 있는 신호는 `params.cursor is None`이며, 이는 **처음부터 시작하라**는 뜻입니다.
* 커서가 **무엇인지**는 직접 정합니다. 여기서는 문자열로 표현한 오프셋입니다. 타임스탬프, 기본 키, base64 덩어리 등 내보낼 때 만들어 낼 수 있고 돌아왔을 때 알아볼 수 있는 것이면 무엇이든 됩니다.
* `next_cursor=None`은 "이것이 마지막 페이지였다"고 알리는 방법입니다. 개수도, 총계도, `has_more`도 없습니다. `None`이 신호의 전부입니다.
!!! tip
`PAGE_SIZE`를 10으로 둔 것은 예제를 읽기 쉽게 하기 위해서입니다. 실제 값은 엔드포인트마다 정하세요.
한 줄짜리 리소스 목록이라면 500개 페이지도 감당할 수 있지만, 덩치 큰 프롬프트 템플릿 목록은 그럴 수 없습니다.
클라이언트는 여기에 관여할 수 없으며, 이는 의도된 설계입니다.
### 직접 해 보기 {#try-it}
`Client(server)`는 `MCPServer`에 연결할 때와 똑같이 저수준 `Server`에 인메모리로 연결합니다.
인자 없이 `list_resources()`를 호출하세요. `book-1`부터 `book-10`까지 리소스 10개가 돌아오고, `next_cursor`는 문자열 `"10"`입니다.
이를 `list_resources(cursor="10")`으로 다시 넘기면 첫 번째 리소스는 `book-11`이고, 새 `next_cursor`는 `"20"`입니다.
열 번째 페이지는 `next_cursor`가 `None`으로 설정되어 돌아옵니다. 끝입니다.
## 클라이언트 루프 {#the-client-loop}
`Client`의 모든 `list_*` 메서드(`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`)는 `cursor=` 키워드를 받습니다. 페이지로 나뉜 목록을 전부 가져오는 것은 `while True` 하나면 됩니다.
```python title="client.py" hl_lines="26-32"
--8<-- "docs_src/pagination/tutorial002.py"
```
* `cursor`는 `None`으로 시작하므로 첫 요청에는 커서가 실리지 않습니다.
* `next_cursor`를 확인하기 **전에** 결과를 덧붙이세요. 마지막 페이지에도 리소스가 있습니다.
* `next_cursor is None`이 종료 조건입니다. 그 외의 값은 손대지 않고 그대로 `cursor=`에 다시 넣습니다.
이 파일의 `main()`을 실행하면 `100 resources`가 출력됩니다. 열 개씩 열 페이지가, 페이지가 열 개라는 사실조차 모르는 루프에 의해 하나로 이어 붙여진 결과입니다.
이 루프는 **[클라이언트](../client/index.md)**에서 모든 `list_*` 동사에 대해 보여 주는 것과 같은 루프이며, 페이지를 나누지 않는 서버에 대해서도 비용이 들지 않습니다. 첫 응답에서 `next_cursor`가 `None`이므로 루프는 한 번만 돕니다.
## 세 가지 규칙 {#the-three-rules}
**커서는 불투명합니다.** 클라이언트는 커서를 파싱하거나, 만들거나, 추측해서는 안 됩니다. 커서를 얻는 유일하게 정당한 출처는 이전 페이지의 `next_cursor`를 그대로 쓰는 것입니다.
**페이지 크기는 서버가 정합니다.** 프로토콜에 `limit=`은 없습니다. 다른 페이지 크기가 필요하면 서버를 바꿔야 합니다.
**페이징을 무시하는 클라이언트도 여전히 동작합니다.** `list_resources()`를 한 번 호출하고, 처음 열 개를 받고, 버린 `next_cursor`는 알아채지 못합니다. 아무것도 깨지지 않습니다. 덜 보일 뿐입니다.
!!! check
불투명하다는 것은 말 그대로 불투명하다는 뜻입니다. 커서를 지어내면(`list_resources(cursor="page-2")`)
프로토콜이 해 줄 수 있는 것은 아무것도 없습니다. 이 서버는 `int("page-2")`를 시도하고, 핸들러는 예외를
일으키며, 클라이언트에게 돌아오는 것은 다음과 같습니다.
```text
MCPError(-32603, 'Internal server error', None)
```
서버에서 받지 않은 커서는 기능 요청이 아니라 버그입니다.
## 요약 {#recap}
* `MCPServer`는 모든 것을 한 페이지로 반환합니다. 페이지네이션은 선택 사항이며, 저수준 `Server`에서 선택합니다.
* `on_list_resources`(그리고 `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`)는 `PaginatedRequestParams | None`을 받으며, 첫 페이지에서는 `params.cursor`가 `None`입니다.
* 페이지와 함께 `next_cursor`를 반환합니다. 나중에 알아볼 수 있는 문자열이면 무엇이든 되고, 남은 것이 없으면 `None`입니다.
* 클라이언트 루프는 `cursor=`를 전달하고, 누적하고, `next_cursor is None`이 될 때까지 반복합니다.
* 커서는 불투명하고, 페이지 크기는 서버가 정하며, 페이징을 하지 않는 클라이언트도 첫 페이지는 받습니다.
직접 작성하는 `Server` API의 나머지(`on_call_tool`, `input_schema` 딕셔너리, `_meta`)는 **[저수준 Server](low-level-server.md)**에서 확인하세요.

View file

@ -0,0 +1,122 @@
---
translation:
sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800]
tool: 1
---
# 캐싱 힌트 {#caching-hints}
2026-07-28 프로토콜에서는 서버가 `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`에 대해 반환하는 모든 결과에 두 필드가 실립니다. 클라이언트가 결과를 신선한 것으로 취급해도 되는 밀리초 수인 `ttlMs`와, 캐시된 결과를 사용자 간에 공유해도 되는지(`"public"`) 아니면 하나의 인가 컨텍스트에 속하는지(`"private"`)를 나타내는 `cacheScope`입니다.
서버는 아무것도 캐시하지 않습니다. 이 필드는 **선언**입니다. "이 도구 목록은 모두에게 동일하며 1분 동안 바뀌지 않습니다"라는 뜻입니다. 그러면 클라이언트(또는 서버 앞단의 게이트웨이)가 왕복을 생략할 수 있습니다. 힌트를 따를지는 클라이언트의 선택이고, 힌트를 내보내는 것은 서버의 일이며, SDK가 이를 대신 처리합니다.
기본적으로 모든 결과는 `ttlMs: 0, cacheScope: "private"`라고 말합니다. 즉시 만료되고 절대 공유되지 않는다는 뜻입니다. 이는 언제나 안전하고 언제나 규격에 맞습니다. 목록이 정말로 안정적이고 모든 호출자에게 동일하다면 생성 시점에 그렇게 알려 주세요.
```python title="server.py" hl_lines="5-8"
--8<-- "docs_src/caching/tutorial001.py"
```
* 맵의 키는 **메서드 이름**이며, 캐시 가능한 여섯 메서드만이 유효한 키입니다. 매개변수 타입이 `Mapping[CacheableMethod, CacheHint]`이므로 에디터가 키를 자동 완성하고 실행 전에 오타를 표시합니다. 타입 검사기를 빠져나간 것은 생성 시점에 예외를 일으킵니다.
* 언급하지 않은 메서드는 기본값을 유지합니다. 맵은 재정의 모음이지 전체 명세가 아닙니다.
* `CacheHint(ttl_ms=5_000)`은 `scope`를 설정하지 않았으므로 `"private"`로 남습니다. 호출자별로 5초 동안 신선합니다. 범위와 TTL은 서로 독립적인 결정입니다.
* `"server/discover"`도 유효한 키입니다. 디스커버리 결과도 다른 목록처럼 캐시할 수 있기 때문입니다.
!!! warning
`cacheScope: "public"`은 캐시된 응답이 **누구에게나** 제공될 수 있다는 뜻입니다. 공유
게이트웨이는 요청이 인증된 경우에도 한 사용자의 결과를 다른 사용자에게 기꺼이 건네줍니다.
결과가 모든 호출자에게 동일할 때만 `"public"`으로 표시하고, `cacheScope`를 접근 제어로
쓰지 마세요. 이것은 라벨이지 자물쇠가 아닙니다.
## 핸들러별 재정의 {#per-handler-override}
저수준 `Server`에서는 핸들러가 결과를 직접 조립하며, `ttl_ms` / `cache_scope`는 결과 모델의 필드일 뿐입니다. 이 필드를 명시적으로 설정한 핸들러는 필드 단위로 언제나 생성자 맵보다 우선합니다.
```python title="server.py" hl_lines="10 16"
--8<-- "docs_src/caching/tutorial002.py"
```
핸들러는 `ttl_ms=1_000`이라고 했고 범위는 언급하지 않았습니다. 실제로 전송되는 값은 `ttlMs: 1000`(맵의 `60_000`이 아니라 핸들러의 값)과 `cacheScope: "public"`(핸들러가 설정하지 않았으므로 맵의 값)입니다. 명시적 설정이 구성값을 이기고, 구성값이 기본값을 이깁니다. 이 규칙은 필드별로 적용되므로 핸들러는 한 필드만 고정하고 다른 필드는 서버 전역 정책에 맡길 수 있습니다.
이는 생성자가 알 수 없는 동적인 상황을 위한 탈출구이기도 합니다. `resources/read`를 사용자별로 필터링하는 핸들러는 그 밖에는 public인 서버에서 특정 URI 하나만 `cache_scope="private"`로 반환할 수 있습니다.
페이지로 나뉜 목록에 관한 주의 사항이 하나 있습니다. 프로토콜은 한 목록의 **모든 페이지에서 같은 `cacheScope`**를 요구합니다. 생성자 맵은 페이지가 아니라 메서드를 키로 하므로 구조상 이를 만족합니다. 그러나 범위를 직접 재정의하는 핸들러는 그 일관성을 스스로 책임집니다. 커서가 있을 때만이 아니라 **모든** 페이지에서 재정의하세요. 그러지 않으면 1페이지와 2페이지가 서로 어긋납니다.
## 클라이언트가 보는 것 {#what-the-client-sees}
2026-07-28 세션에서는 `Client`가 힌트를 대신 따릅니다. 기본으로 켜져 있는 내장 응답 캐시가 있기 때문입니다. `ttlMs`를 싣고 도착한 결과는 저장되고, 그 TTL 안에 동일한 호출이 오면 왕복 없이 캐시에서 제공됩니다. 힌트가 **없는** 결과는 캐시되지 않습니다. 힌트 없는 결과에는 `CacheConfig.default_ttl_ms`가 적용되는데 기본값이 `0`(즉시 만료)이므로, 아무것도 선언하지 않는 서버는 늘 그랬듯 호출마다 요청이 오는 트래픽을 그대로 보게 됩니다.
```python title="client.py" hl_lines="33 35 38"
--8<-- "docs_src/caching/tutorial003.py"
```
호출은 네 번, 서버에서 가져온 것은 세 번입니다. 두 번째 호출은 신선한 항목을 찾았고 서버에 도달하지 않았습니다. (주입된) 시계를 TTL 너머로 진행시키자 세 번째 호출은 다시 가져왔고, 네 번째 호출은 `cache_mode="refresh"`를 지정했습니다. 이 키워드 인자는 캐싱 동사 다섯 개(`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`)에 있습니다.
* `"use"`(기본값)는 신선한 항목이 있으면 해당 항목을 제공하고, 없으면 가져와서 저장합니다.
* `"refresh"`는 캐시에서 제공하는 일이 없습니다. 가져와서 결과를 저장하며, 캐시된 내용이 무엇이든 대체합니다.
* `"bypass"`는 캐시를 전혀 건드리지 않고 왕복합니다. 읽기도 쓰기도 없습니다.
`"use"` 위에 규칙이 하나 더 있습니다. **`meta`를 담은 호출은 언제나 서버에 도달합니다.** `meta`가 설정된 요청(진행률 토큰, 추적 필드)은 실제로 전송되는 요청을 기대하므로, `cache_mode="use"`에서는 `"refresh"`로 취급됩니다. 캐시 읽기는 건너뛰고, 가져온 결과는 여전히 캐시된 항목을 대체합니다. `"bypass"`와 명시적 `"refresh"`는 평소대로 동작합니다.
캐싱을 완전히 끄려면 `Client(server, cache=None)`으로 생성하세요. 모든 호출이 다시 왕복이 되며, `cache_mode`는 여전히 받아들여지지만 아무 일도 하지 않습니다.
범위도 자동으로 존중됩니다. `"private"` 항목은 캐시의 **파티션**(아래 참고)을 키로 하고, `"public"` 항목은 더 넓은 공유를 선택할 수 있습니다. 그리고 알림이 지목하는 바로 그 항목에 대해서는 **알림이 TTL보다 우선합니다**. `list_changed` 알림은 일치하는 캐시된 목록을 축출하고, `resources/updated`는 정확히 그 URI로 저장된 캐시된 읽기 결과를 축출합니다. 얼마나 신선했든 상관없습니다. 2026-07-28 연결에서 이 알림은 `client.listen(...)`으로 여는 `subscriptions/listen` 스트림으로 도착하며, 축출은 감시자가 이벤트를 보기 전에 완료됩니다. 자세한 내용은 **[구독](subscriptions.md)**에서 확인하세요.
`resources/updated`에 관한 주의 사항이 하나 있습니다. 축출은 정확히 일치하는 URI에만 적용됩니다. 스토어 계약에는 열거나 스캔하는 연산이 없으므로(참조 TypeScript 구현과 동일) **하위** 리소스 URI를 담은 알림은 부모의 캐시된 읽기 결과를 축출하지 않습니다. 서버가 하위 리소스를 이런 식으로 알린다면 `cache_mode="refresh"`로 부모를 다시 가져오세요.
### `CacheConfig`로 설정하기 {#configuring-it-cacheconfig}
```python
from mcp.client import CacheConfig
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
```
* `store`: 항목이 저장되는 곳입니다. 기본값은 클라이언트마다 새로 만드는 인메모리 스토어입니다. 클라이언트나 프로세스 간에 캐시를 공유하려면 직접 만든 `ResponseCacheStore` 구현(예: Redis 기반)을 전달하세요. 계약 타입(`ResponseCacheStore`, `CacheKey`, `CacheEntry`, 기본 `InMemoryResponseCacheStore`)은 `mcp.client`에서 가져올 수 있습니다. 조회 한 번에 스토어 `get`을 순차적으로 최대 두 번(private 쪽, 그다음 public 쪽) 호출할 수 있으므로 원격 스토어의 지연 기대치를 그에 맞게 잡으세요. 사용자 정의 스토어에는 명시적인 `partition`이 **필수**입니다.
* `partition`: 공유 스토어 안에서 한 주체의 `"private"` 항목이 다른 주체에게 제공되지 않도록 하는 인가 컨텍스트 라벨입니다.
* `target_id`: 명시적인 서버 식별자로, 사용자 정의 트랜스포트와 인프로세스 서버용입니다(아래 참고).
* `default_ttl_ms`: `ttlMs` 힌트가 없는 결과에 적용되는 TTL입니다. 기본값 `0`은 힌트 없는 결과를 캐시하지 않습니다.
* `share_public`: 서버가 `"public"`이라고 단언한 항목을 파티션 간에 제공합니다(아래 참고). 기본으로 꺼져 있습니다.
* `clock`: 에포크 초 단위의 벽시계 소스입니다. 위 예제처럼 하나를 주입하면 만료 테스트에 sleep이 필요 없습니다.
!!! warning "파티션 = 검증된 주체"
`partition`은 검증된 토큰의 subject 같은 **검증된 자격 증명**에서 도출하세요. 요청이 제공한 데이터에서 도출하지 말고, 서버 URL에서도 도출하지 마세요(서버 식별자는 별도의 키 축입니다). SDK는 자체 인증이 없는 라이브러리입니다. 신뢰의 기준점은 `CacheConfig`를 생성하는 쪽, 즉 테넌트가 아니라 배포입니다. 멀티테넌트 게이트웨이는 인증된 주체마다 `CacheConfig`를 하나씩 만듭니다.
파티션은 `Client`의 수명 동안 고정되기도 합니다. 연결의 인가 컨텍스트가 세션 도중 바뀌면(예를 들어 다른 주체로 재인증하는 경우) 캐시는 따라가지 않습니다. 새 주체용으로 새 `Client`를 생성하세요.
캐시 키에는 **서버의 식별자**도 담깁니다. 연결한 URL 문자열에서 `user:pass@` 형태의 userinfo만 제거하고 나머지는 바이트 그대로입니다. 대소문자 접기도, 쿼리 재정렬도, 끝 슬래시 정리도 없습니다. 정규화를 덜 하면 공유 기회를 잃을 뿐이지만, 지나치게 정규화하면 두 테넌트(`?tenant=a`와 `?tenant=b`)를 합쳐 버릴 수 있으므로, 겉보기에 다른 URL은 그냥 항목을 공유하지 않습니다. URL이 없을 때(인프로세스 서버나 `Transport` 인스턴스)는 클라이언트가 대신 인스턴스별 무작위 식별자를 받습니다. 서버에 이름을 붙이려면 `CacheConfig.target_id`를 설정하세요(사용자 정의 스토어에서는 필수이며 생성 시점에 그렇게 알려 줍니다). 식별자는 키 재료에 들어가기 전에 sha256으로 해시되므로 쿼리 문자열에 비밀을 담은 URL이 스토어 키에 나타나는 일은 없습니다. 해시 전 형태를 직접 로그로 남기지도 마세요.
!!! warning "`share_public`은 서버를 플릿 전체 단위로 신뢰합니다"
기본적으로 `"public"` 항목조차 자기 파티션 안에 머뭅니다. `share_public=True`는 서버가 `cacheScope: "public"`으로 표시한 항목을 스토어를 사용하는 **모든** 파티션에 제공하며, 그 모두를 대신해 서버의 분류를 신뢰합니다. 그러면 테넌트별 데이터에 `"public"`을 찍는 서버(버그든 악의든)는 한 테넌트의 응답을 다른 테넌트에게 유출합니다. 이 플래그는 의도적으로 생성자 수준에만 있습니다. 호출별 `cache_mode`는 캐싱을 좁힐 수 있지만, 호출별 설정 어느 것도 공유를 넓힐 수는 없습니다.
### 캐시가 하지 않는 일 {#what-the-cache-never-does}
* **세션 계층 호출은 캐시를 우회합니다.** `client.session.list_tools()`와 같은 메서드는 언제나 왕복합니다. 캐시는 `Client` 동사에 있습니다.
* **`server/discover`는 캐시에 들어가지 않습니다.** 디스커버 결과는 연결 시 한 번 전달되며, `ttlMs`를 담고 있어도 응답 캐시에 들어가지 않습니다. 재연결 탐지를 건너뛰려고 직접 보관한다면([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)) 그 신선도 관리는 직접 해야 합니다. `DiscoverResult`는 바로 그 용도로 이미 파싱된 `ttl_ms`와 `cache_scope`를 담고 있습니다.
* **이어지는 페이지는 캐시되지 않습니다.** 커서 없는 호출만 참여합니다. 만료된 커서 때문에 거부된 이어지는 페이지는 캐시된 목록을 **축출**합니다. 그 아래에서 목록이 바뀌었기 때문입니다.
* **다중 왕복 읽기는 캐시되지 않습니다.** `input_responses`/`request_state`로 시작했거나 입력 라운드를 거쳐 해결되는 `read_resource`는 캐시에 들어가지 않습니다(명세의 MUST).
* **알림 기반 축출에는 알림이 필요합니다.** 축출은 트랜스포트의 전달 품질만큼만 동작하며, 현대적인 인프로세스 경로(기본 `mode="auto"`의 `Client(server)`)는 현재 단독 알림을 전달하지 않습니다.
* **축출은 즉각적이 아니라 결과적으로 일어납니다.** 전송 경로 알림은 생성된 태스크에서 디스패치되므로, 알림 도착과 경합하는 호출은 축출 전 항목을 한 번 더 받을 수 있습니다. 그 구간은 디스패치 지연으로 제한되며 축출은 결국 적용됩니다.
* **stale-if-error는 없습니다.** 다시 가져오기가 실패했다고 해서 만료된 항목이 제공되는 일은 없습니다. 오류가 전파됩니다.
* **조기 재요청은 없습니다.** 저장된 항목은 TTL이 만료될 때까지 제공되고, 그다음 첫 호출이 왕복 비용을 냅니다. 백그라운드에서 갱신되는 것은 없습니다.
* **병합은 없습니다.** 동시에 일어난 동일한 호출 두 개는 두 번 가져옵니다.
* **24시간을 넘는 TTL은 없습니다.** 더 큰 `ttlMs`는 서버가 보냈든 설정했든 저장 시점에 잘립니다(`mcp.client.caching.MAX_TTL_MS`). 힌트가 아무리 넉넉해도 어떤 항목이든 제공될 수 있는 기간에 상한을 둡니다.
* **공유 스토어**에서는 클라이언트끼리 경합합니다. 각 클라이언트는 진행 중인 가져오기를 축출이 추월했을 때 자기 쓰기를 버리지만, **공동 테넌트** 클라이언트는 자신이 보지 못한 축출이 제거한 항목을 여전히 다시 써넣을 수 있습니다. 그리고 그 경합 관리 자체에도 한계가 있습니다. 추적 키가 4096개를 넘으면 가장 오래된 키의 가드부터 버려집니다. 두 구간 모두 허용된 것이며, 위의 TTL 상한으로 닫힙니다.
* **프로토콜 세대를 넘어 제공하지 않습니다.** 항목은 협상된 프로토콜 버전에 한정됩니다. 공유 영속 스토어에서 세션은 다른 협상 버전으로 기록된 항목을 제공하지 않습니다(SDK가 구버전 세션용으로 2026 필드를 제거하므로 같은 목록이라도 세대별로 실제로 다릅니다). 축출도 마찬가지로 현재 세대의 항목만 건드리며, 다른 세대의 항목은 TTL로 자연히 만료됩니다.
### 힌트를 직접 읽기 {#reading-the-hints-yourself}
힌트는 캐시 가능한 모든 결과의 평범한 필드이기도 하므로(`result.ttl_ms`와 `result.cache_scope`, 이미 파싱됨), 내장 캐시 위에(또는 대신에) 자체 관리 로직을 얹고 싶을 때 쓸 수 있습니다.
**구버전 서버**(2026 이전 프로토콜)를 상대하면 이 필드는 전송되는 메시지에 아예 없고, 모델은 보수적인 기본값을 보여 줍니다. `ttl_ms == 0`과 `cache_scope == "private"`, 즉 만료 상태이며 공유되지 않음으로, 아무것도 선언하지 않은 서버에 대한 올바른 가정입니다. 캐시는 레거시 세션도 같은 방식으로 다룹니다. 거기서는 힌트를 전혀 참고하지 않고(전송 메시지에 어떤 키가 나타나든) `default_ttl_ms`만 적용되며, 그 기본값 `0`은 아무것도 캐시하지 않으므로 2026 이전 연결은 캐시가 존재하기 전과 정확히 똑같이 동작합니다. "서버가 0이라고 했다"와 "서버가 아무 말도 안 했다"를 구별해야 한다면 `"ttl_ms" in result.model_fields_set`을 확인하세요. 필드가 실제로 도착했을 때만 설정됩니다.
## 구버전 클라이언트 {#older-clients}
2026 이전 프로토콜 버전의 클라이언트는 두 필드 모두 보지 못합니다. SDK가 해당 연결에서는 직렬화 시점에 이 필드를 제거합니다. 힌트는 한 번만 설정하세요. 버전별로 따로 작성할 것은 없습니다.
## 요약 {#recap}
* 여섯 메서드가 `ttlMs`/`cacheScope`를 담습니다. SDK는 기본값을 `0`/`"private"`, 즉 만료 상태이고 공유되지 않음으로 두며, 이는 언제나 안전합니다.
* 생성 시점의 `cache_hints={method: CacheHint(...)}`(`MCPServer`와 `Server` 모두)는 메서드별로 서버 전역 값을 설정합니다.
* 결과에 필드를 설정한 핸들러는 필드 단위로 맵을 재정의합니다.
* `"public"`은 결과가 모든 호출자에게 동일하다는 약속입니다. 접근 제어가 아닙니다.
* `Client`는 힌트를 자동으로 따릅니다. 응답 캐시는 기본으로 켜져 있고, 다시 가져오는 대신 신선한 항목을 제공하며, 힌트를 제공하지 않는 서버(또는 세션)에는 아무것도 캐시하지 않습니다.
* 호출별로 `cache_mode="refresh"`는 다시 가져오고 `"bypass"`는 캐시를 건너뜁니다. 생성 시점의 `cache=None`은 캐시를 완전히 끕니다.

View file

@ -0,0 +1,154 @@
---
translation:
sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0]
tool: 1
---
# 클라이언트 콜백 {#client-callbacks}
MCP에서 거의 모든 요청은 한 방향, 즉 클라이언트에서 서버로 갑니다.
서버도 **클라이언트**에 무언가를 요청할 수 있습니다. 사용자에게 질문을 하거나, 사용자의 모델을 샘플링하거나, 사용자의 작업 공간 폴더 목록을 달라고 하는 식입니다. 이런 요청에는 `Client(...)`에 **콜백**을 전달해 응답합니다.
## 요청하는 서버 {#a-server-that-asks}
다음은 도구가 혼자서는 완료할 수 없는 서버입니다.
```python title="server.py" hl_lines="16"
--8<-- "docs_src/client_callbacks/tutorial001.py"
```
* `ctx.elicit(...)` 호출은 `elicitation/create` 요청을 **클라이언트로** 보내고 기다립니다.
* 누군가(폼 앞의 사람이든, 작성한 코드든)가 `name`을 제공하기 전까지 도구는 반환하지 않습니다.
여기까지가 서버 쪽 절반이며, 이 부분은 **[엘리시테이션(elicitation)](../handlers/elicitation.md)** 페이지에서 다룹니다. 이 페이지는 연결의 반대쪽 끝을 다룹니다.
## 엘리시테이션 콜백 {#the-elicitation-callback}
```python title="client.py" hl_lines="6-10 16-17"
--8<-- "docs_src/client_callbacks/tutorial002.py"
```
* 엘리시테이션 콜백은 `async (context, params) -> ElicitResult` 형태입니다.
* `params.message`는 질문입니다. `params.requested_schema`는 서버가 원하는 답의 JSON Schema입니다. 실제 클라이언트는 이것으로 폼을 그리지만, 이 예제는 자동으로 채웁니다.
* `ElicitResult(action="accept", content={...})`를 반환하거나, `action="decline"` 또는 `action="cancel"`을 반환합니다. 그 외 유일한 선택지는 `ErrorData(...)`로, 요청을 거부하고 호출 전체를 실패시킵니다.
* `context`는 `ClientRequestContext`입니다. 살아 있는 `session`, 서버의 `request_id`, 서버가 첨부한 `meta`가 들어 있습니다.
!!! tip
`params`는 두 가지 엘리시테이션 모드의 유니온입니다. 여기서 `params.mode`는 `"form"`이며, `"url"` 요청은
스키마 대신 `params.url`을 담고 있습니다. 콜백 하나로 둘 다 처리하며, `params.mode`로 분기하세요.
전체 패턴은 **[엘리시테이션](../handlers/elicitation.md)**에서 확인하세요.
### 직접 해 보기 {#try-it}
`issue_card`를 호출하고 양쪽 끝을 지켜보세요.
콜백은 이미 파싱된 서버의 질문을 받습니다.
```python
params.mode # 'form'
params.message # 'What name should go on the card?'
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
```
콜백이 응답하면 도구 안에서 `ctx.elicit(...)` 호출이 다시 진행되고, 도구가 완료됩니다.
```python
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
```
클라이언트가 보낸 `tools/call` 하나, 서버가 되돌려 보낸 `elicitation/create` 하나, 그리고 그에 대한 함수의 응답까지, 모두 단일 도구 호출 안에서 일어납니다.
!!! info
`Client(...)` 호출의 `mode="legacy"`는 실제로 중요한 역할을 합니다. 기본적으로 `Client(...)`는 최신
프로토콜 경로를 협상하는데, 그 경로에는 서버에서 클라이언트로 가는 요청을 위한 역방향 채널이 없어서
콜백이 실행되기도 전에 `ctx.elicit` 호출이 실패합니다. 이를 결정하는 것은 트랜스포트가 아니라 협상된
프로토콜이며, 인메모리든 URL을 통하든 마찬가지입니다. 클라이언트가 이런 요청에 응답해야 할 때마다
`mode="legacy"`로 고정하세요. 이 페이지를 뒷받침하는 모든 테스트가 그렇게 합니다. 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 확인하세요.
2026-07-28 세션에서도 콜백이 쓸모없어지는 것은 아니며, 입력을 받는 방식이 다를 뿐입니다. 도구가
`ElicitRequest`를 담은 `InputRequiredResult`를 반환하면 `Client`는 그 항목을 같은
`elicitation_callback`으로 전달하고 호출을 대신 재시도합니다. 이 흐름은 **[다중 왕복 요청](../handlers/multi-round-trip.md)**에서 다룹니다.
## 콜백이 곧 기능 {#a-callback-is-a-capability}
클라이언트가 엘리시테이션 요청에 응답할 수 있다고 서버에 알린 적은 없습니다. SDK가 대신 알렸습니다.
클라이언트는 연결할 때 자신의 `capabilities`를 선언하며, 이는 서버 쪽 선언과 거울처럼 대응됩니다. 이 객체를 직접 작성하지는 않습니다. **콜백을 등록하는 것이 곧 선언입니다.**
| 전달하는 것 | 클라이언트가 선언하는 것 |
| --- | --- |
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
| `sampling_callback=` | `"sampling": {}` |
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
| 아무것도 전달하지 않음 | `{}` |
샘플링 하위 기능이 유일하게 더 세밀한 부분입니다. 샘플러가 `tools` / `tool_choice` 매개변수를 처리한다면 `sampling_callback`과 함께 `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())`를 전달하세요. 서버는 `sampling.tools`가 선언된 것을 확인해야만 이 매개변수를 보낼 수 있습니다.
`logging_callback`과 `message_handler`는 표에 없습니다. 이 둘은 알림을 처리하며, 알림에는 기능 선언이 필요 없습니다.
서버는 `ctx.session.check_client_capability(...)`로 이 선언을 읽어 옵니다. 그렇게 하는 도구를 추가하세요.
```python title="server.py" hl_lines="23-31"
--8<-- "docs_src/client_callbacks/tutorial003.py"
```
`elicitation_callback`만 전달해 연결하고 호출하세요.
```python
result.structured_content # {'result': ['elicitation']}
```
콜백 세 개를 모두 전달하면 `['elicitation', 'sampling', 'roots']`를 받습니다. 아무것도 전달하지 않으면 `[]`를 받습니다.
!!! check
이번에는 잘못된 방식으로 해 보세요. `elicitation_callback` **없이** 연결하고 그래도 `issue_card`를 호출하세요.
서버의 `elicitation/create` 요청은 여전히 클라이언트에 도달하며, 처리할 수 있다고 알린 적이 없으므로
SDK가 대신 오류로 응답합니다. 그 오류가 호출 전체를 무너뜨립니다.
`call_tool`은 `is_error` 결과를 반환하지 않고 예외를 던집니다.
```text
MCPError: Elicitation not supported
```
이는 도구 오류가 아니라 프로토콜 오류(`-32600`, *invalid request*)입니다. 모델이 읽고 재시도할 것이
아무것도 없습니다. 바로 이 때문에 `client_features`를 둘 가치가 있습니다. 제대로 동작하는 서버는
요청하기 전에 확인합니다.
## 지원 중단 예정(deprecated)인 두 콜백 {#the-deprecated-pair}
`sampling_callback`은 `sampling/createMessage`에 응답합니다. 서버가 **클라이언트 쪽** 모델에 무언가를 완성해 달라고 요청하는 것입니다. `list_roots_callback`은 `roots/list`에 응답합니다. 서버가 어느 디렉터리에서 작업해도 되는지 묻는 것입니다.
둘 다 동작합니다. 둘 다 위의 규칙을 따릅니다. 그리고 둘 다 **2026-07-28 사양에서 제거되는** RPC를 처리합니다. 최신 서버는 요청 도중에 클라이언트를 역으로 호출하지 않고, 요청을 도구 결과의 일부로 되돌려 줍니다(**[다중 왕복 요청](../handlers/multi-round-trip.md)**). 콜백 자체가 쓸모없어지는 것은 아닙니다. `InputRequiredResult`가 `CreateMessageRequest`나 `ListRootsRequest`를 담고 있으면 `Client`의 자동 루프가 여기서 등록한 바로 그 `sampling_callback` 또는 `list_roots_callback`으로 전달합니다. 전체 목록은 **[지원 중단 예정 기능](../deprecated.md)**에서 확인하세요.
아직 옮겨 가지 않은 서버와 통신하려면 여전히 이 콜백이 필요합니다. 시그니처는 다음과 같습니다.
```python title="client.py"
--8<-- "docs_src/client_callbacks/tutorial004.py"
```
* 샘플링 콜백은 전체 `CreateMessageRequestParams`(`messages`, `model_preferences`, `max_tokens`)를 받고 `CreateMessageResult`를 반환합니다. 모델을 실행하는 것은 **클라이언트 쪽**이며 방식은 자유입니다. SDK는 요청을 전달할 뿐입니다.
* 루트 콜백은 params를 전혀 받지 않고 `ListRootsResult`를 반환합니다.
* 둘 다 거부하려면 대신 `ErrorData(...)`를 반환할 수 있습니다.
`elicitation_callback`과 똑같이 `Client(...)`에 전달하세요.
## 알림 콜백 {#the-notification-callbacks}
두 개가 더 있습니다. 둘 다 아무것도 선언하지 않습니다.
`logging_callback`은 서버가 보내는 `notifications/message`를 `LoggingMessageNotificationParams`(`level`, `logger`, `data`)로 받습니다. 프로토콜 로깅 자체가 2026-07-28 사양에서 지원 중단 예정이므로(대신 무엇을 해야 하는지는 **[로깅](../handlers/logging.md)**에서 다룹니다), 이 콜백은 여전히 로그를 내보내는 서버를 위해 존재합니다. 2026년 세대 연결에서는 콜백만으로는 아무것도 받지 못합니다. 2026 서버는 옵트인한 요청에만 로그 메시지를 보내기 때문입니다. `Client(...)`에 `log_level="info"`(또는 다른 레벨)를 전달하면 모든 요청에 이 옵트인이 찍혀 해당 레벨 이상을 받습니다. 2026 이전 서버는 이를 무시하고 기존 `logging/setLevel` 동작을 유지합니다.
`message_handler`는 모든 것을 받는 콜백입니다. 세션이 드러내는 모든 서버 알림이 (각각의 전용 콜백과 더불어) 여기에 도달하며, 스트림 기반 트랜스포트에서는 트랜스포트 수준의 모든 `Exception`도 마찬가지입니다. 절대 도달하지 않는 것이 두 가지 있습니다. `notifications/cancelled`는 드러나는 대신 SDK가 직접 적용하고, 살아 있는 `listen()` 스트림에 대한 구독 확인 응답은 그 스트림이 소비합니다. 매개변수에는 `IncomingMessage`(`ServerNotification | Exception`, `mcp.client`에서 내보냄)로 타입을 표기하세요. 알아 둘 만한 패턴은 `if isinstance(message, Exception): raise message` 하나로, 끊어진 연결이 조용히 사라지는 대신 확실하게 실패하도록 합니다.
## 요약 {#recap}
* 서버는 클라이언트에 요청을 보낼 수 있습니다. `Client(...)`에 전달한 콜백으로 응답합니다.
* 현재 기준의 콜백은 엘리시테이션 콜백입니다. `async (context, params) -> ElicitResult` 형태이며, 폼 모드와 URL 모드 모두 함수 하나로 처리합니다.
* **콜백을 등록하는 것이 곧 기능을 선언하는 것입니다.** 콜백이 없으면 SDK가 대신 서버의 요청을 거부하고 호출 전체가 `MCPError`로 실패합니다.
* 서버는 `ctx.session.check_client_capability(...)`로 요청하기 전에 미리 확인합니다.
* `sampling_callback`과 `list_roots_callback`도 같은 방식으로 동작하지만 지원 중단 예정 기능을 처리합니다. 최신 서버는 대신 다중 왕복 요청을 사용합니다.
* `logging_callback`과 `message_handler`는 알림을 받습니다. 아무것도 선언하지 않습니다.
`Client(...)`의 첫 번째 인자는 트랜스포트 객체입니다. 모든 종류는 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다.

View file

@ -0,0 +1,152 @@
---
translation:
sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0]
tool: 1
---
# ID 어설션 {#identity-assertion}
일반적인 OAuth 공급자(**[OAuth 클라이언트](oauth-clients.md)**)는 먼저 MCP 서버에 **어느 인가 서버를 신뢰하는지** 묻는 것으로 시작합니다. 그 답이 가리키는 곳이면 어디든 따라가고, 그런 다음 사람이 로그인하거나 사전 공유된 시크릿이 사람을 대신합니다.
기업은 이 둘 중 어느 것도 서버마다 따로 결정되기를 원하지 않습니다. 기업은 이미 ID 공급자(Okta, Microsoft Entra ID, 또는 자체 운영하는 것)를 운영하고 있고, 사용자는 오늘 아침에 이미 거기에 로그인했으며, 보안 팀이 누가 무엇에 접근할 수 있는지를 결정하고 싶어 하는 유일한 곳이 바로 그곳입니다. **Enterprise-Managed Authorization** 확장인 [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)은 그 결정을 그곳으로 옮깁니다. IdP는 수명이 짧은 JWT, 즉 **Identity Assertion JWT Authorization Grant**, 줄여서 **ID-JAG**에 서명합니다. **이 사용자**가 **이 클라이언트**를 통해 **이 MCP 서버**에 접근해도 된다는 진술입니다. 클라이언트는 이를 일반적인 액세스 토큰으로 교환합니다. 브라우저도, 동의 화면도, 동적 등록도 없습니다.
이 페이지는 그 교환의 양쪽 끝을 모두 다룹니다. MCP 서버 자체는 전혀 바뀌지 않습니다. 여전히 **[인가](../run/authorization.md)**에서 본 리소스 서버이며, 들어오는 토큰이 무엇이든 검사할 뿐입니다.
## 두 번의 토큰 요청 {#two-token-requests}
여기에는 서로 다른 두 권한 주체가 등장하며, 이 둘을 구분해서 부르는 것이 이 페이지를 이해하는 일의 대부분입니다. **엔터프라이즈 IdP**는 조직의 ID 공급자입니다. 직원이 누구인지 알고, 정책이 있는 곳이며, ID-JAG를 발급합니다. SDK는 이 IdP와 절대 통신하지 않습니다. **MCP 인가 서버**는 **[인가](../run/authorization.md)**에서와 같은 당사자입니다. MCP 서버의 메타데이터에 명시된 발급자이자, 그 MCP 서버가 받아들이는 토큰을 발급하는 주체입니다. 일반적인 OAuth 흐름에서는 이 두 역할이 보통 하나의 시스템입니다. 여기서는 둘로 나뉘며, 이 그랜트 전체는 결국 후자가 전자를 신뢰하기로 동의하는 것입니다.
클라이언트는 각각에 토큰 요청을 한 번씩 보냅니다.
1. **엔터프라이즈 IdP에 보내는 요청.** 클라이언트는 사용자의 로그인(OpenID Connect ID 토큰)을 ID-JAG로 교환합니다. 이것은 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 토큰 교환이고, 전적으로 IdP의 API이며, **SDK는 이 요청을 보내지 않습니다**. 하나의 async 콜백 안에서 직접 보냅니다. 정책 결정이 일어나는 곳도 여기입니다. IdP가 거부하면 ID-JAG는 발급되지 않고, 제시할 것도 없습니다.
2. **MCP 인가 서버에 보내는 요청.** 클라이언트는 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` 그랜트(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG를 `assertion`으로)로 ID-JAG를 제시하고 액세스 토큰을 받습니다. **이것이 SDK가 보내는 요청이며**, 이 요청을 받아들이는 것이 이 페이지가 인가 서버에 추가하는 단 하나의 기능입니다.
아래의 모든 내용은 두 번째 요청, 즉 이를 보내는 클라이언트와 이에 응답하는 인가 서버에 관한 것입니다.
## 클라이언트 {#the-client}
**`IdentityAssertionOAuthProvider`**는 `mcp.client.auth.extensions.identity_assertion`에 있습니다. **[OAuth 클라이언트](oauth-clients.md)**의 모든 공급자와 마찬가지로 `httpx2.Auth`입니다. 하나를 생성해 `auth=`에 넣고, `httpx2.AsyncClient`를 트랜스포트에 넘기면 됩니다.
```python title="client.py" hl_lines="49-50 53-61"
--8<-- "docs_src/identity_assertion/tutorial001.py"
```
아래에서부터 읽어 보세요.
* `main()`은 표준 OAuth 클라이언트의 `main()`(**[OAuth 클라이언트](oauth-clients.md)**)이며, 한 줄도 바뀌지 않았습니다. 그것이 핵심입니다. 공급자가 일단 존재하면, 그 뒤의 어떤 코드도 어떤 그랜트가 토큰을 만들어 냈는지 알지 못합니다.
* 공급자는 다른 공급자가 스스로 알아낼 수 없는 것을 받습니다. 누군가가 인가 서버에 **사전 등록**해 둔 `client_id`와 `client_secret`, 그 인가 서버의 `issuer`, 그리고 요청할 때마다 새 ID-JAG를 반환하는 async 콜백인 `assertion_provider`입니다.
* `storage`는 같은 `TokenStorage` 프로토콜입니다. 호출되는 것은 토큰 관련 메서드 두 개뿐입니다. 여기에는 동적 등록이 없으므로 기억해 둘 `client_info`도 없습니다.
### 어설션 공급자 {#the-assertion-provider}
`fetch_id_jag(audience, resource)`가 직접 작성하는 유일한 코드입니다. 토큰 교환마다 한 번씩 await되고, 생성 시점에는 절대 호출되지 않으며, 인가 서버의 메타데이터를 가져와 검증한 **뒤에만** 호출되므로 잘못 설정된 발급자로 어설션이 새어 나가는 일은 없습니다. 두 인수는 ID-JAG를 발급할 때 담아야 하는 클레임 중 두 가지입니다. `audience`는 인가 서버의 발급자(ID-JAG의 `aud`)이고 `resource`는 MCP 서버의 정식 식별자(ID-JAG의 `resource`)입니다. 세 번째는 이미 가지고 있는 값입니다. ID-JAG의 `client_id` 클레임은 공급자에 넘긴 `client_id`를 가리켜야 하며, 그렇지 않으면 인가 서버가 교환을 거부합니다.
그 위에 있는 `idp_issue_id_jag`는 **작성할 코드가 아닙니다**. ID 공급자를 대신하는 것으로, 파일이 그 자체로 완결되고 ID-JAG가 담는 모든 클레임을 읽어 볼 수 있도록 프로세스 안에서 어설션에 서명합니다. 실제 `fetch_id_jag`는 그 대신 앞 절의 첫 번째 토큰 요청을 보냅니다. IdP를 상대로 한 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 토큰 교환이며, [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)이 프로파일로 삼는 Identity Assertion JWT Authorization Grant 초안이 이를 정의합니다. 로그인한 사용자의 ID 토큰이 `subject_token`으로 들어가고, `requested_token_type`은 ID-JAG 고유의 URN(`urn:ietf:params:oauth:token-type:id-jag`)이며, `audience`와 `resource`는 그대로 전달되고, 응답에 ID-JAG가 실려 옵니다. IdP 문서에서 찾아봐야 할 것이 바로 이 이름들로 이루어지는 이 교환입니다.
!!! tip
교환할 때마다 새 ID-JAG를 요청하며, 그것이 의도된 설계입니다. ID-JAG는 수명이 몇 분에 불과한
일회용 그랜트이고, 이 페이지의 인가 서버는 같은 ID-JAG를 두 번 받아들이지 않습니다. 캐시하지
마세요. 재사용되는 것은 ID-JAG로 얻은 액세스 토큰입니다.
### 설정으로 지정하는 발급자 {#the-issuer-is-configuration}
여기서 관계가 뒤집힙니다. `OAuthClientProvider`는 어느 인가 서버를 쓸지 리소스 서버에 묻고 그 답이 가리키는 곳이면 어디든 따라갑니다. 이 공급자는 그렇게 하기를 거부합니다. `issuer`는 필수이고, [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) 메타데이터는 그 발급자 자신의 well-known 경로에서 가져오며, 토큰 엔드포인트는 그 발급자의 오리진에 있어야 하고, 리소스 서버에는 아무것도 묻지 않습니다.
확장이 이를 요구하는 것은 아닙니다. 의도적으로 더 엄격하게 선택한 것입니다. 이 클라이언트는 훔칠 가치가 있는 것을 두 가지 지니고 있습니다. 사전 등록된 시크릿과 audience에 묶인 어설션입니다. 침해된 MCP 서버가 공격자의 인가 서버로 유도하도록 내버려 두는 클라이언트라면 둘 다 그곳에 POST하게 됩니다. 생성 시점에 발급자를 고정하면 그런 대화 자체가 사라집니다.
!!! warning
설정한 `issuer`는 메타데이터 문서의 `issuer` 필드와 RFC 8414 §3.3의 단순 문자열 비교로
대조됩니다. 한 글자씩, 끝의 슬래시까지 포함해, 정규화 없이 비교합니다. 추측하지 마세요. 인가
서버에서 `/.well-known/oauth-authorization-server`를 가져와 반환된 `issuer` 값을 그대로
복사하세요. 이 페이지의 인가 서버라면 그 값은 슬래시가 붙은 `https://auth.example.com/`입니다.
발급자가 pydantic URL 객체로부터 만들어졌기 때문입니다. 일치하지 않으면 자격 증명이나 어설션을
단 하나도 보내기 전에 `OAuthFlowError: Authorization server metadata issuer
mismatch`에서 흐름이 멈춥니다.
### 기밀 클라이언트 {#a-confidential-client}
`client_secret`은 필수이며, 없으면 생성자가 `ValueError`를 발생시킵니다. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)의 기반이 되는 IETF 프로파일은 이 그랜트를 기밀 클라이언트 전용으로 두고, SEP-990은 클라이언트가 인증할 것을 요구하며, 이 SDK는 공유 시크릿을 반드시 요구함으로써 둘 다 강제합니다. `token_endpoint_auth_method`는 시크릿이 어디에 실려 가는지를 고릅니다. `client_secret_post`(기본값, 폼 본문에)나 `client_secret_basic`(HTTP Basic 헤더) 중 하나입니다. 프로파일은 `private_key_jwt`도 허용하지만, 이 공급자는 지원하지 않습니다.
!!! tip
`client_secret`은 환경 변수나 시크릿 관리자에서 읽어 오세요. 소스 관리에서 읽어서는 절대 안 됩니다.
### 공급자가 대신 처리하는 일 {#what-the-provider-does-for-you}
첫 번째 요청은 인증 없이 나가고, 서버의 `401`이 흐름을 시작합니다.
1. **탐색.** 설정된 발급자의 [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known 경로에서 인가 서버 메타데이터를 가져오고, 문서의 `issuer`가 일치하는지 확인하고, 토큰 엔드포인트가 발급자의 오리진에 있는지 확인합니다.
2. **어설션.** `assertion_provider`를 await합니다.
3. **교환.** 토큰 엔드포인트에 `jwt-bearer` 그랜트를 POST하고, `OAuthToken`을 저장한 뒤, 원래 요청을 `Authorization: Bearer ...`를 붙여 다시 보냅니다.
`WWW-Authenticate`에 `insufficient_scope`가 명시된 `403`을 받으면 설정한 `scope`와 챌린지로 요구된 범위의 합집합으로 2단계와 3단계를 다시 실행합니다. (`scope`는 어디까지나 요청일 뿐입니다. 이 페이지의 인가 서버는 ID-JAG에 적힌 것만 부여하고 그 외에는 아무것도 부여하지 않습니다.) 이 과정 어디에도 리프레시 토큰은 없습니다. 액세스 토큰이 만료되면 다음 `401`에서 새 ID-JAG를 발급받아 다시 교환하며, 바로 **그것이** IdP가 쥐고 있는 지렛대입니다. 실패는 **[OAuth 클라이언트](oauth-clients.md)**의 나머지와 같은 두 가지 예외로 나타납니다. 탐색과 검증에는 `OAuthFlowError`, 토큰 엔드포인트가 거부하면 그 하위 클래스인 `OAuthTokenError`입니다.
## 인가 서버 {#the-authorization-server}
대부분의 경우 여기서 멈추면 됩니다. MCP 인가 서버는 다른 누군가의 제품이고, ID-JAG를 받아들이는 것은 그 제품에서 켜야 할 설정이며, [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)에서 SDK가 맡는 절반은 위의 클라이언트입니다.
SDK가 직접 인가 서버가 **될** 수도 있습니다. `create_auth_routes`는 인가 서버의 라우트를 어떤 Starlette 앱이든 마운트할 수 있는 리스트로 반환하며, 저장소의 `examples/servers/simple-auth/`가 바로 이 방식으로 인가 서버를 실행합니다. SEP-990은 그 표면에 플래그 하나와 메서드 하나를 추가합니다.
```python title="auth_server.py" hl_lines="48-50 105-107"
--8<-- "docs_src/identity_assertion/tutorial002.py"
```
* `identity_assertion_enabled=True`가 모든 것의 관문입니다. 꺼져 있으면(기본값), 훅을 구현했더라도 `/token`은 이 그랜트에 `unsupported_grant_type`으로 응답하고 메타데이터에도 언급되지 않습니다. 켜면 메타데이터에 `jwt-bearer` 그랜트 유형이 추가되고, 확장이 지원을 알리는 데 쓰는 필드인 `authorization_grant_profiles_supported`에 `urn:ietf:params:oauth:grant-profile:id-jag`가 나열됩니다. (이 SDK의 클라이언트는 이 필드를 읽지 않습니다. 발급자 하나에 맞춰 프로비저닝되어 있으므로 그냥 요청할 뿐입니다.)
* **`exchange_identity_assertion`**이 훅입니다. 이 훅이 실행되기 전에 SDK는 이미 클라이언트를 인증하고, 공개 클라이언트를 거부하고, 등록 정보에 이 그랜트가 나열되지 않은 클라이언트를 거부한 상태입니다. `IdentityAssertionParams`(원시 `assertion`, 요청된 `scopes`와 `resource`)를 받아 평범한 `OAuthToken`을 반환합니다.
* 동적 클라이언트 등록은 이 그랜트를 무조건 거부하므로, 여기서 `get_client`는 수동으로 프로비저닝한 클라이언트를 내줍니다. ID-JAG 클라이언트는 스스로 등록해서 생겨날 수 없습니다.
* 클래스의 절반은 거부 코드입니다. `OAuthAuthorizationServerProvider`는 인가 서버 **전체**이므로 인가 코드 흐름도 요구합니다. 사용자 로그인까지 처리하는 서버라면 그 부분을 실제로 구현하지만, 이 서버에는 문이 정확히 하나뿐입니다.
!!! warning
SDK는 어설션을 절대 디코딩하지 않습니다. 어떤 IdP를 신뢰하고 그 IdP가 어떤 키를 공개하는지는
해당 배포 환경만 알기 때문이며, 따라서 `exchange_identity_assertion` 안의 모든 코드가 보안을
떠받칩니다. [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3에 따라 IdP가 공개한 키(JWKS, 여기서 쓰는 공유 시크릿은
데모용입니다)로 서명을 검증하고, `iss`와 `exp`도 검증하세요. JWT 헤더의 `typ`이
`oauth-id-jag+jwt`일 것을 요구하세요. 다른 JWT가 그랜트로 재사용되는 것을 막는 프로파일의
안전장치입니다. `aud`가 자기 자신의 발급자일 것을 요구하세요. ID-JAG의 `client_id` 클레임이
핸들러가 인증한 클라이언트와 같을 것을, 그리고 `resource` 클레임이 실제로 서비스하는 리소스를
가리킬 것을 요구하세요. 어설션이 한 번만 받아들여지도록 어설션의 `exp`까지 `jti`를 추적하세요.
그리고 부여하는 범위와, 무엇보다도 발급하는 토큰의 `resource`는 검증된 ID-JAG에서 가져오고
절대 요청에서 가져오지 마세요. `params.resource`는 클라이언트가 입력한 값일 뿐입니다. 전체
처리 규칙은 [Enterprise-Managed Authorization 사양](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)에
있습니다.
잘못된 어설션은 `TokenError("invalid_grant", ...)`로 거부하세요. 이 흐름의 다른 오류 코드는 `invalid_target`입니다. 서비스하지 않는 리소스를 가리키는 ID-JAG는 이 코드로 거부되며, 이것이 이 서버가 다른 누군가의 리소스용 토큰을 발급하지 못하게 막는 장치입니다. 그리고 부여되는 범위는 ID-JAG의 `scope` 클레임에서 옵니다(이 클레임이 없는 어설션도 거부됩니다). 실제 구현에서는 대신 사용자의 그룹을 매핑할 수도 있습니다.
반환되는 `OAuthToken`에 무엇이 없는지도 눈여겨보세요. 리프레시 토큰이 없습니다. IdP는 다음 ID-JAG를 발급할지 말지를 결정함으로써 이 사용자가 얼마나 오래 접근을 유지할지 결정합니다. 여기서 리프레시 토큰을 발급하면 그 결정권을 조용히 되돌려주는 셈이 됩니다.
!!! info
여전히 `auth_server_provider=`로 인가 서버를 내장하는 서버는
`AuthSettings(identity_assertion_enabled=True)`를 통해 같은 코드에 도달합니다. 새 서버가 왜 그
방식으로 시작하면 안 되는지는 **[인가](../run/authorization.md)**에서 설명합니다.
!!! check
이 페이지의 두 파일을 서로 연결하면 그랜트 전체가 `POST /token` 한 번입니다.
```text
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
client_id=finance-agent
resource=http://localhost:8001/mcp
scope=notes:read
client_secret=finance-agent-secret
HTTP/1.1 200 OK
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}
```
`/authorize`도, `/register`도, protected-resource-metadata 가져오기도 없습니다. 실제로 오가는
요청은 `401`을 끌어낸 요청, well-known 가져오기, 이 교환, 그리고 그 뒤로 bearer를 붙인 일반적인
MCP 트래픽뿐입니다. 그리고 검증 코드가 ID-JAG에서 읽어 낸 `sub`는 도구 안에서
`get_access_token().subject`가 보고하는 값과 정확히 같습니다.
### 직접 해 보기 {#try-it}
SDK 저장소의 `examples/stories/identity_assertion/`은 이 페이지를 실제로 실행한 것입니다. 같은 `exchange_identity_assertion` 검증 코드, 그 토큰으로 보호되는 MCP 서버, 대역 IdP, 그리고 클라이언트까지, 스스로 검증하는 프로그램 하나에 모두 담겨 있습니다. `uv run python -m stories.identity_assertion.client --http`는 교환 전체를 실행하고 IdP가 지명한 사용자가 도구가 보는 사용자와 같은지 assert합니다.
## 요약 {#recap}
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)은 최종 사용자가 아니라 엔터프라이즈 ID 공급자가 클라이언트가 어느 MCP 서버에 접근할 수 있는지를 결정하게 합니다. IdP는 그 결정을 **ID-JAG**에 서명해 담습니다.
* ID-JAG를 얻는 것은 **자체 IdP**를 상대로 한 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 토큰 교환이며, SDK는 이를 수행하지 않습니다. ID-JAG를 MCP 인가 서버에 제시하는 것은 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` 그랜트이며, SDK는 그 양쪽을 모두 수행합니다.
* `IdentityAssertionOAuthProvider`는 또 하나의 `httpx2.Auth`입니다. 사전 등록된 기밀 클라이언트, 고정된 `issuer`, 그리고 `assertion_provider(audience, resource)` 콜백 하나로 이루어집니다. 브라우저도, 등록도, 리프레시 토큰도 없습니다.
* 인가 서버를 리소스 서버를 통해 찾아내는 일은 없습니다. `issuer`는 메타데이터 문서가 내주는 문자열과 정확히 같게 설정하세요. 비교는 한 글자씩 이루어집니다.
* 서버 쪽에서는 `identity_assertion_enabled=True`에 `exchange_identity_assertion`을 더합니다. SDK는 클라이언트를 인증하고 그랜트의 관문을 지키며, ID-JAG 검증은 전적으로 직접 구현할 몫이고, 발급되는 토큰은 요청의 것이 아니라 ID-JAG의 `resource`에 묶입니다.
이 페이지가 한 번도 건드리지 않은 당사자는 MCP 서버입니다. 방금 발급한 토큰으로 MCP 서버가 하는 일은 이미 **[인가](../run/authorization.md)**에서 하던 일입니다.

View file

@ -0,0 +1,217 @@
---
translation:
sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30]
tool: 1
---
# 클라이언트 {#the-client}
**`Client`**는 Python 프로그램이 MCP 서버와 통신하는 수단입니다.
하나의 객체에 하나의 생명 주기가 있습니다. 객체를 만들고, `async with`에 들어가고, 메서드를 호출하면 됩니다. 모든 프로토콜 동작(도구 목록 조회, 도구 호출, 리소스 읽기, 프롬프트 렌더링)은 타입이 지정된 결과를 돌려주는 `async` 메서드로 제공됩니다.
## 첫 번째 클라이언트 {#your-first-client}
```python title="client.py" hl_lines="14-18"
--8<-- "docs_src/client/tutorial001.py"
```
맨 위의 서버는 연결할 대상을 마련하기 위해 있을 뿐입니다. 클라이언트는 강조 표시된 다섯 줄입니다.
* `Client(mcp)`에는 **서버 객체 자체**를 넘깁니다. 이것이 인메모리 트랜스포트입니다. 서브프로세스도, 포트도, HTTP도 없습니다. 이 페이지의 모든 예제와 앞으로 작성할 모든 테스트가 이 방식으로 연결합니다.
* `async with`가 **생명 주기**입니다. 들어가면 연결하고 협상하며, 나오면 연결을 끊습니다. `connect()` / `close()` 쌍은 없으며, 블록이 끝난 뒤에는 `Client`를 재사용할 수 없습니다.
* 블록 안에서는 연결 정보가 이미 평범한 프로퍼티로 준비되어 있습니다.
### `Client`에 전달할 수 있는 것 {#what-you-can-pass-to-client}
`Client`는 위치 인자 하나를 받고, 그 타입으로 트랜스포트를 결정합니다.
* `MCPServer`(또는 저수준 `Server`) 인스턴스: **프로세스 내부**에서 연결합니다.
* URL 문자열(`Client("http://localhost:8000/mcp")`): 프로덕션 경로인 Streamable HTTP입니다.
* **트랜스포트**: `async with ... as (read, write)`로 사용할 수 있는 모든 것, 예를 들어 서브프로세스를 감싸는 `stdio_client(...)`입니다.
이 페이지의 나머지 내용은 세 가지 모두에서 동일합니다. 헤더, 서브프로세스, 타임아웃, `Transport` 프로토콜은 별도의 페이지인 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다.
### 연결된 클라이언트에 있는 것 {#whats-on-a-connected-client}
블록에 들어가는 순간 채워지는 읽기 전용 프로퍼티 네 개가 있습니다.
* `client.server_info`: 서버의 신원 정보입니다. 이를 보고하지 않는 2026년 시대의 서버라면 `None`입니다(python-sdk 서버는 기본적으로 보고합니다). 여기서 `server_info.name`은 `"Bookshop"`이고, `server_info.version`은 서버가 보고하는 값입니다.
* `client.server_capabilities`: 서버가 할 수 있는 것(`tools`, `resources`, `prompts`, `completions`, ...)입니다. 서버에 없는 기능은 `None`입니다.
* `client.protocol_version`: 양쪽이 합의한 프로토콜 버전입니다. 여기서는 `"2026-07-28"`입니다.
* `client.instructions`: 서버의 `instructions=` 문자열이며, 설정하지 않았다면 `None`입니다.
프로토콜 버전을 직접 고른 적은 없습니다. 기본적으로 `Client`는 서버를 탐색하고, 오래된 서버에서는 전통적인 핸드셰이크로 대체하므로, 하나의 클라이언트가 어느 시대의 서버와도 동작합니다. 이를 제어해야 할 때 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 확인하세요.
!!! tip
`client.session`은 내부의 `ClientSession`으로, 저수준 탈출구입니다.
이 페이지의 어떤 내용에도 필요하지 않습니다.
## 도구 목록 조회 {#listing-tools}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial002.py"
```
`list_tools()`는 `ListToolsResult`를 반환하며, 도구는 `.tools`에 들어 있습니다. 각 도구는 호스트가 모델에 건네는 완전한 정의입니다.
```python
tool.name # 'search_books'
tool.title # 'Search the catalog'
tool.description # 'Search the catalog by title or author.'
```
그리고 `tool.input_schema`는 서버가 함수의 타입 힌트에서 도출한 JSON Schema입니다.
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
이 스키마는 UI가 인자 입력 폼을 렌더링하는 데 필요한 전부이자, 모델이 유효한 인자를 만들어 내는 데 필요한 전부입니다.
!!! tip
`title`은 선택 사항이므로, 사람에게 도구를 보여 주는 UI는 무엇을 표시할지 골라야 합니다. `title`이 있으면 쓰고,
없으면 `name`을 씁니다. `from mcp.shared.metadata_utils import get_display_name`이 정확히 그 일을 하며,
도구, 리소스, 리소스 템플릿, 프롬프트 모두에 쓸 수 있습니다.
## 도구 호출 {#calling-a-tool}
`call_tool(name, arguments)`는 도구를 실행하고 `CallToolResult`를 돌려줍니다.
```python title="client.py" hl_lines="26-33"
--8<-- "docs_src/client/tutorial003.py"
```
서버의 `lookup_book`은 Pydantic `Book`을 반환합니다. 클라이언트가 보는 것은 다음과 같습니다.
```python
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
result.is_error # False
```
반환값은 하나이고, 읽을 것은 세 가지입니다. 각각 소비하는 쪽이 다릅니다.
### `content`: 모델이 읽는 것 {#content-what-the-model-reads}
`content`는 **콘텐츠 블록**의 `list`이며, 콘텐츠 블록은 `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, `EmbeddedResource`의 유니온입니다. 도구는 서로 다른 종류의 블록을 여러 개 반환할 수 있습니다.
그래서 `main`은 `block.text`를 건드리기 전에 `isinstance(block, TextContent)`로 타입을 좁힙니다. `isinstance` 바깥에는 `.text`가 없다는 점에 주목하세요. `ImageContent`에는 `.text`가 아니라 `.data`가 있기 때문에 타입 검사기가 허용하지 않습니다. 유니온은 도구가 보낼 수 있는 것을 정직하게 드러내며, 코드도 그래야 합니다.
### `structured_content`: 애플리케이션이 읽는 것 {#structured_content-what-your-application-reads}
`structured_content`는 도구의 반환값을 JSON으로 표현한 것으로, 도구가 선언한 `output_schema`와 일치합니다. 문자열 파싱도, 추측도 필요 없습니다.
둘 다 있을 때는 의도적으로 같은 내용을 두 번 말합니다. `content`는 모델을 위한 것이고, `structured_content`는 코드를 위한 것입니다. 구조화된 쪽이 어디서 오고 어떻게 제어하는지는 **[구조화된 출력](../servers/structured-output.md)** 페이지에서 다룹니다.
### `is_error`: 도구의 실패 여부 {#is_error-whether-the-tool-failed}
예외를 발생시키는 도구라도 클라이언트에서 예외를 발생시키지 **않습니다**. `is_error=True`인 평범한 결과로 돌아옵니다.
!!! check
`lookup_book`에 `"Solaris"`(카탈로그에 없는 제목)를 요청하면 함수가
`ValueError`를 발생시킵니다. 그래도 호출은 정상적으로 반환됩니다.
```python
result.is_error # True
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
result.structured_content # None
```
예외 메시지는 **모델**이 읽고 다시 시도할 수 있는 `content`에 담겼습니다. 이는
의도된 것입니다. 도구 오류는 충돌이 아니라 대화의 일부입니다. `structured_content`를
믿기 전에 항상 `is_error`를 확인하세요.
!!! warning
`is_error=True`는 직접 작성한 `raise`보다 더 많은 경우를 포괄합니다. 서버에 아예 없는 도구를 요청해도
(`call_tool("does_not_exist", {})`) 아무 예외도 발생하지 않습니다. 같은 형태로,
`content`에 `Unknown tool: does_not_exist`가 담긴 `is_error=True`가 돌아옵니다. `Client` 메서드는
서버가 결과 대신 JSON-RPC **오류**로 응답할 때만 `MCPError`를 발생시키며,
서버가 언제 어느 쪽을 내보내는지는 **[오류 처리](../servers/handling-errors.md)**에서 다룹니다.
## 리소스 {#resources}
리소스 동작은 짝을 이룹니다. 목록을 조회하는 방법이 둘, 읽는 방법이 하나입니다.
```python title="client.py" hl_lines="22-31"
--8<-- "docs_src/client/tutorial004.py"
```
* `list_resources()`는 **구체적인** 리소스, 즉 URI가 고정된 리소스를 반환합니다. 여기서는 `['catalog://genres']`입니다.
* `list_resource_templates()`는 **매개변수화된** 리소스를 반환합니다. 여기서는 `['catalog://genres/{genre}']`입니다. 템플릿은 값을 채우기 전에는 읽을 수 없으므로 두 목록은 서로 다릅니다.
* `read_resource(uri)`는 평범한 `str` URI를 받으며 둘 다에 동작합니다. `"catalog://genres/poetry"`를 전달하면 서버가 템플릿에 매칭합니다.
`read_resource`는 `TextResourceContents` 또는 `BlobResourceContents`의 리스트인 `contents`를 반환합니다. 도구 콘텐츠와 같은 방식입니다. `isinstance`로 좁힌 다음 `.text`(또는 `.blob`)를 읽으세요.
클라이언트는 리소스가 변경될 때 알림을 받을 수도 있습니다. 2025년 시대의 연결에서는 `subscribe_resource(uri)` / `unsubscribe_resource(uri)`이며, `MCPServer`가 구현하지 않는 메서드 쌍이므로 2026-07-28 와이어(이 동작이 더 이상 존재하지 않는)에서는 요청이 `-32601`, *Method not found*로 응답합니다. 2026년의 대체 수단은 `subscriptions/listen` 스트림으로, `MCPServer`가 **실제로** 제공합니다(여기서 `server_capabilities.resources.subscribe`는 `True`입니다). 이를 `client.listen(...)`으로 소비하는 방법은 이 섹션의 **[구독](subscriptions.md)** 페이지에서 다룹니다.
## 프롬프트 {#prompts}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial005.py"
```
`list_prompts()`는 서버가 무엇을 제공하는지, 각 프롬프트에 무엇이 필요한지 알려 줍니다.
```python
prompt.name # 'recommend'
prompt.title # 'Recommend a book'
prompt.arguments # [PromptArgument(name='genre', required=True)]
```
`get_prompt(name, arguments)`가 프롬프트를 렌더링합니다. 인자 딕셔너리는 `str -> str`입니다. 프롬프트 인자는 항상 문자열입니다. 결과는 `PromptMessage`의 리스트인 `messages`이며, 각 메시지에는 `role`과 `content` 블록이 있습니다.
```python
message.role # 'user'
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
```
호스트는 이 메시지를 곧바로 모델에 건넵니다. 이 기능은 이것이 전부입니다.
## 자동 완성 {#completions}
자동 완성 핸들러가 있는 서버는 사용자가 입력하는 동안 프롬프트와 리소스 템플릿 인자를 자동 완성할 수 있습니다.
```python title="client.py" hl_lines="27-31"
--8<-- "docs_src/client/tutorial006.py"
```
* `ref`는 **어느** 프롬프트 또는 템플릿을 채우고 있는지 지정합니다. `PromptReference` 또는 `ResourceTemplateReference`입니다.
* `argument`는 `{"name": ..., "value": ...}`로, 인자와 사용자가 지금까지 입력한 값입니다.
답은 `result.completion.values`에 있습니다. `"p"`를 입력하면 서버가 `['poetry']`를 돌려줍니다. 서버 쪽 구현과, 핸들러가 이미 채워진 **다른** 인자를 사용해 제안을 좁히는 방법은 **[자동 완성](../servers/completions.md)** 페이지에서 다룹니다.
## 페이지네이션 {#pagination}
모든 `list_*` 메서드는 `cursor=` 키워드를 받고, 모든 결과에는 `next_cursor`가 있습니다. `next_cursor`가 `None`이면 전부 받은 것입니다.
```python title="client.py" hl_lines="22-30"
--8<-- "docs_src/client/tutorial007.py"
```
이 루프는 어떤 서버에 대해서도 올바릅니다. `MCPServer`는 모든 것을 한 페이지에 반환하므로 `next_cursor`는 `None`이고 루프는 한 번만 돌며, 그래서 대부분의 코드는 이 루프를 작성하지 않습니다. 실제로 페이지를 나누는 서버와 커서가 따르는 규칙은 **[페이지네이션](../advanced/pagination.md)**에서 다룹니다.
## 테스트에서 {#in-tests}
프로세스도 포트도 없는 `Client(mcp)`는 그 자체로 이미 서버의 테스트 하네스입니다.
이를 위해 만들어진 생성자 플래그가 하나 있습니다. `Client(mcp, raise_exceptions=True)`입니다. 인메모리 연결에서만 효과가 있으며, 이를 설명하고 전체 패턴을 구축하는 페이지는 **[테스트](../get-started/testing.md)**입니다.
## 요약 {#recap}
* `Client(x)`는 서버 객체에는 인메모리로, URL 문자열에는 Streamable HTTP로, 그 밖의 것에는 트랜스포트를 통해 연결합니다.
* `async with`가 생명 주기의 전부입니다. 그 안에서는 `server_capabilities`와 `protocol_version`이 이미 채워져 있으며, 서버가 제공하는 경우 `server_info`와 `instructions`도 마찬가지입니다.
* `list_tools()`는 각 도구의 `name`, `title`, `description`, `input_schema`를 제공합니다.
* `call_tool()`은 모델을 위한 `content`, 코드를 위한 `structured_content`, 그리고 `is_error`를 반환합니다. 예외를 발생시키는 도구는 예외가 아니라 결과입니다.
* `content`는 블록 타입의 유니온입니다. 읽기 전에 `isinstance`로 좁히세요.
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, `complete`가 나머지 동작을 이룹니다.
* 모든 `list_*`는 `cursor=`를 받습니다. `next_cursor`가 `None`이 될 때까지 루프를 도세요.
서버가 **클라이언트**에 요청할 수 있는 것과 이에 응답하는 방법은 **[클라이언트 콜백](callbacks.md)**에서 다룹니다.

View file

@ -0,0 +1,152 @@
---
translation:
sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b]
tool: 1
---
# OAuth 클라이언트 {#oauth-clients}
일부 MCP 서버는 보호되어 있습니다. 토큰 없이 요청을 보내면 `401 Unauthorized`로 응답합니다.
**`OAuthClientProvider`**가 바로 토큰을 얻는 수단입니다. MCP 객체가 전혀 아닙니다. "모든 요청에 무언가를 한다"는 표준 httpx2 훅인 `httpx2.Auth`입니다. `httpx2.AsyncClient`에 붙이고, 그 클라이언트를 Streamable HTTP 트랜스포트에 넘긴 다음에는 더 신경 쓰지 않아도 됩니다.
이 페이지는 클라이언트 쪽을 다룹니다. 작성한 서버가 토큰을 요구하도록 만드는 방법은 **[인가](../run/authorization.md)**에서 다룹니다.
## 프로바이더 {#the-provider}
```python title="client.py" hl_lines="44-54"
--8<-- "docs_src/oauth_clients/tutorial001.py"
```
프로바이더에는 네 가지를 넘깁니다.
* `server_url`: 연결할 MCP 엔드포인트입니다. 프로바이더가 나머지는 모두 여기서 알아냅니다.
* `client_metadata`: 인가 서버의 "애플리케이션 등록" 양식에 입력할 만한 내용입니다.
* `storage`: 실행과 실행 사이에 토큰이 보관되는 곳입니다.
* `redirect_handler`와 `callback_handler`: 사람이 개입하는 두 순간입니다.
파일의 나머지 부분에는 OAuth가 등장하지 않습니다. `main()`은 토큰을 전혀 보지 못합니다.
### 클라이언트 메타데이터 {#client-metadata}
`OAuthClientMetadata`는 실제 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) 등록 문서를 Pydantic 모델로 옮긴 것입니다.
세 필드를 설정합니다. 나머지는 기본값이 채웁니다. `grant_types`는 이미 `["authorization_code", "refresh_token"]`이고 `response_types`는 이미 `["code"]`이며, 이 프로바이더가 실행하는 흐름이 정확히 이것입니다.
!!! check
Pydantic 모델이므로 **네트워크로 단 한 바이트도 나가기 전에** 검증합니다.
`redirect_uris`를 빼면 생성하는 즉시 그 필드를 지목하는 `ValidationError`와 함께
실패합니다.
```text
redirect_uris
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
```
브라우저도 열리지 않고, 인가 서버에 반쯤 끝난 등록이 남지도 않습니다.
### 토큰 저장소 {#token-storage}
**`TokenStorage`**는 비동기 메서드 네 개를 가진 `Protocol`입니다. 아무것도 상속하지 않습니다. 메서드만 작성하면 어떤 클래스든 토큰 저장소가 됩니다.
* `get_tokens` / `set_tokens`는 `OAuthToken`을 보관합니다. 액세스 토큰, 리프레시 토큰, 만료 시각, 스코프가 여기에 담깁니다.
* `get_client_info` / `set_client_info`는 프로바이더가 등록할 때 인가 서버가 발급한 `OAuthClientInformationFull`을 보관하며, 여기에는 `client_id`가 포함됩니다.
위의 인메모리 버전도 동작합니다. 다만 프로세스가 종료되면 모든 것을 잊어버리므로 다음 실행 때 전체 절차를 처음부터 다시 밟습니다. 파일이나 플랫폼의 키링에 영속화하면 다음 실행은 조용히 지나갑니다.
!!! tip
토큰만이 아니라 `client_info`도 저장하세요. 프로바이더는 저장된 `client_info`가 없으면
처음에 동적으로 등록합니다. 이를 버리면 실행할 때마다 새 등록을 만들어 냅니다.
### 두 핸들러 {#the-two-handlers}
인가 코드 흐름에는 사람이 정확히 한 번 필요합니다. 누군가 로그인해서 "허용"을 클릭해야 합니다.
* **`redirect_handler`**는 완전히 조립된 인가 URL과 함께 await됩니다. `client_id`, `redirect_uri`, `state`, PKCE 챌린지가 이미 들어 있습니다. 할 일은 브라우저를 그 URL로 보내는 것뿐입니다. 데스크톱 앱이라면 `webbrowser.open`을 호출하고, 이 파일은 URL을 출력합니다.
* **`callback_handler`**가 그다음에 await됩니다. 사용자가 `redirect_uri`로 되돌아올 때까지 기다렸다가 그 리다이렉트의 쿼리 파라미터를 `AuthorizationCodeResult`로 반환합니다.
실제 클라이언트는 `input()`을 호출하는 대신 리다이렉트 URI에서 작은 로컬 HTTP 서버를 띄웁니다. 형태는 똑같습니다. 리다이렉트를 받고 `code`, `state`, `iss`를 돌려줍니다.
!!! warning
`state`와 `iss`는 도착한 그대로 전달하세요. 프로바이더는 `state`를 자신이 생성한 값과,
`iss`를 디스커버리로 알아낸 발급자와 비교하고, 일치하지 않으면 거부합니다. 이 둘이 CSRF와
서버 혼동(mix-up) 공격을 막는 방어 장치입니다.
### `Client`에 넣기 {#into-the-client}
`main()`을 보세요. 프로바이더는 **httpx2 클라이언트**에 붙고, httpx2 클라이언트는 `streamable_http_client(url, http_client=...)`에 들어가며, 그 트랜스포트가 `Client`에 들어갑니다.
`streamable_http_client`에는 `auth=` 키워드가 없습니다. HTTP 수준의 것(인증, 헤더, 타임아웃, 프록시)은 모두 직접 가져오는 `httpx2.AsyncClient`에 속합니다. 이 계층 구조는 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다.
## 프로바이더가 대신 해 주는 일 {#what-the-provider-does-for-you}
`Client`가 처음 요청을 보내면 서버는 `401`로 응답합니다. 그러면 프로바이더가 이어받습니다.
1. **디스커버리.** `WWW-Authenticate` 헤더를 읽고, `/.well-known/oauth-protected-resource`에서 서버의 Protected Resource Metadata를 가져오고, 어느 인가 서버가 이 리소스를 보호하는지 알아낸 뒤, **그** 서버의 메타데이터를 가져옵니다.
2. **등록.** 저장소에 아무것도 없으면 `OAuthClientMetadata`로 동적으로 등록하고 결과를 저장합니다.
3. **인가.** PKCE 쌍과 `state`를 생성하고, 인가 URL을 조립하고, `redirect_handler`를 await한 다음, 코드를 받기 위해 `callback_handler`를 await합니다.
4. **교환.** 코드를 `OAuthToken`으로 교환해 저장하고, 원래 요청에 `Authorization: Bearer ...`를 붙여 다시 보냅니다.
그다음부터는 조용합니다. 토큰은 저장소에서 꺼내 쓰고, 만료된 액세스 토큰은 리프레시 토큰으로 갱신하며, 그 어느 것도 통하지 않을 때에만 흐름을 다시 실행합니다.
이 가운데 직접 작성한 코드는 하나도 없습니다. 키워드 인자가 두 개 더 남아 있는데(`client_metadata_url`과 `validate_resource_url`), 이 파일에는 둘 다 필요 없습니다. 알아 둘 만한 것은 `client_metadata_url`이며, 아래에 별도 섹션이 있습니다.
### 직접 해 보기 {#try-it}
이 문서의 예제 대부분은 인메모리 `Client(server)`로 확인할 수 있습니다. 이 예제는 아닙니다. 이 흐름의 핵심이 HTTP `401`인데, 인메모리 클라이언트와 서버 사이에는 HTTP가 없기 때문입니다.
리포지토리에는 실제로 동작하는 버전이 들어 있습니다. `examples/servers/simple-auth/`는 독립 실행형 인가 서버와 보호된 MCP 서버를 실행하고, `examples/clients/simple-auth-client/`는 이 페이지의 클라이언트를 작은 CLI로 키운 것입니다. 그 README에 두 명령이 있습니다. 서버를 시작하고, 그 서버를 대상으로 클라이언트를 실행하면 네 단계가 지나가는 모습을 볼 수 있습니다.
## Client ID Metadata Documents {#client-id-metadata-documents}
사양의 2026-07-28 리비전은 동적 클라이언트 등록을 지원 중단 예정(deprecated)으로 돌리고 **Client ID Metadata Documents**(CIMD)를 권장합니다. 만나는 인가 서버마다 새 등록을 POST하는 대신, 클라이언트는 자신을 설명하는 JSON 문서 하나를 안정적인 HTTPS URL에 게시하고, 그 URL **자체가** `client_id`가 됩니다. 문서는 인가 서버가 가져가며, 프로바이더는 이를 전혀 건드리지 않습니다.
SDK는 이미 이를 지원합니다. 프로바이더를 생성할 때 URL을 `client_metadata_url=`로 전달하세요. 인가 서버의 메타데이터가 `client_id_metadata_document_supported: true`를 광고하면 프로바이더는 `/register` 요청을 완전히 건너뜁니다. URL이 `client_id`로 흐름에 들어가고 `client_secret`은 없습니다. 서버가 이를 광고하지 않거나(아직 대부분 그렇습니다) URL을 전달하지 않으면 프로바이더는 **조용히** 동적 등록으로 되돌아가며, 위의 모든 내용이 설명한 그대로 동작합니다. 저장된 `client_info`는 여전히 둘보다 우선합니다.
URL은 루트가 아닌 경로를 가진 HTTPS여야 합니다. 그 외에는 네트워크 통신이 일어나기 전, 생성 시점에 `ValueError`가 납니다. 함께 제공되는 `examples/clients/simple-auth-client/`는 이를 `MCP_CLIENT_METADATA_URL` 환경 변수로 받습니다.
## 머신 대 머신 {#machine-to-machine}
야간 작업, CI 단계, 다른 서비스. 브라우저도 없고 "허용"을 클릭할 사람도 없습니다. 이것이 **클라이언트 자격 증명(client credentials)** 그랜트입니다. `client_id`와 `client_secret`을 이미 가지고 있고, 토큰 엔드포인트가 흐름의 전부입니다.
`ClientCredentialsOAuthProvider`는 사람만 빠진 똑같은 `httpx2.Auth`입니다.
```python title="client.py" hl_lines="4 27-33"
--8<-- "docs_src/oauth_clients/tutorial002.py"
```
달라진 점은 다음과 같습니다.
* `OAuthClientMetadata`도 핸들러도 없습니다. `client_id`와 `client_secret`을 전달하면 프로바이더가 이를 감싸는 최소한의 `client_credentials` 등록을 만들고 동적 등록은 완전히 건너뜁니다.
* `scope`는 공백으로 구분한 문자열로, OAuth의 전송 형식입니다.
* 그 아래는 모두 동일합니다. 같은 `TokenStorage`, 같은 `httpx2.AsyncClient(auth=...)`, 같은 `streamable_http_client`를 씁니다.
기본적으로 시크릿은 토큰 요청에서 HTTP Basic 인증으로 전달됩니다(`client_secret_basic`). 대신 폼 본문에 넣으려면 `token_endpoint_auth_method="client_secret_post"`를 전달하세요. 둘 중 하나만 받는 인가 서버도 있습니다.
!!! tip
`client_secret`은 환경 변수나 시크릿 매니저에서 읽고, 소스 관리에서는 절대 읽지 마세요.
!!! info
`mcp.client.auth.extensions.client_credentials`에는 프로바이더가 하나 더 있습니다.
공유 시크릿 대신 JWT로 인증하는 클라이언트를 위한 **`PrivateKeyJWTOAuthProvider`**입니다
(`private_key_jwt`, 즉 키 쌍과 워크로드 아이덴티티 방식). 같은 패턴을 따릅니다.
하나를 생성해 `auth=`에 넣으면 됩니다. 같은 모듈에는 그 어설션을 만드는 두 헬퍼인
`SignedJWTParameters`와 `static_assertion_provider`도 들어 있습니다.
사람이 없는 상황이 하나 더 있습니다. 클라이언트가 기업에 속해 있고, 어느 MCP 서버에 접근할 수 있는지를 사용자가 아니라 그 기업의 아이덴티티 공급자가 결정하는 경우입니다. 이는 고유한 신뢰 모델을 가진 다른 그랜트이며, 별도 페이지인 **[아이덴티티 어설션](identity-assertion.md)**에서 다룹니다.
## 실패할 때 {#when-it-fails}
OAuth 흐름이 잘못되면 프로바이더는 `mcp.client.auth`의 `OAuthFlowError`를 발생시킵니다. 하위 클래스가 둘 있습니다. `OAuthRegistrationError`는 등록 결과로 쓸 수 있는 클라이언트를 얻지 못했다는 뜻입니다. 인가 서버가 등록을 거부했거나, 등록은 했지만 이 흐름이 쓸 수 없는 자격 증명(예를 들어 구현하지 않은 인증 방식)을 준 경우입니다. `OAuthTokenError`는 토큰을 얻지 못했다는 뜻입니다. 토큰 엔드포인트가 거절했거나, 저장된 클라이언트 레코드에 이 클라이언트가 적용할 수 없는 인증 방식이 담겨 있는 경우로, 후자는 요청을 보내는 대신 토큰 요청을 조립하는 도중에 보고됩니다. `except OAuthFlowError:` 하나로 디스커버리, 등록, 인가, 교환을 모두 잡을 수 있습니다.
모든 것이 흐름 오류인 것은 아닙니다. 네트워크는 여전히 실패할 수 있으며, 그런 경우는 평범한 `httpx2` 예외이고 그대로 통과합니다.
## 요약 {#recap}
* `OAuthClientProvider`는 `httpx2.Auth`입니다. `httpx2.AsyncClient`에 붙이고, 이를 `streamable_http_client(url, http_client=...)`에 전달하면 `Client`는 OAuth가 일어났는지조차 모릅니다.
* 네 가지를 제공합니다. 서버 URL, `OAuthClientMetadata`, `TokenStorage`, 그리고 리다이렉트/콜백 핸들러 쌍입니다.
* `TokenStorage`는 `Protocol`입니다. 비동기 메서드 네 개, 기반 클래스는 없습니다. 토큰뿐 아니라 `client_info`도 영속화하세요.
* 디스커버리, 등록(동적 등록 또는 **Client ID Metadata Document**를 통한 등록), PKCE, `state`와 `iss` 검사, 토큰 갱신은 프로바이더의 일이지 직접 할 일이 아닙니다.
* `ClientCredentialsOAuthProvider`는 사람이 없는 버전입니다. `client_id` + `client_secret`, 핸들러도 브라우저도 없습니다.
* 모든 OAuth 실패는 `OAuthFlowError`이며, `OAuthRegistrationError`와 `OAuthTokenError`가 그 하위 클래스입니다.
이 핸드셰이크의 나머지 절반, 즉 **서버**가 토큰을 요구하도록 만드는 방법은 **[인가](../run/authorization.md)**에서 다룹니다.

View file

@ -0,0 +1,87 @@
---
translation:
sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73]
tool: 1
---
# 세션 그룹 {#session-groups}
`Client`는 하나의 서버에 연결합니다. 실제 애플리케이션은 여러 서버(검색 서버, 데이터베이스 서버, 내부 API)가 필요한 경우가 많고, 결국 서버마다 연결과 도구 목록을 따로 관리하게 됩니다.
**`ClientSessionGroup`**은 여러 연결을 담고, 각 연결이 제공하는 모든 것을 하나의 뷰로 합쳐 주는 단일 객체입니다.
## 서버 두 개 {#two-servers}
평범한 서버 두 개로 시작합니다. 서로 아무 관련이 없으므로 둘 다 자연스럽게 도구 이름을 `search`라고 지었습니다.
```python title="library_server.py" hl_lines="7"
--8<-- "docs_src/session_groups/tutorial001.py"
```
```python title="web_server.py" hl_lines="7"
--8<-- "docs_src/session_groups/tutorial002.py"
```
## 그룹 하나 {#one-group}
`ClientSessionGroup`을 만들고 서버마다 **`connect_to_server`**를 한 번씩 호출하세요.
```python title="client.py" hl_lines="10-12"
--8<-- "docs_src/session_groups/tutorial003.py"
```
* `connect_to_server`는 서버 객체가 아니라 트랜스포트 매개변수를 받습니다. 서브프로세스를 띄우려면 `StdioServerParameters`(`mcp`에서 가져옴), 이미 URL에서 수신 대기 중인 서버라면 `StreamableHttpParameters` / `SseServerParameters`(`mcp.client.session_group`에서 가져옴)를 사용합니다.
* `group.tools`는 연결된 모든 서버의 도구를 담은 `dict[str, Tool]`입니다. `group.resources`와 `group.prompts`도 같은 형태입니다.
* `group.call_tool(name, arguments)`는 이름을 조회해 그 이름을 소유한 세션을 찾고 호출을 전달합니다. 어느 서버인지 지정할 일이 없습니다.
!!! check
`client.py`를 두 서버와 같은 곳에 두고 실행하세요. 두 번째 `connect_to_server`가 거부합니다.
```text
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
```
이것은 `MCPError`이며, 두 번째 서버의 어떤 것도 등록되기 전에 발생합니다. 이름은 그룹 **전체**에서
고유해야 하고, 직접 제어하지 않는 두 서버는 언젠가 충돌하기 마련입니다.
## `component_name_hook` {#component_name_hook}
이 문제는 서버가 아니라 그룹에서 해결합니다. `(name, server_info)`를 받는 함수를 전달하면 그룹이 등록하는 모든 이름에 대해 그 함수를 실행합니다.
```python title="client.py" hl_lines="7-8 15"
--8<-- "docs_src/session_groups/tutorial004.py"
```
다시 실행하세요. 이제 `print(sorted(group.tools))`가 둘 다 보여 줍니다.
```text
['Library.search', 'Web.search']
```
* **키**는 직접 정한 것입니다. `by_server`가 `server_info.name`, 즉 각 `MCPServer(...)`를 생성할 때 지정한 이름으로 키를 만들었습니다.
* 안에 든 `Tool`은 그대로입니다. `group.tools["Web.search"].name`은 여전히 `"search"`이며, `call_tool`이 전송할 때 쓰는 이름도 바로 이것입니다. 접두사는 프로세스 밖으로 나가지 않습니다.
* 도구만 해당하는 것이 아닙니다. 라이브러리의 `hours` 리소스는 `Library.hours`로 등록됩니다.
!!! tip
훅은 충돌이 있을 때만이 아니라 **모든** 서버의 **모든** 이름에 대해 실행됩니다. 충돌 시에만 접두사를
붙이는 모드는 없습니다. 방식을 하나 정하고 어디에나 적용되도록 하세요.
## 서버 추가와 제거 {#adding-and-removing-servers}
`connect_to_server`는 자신이 연 `ClientSession`을 반환합니다. 나중에 그 서버를 빼고 싶다면 이 값을 보관해 두세요. `await group.disconnect_from_server(session)`이 그 서버의 도구, 리소스, 프롬프트를 그룹에서 제거합니다.
이미 연결된 `ClientSession`을 갖고 있다면(`Client.session`이 그런 예입니다) 새 트랜스포트를 여는 대신 `await group.connect_with_session(server_info, session)`에 넘기세요. 같은 방식으로 합쳐집니다. 그룹은 자신이 열지 않은 세션을 절대 닫지 않습니다. `server_info`는 구성 요소 접두사에 쓰일 서버 이름을 지정합니다. 2026년대 연결에서는 `client.server_info`가 `None`일 수 있으므로(신원 정보는 선택 사항입니다), 그런 경우에는 직접 만든 `Implementation(name=..., version=...)`을 전달하세요.
## 고전 핸드셰이크 {#the-classic-handshake}
`ClientSessionGroup`은 `Client`가 아니라 `ClientSession` 위에 만들어졌습니다. `connect_to_server`는 매번 고전적인 `initialize` 핸드셰이크를 실행합니다. **[프로토콜 버전](../protocol-versions.md)**에서 설명하는 `server/discover` 탐색은 보내지 않습니다. 모든 MCP 서버가 이 핸드셰이크를 이해하므로 호환성에서 잃는 것은 없습니다. 다만 더 나은 경로를 지원하는 서버에도 그룹은 더 오래되고 느린 경로를 택한다는 뜻일 뿐입니다.
## 요약 {#recap}
* `ClientSessionGroup`은 여러 서버 연결을 담고 도구, 리소스, 프롬프트를 각각 하나의 `dict`로 합칩니다.
* 서버마다 `connect_to_server(params)`를 호출합니다. `Client`가 받는 서버 객체나 URL이 아니라 트랜스포트 매개변수를 받습니다.
* `group.call_tool(name, arguments)`는 소유한 서버로 알아서 라우팅합니다.
* 이름은 그룹 전체에서 고유해야 합니다. `search` 도구를 가진 두 서버는 그대로는 공존할 수 없습니다.
* `component_name_hook=`은 등록되는 모든 이름을 다시 씁니다. 딕셔너리 키는 바뀌지만 전송되는 이름은 바뀌지 않습니다.
* `connect_with_session`은 이미 가진 세션을 추가하고, `disconnect_from_server`는 세션을 제거합니다.
그룹이 사용하는 핸드셰이크(그리고 `Client`가 선호하는 더 빠른 핸드셰이크)에 관한 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 확인하세요.

View file

@ -0,0 +1,91 @@
---
translation:
sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2]
tool: 1
---
# 구독 {#subscriptions}
서버의 카탈로그는 고정되어 있지 않습니다. 도구는 런타임에 생겨나고, 리소스 URI 뒤에 있는 내용은 바뀝니다. 클라이언트는 `client.listen(...)`을 통해 이런 변화를 전달받습니다. `subscriptions/listen` 요청 하나를 보내면 그 응답 자체가 **스트림**이 됩니다. 이 스트림은 열린 채로 유지되며 클라이언트가 요청한 변경 알림을 실어 나릅니다.
이 페이지는 클라이언트 쪽 이야기입니다. 스트림을 열고, 메인 흐름 옆에서 지켜보고, 스트림이 끝나는 상황을 처리하는 방법을 다룹니다. 변경 사항 발행, 필터링, 메서드 제공은 서버 쪽 이야기이며, **핸들러 내부** 아래의 **[구독](../handlers/subscriptions.md)**에서 설명합니다. 여기 나오는 예제는 그 페이지에서 만든 스프린트 보드 서버와 통신합니다.
## 스트림 지켜보기 {#watching-the-stream}
구독은 컨텍스트 매니저 하나입니다. 진입하면 키워드 인수를 구독 필터로 삼아 요청을 보내고 서버의 확인 응답을 기다리므로, 블록이 시작될 때는 이미 스트림이 살아 있습니다.
```python title="client.py" hl_lines="15 18 28"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
반복하면 네 가지 타입의 이벤트가 나옵니다. `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, `ResourceUpdated(uri=...)`입니다.
이벤트는 **무엇이** 바뀌었는지만 알려 주고 **어떻게** 바뀌었는지는 알려 주지 않습니다. `follow_board`가 `read_resource`와 `list_tools`를 호출하는 이유가 바로 이것입니다. 이벤트는 다시 가져오라는 신호입니다. 어느 리소스가 바뀌었는지 짐작하지 말고 `event.uri`를 읽으세요. 필터 하나가 여러 URI를 지정할 수 있고, 서버가 그중 하나의 하위 리소스에 대한 변경을 보고할 수도 있습니다.
소비되기를 기다리는 중복 이벤트는 하나로 합쳐지며, 그래도 다시 가져오면 현재 상태를 얻습니다. 합쳐지는 것은 동일한 이벤트뿐입니다. 서로 다른 URI에 대한 `ResourceUpdated` 두 개는 두 개의 이벤트입니다.
핸들에는 속성이 두 가지 더 있습니다.
* `sub.honored`는 서버가 확인 응답으로 인정한 필터입니다. 전달한 필드를 담은 `SubscriptionFilter`이며 속성으로 읽습니다(`sub.honored.prompts_list_changed`). `MCPServer`는 요청한 종류를 모두 인정하므로 요청을 그대로 되돌려 줍니다. 더 적은 종류를 지원하는 서버는 더 적게 인정하며, 인정된 종류라도 한 번도 발생하지 않을 수 있습니다. 서버가 요청을 인정하는 대신 통째로 거부할 수도 있는데(서버 페이지의 [누가 지켜볼 수 있는지 결정하기](../handlers/subscriptions.md#deciding-who-may-watch) 참고), 이 경우 요청의 오류로 나타납니다.
* `sub.subscription_id`는 listen 요청의 id이며, 이 스트림의 모든 프레임에 찍히는 바로 그 값입니다. 여러 구독을 동시에 열어 둘 수 있고, 각각은 자신의 id로 역다중화됩니다.
## 블로킹 없이 지켜보기 {#watching-without-blocking}
`follow_board`는 서버가 스트림을 닫을 때까지 실행되는데, 그 시점이 영영 오지 않을 수도 있으므로 단독으로 두면 프로그램 전체를 차지합니다. 실제 클라이언트는 감시자를 메인 흐름 **옆에** 두고 싶어 합니다. 에이전트가 도구를 호출하는 동안 감시자는 캐시나 UI를 최신 상태로 유지합니다.
먼저 구독을 열고, 그다음 감시자를 시작한 뒤 하던 일을 계속하세요.
=== "asyncio"
```python title="app.py" hl_lines="18 20"
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
```
=== "trio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
```
=== "anyio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
```
!!! note
`app.py`는 첫 번째 예제에서 `BOARD`와 `read_board`를 가져오는데, 이 저장소에서는 그 예제를
`tutorial003.py`로 저장합니다. 렌더링된 파일을 `client.py`와 `app.py`로 나란히 저장했다면
대신 `from client import BOARD, read_board`라고 쓰세요. 아래쪽의 `watch.py` 예제도
같은 방식으로 `read_board`를 가져옵니다.
핵심은 순서입니다. 아무것도 재생되지 않으므로 스트림이 존재하기 전에 발행된 이벤트는 놓칩니다. `client.listen(...)`에 진입하면 확인 응답을 기다리므로, 그 순간부터의 모든 변경이 감시자에게 도달하며 블록 안에서 찍은 스냅샷은 하나도 빠뜨리지 않습니다.
열린 스트림 옆에서도 요청은 자유롭게 실행됩니다. 감시자 태스크에서든 다른 태스크에서든 같은 클라이언트로 보낼 수 있습니다. 소비되지 않은 **중복** 이벤트는 합쳐지므로, 메인 흐름이 바쁘면 다시 가져오기가 세 번이 아니라 한 번만 일어날 수 있습니다. 서로 다른 이벤트는 합쳐지지 않습니다. 여러 URI를 지정한 필터는 URI마다 대기 중인 이벤트를 하나씩 큐에 쌓습니다.
지켜보기를 멈추려면 블록을 벗어나세요. `unsubscribe` 호출은 없습니다. 블록을 소유한 태스크를 취소하면 그렇게 되며, SDK는 트랜스포트가 기대하는 방식으로 listen 요청을 취소합니다. Streamable HTTP에서는 해당 요청의 스트림을 닫습니다. 앱이 살아 있는 동안 계속 도는 감시자는 스스로 반환하지 않으므로, 종료 시 그 감시자나 감시자가 속한 태스크 그룹의 스코프를 취소하세요.
## 스트림의 끝 {#streams-end}
스트림은 두 가지 방식 중 하나로 끝나며, 둘 다 평범한 제어 흐름입니다. 서버가 정상적으로 닫으면 `async for`가 끝나고, 갑자기 끊기면 `SubscriptionLost`가 발생합니다.
이 차이는 진단용일 뿐, 다음에 할 일이 달라지지는 않습니다. 스트림은 사라졌고, 재생된 것은 없으며, 여전히 관심이 있는 감시자는 다시 listen하고 다시 가져옵니다.
```python title="watch.py" hl_lines="16 20"
--8<-- "docs_src/subscriptions/tutorial005.py"
```
서버는 나름의 이유로 스트림을 정상적으로 닫습니다. 백로그가 너무 커진 구독자를 떼어 내는 경우도 여기에 포함되므로, 깔끔한 종료가 지켜보기를 멈추라는 신호는 아닙니다. 다시 listen하기 전에 백오프하세요.
`SubscriptionLost`에는 로컬 원인도 하나 있습니다. 클라이언트는 소비되지 않은 이벤트를 최대 1024개까지 보관하며, 그만큼 뒤처진 소비자는 한없이 불어나는 대신 구독을 잃습니다. `async for` 본문은 짧게 유지하고 느린 작업은 다른 곳에서 하세요.
`keep_following`은 `SubscriptionLost`만 잡습니다. `listen()`에 진입할 때는 `MCPError`(연결이 실패했거나 서버가 해당 메서드를 제공하지 않음), `TimeoutError`(확인 응답이 도착하지 않음), `ListenNotSupportedError`(2026년 이전 연결)도 발생할 수 있습니다. 감시자가 이 중 무엇을 재시도해야 할지 정하세요. 마지막 것은 결코 회복되지 않습니다.
## 요약 {#recap}
* `async with client.listen(...)`에 진입하세요. 진입하면 확인 응답을 기다리므로 그 이후에 발행된 것은 하나도 놓치지 않습니다.
* `async for event in sub`로 반복하세요. 이벤트는 다시 가져오라는 신호이지 페이로드가 아닙니다.
* 구독을 연 다음 감시자를 태스크로 실행하면, 도구 호출은 그 옆에서 계속 흐릅니다.
* 깔끔한 종료는 루프를 멈추고, 끊김은 `SubscriptionLost`를 발생시킵니다. 어느 쪽이든 다시 listen하고, 다시 가져오되, 먼저 백오프하세요.
* 블록을 벗어나는 것이 곧 구독 해지입니다.
이 이벤트를 발행하고, 필터를 좁히고, 단일 프로세스를 넘어 확장하는 것은 서버 쪽 이야기입니다. 자세한 내용은 **[구독](../handlers/subscriptions.md)**에서 확인하세요. 같은 이벤트는 클라이언트 쪽 캐시를 정확하게 유지하는 데도 쓰이며, 다음 페이지는 **[캐싱](caching.md)**입니다.

View file

@ -0,0 +1,128 @@
---
translation:
sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7]
tool: 1
---
# 클라이언트 트랜스포트 {#client-transports}
모든 `Client`는 **트랜스포트**를 통해 서버와 통신합니다. 메시지를 실제로 실어 나르는 것이 바로 트랜스포트입니다.
트랜스포트를 따로 설정할 일은 없습니다. `Client`는 위치 인자 하나만 받으며, 그 타입을 보고 어떤 트랜스포트를 쓸지 판단합니다.
각 트랜스포트의 **서버** 쪽(`mcp.run()`이 하는 일과 배포 대상)은 **[서버 실행하기](../run/index.md)**에서 다룹니다.
## 인메모리 {#in-memory}
서버 객체 자체를 전달하세요.
```python title="client.py" hl_lines="14"
--8<-- "docs_src/client_transports/tutorial001.py"
```
서브프로세스도, 포트도, 네트워크를 오가는 바이트도 없습니다. 클라이언트와 서버는 같은 프로세스 안의 두 객체일 뿐이지만, 호출은 여전히 실제 프로토콜 계층을 거칩니다. `search_books`는 HTTP를 통할 때와 똑같이 나열되고, 검증되고, 호출됩니다.
덕분에 이 방식은 동시에 두 가지 역할을 합니다.
* **테스트 도구.** 이 문서의 모든 예제는 이 방식으로 실행되며, **[테스트](../get-started/testing.md)** 페이지는 전체 패턴을 이 방식 위에 구축합니다.
* **임베딩 API.** 서버를 직접 생성하는 애플리케이션은 도구를 호출하기 위해 네트워크를 거칠 필요가 없습니다.
## Streamable HTTP {#streamable-http}
URL 문자열을 전달하면 **Streamable HTTP**를 얻습니다. 배포 시 사용하는 트랜스포트입니다.
```python title="client.py" hl_lines="5"
--8<-- "docs_src/client_transports/tutorial002.py"
```
이것이 프로덕션 클라이언트의 전부입니다. `Client`가 URL을 `streamable_http_client(...)`로 감싸 주며, 그 아래에는 MCP에 맞게 설정된 `httpx2.AsyncClient`가 있습니다. `follow_redirects=True`, connect/write/pool에 30초 타임아웃, 그리고 서버가 응답 스트림을 열어 둘 수 있으므로 read에는 300초 타임아웃이 적용됩니다.
!!! check
생성만 한 `Client`는 **연결되지 않은** 상태입니다. 생성은 트랜스포트를 고를 뿐이고,
실제로 여는 것은 `async with`입니다. 진입하기 전에 연결을 사용하려 하면 SDK가 이를 알려 줍니다.
```text
RuntimeError: Client must be used within an async context manager
```
`Client("http://...")`를 작성한 시점에는 아무것도 리졸브되거나, 가져오거나, 생성되지 않았습니다. 그 줄은 비용이 들지 않습니다.
### 직접 만든 `httpx2.AsyncClient` 사용하기 {#bring-your-own-httpx2asyncclient}
`Authorization` 헤더, 쿠키, 프록시, mTLS, 다른 타임아웃이 필요해지는 순간, `httpx2.AsyncClient`를 직접 만들어 `streamable_http_client`에 넘기세요.
```python title="client.py" hl_lines="8-14"
--8<-- "docs_src/client_transports/tutorial003.py"
```
눈여겨볼 점이 두 가지 있습니다.
* `httpx2.AsyncClient`의 소유자는 작성한 코드이므로, 진입과 종료도 **직접** 해야 합니다. SDK는 자신이 만들지 않은 클라이언트를 절대 닫지 않습니다.
* `streamable_http_client(url, http_client=...)`는 트랜스포트를 반환하고, `Client(transport)`는 이를 다른 것과 마찬가지로 받아들입니다.
TLS 관련 참고 사항이 하나 있습니다. `httpx2`는 번들된 CA 목록이 아니라
([`truststore`](https://pypi.org/project/truststore/)를 통해) 운영체제의 신뢰 저장소를 기준으로
인증서를 검증합니다. 사용 가능한 시스템 CA 저장소가 없는 환경(일부 최소 컨테이너)에서는 표준
`SSL_CERT_FILE`/`SSL_CERT_DIR` 환경 변수를 설정하거나 `httpx2.AsyncClient`에 명시적으로
`verify=ssl_context`를 전달하세요(배경 설명은
[`httpx2`로 대체된 `httpx`와 `httpx-sse`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)에 있습니다).
!!! warning
`streamable_http_client`는 예전에 `headers=`와 `timeout=`을 직접 받았습니다. 이제는 받지 않습니다.
매개변수는 `url`, `http_client`, `terminate_on_close`뿐입니다. 습관적으로 `headers=`를 쓰면
다음 오류가 납니다.
```text
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
```
HTTP와 관련된 모든 것은 이제 전달하는 `httpx2.AsyncClient` 하나에 담깁니다.
!!! info
`httpx2`는 익숙한 `httpx` API를 그대로 유지하므로, `httpx`를 안다면 여기서 인증, 프록시,
이벤트 훅, 재시도, 연결 제한을 다루는 방법도 이미 아는 셈입니다. SDK는 그 위에 아무것도
더하지 않고 아무것도 빼지 않습니다. OAuth가 연결되는 지점도 여기입니다.
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. 전체 흐름은 **[OAuth 클라이언트](oauth-clients.md)**에서 다룹니다.
## stdio {#stdio}
**stdio** 서버는 서브프로세스입니다. 클라이언트가 이를 실행하고, stdin에 JSON-RPC를 쓰고, stdout에서 JSON-RPC를 읽습니다. 데스크톱 호스트가 사용자 컴퓨터에서 서버를 실행하는 방식이 바로 이것입니다. 호스트는 **곧** 이 코드에 UI를 더한 것이며, **[실제 호스트에 연결하기](../get-started/real-host.md)**는 같은 관계를 호스트 쪽에서 설정 파일로 바라본 것입니다.
`StdioServerParameters`로 프로세스를 기술하고, `stdio_client`로 트랜스포트로 바꾼 다음, **그것**을 `Client`에 넘기세요.
```python title="client.py" hl_lines="4-8 12"
--8<-- "docs_src/client_transports/tutorial004.py"
```
`Client`는 매개변수 객체를 단독으로 받지 않습니다. `StdioServerParameters`는 설정이고, `stdio_client(server)`는 그 설정으로 프로세스를 띄우는 방법을 아는 트랜스포트입니다. 항상 감싸서 전달하세요.
`async with` 블록을 벗어나면 서브프로세스도 함께 종료됩니다. stdin을 닫고, 기다리고, 남아 있으면 강제 종료합니다. 직접 정리할 일은 없습니다.
!!! warning
자식 프로세스는 환경 변수를 상속하지 **않습니다**. 직접 작성하지 않았을 수도 있는 프로세스로
민감한 정보가 새어 나가지 않도록 최소한의 허용 목록(POSIX에서는 `HOME`, `LOGNAME`, `PATH`,
`SHELL`, `TERM`, `USER`)만 전달됩니다.
API 키가 필요한 서버는 거기서 키를 찾지 못합니다. `env=`로 명시적으로 전달하세요. 해당
변수는 허용 목록 위에 병합됩니다. 위 예제에서 `BOOKSHOP_API_KEY`가 하는 일이 바로 이것입니다.
## SSE {#sse}
`mcp.client.sse`의 `sse_client(url)`은 Streamable HTTP로 대체된 이전 HTTP 트랜스포트입니다. 아직 이 방식을 쓰는 서버와 통신하려면 `Client(sse_client("http://localhost:8000/sse"))`처럼 같은 방식으로 감싸서 사용하되, 새로운 것을 이 위에 만들지는 마세요.
## `Transport` 프로토콜 {#the-transport-protocol}
`Client`에게 위의 모든 것은 같은 것입니다.
**트랜스포트**란 `(read, write)` 메시지 스트림 쌍을 내어주는 비동기 컨텍스트 매니저라면 무엇이든 해당합니다. 정식으로는 `mcp.client`의 `Transport` 프로토콜입니다. `Client`는 인자를 타입으로 구분합니다. 서버 객체는 프로세스 내에서 연결하고, `str`은 `streamable_http_client(url)`이 되며, 그 밖의 것은 트랜스포트로 직접 진입합니다. 마지막 규칙 덕분에 `stdio_client(...)`, `streamable_http_client(...)`, `sse_client(...)`가 모두 같은 자리에 들어가고, 직접 만든 트랜스포트도 쓸 수 있습니다.
## 요약 {#recap}
* `Client(mcp)`(서버 객체)는 인메모리로 연결합니다. 테스트와 임베딩에 사용하세요.
* `Client("http://.../mcp")`(URL)는 프로덕션 트랜스포트인 Streamable HTTP로 연결합니다.
* 헤더, 인증, 프록시, 타임아웃은 `streamable_http_client(url, http_client=...)`에 전달하는 `httpx2.AsyncClient`에 설정합니다. `headers=` 키워드는 없습니다.
* stdio는 `Client(stdio_client(StdioServerParameters(...)))`이며, 매개변수 객체만 단독으로 쓰는 일은 절대 없습니다.
* 서브프로세스는 현재 환경이 아니라 허용 목록에 있는 환경 변수만 받습니다. `env=`로 여기에 추가합니다.
* 트랜스포트는 `async with x as (read, write)`로 쓸 수 있는 것이면 무엇이든 됩니다. `Client`는 서버 객체나 URL이 아닌 것은 모두 그 프로토콜에 그대로 넘깁니다.
* `Client`를 생성하면 트랜스포트가 정해집니다. `async with`가 이를 엽니다.
트랜스포트가 열리면 양쪽은 프로토콜 버전에 합의해야 합니다. 보통은 신경 쓸 일이 없지만, 필요할 때는 **[프로토콜 버전](../protocol-versions.md)** 페이지를 확인하세요.

View file

@ -0,0 +1,96 @@
---
translation:
sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c]
tool: 1
---
# 지원 중단 예정 기능 {#deprecated-features}
2026-07-28 사양은 다섯 가지를 퇴역시킵니다. SDK는 여전히 이 다섯 가지를 모두 구현하며, 이제 모두에 **지원 중단 예정(deprecated) 경고**가 붙습니다.
아래 표는 지원 중단 예정인 각 기능의 이름, 사라지는 이유, 그리고 대신 사용할 대체 수단을 정리한 것입니다.
## 지원 중단 예정 대상 {#what-is-deprecated}
| 지원 중단 예정 | 이유 | 대신 할 일 |
|---|---|---|
| **루트**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, `Client(...)`에 전달하는 `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)이 이 기능을 퇴역시킵니다. | 경로를 일반 도구 인자나 리소스 URI로 받거나, `InputRequiredResult`에 `ListRootsRequest`를 담으세요(**[다중 왕복 요청](handlers/multi-round-trip.md)** 참고). |
| **서버 주도 샘플링**: `ctx.session.create_message()`, `Client(...)`에 전달하는 `sampling_callback=` | SEP-2577이 이 기능을 퇴역시킵니다. | `InputRequiredResult`를 반환하고 클라이언트가 호출을 재시도하게 하세요(**[다중 왕복 요청](handlers/multi-round-trip.md)** 참고). |
| **프로토콜 로깅**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577이 이 기능을 퇴역시킵니다. 프로토콜 안에서 이를 대체하는 것은 없습니다. | stderr로 보내는 일반 `import logging`을 사용하세요(**[로깅](handlers/logging.md)** 참고). |
| **`ping`**: `client.send_ping()` | 단순히 지원 중단 예정이 아니라 프로토콜에서 **제거되었습니다**. 2026-07-28에는 `ping` 메서드가 없습니다. | 없습니다. `mode="legacy"` 연결에서만 동작합니다. |
| **클라이언트->서버 진행 상황**: `client.send_progress_notification()` | 2026-07-28에서는 진행 상황이 서버->클라이언트 방향만 허용됩니다. | 보낼 것이 없습니다. **서버**가 `ctx.report_progress()`로 진행 상황을 보고합니다(**[진행 상황](handlers/progress.md)** 참고). |
이 표에서 세 가지를 읽어낼 수 있습니다.
* 루트, 샘플링, 로깅은 한 묶음입니다. 하나의 제안인 **SEP-2577**이 세 기능을 한꺼번에 지원 중단 예정으로 지정합니다.
* 샘플링과 루트는 더 근본적인 문제를 공유합니다. 둘 다 **서버**가 **클라이언트**에게 **요청**을 보내는 지점입니다. 2026-07-28은 바로 이 방향 전체를 **[다중 왕복 요청](handlers/multi-round-trip.md)**으로 대체합니다. 사라지는 것은 독립된 RPC 메서드(`sampling/createMessage`, `roots/list`, 푸시 방식의 `elicitation/create`)이고, `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` 페이로드 타입은 `InputRequiredResult.input_requests`에 담긴 형태로 살아남으며, 클라이언트에서는 같은 콜백에 도달합니다.
* `ping`은 예외적인 경우입니다. 프로토콜은 이를 지원 중단 예정으로 지정한 것이 아니라 제거합니다. SDK 메서드는 여전히 경고를 내며(경고 메시지는 *deprecated*가 아니라 *removed*라고 말합니다), 최신 연결에서 호출하면 *"Method not found"*가 돌아옵니다.
## 지원 중단 예정은 권고 사항입니다 {#deprecated-is-advisory}
오늘 당장 깨지는 것은 없습니다.
위의 모든 메서드는 **2025-11-25 또는 그 이전**으로 협상된 세션에서 계속 동작합니다. 클라이언트에서 `mode="legacy"`로 고정하면 2026년 이전과 정확히 같은 동작을 얻습니다. 와이어 변경은 없고 기능 협상도 그대로입니다.
달라지는 점은 각 메서드가 처음 실행될 때 눈에 띄는 경고가 나온다는 것입니다.
```text
MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
```
`MCPDeprecationWarning`은 `DeprecationWarning`이 **아니라** `UserWarning`의 하위 클래스입니다. 의도된 선택입니다. Python의 기본 필터는 `__main__`으로 직접 실행되는 코드에서만 `DeprecationWarning`을 보여 주는데, 라이브러리가 이런 식으로 지원 중단 예정을 알리면 2년 동안 아무도 눈치채지 못합니다. 이 경고는 `-W` 플래그 없이도 어디서나 나타납니다.
!!! warning
"권고 사항"은 와이어 앞에서 멈춥니다. 샘플링과 루트는 서버에서 클라이언트로 가는
**요청**이고, 2026-07-28 세션에는 이를 실어 나를 채널이 없습니다. 최신 연결의 도구
안에서 `ctx.session.create_message()`를 호출하면 경고는 여전히 발생하고, 그다음 전송이
오류와 함께 실패합니다.
```text
Cannot send 'sampling/createMessage': this transport context has no back-channel
for server-initiated requests.
```
두 개의 신호가 이 순서로 나옵니다. `MCPDeprecationWarning`은 어떤 연결에서든 메서드를
호출하는 순간 발생합니다. 오류는 그다음 SDK가 전송을 시도할 때 돌아오는 결과입니다.
이 두 기능은 클라이언트가 해당 콜백을 등록한 `mode="legacy"` 연결에서만 처음부터
끝까지 동작합니다.
## 경고 끄기 {#silencing-the-warning}
새 코드에서는 끄지 마세요.
하지만 유지보수 중인 서버가 실제로 2026년 이전 클라이언트를 상대한다면 조용한 로그를 가질 자격이 충분합니다. 지원 중단 예정 호출이 처음 실행되기 전에 카테고리를 필터링하세요.
```python
import warnings
from mcp import MCPDeprecationWarning
warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
```
이것이 API의 전부입니다. 메서드별 스위치는 없으며, 있을 필요도 없습니다. 카테고리가 하나라는 것의 핵심은 한 줄로 끄고 한 줄로 다시 켤 수 있다는 점입니다.
!!! check
필터를 반대 방향으로 적용하면 회귀 테스트를 거저 얻습니다. pytest 설정의
`filterwarnings` 항목에 `"error::mcp.MCPDeprecationWarning"`을 추가하면 지원 중단 예정
호출이 경고 대신 예외를 **발생시킵니다**. 여전히 `ctx.info()`를 호출하는 `old_log`라는
도구는 더 이상 통과하지 못하고 다음과 같이 보고하기 시작합니다.
```text
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
```
pytest 설정 한 줄이면, 지원 중단 예정 호출이 테스트를 실패시키지 않고 코드베이스에
몰래 다시 들어오는 일은 결코 없습니다.
## 요약 {#recap}
* 2026-07-28 사양은 **루트**, 서버 주도 **샘플링**, 프로토콜 **로깅**을 지원 중단 예정으로 지정하고(모두 [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), **진행 상황**을 서버에서 클라이언트 방향으로 제한하며, **`ping`**을 제거합니다.
* 대체 수단 열이 다음 단계를 안내합니다. 샘플링과 루트는 **[다중 왕복 요청](handlers/multi-round-trip.md)**, 로깅은 **[로깅](handlers/logging.md)**, 진행 상황은 **[진행 상황](handlers/progress.md)**을 보세요. `ping`은 아무것도 필요 없습니다.
* 지원 중단 예정은 권고 사항입니다. 와이어 변경은 없고, 2026년 이전 세션에서는 모든 것이 계속 동작하며, 눈에 띄는 `MCPDeprecationWarning`이 나옵니다(`UserWarning`이므로 기본적으로 켜져 있습니다).
* 샘플링과 루트는 추가로 2026-07-28 세션에는 없는 백채널이 필요합니다. 최신 연결에서는 경고를 낸 뒤 예외를 발생시킵니다.
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)`은 카테고리 전체를 끄고, pytest의 `"error::mcp.MCPDeprecationWarning"`은 이를 테스트 실패로 바꿉니다.
* 새 코드는 이 기능 중 어느 것에도 기반해서는 안 됩니다.
이 문서의 다른 모든 페이지는 현재 API를 설명합니다.

View file

@ -0,0 +1,143 @@
---
translation:
sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686]
tool: 1
---
# 첫걸음 {#first-steps}
**[랜딩 페이지](../index.md)**는 빠르게 진행합니다. 서버를 작성하고, 실행하고, 도구를 호출합니다.
이 페이지는 천천히 진행합니다. 서버가 노출할 수 있는 세 가지를 모두 다루고, 그 과정에서 등장하는 모든 것에 이름을 붙입니다.
## 호스트, 클라이언트, 서버 {#host-client-and-server}
지금부터 모든 페이지에서 마주칠 세 단어입니다.
* **호스트**는 LLM 애플리케이션입니다. Claude, IDE, 에이전트 런타임이 여기에 해당하며, 사용자가 대화하는 상대가 바로 호스트입니다.
* **클라이언트**는 호스트 안에 있으며 MCP로 통신합니다. 호스트는 연결된 서버마다 클라이언트를 하나씩 실행합니다.
* **서버**는 이 SDK로 만드는 것입니다. 서버는 클라이언트에 여러 가지를 노출하며, 모델과 직접 대화하는 일은 없습니다.
직접 작성하는 것은 서버입니다. 호스트는 다른 누군가가 만든 제품입니다. SDK는 `Client`도 제공합니다. 서버를 테스트할 때 쓰게 되며, 이 페이지 뒷부분에서 다시 등장합니다.
## 세 가지 프리미티브 {#the-three-primitives}
서버가 노출하는 것은 정확히 세 종류입니다. 셋을 가르는 기준은 **누가 사용을 결정하는가**입니다.
| 프리미티브 | 제어 주체 | 설명 | 예시 |
|---------------|-----------------|-----------------------------------------------------|------------------------------------|
| **도구** | 모델 | 모델이 어떤 동작을 수행하려고 호출하는 함수 | API 호출, 데이터베이스 쓰기 |
| **리소스** | 애플리케이션 | 호스트가 모델의 컨텍스트에 불러오는 데이터 | 파일 내용, API 응답 |
| **프롬프트** | 사용자 | 사용자가 이름으로 호출하는 재사용 가능한 메시지 템플릿 | 슬래시 명령, 메뉴 항목 |
"제어 주체"가 이 구분의 핵심입니다. 도구는 **모델**이 호출하기로 결정했기 때문에 실행됩니다. 리소스는 **애플리케이션**이 모델에 필요하다고 판단했기 때문에 첨부됩니다. 프롬프트는 **사용자**가 골랐기 때문에 실행됩니다.
!!! info
웹 API를 만들어 본 적이 있다면 필요한 감각은 이미 대부분 갖추고 있습니다. **리소스**는 `GET`(데이터를
불러오고 아무것도 바꾸지 않음)이고 **도구**는 `POST`(작업을 수행하며 부작용이 있을 수 있음)입니다.
**프롬프트**는 HTTP에 대응하는 것이 없으며, 사용자가 이름으로 실행하는 저장된 쿼리에 더 가깝습니다.
## 서버 하나에 세 가지 모두 {#one-server-all-three}
```python title="server.py" hl_lines="6 12 18"
--8<-- "docs_src/first_steps/tutorial001.py"
```
평범한 함수 셋, 데코레이터 셋입니다. 각 데코레이터가 곧 등록의 전부입니다.
* `@mcp.tool()`은 `add`를 **도구**로 만듭니다.
* `@mcp.resource("greeting://{name}")`은 `greeting`을 **리소스 템플릿**으로 만듭니다. URI의 `{name}`이 함수의 매개변수입니다.
* `@mcp.prompt()`는 `summarize`를 **프롬프트**로 만듭니다. 이 함수가 반환하는 문자열은 사용자 메시지가 됩니다.
나머지(이름, 설명, 인자 스키마)는 모두 SDK가 함수 자체에서 읽어 냅니다. 함수 이름, 독스트링, 타입 힌트에서 가져오는 것입니다. 어느 것도 따로 선언하지 않았습니다.
!!! tip
SDK의 두 부분은 임포트 경로도 둘입니다. `from mcp import Client`와
`from mcp.server import MCPServer`입니다. `from mcp import MCPServer`는 없습니다.
### 직접 해 보기 {#try-it}
MCP Inspector로 실행하세요.
```console
uv run mcp dev server.py
```
출력되는 URL을 여세요. Inspector에는 프리미티브마다 탭이 하나씩 있습니다. 순서대로 살펴보세요.
**도구.** 항목은 `add` 하나이며, *Add two numbers.*라는 설명이 붙어 있습니다. 폼에는 필수 정수 필드가 `a`에 하나, `b`에 하나 있습니다. 값을 채워 호출하면 결과는 `3`입니다. Inspector는 `a: int, b: int`를 보고 이 폼을 만들었습니다. 다른 모든 클라이언트도 마찬가지입니다.
**리소스.** *Resources* 목록은 비어 있습니다. `greeting`은 **Resource Templates** 아래에 있습니다. `greeting://{name}`에 매개변수가 있어서, 누군가 `name`을 제공하기 전까지는 나열할 단일 리소스가 없기 때문입니다. `World`를 넣고 읽어 보세요.
```text
Hello, World!
```
**프롬프트.** 항목은 `summarize` 하나이며, 필수 인자는 `text` 하나입니다. 텍스트를 넣어 프롬프트를 가져오면 `role: user`와 렌더링된 문자열을 내용으로 하는 메시지 하나가 돌아옵니다. 프롬프트는 이것이 전부입니다. 메시지를 만드는 함수일 뿐입니다.
Inspector는 서버를 **stdio**로 실행했습니다. stdio는 MCP 서버가 사용할 수 있는 트랜스포트 중 하나입니다. 아직 트랜스포트를 고를 필요는 없습니다. 그 내용은 **[서버 실행하기](../run/index.md)** 페이지에서 다룹니다.
## 기능 {#capabilities}
Inspector에서 탭 세 개를 보았습니다. Inspector가 세 개라는 것을 어떻게 알았는지 살펴보겠습니다.
클라이언트가 연결하면 서버는 **기능**, 즉 어떤 부류의 요청에 응답할지를 선언합니다. 클라이언트는 이 선언을 보고 애초에 무엇을 요청할지 결정합니다. 이 선언을 작성한 적은 없습니다. `MCPServer`가 대신 선언합니다.
직접 확인해 보세요. SDK의 `Client`는 서버 객체를 그대로 받아 **인메모리**로 연결합니다(서브프로세스도, 포트도 없습니다).
```python
import asyncio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
print(client.server_capabilities.model_dump(exclude_none=True))
asyncio.run(main())
```
```text
{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
```
이 딕셔너리가 서버가 선언한 **기능**입니다. 연결하는 모든 클라이언트가 가장 먼저 알게 되는 내용입니다.
| 기능 | 클라이언트가 이제 호출할 수 있는 것 |
|-------------|------------------------------------------------------------|
| `tools` | `tools/list`, `tools/call` |
| `resources` | `resources/list`, `resources/templates/list`, `resources/read` |
| `prompts` | `prompts/list`, `prompts/get` |
`MCPServer`는 세 프리미티브를 모두 제공하므로 셋 다 항상 선언됩니다.
없는 것에도 주목하세요. `completions`(리소스 템플릿과 프롬프트의 인자 자동 완성)에는 직접 작성하는 핸들러가 필요한데, 이 서버에는 핸들러가 없으므로 해당 기능이 빠져 있고, 올바르게 동작하는 클라이언트라면 요청하지 않습니다. 선택 사항은 모두 이 규칙을 따릅니다. 등록하면 기능이 나타납니다. **[자동 완성](../servers/completions.md)** 페이지가 이를 보여 줍니다.
!!! info
`Client(mcp)`는 이 문서의 모든 예제를 테스트하는 데 쓰이는 바로 그 인메모리 클라이언트이며,
작성한 서버도 같은 방식으로 테스트하게 됩니다. 이를 다루는 페이지가 따로 있습니다. **[테스트](testing.md)**입니다.
## 작성하지 않은 것 {#what-you-did-not-write}
이 페이지를 되돌아보세요. 작성한 것은 작은 Python 함수 세 개입니다. 다음은 작성하지 **않았습니다**.
* JSON Schema. `a: int, b: int`가 **곧** `add`의 스키마입니다.
* 요청 핸들러. `tools/list`, `resources/read`, `prompts/get`은 모두 대신 처리됩니다.
* 기능 선언. `MCPServer`가 대신 만들었습니다.
* 프로토콜 코드 단 한 줄. 버전 협상, JSON-RPC 프레이밍, 기능 교환은 모두 `mcp dev`와 `Client(mcp)` 안에서 일어났고, 눈에 보이지도 않았습니다.
이 비율이야말로 SDK가 존재하는 이유입니다.
## 요약 {#recap}
* **호스트**는 LLM 앱이고, **클라이언트**는 그 안에서 MCP로 통신하는 부분이며, **서버**는 직접 만드는 것입니다.
* 도구는 **모델**이, 리소스는 **애플리케이션**이, 프롬프트는 **사용자**가 제어합니다.
* 프리미티브마다 데코레이터 하나면 됩니다. `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`입니다. 이름, 설명, 스키마는 함수에서 가져옵니다.
* `{param}`이 들어간 URI는 리소스 **템플릿**을 만들며, 구체적인 리소스와는 따로 나열됩니다.
* 서버의 **기능**은 자동으로 선언되며, 클라이언트는 서버가 선언한 것만 요청합니다.
* `Client(mcp)`는 서버 객체에 인메모리로 연결합니다. 첫날부터 갖추는 테스트 하네스입니다.
다음은 **[실제 호스트에 연결하기](real-host.md)**입니다. 이 서버를 Claude Desktop이나 IDE 안에서 실제로 돌려 봅니다. 그다음은 **[테스트](testing.md)**입니다. 페이지 하나, 인메모리 클라이언트 하나면 동작하는지 추측할 일이 없어집니다. 그 뒤로는 프리미티브마다 전용 페이지가 이어지며, 모델이 주도하는 프리미티브인 **[도구](../servers/tools.md)**부터 시작합니다.

View file

@ -0,0 +1,57 @@
---
translation:
sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf]
tool: 1
---
# 시작하기 {#get-started}
MCP가 처음이거나 이 SDK가 처음이라면 여기서 시작하세요. 이곳의 페이지는 아무것도 없는 상태에서 시작해,
테스트까지 마친 동작하는 서버를 완성하도록 안내합니다. [SDK 설치](installation.md),
[첫 번째 서버](first-steps.md) 만들기, [실제 호스트에 연결하기](real-host.md), 그리고 인메모리 클라이언트로
[테스트하기](testing.md) 순서로 진행합니다.
## 코드 실행하기 {#run-the-code}
모든 코드 블록은 그대로 복사해서 바로 사용할 수 있습니다. 하나하나가 완전하게 동작하는 파일입니다.
따라 하려면 코드 블록을 `server.py`에 붙여 넣고 MCP Inspector에서 여세요.
```console
uv run mcp dev server.py
```
코드를 직접 작성(또는 복사)하고, 수정하고, 로컬에서 실행해 보기를 **강력히 권장합니다**. 평소 쓰는 편집기에서 직접 다뤄 봐야 핵심이 제대로 와닿습니다. 작성할 코드가 얼마나 적은지, 자동 완성은 어떤지, 실행하기도 전에 타입 검사가 실수를 잡아내는 모습까지 확인할 수 있습니다.
## 추측할 필요 없는 예제 {#you-will-not-be-guessing}
이 문서의 모든 예제는 SDK 저장소의 [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) 아래에 있는 완전한 파일이며, 하나도 빠짐없이 SDK의 테스트 스위트가 **인메모리 클라이언트**를 통해 실행합니다.
```python
import pytest
from mcp import Client
from server import mcp
@pytest.mark.anyio
async def test_add() -> None:
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
```
서브프로세스도, 포트도, 트랜스포트도 없습니다. `Client(mcp)`가 서버 객체에 직접 연결합니다.
SDK 변경으로 이 문서의 예제가 하나라도 깨지면, 페이지에 문제가 드러나기 전에 CI가 먼저 실패합니다. 여기서 읽는 코드가 곧 실제로 실행되는 코드입니다.
이 방식은 [테스트](testing.md)에서 직접 사용해 봅니다. 작성한 서버를 테스트하는 방법도 바로 이것입니다.
## 다음 단계 {#where-to-go-next}
서버를 실행하고 나면 나머지 문서는 강좌가 아니라 레퍼런스입니다.
모든 페이지가 독립적으로 읽히므로 필요한 곳으로 바로 이동하세요.
* 서버가 노출하는 것(도구, 리소스, 프롬프트)은 **[서버](../servers/index.md)**에서 다룹니다.
* 등록한 함수 안에서 사용할 수 있는 것은 **[핸들러 내부](../handlers/index.md)**에서 다룹니다.
* 클라이언트 앞에 내놓는 방법(stdio, HTTP, 기존 FastAPI 앱)은 **[서버 실행하기](../run/index.md)**에서 다룹니다.
* 반대편, 즉 MCP 서버를 **사용하는** 애플리케이션을 만드는 방법은 **[클라이언트](../client/index.md)**에서 다룹니다.

Some files were not shown because too many files have changed in this diff Show more