docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
6 changed files with 89737 additions and 54 deletions
144
i18n/ko/pages/run/asgi.md
Normal file
144
i18n/ko/pages/run/asgi.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
---
|
||||
translation:
|
||||
sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3]
|
||||
tool: 1
|
||||
---
|
||||
# 기존 앱에 추가하기 {#add-to-an-existing-app}
|
||||
|
||||
`mcp.run("streamable-http")`는 웹 서버를 대신 띄워 줍니다. 하지만 그걸 원하지 않을 때도 있습니다. MCP 서버가 더 큰 웹 애플리케이션의 한 부분이거나, 이미 ASGI 배포 환경이 있는 경우입니다.
|
||||
|
||||
이럴 때 `mcp.streamable_http_app()`은 **Starlette 애플리케이션**을 반환합니다.
|
||||
|
||||
Starlette 앱은 ASGI 앱이므로, ASGI를 호스팅할 수 있는 것이라면 무엇이든(uvicorn, Hypercorn, 또 다른 Starlette, FastAPI) MCP 서버를 호스팅할 수 있습니다.
|
||||
|
||||
## 앱 {#the-app}
|
||||
|
||||
```python title="server.py" hl_lines="12"
|
||||
--8<-- "docs_src/asgi/tutorial001.py"
|
||||
```
|
||||
|
||||
`app`은 평범한 ASGI 애플리케이션입니다. 아무 ASGI 서버에나 넘기면 됩니다.
|
||||
|
||||
```console
|
||||
uvicorn server:app
|
||||
```
|
||||
|
||||
MCP 엔드포인트는 `/mcp`에 있으므로, 클라이언트는 `http://127.0.0.1:8000/mcp`에 연결합니다.
|
||||
|
||||
이 앱은 이미 두 가지를 갖추고 있습니다.
|
||||
|
||||
* 라우트 하나, `/mcp`: Streamable HTTP 엔드포인트입니다.
|
||||
* `mcp.session_manager`를 시작하는 **lifespan**: 살아 있는 모든 세션의 백그라운드 작업을 소유하는 객체입니다.
|
||||
|
||||
앱을 단독으로 실행하면(`uvicorn server:app`) 둘 다 신경 쓸 일이 없습니다.
|
||||
|
||||
!!! tip
|
||||
`streamable_http_app()`은 `mcp.run("streamable-http", ...)`과 같은 키워드 인자를 받되,
|
||||
`port`만 빠집니다. 포트는 앱을 서빙하는 쪽의 몫이기 때문입니다. `host`는 여전히 받지만
|
||||
여기서는 아무것도 바인딩하지 않습니다. 이 값이 실제로 무엇을 제어하는지는 **[배포와 확장](deploy.md)**에서 설명합니다.
|
||||
옵션 자체는 **[서버 실행하기](index.md)**에서 다룹니다.
|
||||
|
||||
`mcp.sse_app()`은 이제 대체된 SSE 트랜스포트에 대해 같은 일을 합니다.
|
||||
|
||||
## 별도로 지정하기 전까지는 localhost 전용 {#localhost-only-until-you-say-otherwise}
|
||||
|
||||
기본적으로 이 앱은 localhost로 오는 요청**만** 받습니다. `streamable_http_app()`은
|
||||
자신이 어떤 호스트 이름 뒤에서 서빙될지 알 수 없으므로, 가능한 한 가장 안전한 허용 목록으로 DNS 리바인딩 보호를
|
||||
켭니다. 개발 머신에서는 이 설정이 정확히 맞습니다. 실제 호스트 이름 뒤에 배포하면,
|
||||
실제로 서빙하는 대상의 허용 목록을 `transport_security=`로 넘기기 전까지 **모든 요청이 `421 Misdirected Request`로 거부됩니다**.
|
||||
작성한 코드는 아예 참조되지도 않습니다. 이 허용 목록을 비롯해, 동작하는 앱과 실제 호스트 이름 사이에 있는 모든 것은
|
||||
**[배포와 확장](deploy.md)**에서 다룹니다.
|
||||
|
||||
## 마운트하기 {#mounting-it}
|
||||
|
||||
MCP 서버가 더 큰 애플리케이션의 **일부**가 되는 순간, 앱을 `Mount` 안에 넣게 됩니다. 그리고 그렇게 하는 순간 lifespan은 직접 챙겨야 할 일이 됩니다.
|
||||
|
||||
```python title="server.py" hl_lines="18-21 25-26"
|
||||
--8<-- "docs_src/asgi/tutorial002.py"
|
||||
```
|
||||
|
||||
* `Mount("/", ...)`에 기본 경로 `/mcp`가 더해져 엔드포인트는 `/mcp`에 그대로 유지됩니다. Starlette는 라우트를 순서대로 시도하고 `Mount("/")`는 **모든** 경로와 매칭되므로, 직접 만든 라우트는 목록에서 그 **앞에** 두어야 합니다. 뒤에 오는 것은 무엇이든 도달할 수 없습니다.
|
||||
* `lifespan` 함수는 **호스트** 앱이 살아 있는 동안 `mcp.session_manager.run()`에 진입합니다. 다들 잊어버리는 줄이 바로 이것입니다.
|
||||
* `mcp.session_manager`는 `streamable_http_app()`이 호출된 **뒤에야** 존재합니다. 그래서 라우트는 모듈 수준에서 만들고, 매니저는 lifespan 안에서만 건드립니다.
|
||||
|
||||
Starlette의 `Host` 라우트도 같은 방식으로 동작합니다. 경로 대신 호스트 이름으로 라우팅하려면 `Mount("/", ...)`를 `Host("mcp.example.com", ...)`로 바꾸세요. lifespan 규칙은 달라지지 않으며, 트랜스포트 보안 규칙도 마찬가지입니다. `Host("mcp.example.com", ...)` 라우트는 해당 호스트 이름으로 오는 요청만 받지만, 트랜스포트 자체의 Host 허용 목록(**[배포와 확장](deploy.md)**)이 여전히 먼저 실행됩니다. 그 목록에 `"mcp.example.com"`이 없으면, 이 라우트는 모든 요청에 `421`로 응답합니다.
|
||||
|
||||
!!! warning "lifespan은 호스트 앱의 소유입니다"
|
||||
`streamable_http_app()`은 반환하는 Starlette의 lifespan에 `session_manager.run()`을 연결해 두지만,
|
||||
**마운트된 하위 애플리케이션의 lifespan은 절대 실행되지 않습니다**. 앱을 마운트하면
|
||||
내장된 lifespan은 죽은 코드가 됩니다. ASGI 스택의 맨 위에 있는 앱이 무엇이든, 그 앱이 자신의 lifespan에서
|
||||
`mcp.session_manager.run()`에 진입해야 합니다.
|
||||
|
||||
!!! check
|
||||
`lifespan=lifespan` 줄을 지우고 서버를 시작해 보세요. 시작됩니다. 라우트도 해석됩니다.
|
||||
그런데 `/mcp`로 가는 첫 요청이 다음과 같이 실패합니다.
|
||||
|
||||
```text
|
||||
RuntimeError: Task group is not initialized. Make sure to use run().
|
||||
```
|
||||
|
||||
세션 매니저를 시작하는 것은 `run()`뿐입니다.
|
||||
|
||||
## 서버 둘, 앱 하나 {#two-servers-one-app}
|
||||
|
||||
각 `MCPServer`는 자체 세션 매니저를 가진 독립된 앱입니다. 원하는 만큼 마운트하고, 하나의 호스트 lifespan에서 모든 매니저에 진입하세요.
|
||||
|
||||
```python title="server.py" hl_lines="27-30 35-36"
|
||||
--8<-- "docs_src/asgi/tutorial003.py"
|
||||
```
|
||||
|
||||
* `AsyncExitStack`이 두 매니저에 모두 진입합니다. 함께 시작하고 역순으로 종료됩니다.
|
||||
* 엔드포인트는 `/notes/mcp`와 `/tasks/mcp`입니다. 마운트 접두사에 기본 경로를 더한 것입니다.
|
||||
|
||||
## 경로 바꾸기 {#changing-the-path}
|
||||
|
||||
끝에 붙는 `/mcp`는 `streamable_http_path`입니다. 이 값을 `"/"`로 설정하면 마운트 접두사가 공개 경로 전체가 됩니다.
|
||||
|
||||
```python title="server.py" hl_lines="25"
|
||||
--8<-- "docs_src/asgi/tutorial004.py"
|
||||
```
|
||||
|
||||
이제 클라이언트는 `/notes/mcp`가 아니라 `/notes`에 연결합니다.
|
||||
|
||||
## 브라우저 클라이언트를 위한 CORS {#cors-for-browser-clients}
|
||||
|
||||
브라우저 기반 클라이언트에는 두 가지 허가가 필요합니다. MCP 요청 헤더를 **보내는** 허가와, MCP가 돌려보내는 헤더를 **읽는** 허가입니다. 둘 다 호스트 앱의 CORS 설정이며, 위의 트랜스포트 보안 허용 목록도 이와 일치해야 합니다.
|
||||
|
||||
```python title="server.py" hl_lines="27-30 33 35-49"
|
||||
--8<-- "docs_src/asgi/tutorial005.py"
|
||||
```
|
||||
|
||||
* `allow_headers`는 다들 잊어버리는 절반입니다. `Content-Type: application/json`과 `Mcp-*` 요청 헤더는 CORS 안전 목록에 없기 때문에 브라우저는 모든 MCP 요청에 대해 **프리플라이트**를 수행하고, 프리플라이트가 허용하지 않은 헤더가 있으면 브라우저는 그 요청을 아예 보내지 않습니다. (`allow_headers=["*"]`도 동작합니다. Starlette는 프리플라이트가 요청한 것을 그대로 응답합니다.)
|
||||
* `expose_headers=["Mcp-Session-Id"]`는 읽는 쪽 절반입니다. Streamable HTTP는 세션 ID를 이 응답 헤더로 돌려주며, 브라우저는 CORS가 이름으로 노출하지 않는 한 응답 헤더를 JavaScript로부터 숨깁니다. 이것이 없으면 클라이언트는 두 번째 요청을 결코 보낼 수 없습니다.
|
||||
* `allow_origins`는 MCP가 아니라 직접 결정할 사항입니다. 정확하게 지정하고, 위의 `allowed_origins=`에도 똑같이 반영하세요. CORS는 브라우저가 강제하지만 서버도 `Origin`을 직접 검사하므로, 트랜스포트가 신뢰하지 않는 오리진은 프리플라이트를 깔끔하게 통과한 뒤에도 `403`을 받습니다.
|
||||
* `allow_methods`는 Streamable HTTP가 쓰는 세 가지 메서드를 나열합니다. 메시지를 보내는 `POST`, 서버에서 클라이언트로 가는 스트림을 여는 `GET`, 세션을 끝내는 `DELETE`입니다.
|
||||
|
||||
## 커스텀 라우트 {#custom-routes}
|
||||
|
||||
`@mcp.custom_route()`는 같은 앱에 평범한 HTTP 엔드포인트를 등록합니다. 배포된 모든 서비스에 필요하지만 MCP와는 무관한 것, 이를테면 헬스 체크나 OAuth 콜백을 위한 것입니다.
|
||||
|
||||
```python title="server.py" hl_lines="15-17"
|
||||
--8<-- "docs_src/asgi/tutorial006.py"
|
||||
```
|
||||
|
||||
* 핸들러는 평범한 Starlette입니다. `Request`를 받아 `Response`를 돌려주는 `async` 함수입니다.
|
||||
* `streamable_http_app()`은 모든 커스텀 라우트를 가져갑니다. 이제 `app.routes`는 `/mcp`와 `/health`입니다.
|
||||
* `GET /health`는 MCP와 전혀 상관없이 `{"status": "ok"}`로 응답합니다.
|
||||
|
||||
!!! warning
|
||||
커스텀 라우트는 서버의 나머지 부분이 인증되더라도 **절대 인증되지 않습니다**. 이는
|
||||
의도된 것입니다. 헬스 체크와 OAuth 콜백은 토큰이 존재하기 전에도 도달할 수 있어야 하기 때문입니다.
|
||||
비공개인 것은 그 뒤에 두지 마세요.
|
||||
|
||||
## 요약 {#recap}
|
||||
|
||||
* `mcp.streamable_http_app()`은 라우트 하나(`/mcp`)를 가진 Starlette 앱을 반환합니다. 어떤 ASGI 서버로든 실행할 수 있습니다.
|
||||
* 기본적으로 이 앱은 localhost로 오는 요청만 받으며, 실제 호스트 이름 뒤에서는 `transport_security=`로 허용 목록을 넘기기 전까지 모든 요청을 `421`로 거부합니다. 이 부분과 프로덕션까지의 나머지 여정은 **[배포와 확장](deploy.md)**에서 다룹니다.
|
||||
* `Mount`(또는 `Host`)로 더 큰 Starlette나 FastAPI 앱 안에 넣습니다.
|
||||
* **마운트하면 내장 lifespan이 비활성화됩니다.** 호스트 앱의 lifespan이 `mcp.session_manager.run()`에 진입해야 하며, 그러지 않으면 첫 요청이 실패합니다.
|
||||
* 한 앱에 여러 서버를 두려면 마운트를 여러 개 하고, 모든 세션 매니저에 진입하는 lifespan 하나를 둡니다.
|
||||
* `streamable_http_path="/"`는 엔드포인트를 마운트 접두사 자체로 옮깁니다.
|
||||
* 브라우저 클라이언트에는 CORS가 필요합니다. `Mcp-*` 요청 헤더를 위한 `allow_headers`, 응답을 위한 `expose_headers=["Mcp-Session-Id"]`입니다.
|
||||
* `@mcp.custom_route()`는 `/mcp` 옆에 인증되지 않는 평범한 HTTP 엔드포인트를 추가합니다.
|
||||
|
||||
서버가 실제 URL로 도달 가능해지면, **[클라이언트](../client/index.md)**는 서버 객체 대신 그 URL로 연결합니다.
|
||||
130
i18n/ko/pages/run/authorization.md
Normal file
130
i18n/ko/pages/run/authorization.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
translation:
|
||||
sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31]
|
||||
tool: 1
|
||||
---
|
||||
# 인가 {#authorization}
|
||||
|
||||
Streamable HTTP에서 MCP 서버는 평범한 웹 서비스이며, 다른 웹 서비스와 똑같은 방식으로 보호합니다. 바로 OAuth 2.1 bearer 토큰입니다.
|
||||
|
||||
OAuth 용어로 말하면 서버는 **리소스 서버**입니다. 누구도 로그인시키지 않고 토큰을 발급하지도 않습니다. 하는 일은 단 하나, 각 요청의 `Authorization` 헤더를 보고 그 안의 토큰이 유효한지 판단하는 것입니다.
|
||||
|
||||
이 페이지는 서버 쪽을 다룹니다. 인가 서버를 찾아내고 토큰을 가져오는 클라이언트는 **[OAuth 클라이언트](../client/oauth-clients.md)**에서 확인하세요.
|
||||
|
||||
## 세 당사자 {#the-three-parties}
|
||||
|
||||
* **인가 서버**는 사용자를 로그인시키고 액세스 토큰을 발급합니다. 직접 작성하는 것이 아닙니다. ID 제공자(Auth0, Keycloak, Entra, 자체 구축한 것)가 이 역할을 합니다.
|
||||
* **리소스 서버**는 MCP 서버입니다. 모든 요청에서 토큰을 검증합니다.
|
||||
* **클라이언트**는 서버가 신뢰하는 인가 서버가 어디인지 찾아내고, 거기서 토큰을 받아 `Authorization: Bearer <token>`으로 서버에 보냅니다.
|
||||
|
||||
삼각형은 이것이 전부입니다. 이 페이지의 모든 내용은 가운데 항목에 관한 것입니다.
|
||||
|
||||
## 토큰 검증기 {#a-token-verifier}
|
||||
|
||||
SDK는 유효한 토큰이 어떤 모습인지에 대해 아무런 의견이 없습니다. **`TokenVerifier`**를 구현해서 알려 주면 됩니다.
|
||||
|
||||
```python title="server.py" hl_lines="12-14 19-24"
|
||||
--8<-- "docs_src/authorization/tutorial001.py"
|
||||
```
|
||||
|
||||
* `TokenVerifier`는 async 메서드 하나를 가진 프로토콜입니다. `verify_token`은 `Authorization` 헤더에서 꺼낸 원시 토큰을 받아, 유효하면 **`AccessToken`**을, 유효하지 않으면 `None`을 반환합니다. 그 외에 구현할 것은 없습니다.
|
||||
* 이 예제는 테이블에서 토큰을 조회합니다. 실제 구현은 JWT 서명을 검증하거나 인가 서버의 토큰 인트로스펙션 엔드포인트를 호출합니다. 그 코드는 직접 작성하는 것이고, SDK는 호출만 합니다.
|
||||
* `token_verifier=`와 `auth=`는 항상 함께 다닙니다. 한쪽만 전달하면 `MCPServer(...)`가 요청을 하나도 처리하기 전에 `ValueError`를 발생시킵니다.
|
||||
|
||||
`AuthSettings`는 리소스 서버의 공개 정보입니다.
|
||||
|
||||
* `issuer_url`: 토큰을 발급하는 인가 서버입니다.
|
||||
* `resource_server_url`: 이 MCP 엔드포인트의 공개 URL입니다. 토큰이 **어떤** 리소스를 위한 것인지 지칭하며, 디스커버리 문서가 위치하는 곳이기도 합니다.
|
||||
* `required_scopes`: 모든 토큰이 이 스코프를 전부 가지고 있어야 합니다.
|
||||
|
||||
!!! tip
|
||||
SDK 저장소의 `examples/servers/simple-auth/`에는 실제 인가 서버의
|
||||
[RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 엔드포인트를 호출하는 `IntrospectionTokenVerifier`가 있습니다. 대부분의 프로덕션 검증기가 취하는 형태입니다.
|
||||
|
||||
## HTTP에서 얻는 것 {#what-you-get-over-http}
|
||||
|
||||
인가는 HTTP 헤더에 있으므로 HTTP 트랜스포트에서만 존재합니다. 배포할 트랜스포트로 실행하세요. `mcp.run(transport="streamable-http")`는 서버를 `http://127.0.0.1:8000/mcp`에 올리며, 나머지는 **[서버 실행하기](index.md)**에서 확인하세요. 이제 앱에는 라우트가 두 개 있습니다.
|
||||
|
||||
```text
|
||||
/mcp
|
||||
/.well-known/oauth-protected-resource/mcp
|
||||
```
|
||||
|
||||
등록한 도구는 하나입니다. 두 번째 라우트는 SDK가 만든 것입니다.
|
||||
|
||||
### 디스커버리 {#discovery}
|
||||
|
||||
이 well-known 경로에 `GET` 요청을 보내면 `AuthSettings`에서 곧바로 만들어진 **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**가 돌아옵니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"resource": "http://127.0.0.1:8000/mcp",
|
||||
"authorization_servers": ["https://auth.example.com/"],
|
||||
"scopes_supported": ["notes:read"],
|
||||
"bearer_methods_supported": ["header"]
|
||||
}
|
||||
```
|
||||
|
||||
서버를 전혀 모르는 클라이언트가 들어오는 길을 찾는 수단이 바로 이 문서입니다. `authorization_servers`를 읽고 그곳에서 토큰을 받아옵니다. 이 문서는 한 줄도 직접 작성하지 않았습니다.
|
||||
|
||||
!!! check
|
||||
토큰 없이(또는 검증기가 `None`을 반환한 토큰으로) `/mcp`를 호출하면 요청은
|
||||
문 앞에서 차단됩니다.
|
||||
|
||||
```text
|
||||
HTTP/1.1 401 Unauthorized
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
|
||||
|
||||
{"error": "invalid_token", "error_description": "Authentication required"}
|
||||
```
|
||||
|
||||
아무것도 파싱되지 않았고 어떤 도구도 실행되지 않았습니다. 그리고 `WWW-Authenticate`의 `resource_metadata`
|
||||
포인터가 디스커버리를 자동으로 만들어 줍니다. 401 -> 메타데이터 문서 -> 인가 서버 -> 토큰 -> 재시도 순서입니다.
|
||||
|
||||
!!! warning
|
||||
이 중 어느 것도 `stdio`를 보호하지 않습니다. 파이프에는 `Authorization` 헤더가 없으므로
|
||||
`token_verifier`는 거기서 전혀 호출되지 않습니다. `stdio` 서버의 보안 경계는 서버를 실행한 프로세스입니다.
|
||||
테스트에서 사용하는 인메모리 `Client(mcp)`도 마찬가지입니다. 서버 객체에 직접 연결하여
|
||||
인가를 포함한 HTTP 계층을 건너뜁니다.
|
||||
|
||||
## 호출자의 신원 {#the-callers-identity}
|
||||
|
||||
어떤 핸들러 안에서든 **`get_access_token()`**은 현재 요청에 대해 검증기가 반환한 `AccessToken`입니다.
|
||||
|
||||
```python title="server.py" hl_lines="4 32-35"
|
||||
--8<-- "docs_src/authorization/tutorial002.py"
|
||||
```
|
||||
|
||||
* 도구, 리소스, 프롬프트에서 모두 동작하며 전달해야 할 것은 아무것도 없습니다. 인증 미들웨어가 요청마다 컨텍스트 변수에 저장합니다.
|
||||
* **검증기가 만든 것과 동일한 객체**가 돌아옵니다. `client_id`, `scopes`, `subject`, `expires_at`, 그리고 덧붙인 추가 `claims`까지 그대로입니다. 도구별 규칙을 걸 지점이 바로 여기입니다. 스코프를 읽고 거부하면 됩니다.
|
||||
* 인증된 HTTP 요청 밖에서는 `None`을 반환합니다. 인메모리와 `stdio`에서는 항상 `None`입니다.
|
||||
|
||||
`Authorization: Bearer alice-token`으로 `whoami`를 호출하면 모델은 다음을 읽습니다.
|
||||
|
||||
```text
|
||||
alice (scopes: notes:read)
|
||||
```
|
||||
|
||||
## SDK가 하지 않는 절반 {#the-half-the-sdk-doesnt-do}
|
||||
|
||||
SDK는 리소스 서버 쪽 절반을 제공합니다. 검증하고, 알리고, 거부합니다. 로그인 페이지, 동의 화면, 토큰은 제공하지 않습니다.
|
||||
|
||||
세 당사자가 모두 움직이는 모습을 보려면 SDK 저장소의 `examples/servers/simple-auth/`(작은 인가 서버와 이 페이지와 똑같이 설정된 리소스 서버)를 실행한 다음, `examples/clients/simple-auth-client/`를 그 서버로 연결해 디스커버리부터 토큰까지의 전체 흐름을 확인하세요.
|
||||
|
||||
!!! info
|
||||
두 번째 생성자 인자인 `auth_server_provider=`는 MCP 서버 안에 완전한 인가 서버를
|
||||
내장합니다. MCP 인가 사양의 근간인 AS/RS 분리가 도입되기 전에 만들어진 것입니다.
|
||||
새 서버에서는 사용하지 않아야 합니다.
|
||||
|
||||
인가 서버는 사용자가 동의 화면을 클릭하는 대신 기업 ID 제공자의 서명된 어설션을 받을 수도 있으며, SDK는 이 교환의 양쪽을 모두 지원합니다. 이 그랜트와 이를 제시하는 클라이언트는 **[ID 어설션](../client/identity-assertion.md)**에서 확인하세요.
|
||||
|
||||
## 요약 {#recap}
|
||||
|
||||
* Streamable HTTP에서 서버는 OAuth 2.1 **리소스 서버**입니다. 토큰을 검증할 뿐, 결코 발급하지 않습니다.
|
||||
* `TokenVerifier`가 통합 지점의 전부입니다. async 메서드 하나에 토큰이 들어가고 `AccessToken | None`이 나옵니다.
|
||||
* `token_verifier=`와 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])`는 항상 함께 다닙니다.
|
||||
* SDK는 `/.well-known/oauth-protected-resource/...`에 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata를 게시하고, 인증되지 않은 요청에는 이 문서를 가리키는 `WWW-Authenticate` 헤더가 담긴 401로 응답합니다. 디스커버리는 이것이 전부입니다.
|
||||
* 어떤 핸들러에서든 `get_access_token()`이 곧 호출자입니다.
|
||||
* 인가는 HTTP의 관심사입니다. `stdio`와 인메모리 클라이언트에서는 인가가 전혀 보이지 않습니다.
|
||||
|
||||
클라이언트 쪽 절반(인가 서버를 찾아내고 토큰을 대신 가져오는 일)은 **[OAuth 클라이언트](../client/oauth-clients.md)**에서 확인하세요. 그리고 사용자에게 신원을 묻는 대신 신원을 **어설션**하는 클라이언트는 **[ID 어설션](../client/identity-assertion.md)**에서 확인하세요.
|
||||
179
i18n/ko/pages/run/deploy.md
Normal file
179
i18n/ko/pages/run/deploy.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
---
|
||||
translation:
|
||||
sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a]
|
||||
tool: 1
|
||||
---
|
||||
# 배포와 확장 {#deploy-scale}
|
||||
|
||||
서버는 잘 동작합니다. 이제 실제 호스트명이 필요하고, 그 뒤에 워커도 둘 이상 두어야 합니다.
|
||||
|
||||
그중 MCP가 관여하는 부분은 거의 없습니다. ASGI 서버, 프로세스 매니저, 로드 밸런서는 직접 준비합니다. 이 페이지가 다루는 것은 MCP가 **실제로** 관여하는 몇 가지뿐입니다. 모든 배포의 관문이 되는 설정 하나, 그리고 "워커가 둘 이상"일 때 SDK의 동작이 달라지는 두 곳입니다.
|
||||
|
||||
## 가장 먼저: Host 허용 목록 {#before-anything-else-the-host-allowlist}
|
||||
|
||||
`streamable_http_app()`은 어떤 호스트명 뒤에서 서비스될지 알 수 없으므로 가장 안전한 답인 localhost를 가정합니다. `transport_security=` 인자를 지정하지 않으면 앱은 **DNS 리바인딩 보호**를 켜고, `Host` 헤더가 `127.0.0.1:<port>`, `localhost:<port>`, `[::1]:<port>` 중 하나일 때만 요청을 받습니다. `Origin` 헤더가 있다면 같은 값의 `http://` 형태여야 합니다. 개발 머신에서는 이것이 정확히 맞는 동작입니다. 악성 웹 페이지가 `127.0.0.1`로 리바인딩한 DNS 이름을 통해 로컬 서버를 조종하지 못하게 막아 줍니다.
|
||||
|
||||
실제 호스트명 뒤에 배포하면 바로 그 기본값이 별도로 지정하기 전까지 **모든 요청**을 거부합니다. 이 검사는 MCP와 관련된 어떤 처리보다 먼저 실행되므로, 작성한 코드는 참조조차 되지 않습니다.
|
||||
|
||||
```text
|
||||
421 Misdirected Request Invalid Host header the Host is not in the allowlist
|
||||
403 Forbidden Invalid Origin header the Origin is not in the allowlist
|
||||
```
|
||||
|
||||
해결책은 `transport_security=`입니다. 실제로 서비스하는 것을 허용 목록에 넣으세요.
|
||||
|
||||
```python title="server.py" hl_lines="2 13-17"
|
||||
--8<-- "docs_src/deploy/tutorial001.py"
|
||||
```
|
||||
|
||||
* `allowed_hosts` 항목은 정확히 일치하는 문자열입니다. `"mcp.example.com"`은 포트 없는 `Host` 헤더와 일치하고, `"mcp.example.com:*"` 형태는 모든 포트와 일치합니다. 둘 다 나열하세요.
|
||||
* `allowed_origins`는 브라우저에만 의미가 있습니다. 다른 것은 `Origin`을 보내지 않기 때문입니다. 이는 **[기존 앱에 추가하기](asgi.md)**에서 다루는 CORS 설정과 짝을 이루는 서버 측 설정입니다.
|
||||
* `Host` 헤더를 이미 통제하는 리버스 프록시 뒤에서는 `TransportSecuritySettings(enable_dns_rebinding_protection=False)`로 검사를 끄는 것이 정직한 설정입니다.
|
||||
* localhost가 아닌 `host=` 값(예: `host="mcp.example.com"`)을 전달해도 그 호스트명이 허용 목록에 들어가지 **않습니다**. localhost 기본값이 보호를 활성화하지 않게 할 뿐이며, 그러면 모든 Host와 Origin이 허용됩니다. 대신 `transport_security=` 인자로 의도를 명확히 지정하세요.
|
||||
|
||||
!!! check
|
||||
`transport_security=security` 인자를 지우고 앱을 그대로 배포해 보세요. 앱은 시작되고 `/mcp`도
|
||||
라우팅되지만, 모든 요청(평범한 `curl`도 포함)이 다음과 같이 돌아옵니다.
|
||||
|
||||
```text
|
||||
HTTP/1.1 421 Misdirected Request
|
||||
|
||||
Invalid Host header
|
||||
```
|
||||
|
||||
클라이언트 쪽에서는 이 문구를 볼 수 없습니다. `421`은 JSON-RPC 오류가 아니라 평문 HTTP 응답이므로
|
||||
MCP 클라이언트는 일반적인 트랜스포트 오류를 발생시키고, 거부된 호스트명은 **서버** 로그에
|
||||
경고 한 줄로만 나타납니다. 새로 배포한 서버가 모든 연결을 거부한다면, 달리 밝혀지기 전까지는
|
||||
Host 허용 목록 문제입니다. **[문제 해결](../troubleshooting.md)**도 여기서 시작합니다.
|
||||
|
||||
## 워커, 그리고 스티키가 필요한 쪽 {#workers-and-who-has-to-be-sticky}
|
||||
|
||||
호스트명이 응답하기 시작했다면 그 뒤에 워커를 둘 이상 두세요. 이를 위한 SDK 설정은 없습니다. Starlette 앱은 다른 ASGI 앱과 똑같이, 포크할 줄 아는 도구에 객체를 넘겨서 확장합니다.
|
||||
|
||||
```console
|
||||
uvicorn server:app --workers 4
|
||||
```
|
||||
|
||||
프로세스 네 개, 소켓 하나. 이제 모든 배포가 답해야 하는 질문은 **요청이 직전 요청을 받았던 바로 그 워커에 도달해야 하는가**입니다.
|
||||
|
||||
**2026-07-28** 프로토콜을 사용하는 클라이언트라면 그럴 필요가 없습니다. 최신 방식의 요청은 독립된 POST 하나입니다. 그 앞에 `initialize` 핸드셰이크도 없고, 응답에 `Mcp-Session-Id`도 없으며, 두 번째 요청이 되돌아갈 **대상** 자체가 없습니다. 어느 워커로 보내도 됩니다.
|
||||
|
||||
이것은 켜는 모드가 아닙니다. `stateless_http=True`가 그런 스위치처럼 보이지만, 트랜스포트는 `MCP-Protocol-Version` 요청 헤더로 라우팅하여 최신 요청을 최신 핸들러에 넘기고 **반환합니다**. `stateless_http`를 읽는 줄은 그 반환 **뒤에** 있습니다. 2026-07-28 경로에서 이 플래그가 무시되는 것이 아니라 아예 도달하지 않는 것입니다. `stateless_http`는 **레거시** 경로 전용 설정이며, 최신 경로는 구조상 세션이 없습니다.
|
||||
|
||||
사양 버전 2025-11-25 이하의 레거시 클라이언트라면 답은 그 플래그에 따라 달라집니다.
|
||||
|
||||
| 클라이언트의 프로토콜 버전 | 세션 | 로드 밸런서가 해야 할 일 |
|
||||
| --- | --- | --- |
|
||||
| **2026-07-28** | 없음. `Mcp-Session-Id`는 설정되지 않습니다. | 없음. 어느 워커든 어느 요청이든 처리합니다. |
|
||||
| **2025-11-25 이하**(기본값) | `Mcp-Session-Id`, 한 워커의 메모리에 보관됩니다. | **스티키 세션.** 후속 요청이 다른 워커에 도달하면 `404` *"Session not found"*를 받습니다. |
|
||||
| **2025-11-25 이하**, `stateless_http=True` 사용 | 없음. | 없음. 대가는 서버에서 클라이언트로 가는 역방향 채널, 즉 샘플링, 푸시 엘리시테이션(elicitation), `roots/list`와 재개 기능을 잃는 것입니다. |
|
||||
|
||||
스티키 세션과 레거시 경로의 비용은 별도 페이지인 **[레거시 클라이언트 지원](legacy-clients.md)**에서 다루고, 두 시대 자체는 **[프로토콜 버전](../protocol-versions.md)**에서 다룹니다. 여기서 중요한 것은 답의 형태입니다. **2026-07-28에서는 이미 무상태이며 설정할 것이 없습니다.**
|
||||
|
||||
이 페이지의 나머지는 무상태라고 해서 저절로 해결되지 **않는** 두 가지입니다.
|
||||
|
||||
## 워커 간 `requestState` {#requeststate-across-workers}
|
||||
|
||||
**[다중 왕복](../handlers/multi-round-trip.md)** 도구는 클라이언트가 가져와야 하는 것(확인, 선택, 자격 증명)이 필요하므로, 답 대신 질문을 반환하고 재시도에서 마무리합니다. 두 라운드 사이에 클라이언트는 서버가 발급한 불투명한 `request_state` 토큰을 들고 있습니다. 재시도 때 서버는 그 토큰을 다시 열어야 합니다.
|
||||
|
||||
**문제는 어떤 키로 봉인하느냐입니다.** 기본적으로는 서버가 생성 시점에 `os.urandom(32)`로 만든 키입니다. `--workers 4`에서는 네 프로세스에서 생성이 네 번 일어납니다. 서로 다른 키 네 개가 어디에도 기록되지 않고, 공유되지 않으며, 재시작하면 사라집니다.
|
||||
|
||||
다음은 아무것도 설정하지 않은 서버에서, 실행하기 전에 먼저 묻는 도구입니다.
|
||||
|
||||
```python title="server.py" hl_lines="14 20"
|
||||
--8<-- "docs_src/deploy/tutorial002.py"
|
||||
```
|
||||
|
||||
첫 라운드는 워커 A에 도달합니다. 워커 A는 **자신의** 키로 `refund:120` 값을 봉인하고 토큰을 반환합니다. 클라이언트는 질문을 사람에게 보여 주고 승낙을 받은 뒤 재시도합니다. 재시도는 완전히 새로운 HTTP 요청입니다.
|
||||
|
||||
!!! check
|
||||
그 재시도가 워커 B에 도달하게 해 보세요. B는 자신이 발급하지 않은 토큰의 봉인을 풀려고 하지만
|
||||
풀 수 없어 라운드 전체를 거부합니다. `refund`는 호출되지 않고, 클라이언트는 JSON-RPC 오류를
|
||||
받습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "Invalid or expired requestState",
|
||||
"data": {"reason": "invalid_request_state"}
|
||||
}
|
||||
```
|
||||
|
||||
이 메시지는 **고정**되어 있습니다. 만료됐든, 변조됐든, 다른 인자로 재전송됐든, (실제 배포에서
|
||||
단연 가장 흔한 원인인) 형제 워커가 봉인했든, 클라이언트는 매번 같은 메시지를 받으므로 어떤
|
||||
검사가 실패했는지 와이어에서는 드러나지 않습니다. 진짜 이유는 서버 로그의 `WARNING` 한 줄에
|
||||
있습니다.
|
||||
|
||||
```text
|
||||
requestState rejected on tools/call: unknown key
|
||||
```
|
||||
|
||||
워커 하나일 때는 잘 되다가 둘이 되자 **가끔씩** 실패하기 시작한 다중 왕복 도구가 바로 이
|
||||
경우입니다. 두 라운드가 여전히 같은 프로세스에 도달해야 하므로, 로드 밸런서가 둘을 갈라놓는
|
||||
빈도만큼 정확히 실패합니다.
|
||||
|
||||
두 라운드는 독립된 HTTP 요청 두 개이며, 평범한 일 몇 가지가 둘을 갈라놓습니다. 요청 단위로 분산하는 프록시, 중간에 끊어진 연결, 배포나 재시작, `request_state`를 저장해 두었다가 전혀 다른 프로세스에서 재개하는 클라이언트(**[루프를 직접 구동하기](../handlers/multi-round-trip.md#driving-the-loop-yourself)**) 등입니다. 이 중 어느 것이든 "다른 워커"가 됩니다.
|
||||
|
||||
해결책은 인자 하나입니다. 이 인자에는 **두** 부분이 있습니다.
|
||||
|
||||
```python title="server.py" hl_lines="1 12 14"
|
||||
--8<-- "docs_src/deploy/tutorial003.py"
|
||||
```
|
||||
|
||||
* **`keys=[...]`**는 누구나 찾아내는 부분입니다. 모든 인스턴스에 같은 비밀 값(최소 32바이트)을 주면, 어느 형제가 발급한 것이든 모든 인스턴스가 봉인을 풀 수 있습니다. `keys[0]` 항목이 봉인하고 목록의 모든 키가 봉인을 푸는데, 이것이 로테이션 링입니다. 다운타임 없이 이를 돌리는 방법은 **[키 로테이션](../handlers/multi-round-trip.md#rotating-keys)**에서 확인하세요.
|
||||
* **서버의 이름**은 거의 아무도 찾지 못하는 부분이자, 키를 공유한 뒤에도 인스턴스 간 재시도가 계속 실패하는 이유입니다. 봉인된 모든 토큰은 서버의 `name`을 **audience 클레임**으로 담고 있으며, 돌아올 때 엄격하게 검사됩니다. 같은 코드로 만든 두 인스턴스는 이름이 같으므로 이를 알아챌 일이 없습니다. 이름을 서로 다르게 지으면(`MCPServer(f"billing-{POD}")`는 관측 가능성 측면에서 좋은 습관처럼 보입니다), 키를 공유했든 아니든 모든 인스턴스 간 재시도가 위와 똑같이 거부됩니다. 로그에는 `unknown key` 대신 `audience`가 찍히지만, 클라이언트는 그 차이를 알 수 없습니다.
|
||||
|
||||
비밀 값은 한 번만 만들어 모든 인스턴스에 같은 값을 넘기세요. 32바이트 미만을 전달하면 SDK의 오류 메시지가 직접 실행하라고 알려 주는 명령이 바로 이것입니다.
|
||||
|
||||
```console
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
!!! warning "같은 키, **그리고** 같은 이름"
|
||||
다중 인스턴스 배포는 둘 다 공유해야 합니다. 인스턴스별 이름이 꼭 필요하다면 대신
|
||||
`RequestStateSecurity(keys=[...], audience="billing")`처럼 전체 인스턴스에 명시적인 audience 하나를
|
||||
지정하세요. 그러면 이름이 무엇이든 모든 인스턴스가 `"billing"`으로 발급하고 수락합니다.
|
||||
|
||||
봉인에 관한 나머지 모든 것, 즉 무엇을 묶는지, 라운드별 `ttl`(기본 600초), 자체 코덱 사용하기, 설정하지 않은 기본값이 `stdio`에서는 정확히 맞는 이유는 **[`requestState` 보호하기](../handlers/multi-round-trip.md#protecting-requeststate)**에서 다룹니다. 이 페이지가 보태는 것은 두 항목짜리 체크리스트뿐입니다. **같은 키, 같은 이름.**
|
||||
|
||||
!!! info
|
||||
`InputRequiredResult`를 한 번도 입력해 본 적이 없어도 이 경로에 해당합니다. 매개변수에
|
||||
`Resolve(...)`를 쓰는 도구(**[의존성](../handlers/dependencies.md)**)는 다중 왕복 도구이며,
|
||||
SDK가 대신 `request_state`를 발급하고 봉인합니다. 기본 키도 같고, 워커 간 실패도 같고,
|
||||
해결책도 같습니다.
|
||||
|
||||
## 레플리카 간 변경 알림 {#change-notifications-across-replicas}
|
||||
|
||||
클라이언트의 `subscriptions/listen` 스트림은 오래 유지되는 응답 하나이므로 살아 있는 동안 내내 레플리카 하나에 고정됩니다. **다른** 레플리카에서 발행한 `ctx.notify_resource_updated(...)`가 그 스트림에 도달해야 합니다.
|
||||
|
||||
둘 사이의 접점은 `SubscriptionBus`입니다. 서버에 어떤 버스를 주든 모든 발행이 그 버스로 들어가고 열려 있는 모든 스트림이 그 버스를 듣습니다. 따라서 모든 레플리카에 같은 버스를 넘기세요.
|
||||
|
||||
```python title="server.py" hl_lines="2 7 9"
|
||||
--8<-- "docs_src/deploy/tutorial004.py"
|
||||
```
|
||||
|
||||
팬아웃은 스트림이 어느 서버 객체에 붙어 있는지 전혀 신경 쓰지 않습니다. `InMemorySubscriptionBus` 하나를 공유하는 서버 두 개는 이미 이렇게 동작합니다. 한쪽에서 listen 스트림을 열고 다른 쪽에서 `edit_note`를 호출하면 스트림이 그 소식을 듣습니다. 이 인메모리 버스는 한 프로세스 안의 서버 객체에만 걸쳐 있으므로, 배포 방식이 아니라 모델일 뿐입니다.
|
||||
|
||||
* 실제 프로세스 간에는 **SDK가 제공하는 버스 중 도움이 되는 것이 없습니다.** `SubscriptionBus`는 메서드 두 개(`publish`와 `subscribe`)짜리 `Protocol`이며, 자체 pub/sub 백엔드(Redis, NATS, 이미 운영 중인 무엇이든) 위에 구현해서 `MCPServer(subscriptions=...)`로 전달합니다. 스케치와 계약은 **[구독](../handlers/subscriptions.md#scaling-past-one-process)**에서 확인하세요.
|
||||
* 버스는 작은 타입 이벤트 네 가지만 나르며, JSON-RPC는 절대 나르지 않습니다. 확인 응답, 필터링, 스트림 생명 주기는 SDK에 남아 있으므로, 버스가 프로토콜을 깨뜨릴 수는 없고 프로세스 간에 이벤트를 옮길 수만 있습니다.
|
||||
* 스트림은 재개할 수 **없고** 이벤트는 재생되지 **않습니다**. 레플리카를 잃으면 그 스트림도 끊기고, 클라이언트는 다시 listen하고 다시 가져옵니다. 공유할 이벤트 저장소도, 따로 설정할 것도 없습니다. 확장이 정말로 같은 것을 더 늘리는 일에 불과한 곳은 여기 하나뿐입니다.
|
||||
|
||||
## SDK가 제공하지 않는 것 {#what-the-sdk-does-not-give-you}
|
||||
|
||||
`MCPServer`는 애플리케이션 서버가 아니라 프로토콜 구현체입니다. 다음으로 찾게 될 배포 설정은 의도적으로 빠져 있습니다.
|
||||
|
||||
* **`workers=` 없음.** `mcp.run("streamable-http")` 호출은 uvicorn 프로세스를 정확히 하나 시작하며, 앞으로도 그 이상은 시작하지 않습니다. 다중 프로세스는 `streamable_http_app()`을 이미 ASGI 배포에 쓰고 있는 도구, 즉 `uvicorn --workers`, gunicorn, 플랫폼의 프로세스 매니저에 넘기는 것입니다. 이 페이지는 일부러 그중 어느 것의 튜토리얼도 되지 않습니다. 여기에 옮겨 적는 것보다 각 도구의 문서가 더 낫기 때문입니다.
|
||||
* **헬스 체크 라우트 없음.** `@mcp.custom_route("/health", methods=["GET"])`가 답의 전부이며, 서버의 나머지가 인증을 요구하더라도 이 라우트는 인증되지 않습니다. 활성 프로브에는 맞지만 비공개여야 하는 것에는 맞지 않습니다. 예시는 **[기존 앱에 추가하기](asgi.md#custom-routes)**에서 확인하세요.
|
||||
* **프로덕션 설정 객체 없음.** `MCPServer`에는 타임아웃, TLS, 정상 종료, 연결 제한을 적어 둘 곳이 없습니다. 그중 어느 것도 이 클래스의 일이 아니기 때문입니다. 이들은 ASGI 서버의 몫이며 거기서 설정합니다. 생성자가 **실제로** 받는 몇 안 되는 설정은 **[서버 실행하기](index.md)**에서 다룹니다.
|
||||
* **제공되는 `EventStore` 없음, 그리고 2026-07-28에서는 쓸 일도 없음.** 재개 기능은 레거시 상태 유지 경로의 기능입니다. 최신 방식의 교환은 POST 하나, 응답 하나이며 재개할 것이 없습니다.
|
||||
|
||||
## 요약 {#recap}
|
||||
|
||||
* 기본적으로 이 앱은 localhost로 오는 요청만 받습니다. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`가 서비스 개시의 관문입니다. 이를 전달하기 전까지 실제 호스트명 뒤의 모든 요청은 `421`이 되며, 그 이유는 서버 로그에만 남습니다.
|
||||
* 2026-07-28에는 세션이 없고, 로드 밸런서가 스티키로 붙들 대상도 없습니다. `stateless_http=True`는 레거시 전용 설정입니다. 최신 요청은 이 플래그를 읽기도 전에 라우팅되고 응답되기 때문입니다.
|
||||
* 기본 `requestState` 키는 프로세스마다 만들어지는 `os.urandom(32)`입니다. 다른 워커에 도달한 다중 왕복 재시도는 `-32602` *"Invalid or expired requestState"*로 실패합니다.
|
||||
* 해결책은 `RequestStateSecurity(keys=[...])`를 쓰는 것, **그리고** 모든 인스턴스에 같은 서버 이름을 쓰는 것입니다. 이름은 토큰의 기본 audience 클레임입니다. 같은 키, 같은 이름.
|
||||
* 변경 알림은 공유 `SubscriptionBus` 하나를 통해 레플리카를 넘나듭니다. SDK의 유일한 구현은 프로세스 내부용이며, 자체 pub/sub 위의 메서드 두 개짜리 `Protocol`은 직접 작성해야 합니다.
|
||||
* `workers=`도, 헬스 라우트도, 프로덕션 설정 객체도 없습니다. ASGI 서버는 직접 준비하세요.
|
||||
|
||||
실제 호스트명 앞에 필요한 또 하나는 토큰입니다. **[인가](authorization.md)**에서 이어집니다.
|
||||
156
i18n/ko/pages/run/index.md
Normal file
156
i18n/ko/pages/run/index.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
translation:
|
||||
sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0]
|
||||
tool: 1
|
||||
---
|
||||
# 서버 실행하기 {#running-your-server}
|
||||
|
||||
`mcp.run()`이 서버를 시작합니다.
|
||||
|
||||
결정해야 할 것은 **트랜스포트** 하나뿐입니다. 서버와 클라이언트 사이에서 바이트가 실제로 어떻게 오가는지를 정하는 것입니다.
|
||||
|
||||
## 트랜스포트 선택 {#pick-a-transport}
|
||||
|
||||
| 트랜스포트 | 설명 | 사용 시점 |
|
||||
|---|---|---|
|
||||
| `stdio` | 호스트가 파일을 서브프로세스로 실행하고 stdin과 stdout으로 통신합니다. | 로컬 서버. 기본값입니다. |
|
||||
| `streamable-http` | 포트에서 수신 대기하는 실제 HTTP 서버입니다. | 배포하는 모든 것. |
|
||||
| `sse` | 예전 HTTP 트랜스포트입니다. | 사용하지 않습니다. |
|
||||
|
||||
!!! warning
|
||||
SSE는 2025-03-26 프로토콜 개정에서 Streamable HTTP로 대체되었습니다.
|
||||
`mcp.run(transport="sse")`는 여전히 동작하며 고유한 `sse_path=`와 `message_path=`
|
||||
옵션도 있지만, 아직 옮겨 가지 않은 클라이언트를 위해 남아 있을 뿐입니다. 새로 만드는 것은 여기에 기반하지 마세요.
|
||||
|
||||
## `mcp.run()` {#mcprun}
|
||||
|
||||
```python title="server.py" hl_lines="12-13"
|
||||
--8<-- "docs_src/run/tutorial001.py"
|
||||
```
|
||||
|
||||
* `run()`은 동기 함수입니다. 서버가 살아 있는 동안 블로킹합니다.
|
||||
* 인수가 없으면 트랜스포트는 `stdio`입니다.
|
||||
* `if __name__ == "__main__":` 아래에 두는 이유는 서버를 불러오는 모든 것(`mcp dev`, `mcp run`, `mcp install`, 테스트)이 이 파일을 **임포트**하기 때문입니다. 이 가드가 임포트가 실행 중인 서버로 바뀌는 것을 막아 줍니다.
|
||||
|
||||
### stdio {#stdio}
|
||||
|
||||
설정할 것이 없습니다. 호스트가 파일을 자식 프로세스로 시작하고, stdin에 요청을 쓰고, stdout에서 응답을 읽습니다.
|
||||
|
||||
직접 실행해 보면 그 결과를 확인할 수 있습니다.
|
||||
|
||||
```console
|
||||
python server.py
|
||||
```
|
||||
|
||||
아무것도 출력되지 않고, 반환하지도 않습니다. 호스트가 먼저 말을 걸기를 stdin에서 기다리고 있는 것입니다.
|
||||
|
||||
이는 stdout이 **곧 통신 회선**이라는 뜻이기도 합니다. 서비스하는 동안 SDK는 이 회선을 비공개 디스크립터로 옮기고, stdout으로 **플러시되는** 출력(상속받은 stdout에 쓰는 서브프로세스, 플러시된 `print()`)을 스트림을 망가뜨릴 수 없는 stderr로 돌립니다. 서비스가 시작되기 **전에** stdout으로 플러시된 출력(래퍼 스크립트의 echo, 버퍼링되지 않은 임포트 시점의 print)은 여전히 회선에 실리며, 인터프리터가 종료 시 비울 때까지 버퍼에 남아 있는 `print()`도 마찬가지입니다. 실제로 원하는 출력에는 `logging` 모듈이 알맞은 도구입니다. 이 모듈의 핸들러는 각 레코드를 발생 즉시 stderr로 플러시합니다. 자세한 내용은 **[로깅](../handlers/logging.md)**에서 확인하세요.
|
||||
|
||||
### 직접 해 보기 {#try-it}
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
Inspector는 실제 호스트가 하는 일을 그대로 합니다. `server.py`를 서브프로세스로 실행하고 stdio로 연결합니다.
|
||||
|
||||
포트를 지정한 적이 없습니다. 포트는 애초에 없습니다.
|
||||
|
||||
## Streamable HTTP {#streamable-http}
|
||||
|
||||
같은 서버를 포트에 올리려면 `run()`에 트랜스포트(와 그 옵션)를 지정하세요.
|
||||
|
||||
```python title="server.py" hl_lines="13"
|
||||
--8<-- "docs_src/run/tutorial002.py"
|
||||
```
|
||||
|
||||
그 한 줄이 Starlette 앱을 만들고 uvicorn으로 서비스합니다. 클라이언트는 `http://127.0.0.1:3001/mcp`에 연결합니다.
|
||||
|
||||
트랜스포트마다 고유한 키워드 인수가 있으며, 모두 `run()`에 전달합니다.
|
||||
|
||||
* `host` / `port`: 수신 대기할 위치. 기본값은 `127.0.0.1`과 `8000`입니다.
|
||||
* `streamable_http_path`: MCP 엔드포인트가 위치하는 경로. 기본값은 `/mcp`입니다.
|
||||
* `json_response=True`: 각 POST에 SSE 스트림 대신 단일 JSON 본문으로 응답합니다. 이 본문에는 응답 외에 다른 것을 담을 자리가 없으므로, 요청 도중 클라이언트를 다시 호출하는 도구(`ctx.elicit()`, 샘플링)는 이 구간에서 `NoBackChannelError`를 발생시키고, 진행 중인 호출에 묶인 알림(`ctx.report_progress()`의 진행 상황, 호출별 로그 메시지)은 버려집니다. 독립된 `GET` 스트림은 관련 없는 알림을 여전히 전달합니다.
|
||||
* `stateless_http=True`: 요청마다 새 트랜스포트를 만들고 세션을 추적하지 않습니다.
|
||||
* `max_request_body_size`: 허용되는 POST 본문의 최대 크기(바이트). 기본값은 4MiB이며, 더 큰 요청은
|
||||
파싱이나 세션 생성 전에 HTTP 413을 받습니다. 정상적인 MCP 메시지가 이 크기를 넘을 때만
|
||||
올리세요.
|
||||
* `event_store`, `retry_interval`, `transport_security`: 재개 가능성과 DNS 리바인딩 보호. localhost가 아닌 곳에 배포하기 전까지는 미뤄도 됩니다. `transport_security`는 **[배포와 확장](deploy.md)**에서 다룹니다.
|
||||
|
||||
!!! warning
|
||||
트랜스포트 옵션은 `MCPServer(...)`가 **아니라** `run()`에 전달합니다. 생성자는 서버가
|
||||
**무엇인지**(이름, 버전, 지침)를 기술하고, `run()`은 어떻게 서비스되는지를 기술합니다. 거꾸로
|
||||
하면 MCP가 관여하기도 전에 Python이 답합니다.
|
||||
|
||||
```text
|
||||
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
|
||||
```
|
||||
|
||||
`run()`은 지름길입니다. 더 많은 것이 필요한 순간(기존 앱 안에 서버를 마운트하기, 한 프로세스에 서버 두 개, 브라우저 클라이언트를 위한 CORS)이 오면, ASGI 앱을 직접 만들어 아무 ASGI 호스트에나 넘기면 됩니다. 그 내용은 **[기존 앱에 추가하기](asgi.md)**에 있습니다.
|
||||
|
||||
## 서버 설정 {#server-settings}
|
||||
|
||||
실행에 관한 것 중 몇 가지는 트랜스포트와 관계가 없습니다. 생성자 인수입니다.
|
||||
|
||||
```python title="server.py" hl_lines="3"
|
||||
--8<-- "docs_src/run/tutorial003.py"
|
||||
```
|
||||
|
||||
* `log_level`: `MCPServer(...)`가 생성되는 순간 `logging.basicConfig()`에 전달됩니다. 이는 **루트** 로거를 설정하므로 SDK의 로거뿐 아니라 직접 만든 로거의 레벨도 정합니다. 기본값은 `"INFO"`입니다.
|
||||
* `debug`: HTTP 트랜스포트가 만드는 Starlette 앱으로 전달됩니다. 기본값은 `False`입니다.
|
||||
|
||||
둘 다 `mcp.settings`에 저장되며, 런타임에 다시 읽을 수 있습니다.
|
||||
|
||||
## `mcp` 명령 {#the-mcp-command}
|
||||
|
||||
`[cli]` 엑스트라는 이 모든 것을 감싸는 작은 명령줄 도구를 설치합니다.
|
||||
|
||||
`mcp dev`는 **MCP Inspector** 아래에서 서버를 실행합니다.
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
uv run mcp dev server.py --with pandas --with numpy
|
||||
uv run mcp dev server.py --with-editable .
|
||||
```
|
||||
|
||||
`--with`는 빌드되는 환경에 패키지를 추가하고, `--with-editable`은 직접 만든 패키지를 그 환경에 설치합니다. `PATH`에 `npx`가 있어야 합니다. Inspector는 Node.js 앱이기 때문입니다.
|
||||
|
||||
`mcp run`은 파일을 임포트하고, 서버 객체(모듈 수준의 `mcp`, `server`, `app`)를 찾아 `run()`을 호출합니다.
|
||||
|
||||
```console
|
||||
uv run mcp run server.py
|
||||
uv run mcp run server.py:bookshop
|
||||
```
|
||||
|
||||
`:` 접미사는 객체 이름이 `mcp`, `server`, `app`이 아닐 때 객체를 지정합니다.
|
||||
|
||||
여기서는 `if __name__ == "__main__":` 블록이 전혀 실행되지 않습니다. `mcp run`이 직접 `run()`을 호출하며, 전달하는 옵션은 `--transport`뿐입니다.
|
||||
|
||||
`mcp install`은 서버를 **Claude Desktop**에 등록해 앱이 대신 실행하도록 합니다.
|
||||
|
||||
```console
|
||||
uv run mcp install server.py --name "Bookshop"
|
||||
uv run mcp install server.py -v API_KEY=abc123 -f .env
|
||||
```
|
||||
|
||||
`-v KEY=VALUE`와 `-f .env`는 환경 변수를 해당 항목에 기록합니다. Claude Desktop은 서버를 자체 프로세스에서 시작합니다. 셸의 환경은 거기에 없습니다.
|
||||
|
||||
`mcp install`이 아는 호스트는 Claude Desktop뿐입니다. 다른 호스트(Claude Code, Cursor, VS Code)는 모두 같은 실행 명령을 각자의 설정 파일에 받으며, 호스트별 방법은 **[실제 호스트에 연결하기](../get-started/real-host.md)**에서 확인하세요.
|
||||
|
||||
`mcp version`은 설치된 SDK 버전을 출력합니다.
|
||||
|
||||
!!! tip
|
||||
`mcp dev`와 `mcp run`은 `MCPServer`만 이해합니다. 저수준 `Server`로 만들었다면
|
||||
직접 실행해야 합니다. **[저수준 Server](../advanced/low-level-server.md)**를 참고하세요.
|
||||
|
||||
## 요약 {#recap}
|
||||
|
||||
* **트랜스포트**는 바이트가 서버에 도달하는 방식입니다. 로컬 서브프로세스에는 `stdio`, 포트에는 `streamable-http`를 씁니다. SSE는 대체되었습니다.
|
||||
* `mcp.run()`이 트랜스포트를 고릅니다. 인수가 없으면 `stdio`이고, 블로킹합니다.
|
||||
* 모든 트랜스포트 옵션(`host`, `port`, `streamable_http_path`, ...)은 `run()`의 인수이지, 결코 `MCPServer(...)`의 인수가 아닙니다.
|
||||
* `run()`은 `if __name__ == "__main__":` 아래에 두세요. 서버를 불러오는 모든 것이 먼저 파일을 임포트합니다.
|
||||
* `log_level=`과 `debug=`는 생성자 인수이며 `mcp.settings`에 저장됩니다.
|
||||
* Inspector에는 `mcp dev`, 파일 실행에는 `mcp run`, Claude Desktop에는 `mcp install`, 버전 확인에는 `mcp version`을 씁니다.
|
||||
* 트랜스포트는 서버가 **무엇인지**를 결코 바꾸지 않습니다. 이 페이지의 세 파일은 모두 동일한 도구를 노출합니다.
|
||||
|
||||
`run()` 자체가 한계인 경우(이미 존재하는 앱 안에 서버를 넣는 경우)는 **[기존 앱에 추가하기](asgi.md)**에서 다룹니다. 실제 호스트 이름과 둘 이상의 워커는 **[배포와 확장](deploy.md)**에서 다룹니다. 그리고 일부 클라이언트가 아직 사양 버전 2025-11-25 이하에 머물러 있다면, **[레거시 클라이언트 서비스하기](legacy-clients.md)**에서 반가운 소식을 확인하세요.
|
||||
131
i18n/ko/pages/run/legacy-clients.md
Normal file
131
i18n/ko/pages/run/legacy-clients.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
---
|
||||
translation:
|
||||
sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c]
|
||||
tool: 1
|
||||
---
|
||||
# 레거시 클라이언트 지원 {#serving-legacy-clients}
|
||||
|
||||
MCP에는 두 가지 프로토콜 시대가 있습니다. 사양 버전 `2025-11-25`까지의 `initialize` 핸드셰이크 시대와 현대 시대인 `2026-07-28`입니다. 이 구분 자체를 다루는 페이지는 **[프로토콜 버전](../protocol-versions.md)**입니다.
|
||||
|
||||
이 페이지는 그 구분의 서버 쪽을 다루며, 답은 한 문장이면 충분합니다. **이미 배포하고 있는 `streamable_http_app()`이 두 시대를 모두 지원합니다.**
|
||||
|
||||
SDK는 모든 요청을 `MCP-Protocol-Version` 헤더에 따라 라우팅합니다. `2026-07-28` 버전을 명시한 요청은 현대 핸들러로 갑니다. 핸드셰이크 시대의 버전을 명시한 요청이나 헤더가 아예 없는 요청(2026 이전 클라이언트의 `initialize`가 바로 이렇게 도착합니다)은 그런 클라이언트가 기대하는 트랜스포트로 갑니다. `initialize` 핸드셰이크, 세션 등 모든 것을 갖춘 트랜스포트입니다. 이 라우팅은 요청마다, 작성한 코드보다 먼저, 하나의 앱 안에서 일어납니다.
|
||||
|
||||
따라서 레거시 클라이언트는 따로 대비해서 만들어야 하는 대상이 아닙니다. 이미 작성한 서버에 접속해 오는 존재일 뿐입니다. 설정할 것은 아무것도 없습니다.
|
||||
|
||||
!!! note
|
||||
말 그대로 아무것도 없습니다. `legacy=` 옵션도, 버전 허용 목록도, 특정 시대를 거부하거나
|
||||
비활성화하는 방법도 없습니다. `streamable_http_app()`에도, `run()`에도, 세션 매니저에도 없습니다.
|
||||
두 시대는 항상 켜져 있습니다. 그 시그니처에서 시대별 스위치에 가장 가까운 것은
|
||||
`stateless_http`이며, 이 페이지의 대부분이 이 옵션을 다룹니다.
|
||||
|
||||
## 하나의 핸들러, 두 시대 {#one-handler-both-eras}
|
||||
|
||||
다음은 사용자에게 무언가를 물어봐야 하는 도구와, 그 도구를 호출하는 두 시대의 클라이언트입니다.
|
||||
|
||||
```python title="server.py" hl_lines="24 37-38"
|
||||
--8<-- "docs_src/legacy_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
`reserve`에는 모델이 제공하지 않은 정보가 하나 필요합니다. 몇 권인지입니다. `Annotated[..., Resolve(ask_quantity)]`는 도구가 이를 선언하는 방법입니다(자세한 내용은 **[의존성](../handlers/dependencies.md)**에서 확인하세요). `reserve` 안에는 버전을 명시하거나, 기능을 확인하거나, 분기하는 코드가 전혀 없습니다.
|
||||
|
||||
두 클라이언트는 같은 `mcp` 객체에 **동시에** 열려 있습니다. `mode="legacy"`는 `initialize` 핸드셰이크를 실행합니다. 2026 이전 클라이언트가 여는 바로 그 연결입니다. 다른 하나는 기본값을 사용해 `2026-07-28` 버전으로 연결됩니다.
|
||||
|
||||
```text
|
||||
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
|
||||
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
|
||||
```
|
||||
|
||||
같은 서버, 같은 핸들러, 같은 답입니다. 이 기능은 이것이 전부입니다.
|
||||
|
||||
**어떻게** 가능한지는 잠시 짚고 넘어갈 만합니다. 두 클라이언트는 완전히 다른 두 가지 전송 경로를 통해 같은 질문을 받았기 때문입니다. `2026-07-28` 연결에는 서버가 요청을 보낼 채널이 없으므로, `Resolve`는 질문을 도구 결과 안에 담아 반환했고 클라이언트는 답을 담아 호출을 재시도했습니다(**[다중 왕복 요청](../handlers/multi-round-trip.md)**). `2025-11-25` 연결에는 그런 것이 없습니다. 거기서는 `Resolve`가 호출 도중에 실제 `elicitation/create` 요청을 보내고 기다렸습니다. 둘 다 직접 작성한 것이 아닙니다. `Resolve`는 연결에서 협상된 버전을 읽고 방식을 고릅니다. 도구 본문은 어느 쪽이든 `AcceptedElicitation`을 받습니다.
|
||||
|
||||
!!! tip
|
||||
이러한 시대 이식성이 바로 `Resolve`를 기반으로 삼아야 하는 **이유**입니다. 더 오래된 형제 격인 `ctx.elicit()`
|
||||
(**[엘리시테이션(elicitation)](../handlers/elicitation.md)**)은 언제나 `elicitation/create`만 보내므로
|
||||
레거시 연결에서만 동작합니다. `2026-07-28` 연결에서는 호출이 실패합니다. 아직 이를 사용하는 도구가
|
||||
있다면, 해결책은 버전 확인이 아니라 위에서 본 방식입니다.
|
||||
|
||||
## 레거시 세션의 비용 {#what-a-legacy-session-costs-you}
|
||||
|
||||
라우팅은 공짜입니다. 세션은 그렇지 않습니다.
|
||||
|
||||
`2026-07-28` 연결은 **세션이 없습니다**. 모든 요청이 독립적이며, 현대 핸들러는 `Mcp-Session-Id`를 발급하지 않습니다. 레거시 연결은 정반대입니다. 2026 이전 클라이언트가 `initialize`를 보내는 순간 SDK는 `Mcp-Session-Id`를 발급해 응답 헤더에 담아 돌려주고, 클라이언트의 이후 요청이 찾을 수 있도록 그 뒤에 살아 있는 기록을 유지합니다. 협상된 버전, 열린 스트림, 세션을 구동하는 백그라운드 작업이 그 기록입니다.
|
||||
|
||||
그 기록은 **평범한 프로세스 내부 `dict`**입니다. 분산 세션 저장소는 없으며 연결할 방법도 없습니다.
|
||||
|
||||
워커가 하나일 때는 보이지 않습니다. 둘이면 이것이 문제의 전부입니다. `Mcp-Session-Id`를 가진 요청이 그 ID를 발급하지 않은 워커에 도착하면 해당 dict에서 아무것도 찾지 못하고, 응답은 도구 결과가 아니라 `404`(`Session not found`)입니다. 따라서 워커를 둘 이상 실행하는 순간 **레거시 클라이언트에는 스티키 라우팅이 필요합니다**. 한 세션의 모든 요청은 그 세션을 시작한 프로세스에 도달해야 합니다. 현대 클라이언트는 그럴 필요가 전혀 없습니다. 고정될 세션 자체가 없기 때문입니다. 스티키 라우팅을 비롯해 둘 이상을 실행하는 데 관한 모든 내용은 **[배포와 확장](deploy.md)**에서 다룹니다.
|
||||
|
||||
!!! warning
|
||||
`event_store=` 옵션은 해결책처럼 보이지만 아닙니다. 이것은 세션 저장소가 아니라 **재개 기능**(**같은**
|
||||
세션에 다시 연결하는 클라이언트에게 놓친 SSE 이벤트를 재생하는 것)입니다. 다른 프로세스에서
|
||||
세션에 도달할 수 있게 해 주는 일은 결코 없습니다.
|
||||
|
||||
## 유일한 옵션: `stateless_http` {#the-one-knob-stateless_http}
|
||||
|
||||
스티키 라우팅이 치르기 싫은 비용이라면, 바꿀 수 있는 것은 정확히 하나입니다.
|
||||
|
||||
```python title="server.py" hl_lines="28"
|
||||
--8<-- "docs_src/legacy_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
페이지 맨 위의 서버에 키워드 하나를 더한 것입니다. `stateless_http=True`는 레거시 경로가 요청마다 쓰고 버리는 세션을 만들게 합니다. `Mcp-Session-Id`를 발급하지 않고 요청 사이에 아무것도 기억하지 않으므로, 어느 워커든 어느 요청이나 처리할 수 있고 로드 밸런서는 마음대로 분배할 수 있습니다.
|
||||
|
||||
이 옵션에 관해서는 무엇을 하는지보다 더 중요한 두 가지가 있습니다.
|
||||
|
||||
**레거시 경로에만 영향을 줍니다.** 요청은 `stateless_http`를 읽기 **전에** 버전 헤더로 라우팅되므로 현대 경로는 이 옵션을 보지 못합니다. `2026-07-28` 연결은 이미 세션이 없으며 어느 값이든 완전히 똑같습니다.
|
||||
|
||||
**그 경로에서 서버에서 클라이언트로 가는 두 채널을 모두 잃습니다.** `POST` 하나 동안만 사는 세션에는 서버가 요청을 밀어 보낼 스트림도, 알림을 밀어 보낼 독립 스트림도 없습니다. 서버가 시작하는 모든 요청은 `NoBackChannelError`를 일으킵니다. `ctx.elicit()`, 이제 은퇴한 샘플링과 루트 호출(**[지원 중단 예정 기능](../deprecated.md)**), 그리고 `Resolve`가 **레거시** 클라이언트에게 질문하는 경우도 마찬가지입니다. 알림은 오류조차 나지 않고 조용히 버려집니다.
|
||||
|
||||
!!! note
|
||||
`json_response=True`는 그 옵션이 아니지만, **모든** 레거시 세션에서 같은 비용의 절반을 치릅니다.
|
||||
JSON 본문 하나로 응답하는 `POST`에는 요청 범위 채널을 위한 스트림이 없으므로, 요청 도중의
|
||||
`ctx.elicit()`은 같은 `NoBackChannelError`를 일으키고 요청에 묶인 알림은 버려집니다. 세션의
|
||||
독립 스트림은 영향을 받지 않으므로 관련 없는 알림은 여전히 도착합니다.
|
||||
|
||||
!!! check
|
||||
일부러 잘못된 설정을 해 보세요. `reserve`는 방금 두 클라이언트를 모두 지원한 바로 그 도구입니다.
|
||||
`stateless_http=True`로 배포하고, 같은 두 클라이언트를 HTTP로 연결한 뒤, 각각에서 호출해 보세요.
|
||||
|
||||
현대 클라이언트는 여전히 `Reserved 2 of 'Dune'.`을 받습니다. 현대 경로는 바뀌지 않았습니다.
|
||||
|
||||
레거시 클라이언트의 호출은 모델이 읽을 수 있는 `is_error` 결과로 돌아오지 않습니다.
|
||||
요청 전체가 최상위 프로토콜 오류로 실패합니다.
|
||||
|
||||
```text
|
||||
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
|
||||
```
|
||||
|
||||
`Resolve`도 구해 주지 못했습니다. `2025-11-25` 연결에서는 `elicitation/create`를 **반드시** 보내야 하며,
|
||||
그때 필요한 채널이 바로 `stateless_http=True`가 포기한 것입니다. 시대 이식성이 있는 코드라고 해서
|
||||
백 채널이 필요 없는 코드인 것은 아닙니다.
|
||||
|
||||
따라서 이것은 실제 트레이드오프이며, 레거시 경로에만 존재합니다. **세션을 유지하고 스티키로 가거나, 상태 없이 단방향으로 가거나**입니다. 도구가 클라이언트를 되불러 호출하는 일이 전혀 없다면 `stateless_http=True`는 공짜이니 선택하세요. 그런 일이 있다면 세션을 유지하고 라우팅도 스티키로 유지하세요.
|
||||
|
||||
## 코드가 실제로 갈라지는 지점 {#where-your-code-actually-forks}
|
||||
|
||||
거의 없습니다.
|
||||
|
||||
도구, 리소스, 프롬프트, 구조화된 출력, 진행 상황, 오류 중 어느 것도 어느 시대가 호출했는지 신경 쓰지 않습니다. `initialize` 핸드셰이크, `Mcp-Session-Id`, 독립 스트림, 세션을 끝내는 `DELETE`는 모두 SDK가 소유하며 핸들러는 그 어느 것도 보지 못합니다. 대화형 입력은 두 시대가 전송 수준에서 실제로 다른 **유일한** 지점이며, 그것이 신경 쓸 문제가 되지 않도록 `Resolve`가 존재합니다. 방금 하나의 도구가 두 시대를 모두 지원하는 것을 보았습니다.
|
||||
|
||||
남은 것은 정확히 하나, **변경 알림**입니다. 두 시대가 서로 다른 통로에서 듣기 때문입니다.
|
||||
|
||||
* `2026-07-28` 클라이언트는 `subscriptions/listen` 스트림을 열고 구독 버스를 읽습니다. `ctx.notify_resource_updated()`(그리고 `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`)는 거기에, **오직** 거기에만 게시합니다. 자세한 내용은 **[구독](../handlers/subscriptions.md)**에서 확인하세요.
|
||||
* 레거시 클라이언트는 세션이 열어 두는 독립 스트림을 읽습니다. `ctx.session.send_resource_updated()`(그리고 `send_tool_list_changed()` 등)는 요청을 실어 온 **연결**에 씁니다. 레거시 세션에서는 그것이 독립 스트림입니다. 현대 연결에는 이를 받을 곳이 없습니다. HTTP에서는 그런 채널이 없고, stdio에서는 네 가지 변경 알림이 `subscriptions/listen` 스트림으로만 전달되므로, 현대 연결에서는 알림이 조용히 버려집니다.
|
||||
|
||||
HTTP에서는 어느 호출도 다른 시대의 클라이언트에 도달하지 않습니다. 모두에게 알리려면 둘 다 호출하세요.
|
||||
|
||||
```python title="server.py" hl_lines="19-20"
|
||||
--8<-- "docs_src/legacy_clients/tutorial003.py"
|
||||
```
|
||||
|
||||
두 줄, `if`도 버전 확인도 없이 끝입니다. 레거시 클라이언트가 존재한다는 이유로 핸들러가 다르게 하는 일은 이것이 전부입니다.
|
||||
|
||||
## 요약 {#recap}
|
||||
|
||||
* 하나의 `streamable_http_app()`이 두 프로토콜 시대를 모두 지원합니다. SDK가 각 요청을 `MCP-Protocol-Version` 헤더에 따라 라우팅하며, 설정할 것도 찾아볼 시대별 옵션도 없습니다.
|
||||
* 레거시 클라이언트의 비용은 세션입니다. 뒤에 분산 저장소가 없는 프로세스 내부 `Mcp-Session-Id` 기록입니다. 워커가 둘 이상이면 **스티키 라우팅**이 필요하며, 그렇지 않으면 엉뚱한 워커가 `404 Session not found`로 응답합니다. 다중 워커에 관한 자세한 내용은 **[배포와 확장](deploy.md)**에서 확인하세요.
|
||||
* `stateless_http=True`가 유일한 옵션이며, **레거시 경로에만** 적용됩니다. 레거시 클라이언트에 자유로운 로드 밸런싱을 제공하는 대신 그 경로에서 서버에서 클라이언트로 가는 두 채널을 모두 잃습니다. 서버가 시작하는 요청은 `NoBackChannelError`를 일으키고(클라이언트에서는 `is_error` 결과가 아니라 최상위 오류), 알림은 버려집니다.
|
||||
* `2026-07-28` 연결은 어느 쪽이든 세션이 없습니다. `stateless_http`는 이 연결을 건드리지 않습니다.
|
||||
* 핸들러 코드가 시대에 따라 갈라지는 곳은 정확히 한 군데, 변경 알림입니다. `ctx.notify_*` 계열은 `subscriptions/listen` 클라이언트에 도달하고, `ctx.session.send_*` 계열은 레거시 세션에 도달합니다. 둘 다 호출하세요.
|
||||
* 그 밖의 모든 것(`Resolve`를 통해 사용자에게 입력을 요청하는 것 포함)은 설계상 시대 이식성을 갖습니다. 현대 방식으로 한 번만 작성하세요.
|
||||
85
i18n/ko/pages/run/opentelemetry.md
Normal file
85
i18n/ko/pages/run/opentelemetry.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
translation:
|
||||
sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607]
|
||||
tool: 1
|
||||
---
|
||||
# OpenTelemetry {#opentelemetry}
|
||||
|
||||
서버는 이미 추적되고 있습니다. 아무것도 추가하지 않아도 됩니다.
|
||||
|
||||
생성하는 모든 서버는 처리하는 모든 메시지에 대해 [OpenTelemetry](https://opentelemetry.io/) 스팬을 내보냅니다. 직접 작성하지도 않았고, 임포트하지도 않습니다. `MCPServer(...)`를 호출하는 순간 이미 들어 있습니다.
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/opentelemetry/tutorial001.py"
|
||||
```
|
||||
|
||||
이것으로 추적이 적용된 완전한 서버가 완성됩니다. `search_books`를 호출하면 그에 해당하는 스팬이 만들어집니다. 저수준 `Server`도 마찬가지입니다. 추적은 양쪽 모두에 들어 있습니다.
|
||||
|
||||
## 얻게 되는 것 {#what-you-get}
|
||||
|
||||
들어오는 모든 메시지는 메서드와 그 대상의 이름을 딴 `SERVER` 스팬이 됩니다. 따라서 `search_books`에 대한 `tools/call`은 `tools/call search_books` 스팬이 되고, 대상이 없는 `tools/list`는 그냥 `tools/list`가 됩니다.
|
||||
|
||||
각 스팬에는 몇 가지 속성이 붙습니다.
|
||||
|
||||
* `mcp.method.name`과 `mcp.protocol.version`은 모든 스팬에 있습니다.
|
||||
* `jsonrpc.request.id`는 요청에 있습니다(알림에는 없습니다).
|
||||
* 핸들러가 예외를 일으키면 스팬 상태가 오류로 설정됩니다. `is_error=True`인 도구 결과도 마찬가지입니다.
|
||||
|
||||
도구 호출을 추적하려는 요구가 워낙 흔하기 때문에, `tools/call` 스팬은 OpenTelemetry의 [GenAI 시맨틱 컨벤션](https://opentelemetry.io/docs/specs/semconv/gen-ai/)을 따릅니다.
|
||||
|
||||
* `gen_ai.operation.name`은 `"execute_tool"`로 설정됩니다.
|
||||
* `gen_ai.tool.name`은 호출되는 도구로 설정됩니다.
|
||||
|
||||
`prompts/get` 스팬에도 같은 취지로 `gen_ai.prompt.name`이 붙습니다. 목록 조회 메서드에는 이름을 붙일 대상이 없으므로 `gen_ai.*` 키가 없습니다.
|
||||
|
||||
!!! tip
|
||||
이 GenAI 속성 덕분에 추적 UI가 도구 호출을 다른 에이전트의 호출과 같은 방식으로 묶어 보여 줍니다. 추가 코드 없이 이 그룹화를 그대로 얻습니다.
|
||||
|
||||
## 원할 때까지는 비용이 들지 않습니다 {#it-costs-nothing-until-you-want-it}
|
||||
|
||||
"기본적으로 켜져 있음"이 부담 없는 기본값이 되는 이유는 다음과 같습니다.
|
||||
|
||||
SDK는 OpenTelemetry의 가벼운 절반인 `opentelemetry-api`에만 의존합니다. SDK와 익스포터가 설치되어 있지 않으면 스팬을 만드는 일은 아무 동작도 하지 않습니다. 따라서 서버가 지금 내보내고 있는 스팬은 비용이 거의 들지 않으며, 아무도 수집하지 않습니다.
|
||||
|
||||
스팬을 실제로 **보고** 싶은 날이 오면, 나머지 절반을 설치하고 보낼 곳을 지정하면 됩니다.
|
||||
|
||||
```console
|
||||
uv add opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
```
|
||||
|
||||
일반적인 OpenTelemetry 방식으로 익스포터를 설정하면, SDK가 조용히 만들어 오던 모든 스팬이 드러납니다. 서버 코드는 바뀌지 않습니다. 단 한 줄도 바뀌지 않습니다.
|
||||
|
||||
!!! info
|
||||
[Pydantic Logfire](https://logfire.pydantic.dev/)가 그런 백엔드 중 하나이며, 설정까지 대신 해 줍니다. `pip install logfire`, `logfire.configure()`만 하면 MCP 스팬이 라이브 뷰에 나타납니다. OpenTelemetry 위에 만들어졌으므로 아래 내용도 모두 적용됩니다.
|
||||
|
||||
## 네트워크를 건너는 트레이스 {#traces-that-cross-the-wire}
|
||||
|
||||
트레이스는 클라이언트에서 서버까지 요청을 하나로 이어진 그림으로 따라갈 때 가장 유용합니다.
|
||||
|
||||
클라이언트와 서버가 모두 SDK를 실행하면 이 연결은 자동으로 이루어집니다. 클라이언트는 요청에 [W3C 트레이스 컨텍스트](https://www.w3.org/TR/trace-context/)를 주입하고, 서버는 이를 다시 읽어 내어 서버 스팬이 같은 트레이스 안에서 클라이언트 스팬 아래에 중첩됩니다. 이것이 [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)이며, 따로 요청하지 않아도 얻습니다.
|
||||
|
||||
들어오는 메시지에 트레이스 컨텍스트가 없으면, 예를 들어 SDK가 아닌 클라이언트가 보낸 요청이면, 서버 스팬은 완전히 새로운 고아 트레이스를 시작하는 대신 서버에서 이미 현재 상태인 스팬을 부모로 삼습니다.
|
||||
|
||||
## 끄기 {#turning-it-off}
|
||||
|
||||
추적은 미들웨어이며, 서버 목록의 첫 번째 미들웨어입니다. 스팬을 전혀 내보내지 않는 서버를 정말로 원한다면 제거하세요.
|
||||
|
||||
```python
|
||||
from mcp.server._otel import OpenTelemetryMiddleware
|
||||
|
||||
mcp._lowlevel_server.middleware[:] = [
|
||||
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
|
||||
]
|
||||
```
|
||||
|
||||
!!! warning
|
||||
이 임포트에는 앞에 밑줄이 붙어 있으며, 의도된 것입니다. 이 클래스는 [`Server.middleware`](../advanced/middleware.md)가 잠정적인 것과 마찬가지로 잠정적이므로, 임포트 경로는 바뀔 수 있다고 예상해야 합니다. 이 작업이 필요한 경우는 거의 없습니다. 익스포터가 설치되어 있지 않으면 스팬은 공짜이므로, 보통은 켜 둔 채 익스포터를 설치하지 않는 것이 답입니다.
|
||||
|
||||
## 요약 {#recap}
|
||||
|
||||
* 모든 `MCPServer`와 모든 저수준 `Server`는 기본적으로 들어오는 메시지마다 `SERVER` 스팬을 하나씩 내보냅니다. 작성할 것은 아무것도 없습니다.
|
||||
* 스팬에는 `mcp.method.name`과 `mcp.protocol.version`이 붙고, `tools/call`과 `prompts/get`에는 GenAI 속성도 붙어 도구 호출이 다른 에이전트의 호출처럼 묶입니다.
|
||||
* OpenTelemetry SDK와 익스포터를 설치하기 전까지는 비용이 들지 않으며, 설치하고 나면 서버를 바꾸지 않고도 드러납니다.
|
||||
* 양쪽이 모두 SDK를 실행하면 클라이언트에서 서버로 트레이스 컨텍스트가 자동으로 전파됩니다.
|
||||
|
||||
요청을 아예 실행할지 말지를 결정하는 것은 **[인가](authorization.md)**입니다.
|
||||
Loading…
Reference in a new issue