docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
8 changed files with 89737 additions and 54 deletions
122
i18n/ko/pages/client/caching.md
Normal file
122
i18n/ko/pages/client/caching.md
Normal 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`은 캐시를 완전히 끕니다.
|
||||
154
i18n/ko/pages/client/callbacks.md
Normal file
154
i18n/ko/pages/client/callbacks.md
Normal 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)**에서 다룹니다.
|
||||
152
i18n/ko/pages/client/identity-assertion.md
Normal file
152
i18n/ko/pages/client/identity-assertion.md
Normal 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)**에서 하던 일입니다.
|
||||
217
i18n/ko/pages/client/index.md
Normal file
217
i18n/ko/pages/client/index.md
Normal 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)**에서 다룹니다.
|
||||
152
i18n/ko/pages/client/oauth-clients.md
Normal file
152
i18n/ko/pages/client/oauth-clients.md
Normal 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)**에서 다룹니다.
|
||||
87
i18n/ko/pages/client/session-groups.md
Normal file
87
i18n/ko/pages/client/session-groups.md
Normal 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)**에서 확인하세요.
|
||||
91
i18n/ko/pages/client/subscriptions.md
Normal file
91
i18n/ko/pages/client/subscriptions.md
Normal 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)**입니다.
|
||||
128
i18n/ko/pages/client/transports.md
Normal file
128
i18n/ko/pages/client/transports.md
Normal 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)** 페이지를 확인하세요.
|
||||
Loading…
Reference in a new issue