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

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

View file

@ -0,0 +1,123 @@
---
translation:
sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800]
tool: 1
---
# Sugerencias de caché {#caching-hints}
En el protocolo 2026-07-28, cada resultado que un servidor devuelve para `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` y `server/discover` lleva dos campos: `ttlMs`, cuántos milisegundos puede un cliente tratar el resultado como vigente, y `cacheScope`, si un resultado en caché puede compartirse entre usuarios (`"public"`) o pertenece a un único contexto de autorización (`"private"`).
El servidor no guarda nada en caché. Los campos son una *declaración*: "esta lista de herramientas es la misma para todos y no cambiará durante un minuto". Un cliente (o un gateway delante de ti) puede entonces saltarse la ida y vuelta. Respetar las sugerencias es decisión del cliente; emitirlas es trabajo del servidor, y el SDK lo hace por ti.
Por defecto, cada resultado dice `ttlMs: 0, cacheScope: "private"`: caducado de inmediato, nunca compartido. Eso siempre es seguro y siempre conforme. Si tus listas realmente son estables e idénticas para todos los que llaman, dilo en la construcción:
```python title="server.py" hl_lines="5-8"
--8<-- "docs_src/caching/tutorial001.py"
```
* El mapa usa como clave el **nombre del método**, y los seis métodos que admiten caché son las únicas claves válidas. El parámetro tiene el tipo `Mapping[CacheableMethod, CacheHint]`, así que tu editor autocompleta las claves y marca un error tipográfico antes de ejecutar; lo que se le escape al verificador de tipos lanza una excepción en la construcción.
* Un método que no mencionas conserva los valores por defecto. El mapa es un conjunto de sobrescrituras, no un manifiesto.
* `CacheHint(ttl_ms=5_000)` dejó `scope` sin definir, así que sigue siendo `"private"`: cinco segundos de vigencia, por cada llamador. El alcance y el TTL son decisiones independientes.
* `"server/discover"` también es una clave válida, ya que el resultado de descubrimiento admite caché como cualquier lista.
!!! warning
`cacheScope: "public"` significa que *cualquiera* puede recibir tu respuesta en caché. Un
gateway compartido entregará sin problema el resultado de un usuario a otro, incluso cuando
la solicitud estaba autenticada. Marca un resultado como `"public"` solo cuando sea idéntico
para todos los llamadores, y nunca uses `cacheScope` como control de acceso: es una etiqueta,
no un candado.
## Sobrescritura por handler {#per-handler-override}
En el `Server` de bajo nivel, los handlers construyen sus resultados a mano, y `ttl_ms` / `cache_scope` son simplemente campos de los modelos de resultado. Un handler que los define explícitamente siempre gana al mapa del constructor, campo por campo:
```python title="server.py" hl_lines="10 16"
--8<-- "docs_src/caching/tutorial002.py"
```
El handler dijo `ttl_ms=1_000` y nada sobre el alcance. En lo que se transmite: `ttlMs: 1000` (el del handler, no el `60_000` del mapa) y `cacheScope: "public"` (el del mapa, porque el handler lo dejó sin definir). Lo explícito gana a lo configurado, y lo configurado gana a lo por defecto. Esto vale por campo, así que un handler puede fijar un campo y dejar el otro a la política de todo el servidor.
Esta es también la vía de escape para dinámicas que el constructor no puede conocer: un handler que filtra `resources/read` por usuario puede devolver `cache_scope="private"` para una URI desde un servidor por lo demás público.
Una salvedad sobre las listas paginadas: el protocolo exige el **mismo `cacheScope` en cada página** de una misma lista. El mapa del constructor lo cumple por construcción, ya que usa como clave el método, no la página. Pero un handler que sobrescribe el alcance se hace responsable de esa coherencia: sobrescríbelo en *todas* las páginas, nunca solo cuando hay un cursor, o la página uno y la página dos no coincidirán.
## Lo que ve el cliente {#what-the-client-sees}
En una sesión 2026-07-28, `Client` respeta las sugerencias por ti: tiene una caché de respuestas integrada, activada por defecto. Un resultado que llega con un `ttlMs` se almacena, y una llamada idéntica dentro de ese TTL se sirve desde la caché sin ida y vuelta. Un resultado que llega *sin* sugerencia no se guarda en caché: los resultados sin sugerencia reciben `CacheConfig.default_ttl_ms`, que es `0` por defecto (caducado de inmediato), así que un servidor que no declara nada ve exactamente el mismo tráfico llamada por llamada de siempre.
```python title="client.py" hl_lines="33 35 38"
--8<-- "docs_src/caching/tutorial003.py"
```
Cuatro llamadas, tres consultas al servidor. La segunda llamada encontró una entrada vigente y nunca llegó al servidor; adelantar el reloj (inyectado) más allá del TTL hizo que la tercera volviera a consultar; la cuarta dijo `cache_mode="refresh"`. Ese argumento nombrado existe en los cinco verbos con caché (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`):
* `"use"` (el valor por defecto) sirve una entrada vigente si la hay, y almacena lo consultado si no.
* `"refresh"` nunca sirve desde la caché: consulta al servidor y almacena el resultado, reemplazando lo que hubiera en caché.
* `"bypass"` hace la ida y vuelta sin tocar la caché en absoluto: ni lectura ni escritura.
Hay una regla por encima de `"use"`: **las llamadas que llevan `meta` siempre llegan al servidor.** Una solicitud con `meta` definido (un token de progreso, campos de trazado) espera una solicitud real por el canal, así que con `cache_mode="use"` se trata como `"refresh"`: se omite la lectura de la caché, y el resultado obtenido sigue reemplazando la entrada en caché. `"bypass"` y un `"refresh"` explícito se comportan como siempre.
Para desactivar la caché por completo, construye con `Client(server, cache=None)`: cada llamada vuelve a ser una ida y vuelta, y `cache_mode`, aunque se sigue aceptando, no hace nada.
El alcance también se respeta automáticamente: las entradas `"private"` se asocian a la *partición* de la caché (más abajo), mientras que las `"public"` pueden optar por compartirse más ampliamente. Y **las notificaciones ganan al TTL** para las entradas exactas que nombran: una notificación `list_changed` desaloja el listado en caché correspondiente, y `resources/updated` desaloja la lectura en caché almacenada exactamente bajo su URI, por muy vigentes que estuvieran. En una conexión 2026-07-28 esas notificaciones llegan por un stream `subscriptions/listen` que abres con `client.listen(...)`, y el desalojo se completa antes de que tu observador vea el evento; **[Suscripciones](subscriptions.md)** es esa página.
Una salvedad sobre `resources/updated`: el desalojo es solo por URI exacta. El contrato del almacén no tiene operación de enumerar ni de recorrer (igual que la implementación de referencia en TypeScript), así que una notificación que lleva la URI de un *sub*recurso no desaloja una lectura en caché de su padre. Si tu servidor señala los subrecursos de esta forma, vuelve a consultar el padre con `cache_mode="refresh"`.
### Configurarla: `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`: dónde viven las entradas. Por defecto es un almacén en memoria nuevo por cliente; pasa tu propia implementación de `ResponseCacheStore` (respaldada por Redis, por ejemplo) para compartir una caché entre clientes o procesos. Los tipos del contrato (`ResponseCacheStore`, `CacheKey`, `CacheEntry` y el `InMemoryResponseCacheStore` por defecto) se pueden importar desde `mcp.client`. Una búsqueda puede emitir hasta dos `get` secuenciales al almacén (la rama privada, luego la pública), así que ajusta en consecuencia las expectativas de latencia de un almacén remoto. Un almacén personalizado **exige** una `partition` explícita.
* `partition`: la etiqueta de contexto de autorización que evita que las entradas `"private"` de un principal se sirvan a otro dentro de un almacén compartido.
* `target_id`: identidad explícita del servidor, para transportes personalizados y servidores en proceso (más abajo).
* `default_ttl_ms`: TTL aplicado a los resultados que no llevan sugerencia `ttlMs`. El `0` por defecto deja sin caché los resultados sin sugerencia.
* `share_public`: sirve entre particiones las entradas que el servidor afirma como `"public"` (más abajo). Desactivado por defecto.
* `clock`: la fuente de hora de reloj, en segundos desde la época Unix. Inyecta una, como hace el ejemplo de arriba, y las pruebas de caducidad no necesitan dormir.
!!! warning "Partición = principal verificado"
Deriva `partition` de una **credencial verificada**, como el subject de un token validado. Nunca la derives de datos proporcionados por la solicitud, y nunca de la URL del servidor (la identidad del servidor es un eje de clave aparte). El SDK es una biblioteca sin autenticación propia: el ancla de confianza es quien construye el `CacheConfig`, que es el despliegue, no el inquilino. Un gateway multiinquilino crea un `CacheConfig` por cada principal autenticado.
La partición también queda fija durante toda la vida del `Client`. Si el contexto de autorización de la conexión cambia a mitad de sesión (una reautenticación como un principal distinto, por ejemplo), la caché no lo sigue; construye un nuevo `Client` para el nuevo principal.
Las claves de caché también llevan la **identidad del servidor**: la cadena de URL a la que te conectaste, sin el userinfo `user:pass@` y, por lo demás, exacta byte por byte. Sin normalizar mayúsculas, sin reordenar la query, sin limpiar la barra final. Normalizar de menos solo cuesta compartición, mientras que normalizar de más podría fusionar dos inquilinos (`?tenant=a` frente a `?tenant=b`), así que las URL superficialmente distintas simplemente no comparten entradas. Cuando no hay URL (un servidor en proceso, o una instancia de `Transport`), el cliente recibe en su lugar una identidad aleatoria por instancia; define `CacheConfig.target_id` para nombrar el servidor (con un almacén personalizado es obligatorio, y la construcción lo dice). La identidad se pasa por un hash sha256 antes de entrar en el material de la clave, así que una URL con secretos en su cadena de consulta nunca aparece en las claves del almacén. Tampoco registres tú la forma previa al hash.
!!! warning "`share_public` confía en el servidor, para toda la flota"
Por defecto, incluso las entradas `"public"` permanecen dentro de su partición. `share_public=True` sirve las entradas que el servidor marcó `cacheScope: "public"` a **todas** las particiones que usan el almacén, confiando en la clasificación del servidor en nombre de todas ellas. Un servidor que pone `"public"` a datos por inquilino (por error o por malicia) filtra entonces la respuesta de un inquilino a los demás. La opción es deliberadamente solo de nivel constructor: el `cache_mode` por llamada puede restringir la caché, pero nada por llamada puede ampliar la compartición.
### Lo que la caché nunca hace {#what-the-cache-never-does}
* **Las llamadas del nivel de sesión la omiten.** `client.session.list_tools()` y compañía siempre hacen la ida y vuelta; la caché vive en los verbos de `Client`.
* **`server/discover` queda fuera.** El resultado de discover se entrega una vez, al conectar, y nunca entra en la caché de respuestas, incluso cuando lleva un `ttlMs`. Si persistes uno tú mismo para saltarte el sondeo de reconexión ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), su vigencia es tu responsabilidad: `DiscoverResult` lleva `ttl_ms` y `cache_scope`, ya analizados, exactamente para eso.
* **Las páginas de continuación nunca se guardan en caché.** Solo participan las llamadas sin cursor. Una página de continuación rechazada por un cursor caducado sí *desaloja* el listado en caché, porque el listado cambió por debajo.
* **Las lecturas de varias idas y vueltas (multi-round-trip) nunca se guardan en caché.** Un `read_resource` iniciado con `input_responses`/`request_state`, o uno que se resuelve a través de rondas de entrada, nunca entra en la caché (un MUST de la especificación).
* **El desalojo por notificación necesita notificaciones.** El desalojo es tan bueno como la entrega del transporte, y la ruta moderna en proceso (`Client(server)` con el `mode="auto"` por defecto) hoy no entrega notificaciones independientes.
* **El desalojo es diferido, no instantáneo.** Las notificaciones de la ruta de red se despachan desde tareas lanzadas aparte, así que una llamada que compite con la llegada de una notificación puede recibir una vez más la entrada previa al desalojo; la ventana está acotada por la latencia de despacho, y el desalojo igualmente se produce.
* **Sin stale-if-error.** Una entrada caducada nunca se sirve porque la nueva consulta falló; el error se propaga.
* **Sin reconsulta anticipada.** Una entrada almacenada se sirve hasta que caduca su TTL y la siguiente llamada después de eso paga la ida y vuelta; nada se refresca en segundo plano.
* **Sin coalescencia.** Dos llamadas idénticas concurrentes son dos consultas.
* **Ningún TTL de más de 24 horas.** Un `ttlMs` mayor, ya sea enviado por el servidor o configurado, se recorta al almacenar (`mcp.client.caching.MAX_TTL_MS`), lo que acota cuánto tiempo puede servirse cualquier entrada, por generosa que sea su sugerencia.
* En un **almacén compartido**, los clientes compiten entre sí. Cada cliente descarta su propia escritura cuando un desalojo adelantó a la consulta en curso, pero un cliente *coinquilino* aún puede volver a escribir una entrada que un desalojo que nunca vio había eliminado; y esa contabilidad de carreras está acotada a su vez: pasadas 4096 claves rastreadas, primero se descarta la guarda de la clave más antigua. Ambas ventanas se aceptan, y las cierra el límite de TTL de arriba.
* **Nada se sirve entre generaciones del protocolo.** Las entradas están acotadas a la versión de protocolo negociada: en un almacén persistente compartido, una sesión nunca sirve una entrada escrita bajo otra versión negociada (el mismo listado difiere de verdad según la generación, ya que el SDK quita los campos 2026 para las sesiones más antiguas). El desalojo, igualmente, solo toca las entradas de la generación actual; las entradas de otra generación simplemente caducan por TTL.
### Leer las sugerencias por tu cuenta {#reading-the-hints-yourself}
Las sugerencias también son campos normales en cada resultado que admite caché (`result.ttl_ms` y `result.cache_scope`, ya analizados), por si quieres añadir tu propia contabilidad encima de la caché integrada (o en lugar de ella).
Contra un **servidor más antiguo** (protocolo anterior a 2026), los campos simplemente no aparecen en lo que se transmite, y los modelos muestran sus valores por defecto conservadores: `ttl_ms == 0` y `cache_scope == "private"`, caducado y sin compartir, la suposición correcta para un servidor que no declaró nada. La caché trata una sesión heredada de la misma forma: allí las sugerencias nunca se consultan (sean cuales sean las claves que aparezcan en lo que se transmite), solo se aplica `default_ttl_ms`, y su valor por defecto de `0` no guarda nada en caché, así que una conexión anterior a 2026 se comporta exactamente como antes de que existiera la caché. Si necesitas distinguir "el servidor dijo 0" de "el servidor no dijo nada", comprueba `"ttl_ms" in result.model_fields_set`: solo está definido cuando el campo llegó de verdad.
## Clientes más antiguos {#older-clients}
Los clientes con versiones del protocolo anteriores a 2026 nunca ven ninguno de los dos campos; el SDK los quita en la serialización para esas conexiones. Configura tus sugerencias una vez; no hay nada específico de versión que escribir.
## Resumen {#recap}
* Seis métodos llevan `ttlMs`/`cacheScope`; el SDK los deja por defecto en `0`/`"private"`, caducado y sin compartir, siempre seguro.
* `cache_hints={method: CacheHint(...)}` en la construcción (tanto en `MCPServer` como en `Server`) fija valores para todo el servidor por método.
* Un handler que define los campos en su resultado sobrescribe el mapa, campo por campo.
* `"public"` es una promesa de que el resultado es idéntico para todos los llamadores. No es control de acceso.
* `Client` respeta las sugerencias automáticamente: su caché de respuestas está activada por defecto, sirve entradas vigentes en lugar de volver a consultar, y no guarda nada en caché para servidores (o sesiones) que no proporcionan sugerencias.
* Por llamada, `cache_mode="refresh"` vuelve a consultar y `"bypass"` se salta la caché; `cache=None` en la construcción la desactiva por completo.

View file

@ -0,0 +1,154 @@
---
translation:
sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0]
tool: 1
---
# Callbacks del cliente {#client-callbacks}
Casi todas las solicitudes en MCP van en una sola dirección: del cliente al servidor.
Un servidor también puede pedirle cosas al **cliente**: hacerle una pregunta al usuario, muestrear el modelo del usuario, listar las carpetas del espacio de trabajo del usuario. Respondes a esas solicitudes pasando **callbacks** a `Client(...)`.
## Un servidor que pregunta {#a-server-that-asks}
Aquí tienes un servidor cuya herramienta no puede terminar por sí sola:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/client_callbacks/tutorial001.py"
```
* `ctx.elicit(...)` envía una solicitud `elicitation/create` **al cliente** y espera.
* La herramienta no devuelve nada hasta que alguien (una persona en un formulario, o tu código) proporciona un `name`.
Esa es la mitad del servidor, y la página **[Elicitación](../handlers/elicitation.md)** se ocupa de ella. Esta página es el otro extremo de la conexión.
## El callback de elicitación {#the-elicitation-callback}
```python title="client.py" hl_lines="6-10 16-17"
--8<-- "docs_src/client_callbacks/tutorial002.py"
```
* Un callback de elicitación (elicitation) es `async (context, params) -> ElicitResult`.
* `params.message` es la pregunta. `params.requested_schema` es el JSON Schema de la respuesta que quiere el servidor. Un cliente real genera un formulario a partir de él; este lo rellena automáticamente.
* Devuelves `ElicitResult(action="accept", content={...})`, o `action="decline"`, o `action="cancel"`. La única otra opción es `ErrorData(...)`, que rechaza la solicitud y hace fallar toda la llamada.
* `context` es un `ClientRequestContext`: la `session` activa, el `request_id` del servidor y cualquier `meta` que haya adjuntado.
!!! tip
`params` es una unión de los dos modos de elicitación. Aquí `params.mode` es `"form"`; una solicitud
`"url"` lleva `params.url` en lugar de un esquema. Un solo callback maneja ambos; bifurca según `params.mode`.
**[Elicitación](../handlers/elicitation.md)** muestra el patrón completo.
### Pruébalo {#try-it}
Llama a `issue_card` y observa ambos extremos.
Tu callback recibe la pregunta del servidor, ya analizada:
```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'}
```
Responde, `ctx.elicit(...)` se reanuda dentro de la herramienta y la herramienta termina:
```python
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
```
Un `tools/call` tuyo, un `elicitation/create` de vuelta desde el servidor, respondido por tu función, todo dentro de una sola llamada a herramienta.
!!! info
`mode="legacy"` en la llamada a `Client(...)` hace trabajo real. Por defecto, `Client(...)` negocia la ruta
moderna del protocolo, y esa ruta no tiene canal de retorno (back-channel) para las solicitudes del servidor al cliente: `ctx.elicit`
falla antes de que tu callback llegue a ejecutarse. No lo decide el transporte; lo decide el protocolo
negociado, tanto en memoria como a través de una URL. Fija `mode="legacy"` siempre que tu cliente tenga
que responder a una; todas las pruebas detrás de esta página lo hacen. **[Versiones del protocolo](../protocol-versions.md)** tiene todos los detalles.
En una sesión 2026-07-28 el callback no está muerto, se alimenta de otra forma: cuando una herramienta devuelve un
`InputRequiredResult` que lleva un `ElicitRequest`, `Client` despacha esa entrada al mismo
`elicitation_callback` y reintenta la llamada por ti. Ese flujo está en **[Solicitudes de varias idas y vueltas](../handlers/multi-round-trip.md)**.
## Un callback es una capacidad {#a-callback-is-a-capability}
Nunca le dijiste al servidor que tu cliente puede responder solicitudes de elicitación. Lo hizo el SDK.
Cuando un cliente se conecta declara sus `capabilities`, la imagen especular de las del servidor. No escribes ese objeto. **Registrar un callback es la declaración.**
| lo que pasas | lo que declara el cliente |
| --- | --- |
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
| `sampling_callback=` | `"sampling": {}` |
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
| ninguno de ellos | `{}` |
Las subcapacidades de muestreo (sampling) son el único refinamiento: pasa `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` junto con `sampling_callback` cuando tu muestreador maneja los parámetros `tools` / `tool_choice`. Los servidores deben ver `sampling.tools` declarado antes de poder enviarlos.
`logging_callback` y `message_handler` no están en la tabla. Manejan notificaciones, y las notificaciones no necesitan ninguna capacidad.
El servidor lee la declaración con `ctx.session.check_client_capability(...)`. Añade una herramienta que lo haga:
```python title="server.py" hl_lines="23-31"
--8<-- "docs_src/client_callbacks/tutorial003.py"
```
Conéctate solo con `elicitation_callback` y llámala:
```python
result.structured_content # {'result': ['elicitation']}
```
Pasa los tres callbacks y obtienes `['elicitation', 'sampling', 'roots']`. No pases ninguno y obtienes `[]`.
!!! check
Ahora haz lo incorrecto: conéctate **sin** `elicitation_callback` y llama a `issue_card` de todos modos.
La solicitud `elicitation/create` del servidor sigue llegando a tu cliente, y el SDK la responde por
ti, con un error, porque nunca dijiste que pudieras manejarla. Ese error hunde toda la llamada.
`call_tool` no devuelve un resultado `is_error`; lanza:
```text
MCPError: Elicitation not supported
```
Eso es un error de protocolo (`-32600`, *invalid request*), no un error de herramienta: no hay nada que
el modelo pueda leer y reintentar. Por eso vale la pena tener `client_features`: un servidor bien educado
comprueba antes de preguntar.
## El par obsoleto {#the-deprecated-pair}
`sampling_callback` responde a `sampling/createMessage`: el servidor pidiéndole a *tu* modelo que complete algo. `list_roots_callback` responde a `roots/list`: el servidor preguntando en qué directorios puede trabajar.
Ambos funcionan. Ambos siguen la regla anterior. Y ambos atienden RPC que **la especificación 2026-07-28 elimina**: un servidor moderno no llama de vuelta a tu cliente a mitad de una solicitud, te devuelve la solicitud como parte del resultado de la herramienta (**[Solicitudes de varias idas y vueltas](../handlers/multi-round-trip.md)**). Los callbacks en sí no están muertos. Cuando un `InputRequiredResult` lleva un `CreateMessageRequest` o un `ListRootsRequest`, el bucle automático de `Client` lo despacha al mismo `sampling_callback` o `list_roots_callback` que registraste aquí. La lista completa está en **[Funcionalidades obsoletas](../deprecated.md)**.
Sigues necesitando los callbacks para hablar con servidores que no han migrado. Las firmas:
```python title="client.py"
--8<-- "docs_src/client_callbacks/tutorial004.py"
```
* Un callback de muestreo recibe los `CreateMessageRequestParams` completos (`messages`, `model_preferences`, `max_tokens`) y devuelve un `CreateMessageResult`. *Tú* ejecutas el modelo, como prefieras; el SDK solo transporta la solicitud.
* Un callback de roots no recibe ningún parámetro y devuelve un `ListRootsResult`.
* Cualquiera de los dos puede devolver `ErrorData(...)` en su lugar, para rechazar.
Pásalos a `Client(...)` exactamente igual que `elicitation_callback`.
## Los callbacks de notificaciones {#the-notification-callbacks}
Dos más. Ninguno declara nada.
`logging_callback` recibe el `notifications/message` que envía un servidor, como `LoggingMessageNotificationParams` (`level`, `logger`, `data`). El logging del protocolo está a su vez obsoleto según la especificación 2026-07-28 (**[Logging](../handlers/logging.md)** explica qué hacer en su lugar), así que este callback existe para los servidores que todavía lo emiten. En una conexión de la generación 2026 el callback por sí solo no te da nada, porque los servidores 2026 envían mensajes de log solo a las solicitudes que lo piden: pasa `log_level="info"` (u otro nivel) a `Client(...)` para marcar esa preferencia en cada solicitud y recibir ese nivel y los superiores. Los servidores anteriores a 2026 lo ignoran y mantienen su comportamiento de `logging/setLevel`.
`message_handler` es el comodín: toda notificación del servidor que la sesión expone le llega (además de a su callback específico), y en un transporte basado en flujos también toda `Exception` a nivel de transporte. Dos nunca llegan: `notifications/cancelled` la aplica el SDK en lugar de exponerla, y la confirmación de suscripción de un flujo `listen()` activo la consume ese flujo. Anota el parámetro con `IncomingMessage` (`ServerNotification | Exception`, exportado desde `mcp.client`). El único patrón que vale la pena conocer es `if isinstance(message, Exception): raise message`, para que una conexión rota falle de forma visible en lugar de desvanecerse.
## Resumen {#recap}
* Un servidor puede enviar solicitudes al cliente. Las respondes con callbacks pasados a `Client(...)`.
* El callback de elicitación es el vigente: `async (context, params) -> ElicitResult`, una sola función para los modos formulario y URL.
* **Registrar un callback es declarar la capacidad.** Sin él, el SDK rechaza la solicitud del servidor en tu nombre y toda la llamada falla con `MCPError`.
* Un servidor lo averigua antes de preguntar con `ctx.session.check_client_capability(...)`.
* `sampling_callback` y `list_roots_callback` funcionan igual pero atienden funcionalidades obsoletas; los servidores modernos usan solicitudes de varias idas y vueltas en su lugar.
* `logging_callback` y `message_handler` reciben notificaciones. No declaran nada.
El primer argumento de `Client(...)` es un objeto de transporte. **[Transportes del cliente](transports.md)** cubre todos los tipos.

View file

@ -0,0 +1,153 @@
---
translation:
sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0]
tool: 1
---
# Aserción de identidad {#identity-assertion}
Un proveedor OAuth ordinario (**[Clientes OAuth](oauth-clients.md)**) empieza por hacerle una pregunta al servidor MCP: *¿en qué servidor de autorización confías?* Sigue la respuesta adonde apunte y, a partir de ahí, o bien una persona inicia sesión o bien un secreto compartido de antemano ocupa su lugar.
Una empresa no quiere que ninguna de las dos cosas se decida servidor por servidor. Ya tiene un proveedor de identidad en marcha (Okta, Microsoft Entra ID, el tuyo propio); el usuario ya inició sesión en él esta mañana; y es el único lugar donde el equipo de seguridad quiere decidir quién puede acceder a qué. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), la extensión **Enterprise-Managed Authorization**, traslada la decisión allí. El IdP firma un JWT de corta duración, un **Identity Assertion JWT Authorization Grant**, el **ID-JAG**: una declaración de que *este usuario*, a través de *este cliente*, puede acceder a *este servidor MCP*. El cliente lo intercambia por un token de acceso ordinario. Sin navegador, sin pantalla de consentimiento, sin registro dinámico.
Esta página cubre los dos extremos de ese intercambio. El servidor MCP en sí nunca cambia: sigue siendo el servidor de recursos de **[Autorización](../run/authorization.md)**, que comprueba cualquier token que le llegue.
## Dos solicitudes de token {#two-token-requests}
Hay dos autoridades distintas en juego, y saber distinguirlas por su nombre es casi todo lo que hace falta para entender esta página. El **IdP de la empresa** es el proveedor de identidad de tu organización: sabe quién es el empleado, es donde vive la política y es quien emite el ID-JAG. El SDK nunca habla con él. El **servidor de autorización MCP** es la misma parte que era en **[Autorización](../run/authorization.md)**: el emisor nombrado en los metadatos del servidor MCP, lo que acuña los tokens que ese servidor MCP acepta. En un flujo OAuth ordinario, esos dos roles suelen ser una sola caja. Aquí son dos, y toda la concesión consiste en que el segundo acepte confiar en el primero.
El cliente hace una solicitud de token a cada uno.
1. **Al IdP de la empresa.** El cliente intercambia el inicio de sesión del usuario (su token de ID de OpenID Connect) por el ID-JAG. Es un intercambio de tokens de [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), es por completo la API de tu IdP y **el SDK no lo hace**. Lo haces tú, dentro de un callback asíncrono. Es también donde ocurre la decisión de política: un IdP que dice que no nunca emite el ID-JAG, y no hay nada que presentar.
2. **Al servidor de autorización MCP.** El cliente presenta el ID-JAG bajo la concesión `jwt-bearer` de [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, con el ID-JAG como `assertion`) y recibe el token de acceso. **Esta es la solicitud que hace el SDK**, y aceptarla es lo único que esta página añade a un servidor de autorización.
Todo lo que sigue es la segunda solicitud: el cliente que la envía y el servidor de autorización que la responde.
## El cliente {#the-client}
**`IdentityAssertionOAuthProvider`** vive en `mcp.client.auth.extensions.identity_assertion`. Como todos los proveedores de **[Clientes OAuth](oauth-clients.md)**, es un `httpx2.Auth`: construyes uno, lo pones en `auth=` y le pasas el `httpx2.AsyncClient` al transporte.
```python title="client.py" hl_lines="49-50 53-61"
--8<-- "docs_src/identity_assertion/tutorial001.py"
```
Léelo desde abajo.
* `main()` es el `main()` estándar de un cliente OAuth (**[Clientes OAuth](oauth-clients.md)**), sin cambiar una sola línea. Esa es la idea: una vez que existe el proveedor, nada de lo que viene después sabe qué concesión produjo el token.
* El proveedor recibe lo que los demás proveedores no pueden descubrir: un `client_id` y un `client_secret` que alguien **registró de antemano** en el servidor de autorización, el `issuer` de ese servidor de autorización y `assertion_provider`, un callback asíncrono que devuelve un ID-JAG nuevo cuando se le pide.
* `storage` es el mismo protocolo `TokenStorage`. Solo se llama a los dos métodos de tokens; aquí no hay registro dinámico, así que no hay ningún `client_info` que recordar.
### El proveedor de aserciones {#the-assertion-provider}
`fetch_id_jag(audience, resource)` es el único código que escribes. Se espera una vez por intercambio de tokens, nunca en la construcción, y solo *después* de que los metadatos del servidor de autorización se hayan obtenido y validado, de modo que un emisor mal configurado nunca filtra una aserción. Sus dos argumentos son dos de los claims con los que debe acuñarse el ID-JAG: `audience` es el emisor del servidor de autorización (el `aud` del ID-JAG) y `resource` es el identificador canónico del servidor MCP (el `resource` del ID-JAG). El tercero ya lo tienes: el claim `client_id` del ID-JAG debe nombrar el `client_id` que le diste al proveedor, o el servidor de autorización rechaza el intercambio.
`idp_issue_id_jag`, justo encima, **no es tu código**. Hace las veces del proveedor de identidad y firma la aserción dentro del mismo proceso para que el archivo esté completo y puedas leer cada claim que lleva un ID-JAG. Un `fetch_id_jag` real hace, en cambio, la primera solicitud de token de la sección anterior: un intercambio de tokens de [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) contra tu IdP, definido por el borrador Identity Assertion JWT Authorization Grant que [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) perfila. El token de ID del usuario que inició sesión entra como `subject_token`, el `requested_token_type` es el URN propio del ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` y `resource` pasan tal cual, y la respuesta trae el ID-JAG. Ese intercambio, con esos nombres, es lo que debes buscar en la documentación de tu IdP.
!!! tip
Se solicita un ID-JAG nuevo en cada intercambio, y esa es la idea: es una concesión de un
solo uso que vive minutos, y el servidor de autorización de esta página se niega a aceptar el
mismo dos veces. No lo guardes en caché. Lo que se reutiliza es el token de acceso que te compra.
### El emisor es configuración {#the-issuer-is-configuration}
Aquí está la inversión. `OAuthClientProvider` le pregunta al servidor de recursos qué servidor de autorización usar y sigue la respuesta adonde apunte. Este proveedor se niega a hacerlo: `issuer` es obligatorio, los metadatos de [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) se obtienen de la ruta well-known propia de ese emisor, el endpoint de token debe estar en el origen de ese emisor y al servidor de recursos nunca se le pregunta nada.
La extensión no exige esto; es una elección deliberadamente más estricta. Este cliente lleva dos cosas que vale la pena robar, un secreto registrado de antemano y una aserción vinculada a una audiencia, y un cliente que dejara que un servidor MCP comprometido lo dirigiera al servidor de autorización de un atacante le enviaría ambas. Fijar el emisor en la construcción elimina esa conversación.
!!! warning
El `issuer` configurado se compara con el campo `issuer` del documento de metadatos mediante la
comparación simple de cadenas de RFC 8414 §3.3: carácter por carácter, barra final incluida,
sin normalización. No lo adivines. Obtén `/.well-known/oauth-authorization-server` de tu
servidor de autorización y copia el valor `issuer` que devuelve. Para el servidor de
autorización de esta página es `https://auth.example.com/`, con la barra, porque su emisor se
construyó a partir de un objeto URL de pydantic. Una discrepancia detiene el flujo en
`OAuthFlowError: Authorization server metadata issuer
mismatch` antes de que se envíe una sola credencial o aserción.
### Un cliente confidencial {#a-confidential-client}
`client_secret` es obligatorio; el constructor lanza `ValueError` si falta. El perfil del IETF que hay debajo de [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserva esta concesión para clientes confidenciales, SEP-990 exige que el cliente se autentique, y este SDK hace cumplir ambas cosas insistiendo en un secreto compartido. `token_endpoint_auth_method` elige por dónde viaja: `client_secret_post` (el valor por defecto, en el cuerpo del formulario) o `client_secret_basic` (una cabecera HTTP Basic). El perfil también permite `private_key_jwt`; este proveedor no lo admite.
!!! tip
Lee `client_secret` del entorno o de un gestor de secretos, nunca del control de versiones.
### Lo que el proveedor hace por ti {#what-the-provider-does-for-you}
La primera solicitud sale sin autenticar, y el `401` del servidor inicia el flujo.
1. **Descubrimiento.** Obtiene los metadatos del servidor de autorización de la ruta well-known de [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) del emisor configurado, comprueba que el `issuer` del documento coincide y comprueba que el endpoint de token está en el origen del emisor.
2. **La aserción.** Espera tu `assertion_provider`.
3. **Intercambio.** Envía con POST la concesión `jwt-bearer` al endpoint de token, guarda el `OAuthToken` y repite tu solicitud original con `Authorization: Bearer ...`.
Un `403` cuyo `WWW-Authenticate` indica `insufficient_scope` ejecuta los pasos 2 y 3 de nuevo con la unión de tu `scope` y el reclamado. (`scope` nunca es más que una petición; el servidor de autorización de esta página concede lo que dice el ID-JAG y nada más.) No hay token de actualización en ninguna parte de esto: cuando el token de acceso caduca, el siguiente `401` acuña un ID-JAG nuevo y vuelve a intercambiar, y *esa* es la palanca que tiene el IdP. Los fallos son las mismas dos excepciones que en el resto de **[Clientes OAuth](oauth-clients.md)**: `OAuthFlowError` para el descubrimiento y la validación, y su subclase `OAuthTokenError` cuando el endpoint de token dice que no.
## El servidor de autorización {#the-authorization-server}
La mayoría de las veces te detienes aquí. El servidor de autorización MCP es el producto de otra persona, aceptar ID-JAG es una configuración suya que hay que activar, y la mitad de [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) que le toca al SDK es el cliente de arriba.
El SDK también puede *ser* el servidor de autorización: `create_auth_routes` devuelve las rutas del servidor de autorización como una lista que cualquier app de Starlette puede montar, que es como `examples/servers/simple-auth/` en el repositorio ejecuta uno. SEP-990 añade una bandera y un método a esa superficie:
```python title="auth_server.py" hl_lines="48-50 105-107"
--8<-- "docs_src/identity_assertion/tutorial002.py"
```
* `identity_assertion_enabled=True` lo controla todo. Desactivado, que es el valor por defecto, `/token` responde a esta concesión con `unsupported_grant_type` aunque hayas implementado el hook, y los metadatos no la mencionan. Activado, los metadatos ganan el tipo de concesión `jwt-bearer` y listan `urn:ietf:params:oauth:grant-profile:id-jag` en `authorization_grant_profiles_supported`, el campo que la extensión usa para anunciar la compatibilidad. (El cliente de este SDK nunca lo lee: está aprovisionado para un solo emisor y simplemente pregunta.)
* **`exchange_identity_assertion`** es el hook. Antes de que se ejecute, el SDK ha autenticado al cliente, ha rechazado los clientes públicos y ha rechazado los clientes cuyo registro no lista la concesión. Recibes un `IdentityAssertionParams` (la `assertion` sin procesar, los `scopes` solicitados y el `resource`) y devuelves un `OAuthToken` simple.
* El registro dinámico de clientes rechaza esta concesión sin excepciones, así que `get_client` aquí sirve un cliente aprovisionado a mano. Un cliente ID-JAG no puede registrarse a sí mismo para existir.
* La mitad de la clase son rechazos. `OAuthAuthorizationServerProvider` es el servidor de autorización *completo*, así que también pide el flujo de código de autorización; un servidor que además inicia la sesión de los usuarios implementa esos de verdad, y este tiene exactamente una puerta.
!!! warning
El SDK nunca decodifica la aserción: solo tu despliegue sabe en qué IdP confía y qué claves
publica ese IdP, así que todo lo que hay dentro de `exchange_identity_assertion` es esencial.
Verifica la firma contra las claves publicadas del IdP (su JWKS; el secreto compartido de aquí
es el de la demo), así como `iss` y `exp`, según [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Exige que el `typ` de la cabecera
del JWT sea `oauth-id-jag+jwt`, la protección del perfil contra que algún otro JWT se reenvíe
como concesión. Exige que `aud` sea tu propio emisor. Exige que el claim `client_id` del ID-JAG
sea igual al cliente que el handler autenticó, y que su claim `resource` nombre un recurso que
realmente sirves. Lleva registro de `jti` hasta el `exp` de la aserción para que se acepte una
sola vez. Y toma los scopes concedidos y, sobre todo, el `resource` del token emitido del ID-JAG
validado, nunca de la solicitud: `params.resource` es lo que sea que el cliente escribió. Las
reglas de procesamiento completas están en la
[especificación Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization).
Rechaza una aserción inválida con `TokenError("invalid_grant", ...)`. El otro código de error de este flujo es `invalid_target`: un ID-JAG que nombra un recurso que no sirves se rechaza con él, que es lo que impide que este servidor acuñe tokens para el de otra persona. Y los scopes concedidos salen del claim `scope` del ID-JAG (una aserción sin él también se rechaza); el tuyo podría mapear los grupos del usuario en su lugar.
Y fíjate en lo que el `OAuthToken` devuelto no lleva: un token de actualización. El IdP decide cuánto tiempo conserva el acceso este usuario decidiendo si emite el siguiente ID-JAG. Un token de actualización acuñado aquí le devolvería en silencio esa decisión.
!!! info
Un servidor que todavía incrusta su servidor de autorización con `auth_server_provider=` llega al
mismo código a través de `AuthSettings(identity_assertion_enabled=True)`. **[Autorización](../run/authorization.md)** explica
por qué los servidores nuevos no deberían empezar por ahí.
!!! check
Conecta los dos archivos de esta página entre sí y toda la concesión es un solo `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"}
```
Sin `/authorize`, sin `/register`, sin obtener los metadatos del recurso protegido. Las únicas
solicitudes que se transmiten son la que provocó el `401`, la obtención del well-known, este
intercambio y luego tráfico MCP ordinario con el bearer adjunto. Y el `sub` que tu validador
leyó del ID-JAG es exactamente lo que `get_access_token().subject` informa dentro de una herramienta.
### Pruébalo {#try-it}
`examples/stories/identity_assertion/` en el repositorio del SDK es esta página funcionando de verdad: el mismo validador `exchange_identity_assertion`, un servidor MCP protegido por sus tokens, un IdP sustituto y el cliente, en un solo programa que se verifica a sí mismo. `uv run python -m stories.identity_assertion.client --http` ejecuta todo el intercambio y comprueba que el usuario que nombró el IdP es el usuario que ve la herramienta.
## Resumen {#recap}
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) permite que el proveedor de identidad de la empresa, y no el usuario final, decida a qué servidores MCP puede acceder un cliente. El IdP firma esa decisión en un **ID-JAG**.
* Obtener el ID-JAG es un intercambio de tokens de [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) contra *tu IdP*, y el SDK no lo hace. Presentarlo al servidor de autorización MCP es la concesión `jwt-bearer` de [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), y el SDK cubre los dos lados de eso.
* `IdentityAssertionOAuthProvider` es otro `httpx2.Auth`: un cliente confidencial registrado de antemano, un `issuer` fijado y un callback `assertion_provider(audience, resource)`. Sin navegador, sin registro, sin token de actualización.
* El servidor de autorización nunca se descubre desde el servidor de recursos. Configura `issuer` con exactamente la cadena que sirve su documento de metadatos; la comparación es carácter por carácter.
* Del lado del servidor, `identity_assertion_enabled=True` más `exchange_identity_assertion`. El SDK autentica al cliente y controla la concesión; validar el ID-JAG es enteramente cosa tuya, y el token emitido queda vinculado al `resource` del ID-JAG, no al de la solicitud.
La única parte que esta página nunca tocó es el servidor MCP. Lo que hace con el token que acabas de acuñar ya lo hacía en **[Autorización](../run/authorization.md)**.

View file

@ -0,0 +1,217 @@
---
translation:
sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30]
tool: 1
---
# El cliente {#the-client}
Un **`Client`** es la forma en que un programa de Python se comunica con un servidor MCP.
Es un solo objeto con un solo ciclo de vida: lo construyes, entras en `async with`, llamas a sus métodos. Cada verbo del protocolo (listar las herramientas, llamar a una, leer un recurso, renderizar un prompt) es un método `async` del objeto que devuelve un resultado tipado.
## Tu primer cliente {#your-first-client}
```python title="client.py" hl_lines="14-18"
--8<-- "docs_src/client/tutorial001.py"
```
El servidor del principio solo está ahí para que tengas algo a lo que conectarte. El cliente son las cinco líneas resaltadas.
* `Client(mcp)` recibe **el propio objeto servidor**. Ese es el transporte en memoria: sin subproceso, sin puerto, sin HTTP. Así se conectan todos los ejemplos de esta página y todas las pruebas que escribas.
* `async with` es el **ciclo de vida**. Al entrar se conecta y negocia; al salir se desconecta. No hay un par `connect()` / `close()`, y un `Client` no se puede reutilizar una vez que termina el bloque.
* Dentro del bloque, los datos de la conexión ya están ahí como propiedades simples.
### Qué puedes pasarle a `Client` {#what-you-can-pass-to-client}
`Client` recibe un solo argumento posicional y resuelve el transporte a partir de su tipo:
* Una instancia de `MCPServer` (o del `Server` de bajo nivel): se conecta **en el mismo proceso**.
* Una cadena con una URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, el camino de producción.
* Un **transporte**: cualquier cosa que puedas usar con `async with ... as (read, write)`, como `stdio_client(...)` envolviendo un subproceso.
Todo lo demás en esta página es idéntico en los tres casos. Los encabezados, los subprocesos, los timeouts y el protocolo `Transport` tienen su propia página: **[Transportes del cliente](transports.md)**.
### Qué hay en un cliente conectado {#whats-on-a-connected-client}
Cuatro propiedades de solo lectura, que se rellenan en cuanto entras en el bloque:
* `client.server_info`: la identidad del servidor, o `None` para un servidor de la generación 2026 que no la informa (los servidores de python-sdk lo hacen por defecto). Aquí `server_info.name` es `"Bookshop"` y `server_info.version` es lo que el servidor informe.
* `client.server_capabilities`: lo que el servidor puede hacer (`tools`, `resources`, `prompts`, `completions`, ...). Una capacidad que el servidor no tiene es `None`.
* `client.protocol_version`: la versión del protocolo que acordaron las dos partes. Aquí es `"2026-07-28"`.
* `client.instructions`: la cadena `instructions=` del servidor, o `None` si no definió una.
Nunca elegiste una versión del protocolo. Por defecto, el `Client` sondea el servidor y recurre al handshake clásico con los más antiguos, así que un mismo cliente funciona contra servidores de cualquier generación. Cuando necesites controlar eso, **[Versiones del protocolo](../protocol-versions.md)** tiene todos los detalles.
!!! tip
`client.session` es la `ClientSession` subyacente, la vía de escape de bajo nivel.
No la necesitarás para nada de esta página.
## Listar herramientas {#listing-tools}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial002.py"
```
`list_tools()` devuelve un `ListToolsResult`; las herramientas están en `.tools`. Cada una es la definición completa que un host le entregaría a un modelo:
```python
tool.name # 'search_books'
tool.title # 'Search the catalog'
tool.description # 'Search the catalog by title or author.'
```
y `tool.input_schema` es el JSON Schema que el servidor derivó de las anotaciones de tipo de la función:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
Ese esquema es todo lo que una UI necesita para renderizar un formulario de argumentos, y todo lo que un modelo necesita para producir argumentos válidos.
!!! tip
`title` es opcional, así que una UI que muestra herramientas a una persona tiene que elegir: el `title` si lo hay,
el `name` si no. `from mcp.shared.metadata_utils import get_display_name` hace exactamente eso,
para herramientas, recursos, plantillas de recursos y prompts.
## Llamar a una herramienta {#calling-a-tool}
`call_tool(name, arguments)` ejecuta la herramienta y te devuelve un `CallToolResult`.
```python title="client.py" hl_lines="26-33"
--8<-- "docs_src/client/tutorial003.py"
```
El `lookup_book` del servidor devuelve un `Book` de Pydantic. Esto es lo que ve el cliente:
```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
```
Un solo valor de retorno, tres cosas que leer. Cada una tiene un consumidor distinto.
### `content`: lo que lee el modelo {#content-what-the-model-reads}
`content` es una `list` de **bloques de contenido**, y un bloque de contenido es una unión: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` o `EmbeddedResource`. Una herramienta puede devolver varios, de distintos tipos.
Por eso `main` acota el tipo con `isinstance(block, TextContent)` antes de tocar `block.text`. Fíjate en que no hay ningún `.text` fuera del `isinstance`: el verificador de tipos no lo permite, porque `ImageContent` tiene `.data`, no `.text`. La unión es honesta sobre lo que una herramienta puede enviarte; tu código también debería serlo.
### `structured_content`: lo que lee tu aplicación {#structured_content-what-your-application-reads}
`structured_content` es el valor de retorno de la herramienta en JSON, conforme al `output_schema` que declara la herramienta. Sin analizar cadenas, sin adivinar.
Cuando ambos están presentes dicen lo mismo dos veces a propósito: `content` es para un modelo, `structured_content` es para el código. De dónde sale la mitad estructurada, y cómo controlarla, está en la página **[Salida estructurada](../servers/structured-output.md)**.
### `is_error`: si la herramienta falló {#is_error-whether-the-tool-failed}
Una herramienta que lanza una excepción **no** la lanza en tu cliente. Vuelve como un resultado normal con `is_error=True`.
!!! check
Pídele `"Solaris"` a `lookup_book` (un título que no está en el catálogo) y la función lanza
`ValueError`. Aun así, la llamada devuelve un resultado normal:
```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
```
El mensaje de la excepción acabó en `content`, donde el **modelo** puede leerlo y volver a intentarlo. Es
deliberado: un error de herramienta es parte de la conversación, no un fallo fatal. Mira siempre `is_error`
antes de confiar en `structured_content`.
!!! warning
`is_error=True` cubre más que tu propio `raise`. Pide una herramienta que el servidor ni siquiera tiene
(`call_tool("does_not_exist", {})`) y no se lanza nada. Recibes la misma forma de vuelta,
`is_error=True` con `Unknown tool: does_not_exist` en `content`. Un método de `Client` lanza
`MCPError` solo cuando el servidor responde con un **error** JSON-RPC en lugar de un resultado, y
**[Manejo de errores](../servers/handling-errors.md)** explica cuándo un servidor produce cada cosa.
## Recursos {#resources}
Los verbos de recursos vienen en pares: dos formas de listar, una de leer.
```python title="client.py" hl_lines="22-31"
--8<-- "docs_src/client/tutorial004.py"
```
* `list_resources()` devuelve los recursos **concretos**, los que tienen una URI fija. Aquí: `['catalog://genres']`.
* `list_resource_templates()` devuelve los **parametrizados**. Aquí: `['catalog://genres/{genre}']`. Son dos listas distintas porque una plantilla no se puede leer hasta que la rellenas.
* `read_resource(uri)` recibe una URI como `str` simple y funciona con ambos: pasa `"catalog://genres/poetry"` y el servidor la hace coincidir con la plantilla.
`read_resource` devuelve `contents`, una lista de `TextResourceContents` o `BlobResourceContents`. La misma idea que con el contenido de las herramientas: acota con `isinstance` y luego lee `.text` (o `.blob`).
A un cliente también se le puede avisar cuando cambia un recurso. En conexiones de la generación 2025 eso es `subscribe_resource(uri)` / `unsubscribe_resource(uri)`, un par de métodos que `MCPServer` no implementa, así que con el protocolo 2026-07-28 (donde esos verbos ya no existen) la solicitud responde `-32601`, *Method not found*. El reemplazo de 2026 es un stream `subscriptions/listen`, que `MCPServer` *sí* sirve (allí `server_capabilities.resources.subscribe` es `True`), y cómo consumirlo con `client.listen(...)` es la página **[Suscripciones](subscriptions.md)** de esta sección.
## Prompts {#prompts}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial005.py"
```
`list_prompts()` te dice qué ofrece el servidor y qué necesita cada prompt:
```python
prompt.name # 'recommend'
prompt.title # 'Recommend a book'
prompt.arguments # [PromptArgument(name='genre', required=True)]
```
`get_prompt(name, arguments)` lo renderiza. El diccionario de argumentos es `str -> str`: los argumentos de un prompt siempre son cadenas. El resultado es `messages`, una lista de `PromptMessage`, cada uno con un `role` y un bloque `content`:
```python
message.role # 'user'
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
```
Un host le entrega esos mensajes directamente al modelo. Esa es toda la funcionalidad.
## Autocompletado {#completions}
Un servidor con un handler de autocompletado puede autocompletar argumentos de prompts y de plantillas de recursos mientras el usuario escribe.
```python title="client.py" hl_lines="27-31"
--8<-- "docs_src/client/tutorial006.py"
```
* `ref` dice *qué* prompt o plantilla estás rellenando: un `PromptReference` o un `ResourceTemplateReference`.
* `argument` es `{"name": ..., "value": ...}`: el argumento y lo que el usuario ha escrito hasta ahora.
La respuesta está en `result.completion.values`. Escribe `"p"` y el servidor devuelve `['poetry']`. El lado del servidor, y cómo un handler usa los *otros* argumentos ya rellenados para acotar sus sugerencias, es la página **[Autocompletado](../servers/completions.md)**.
## Paginación {#pagination}
Cada método `list_*` acepta un argumento nombrado `cursor=` y cada resultado trae un `next_cursor`. Cuando `next_cursor` es `None`, ya lo tienes todo.
```python title="client.py" hl_lines="22-30"
--8<-- "docs_src/client/tutorial007.py"
```
Este bucle es correcto contra cualquier servidor. `MCPServer` devuelve todo en una sola página, así que `next_cursor` es `None` y el bucle se ejecuta una vez; por eso la mayoría del código nunca lo escribe. Los servidores que realmente paginan, y las reglas que siguen los cursores, están en **[Paginación](../advanced/pagination.md)**.
## En las pruebas {#in-tests}
`Client(mcp)`, sin proceso y sin puerto, ya es un banco de pruebas para tu servidor.
Hay una opción del constructor pensada para eso: `Client(mcp, raise_exceptions=True)`. Solo tiene efecto en conexiones en memoria, y **[Pruebas](../get-started/testing.md)** es la página que la explica y construye todo el patrón a su alrededor.
## Resumen {#recap}
* `Client(x)` se conecta en memoria a un objeto servidor, por Streamable HTTP a una cadena con una URL, y por cualquier otra cosa mediante un transporte.
* `async with` es todo el ciclo de vida. Dentro, `server_capabilities` y `protocol_version` ya están rellenas; `server_info` e `instructions` también, cuando el servidor las proporciona.
* `list_tools()` te da el `name`, `title`, `description` e `input_schema` de cada herramienta.
* `call_tool()` devuelve `content` para el modelo, `structured_content` para tu código, e `is_error`. Una herramienta que lanza una excepción es un resultado, no una excepción.
* `content` es una unión de tipos de bloque; acota con `isinstance` antes de leer.
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` y `complete` completan los verbos.
* Cada `list_*` acepta `cursor=`; itera hasta que `next_cursor` sea `None`.
Lo que un servidor puede pedirle al *cliente*, y cómo le respondes, está en **[Callbacks del cliente](callbacks.md)**.

View file

@ -0,0 +1,152 @@
---
translation:
sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b]
tool: 1
---
# Clientes OAuth {#oauth-clients}
Algunos servidores MCP están protegidos. Envíales una solicitud sin token y responden `401 Unauthorized`.
**`OAuthClientProvider`** es la forma de conseguir el token. No es un objeto de MCP en absoluto. Es un `httpx2.Auth`, el hook estándar de httpx2 para "hacer algo con cada solicitud". Lo asocias a un `httpx2.AsyncClient`, le pasas ese cliente al transporte Streamable HTTP y dejas de pensar en ello.
Esta página es el lado del cliente. Hacer que tu propio servidor exija un token es **[Autorización](../run/authorization.md)**.
## El proveedor {#the-provider}
```python title="client.py" hl_lines="44-54"
--8<-- "docs_src/oauth_clients/tutorial001.py"
```
Le das cuatro cosas:
* `server_url`: el endpoint MCP al que te conectas. El proveedor descubre todo lo demás a partir de él.
* `client_metadata`: lo que escribirías en el formulario de "registrar una aplicación" de un servidor de autorización.
* `storage`: dónde viven los tokens entre ejecuciones.
* `redirect_handler` y `callback_handler`: los dos momentos en los que interviene un humano.
Nada más en el archivo menciona OAuth. `main()` nunca ve un token.
### Metadatos del cliente {#client-metadata}
`OAuthClientMetadata` es el documento de registro real de [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), como modelo de Pydantic.
Defines tres campos. Los valores por defecto completan el resto: `grant_types` ya es `["authorization_code", "refresh_token"]` y `response_types` ya es `["code"]`, que es exactamente el flujo que ejecuta este proveedor.
!!! check
Al ser un modelo de Pydantic, valida **antes de que un solo byte salga a la red**.
Omite `redirect_uris` y la construcción falla en el acto con un `ValidationError` que
nombra el campo:
```text
redirect_uris
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
```
No se abre ningún navegador ni queda un registro a medias en el servidor de autorización.
### Almacenamiento de tokens {#token-storage}
**`TokenStorage`** es un `Protocol` con cuatro métodos asíncronos. No heredas de nada; escribe los métodos y cualquier clase es un almacén de tokens:
* `get_tokens` / `set_tokens` guardan el `OAuthToken`: token de acceso, token de actualización, caducidad, scope.
* `get_client_info` / `set_client_info` guardan el `OAuthClientInformationFull` que el servidor de autorización emitió cuando el proveedor te registró, incluido tu `client_id`.
La versión en memoria de arriba funciona. También olvida todo cuando el proceso termina, así que la siguiente ejecución repite todo el proceso. Persístelo en un archivo o en el llavero de tu plataforma y la siguiente ejecución transcurre en silencio.
!!! tip
Guarda `client_info`, no solo los tokens. El proveedor se registra dinámicamente la primera vez que
no encuentra un `client_info` almacenado. Si lo descartas, generas un registro nuevo en cada ejecución.
### Los dos handlers {#the-two-handlers}
El flujo de código de autorización necesita un humano exactamente una vez: alguien tiene que iniciar sesión y hacer clic en "permitir".
* **`redirect_handler`** se espera con la URL de autorización ya construida por completo. El `client_id`, el `redirect_uri`, el `state` y el desafío PKCE ya están en ella. Tu único trabajo es llevar un navegador hasta allí. Una app de escritorio llama a `webbrowser.open`; este archivo la imprime.
* **`callback_handler`** se espera a continuación. Aguarda hasta que el usuario vuelve a tu `redirect_uri` y devuelve los parámetros de consulta de esa redirección como un `AuthorizationCodeResult`.
Un cliente real ejecuta un pequeño servidor HTTP local en el URI de redirección en lugar de llamar a `input()`. La forma es idéntica: recibe la redirección y devuelve `code`, `state` e `iss`.
!!! warning
Pasa `state` e `iss` exactamente como llegaron. El proveedor compara `state` con el que
generó e `iss` con el emisor que descubrió, y rechaza cualquier discrepancia. Son las defensas
contra CSRF y contra la confusión de servidores.
### Dentro del `Client` {#into-the-client}
Mira `main()`. El proveedor va en el **cliente httpx2**, el cliente httpx2 va en `streamable_http_client(url, http_client=...)` y ese transporte va en `Client`.
`streamable_http_client` no tiene argumento nombrado `auth=`. Todo lo que es de nivel HTTP (autenticación, cabeceras, timeouts, proxies) pertenece al `httpx2.AsyncClient` que traes. Esa organización en capas está en **[Transportes del cliente](transports.md)**.
## Lo que el proveedor hace por ti {#what-the-provider-does-for-you}
La primera vez que `Client` envía una solicitud, el servidor responde `401`. El proveedor toma el control:
1. **Descubrimiento.** Lee la cabecera `WWW-Authenticate`, obtiene los Protected Resource Metadata del servidor desde `/.well-known/oauth-protected-resource`, averigua qué servidor de autorización protege este recurso y obtiene los metadatos de *ese* servidor.
2. **Registro.** ¿No hay nada en el almacenamiento? Te registra dinámicamente con tu `OAuthClientMetadata` y guarda el resultado.
3. **Autorización.** Genera el par PKCE y un `state`, construye la URL de autorización, espera tu `redirect_handler` y luego espera tu `callback_handler` para obtener el código.
4. **Intercambio.** Cambia el código por un `OAuthToken`, lo guarda y repite tu solicitud original con `Authorization: Bearer ...`.
Después de eso, se queda callado. Los tokens salen del almacenamiento, un token de acceso caducado se renueva con el token de actualización y solo cuando nada de eso funciona vuelve a ejecutar el flujo.
No escribiste nada de eso. Quedan dos argumentos nombrados (`client_metadata_url` y `validate_resource_url`), y este archivo no necesita ninguno. `client_metadata_url` es el que vale la pena conocer; tiene su propia sección más abajo.
### Pruébalo {#try-it}
La mayoría de los ejemplos de esta documentación puedes comprobarlos con un `Client(server)` en memoria. Este no: todo el sentido del flujo es un `401` HTTP, y no hay HTTP entre un cliente en memoria y su servidor.
El repositorio incluye la versión real. `examples/servers/simple-auth/` ejecuta un servidor de autorización independiente y un servidor MCP protegido; `examples/clients/simple-auth-client/` es el cliente de esta página convertido en una pequeña CLI. Su README tiene los dos comandos: inicia los servidores, ejecuta el cliente contra ellos y verás pasar los cuatro pasos.
## Client ID Metadata Documents {#client-id-metadata-documents}
La revisión 2026-07-28 de la especificación declara obsoleto el registro dinámico de clientes en favor de los **Client ID Metadata Documents** (CIMD). En lugar de enviar un POST con un registro nuevo a cada servidor de autorización que encuentra, tu cliente publica un único documento JSON sobre sí mismo en una URL HTTPS estable, y esa URL *es* su `client_id`. El servidor de autorización obtiene el documento; el proveedor nunca lo toca.
El SDK ya lo admite: pasa la URL como `client_metadata_url=` al construir el proveedor. Cuando los metadatos del servidor de autorización anuncian `client_id_metadata_document_supported: true`, el proveedor se salta por completo la solicitud a `/register`: la URL entra en el flujo como `client_id` y no hay `client_secret`. Cuando el servidor no lo anuncia (la mayoría aún no lo hace), o nunca pasas una URL, el proveedor recurre al registro dinámico **en silencio**, y todo lo anterior funciona exactamente como se describe. Un `client_info` almacenado sigue teniendo prioridad sobre ambos.
La URL debe ser HTTPS con una ruta que no sea la raíz; cualquier otra cosa es un `ValueError` en la construcción, antes de que ocurra nada en la red. El ejemplo incluido en `examples/clients/simple-auth-client/` la toma de la variable de entorno `MCP_CLIENT_METADATA_URL`.
## De máquina a máquina {#machine-to-machine}
Un trabajo nocturno, un paso de CI, otro servicio. No hay navegador ni nadie que haga clic en "permitir". Ese es el grant **client credentials**: ya tienes un `client_id` y un `client_secret`, y el endpoint de token es todo el flujo.
`ClientCredentialsOAuthProvider` es el mismo `httpx2.Auth`, sin el humano:
```python title="client.py" hl_lines="4 27-33"
--8<-- "docs_src/oauth_clients/tutorial002.py"
```
Qué cambió:
* Sin `OAuthClientMetadata`, sin handlers. Pasas `client_id` y `client_secret`; el proveedor construye un registro `client_credentials` mínimo en torno a ellos y se salta el registro dinámico por completo.
* `scope` es una cadena separada por espacios, el formato que OAuth usa en lo que se transmite.
* Todo lo que viene después es idéntico: el mismo `TokenStorage`, el mismo `httpx2.AsyncClient(auth=...)`, el mismo `streamable_http_client`.
Por defecto, el secreto viaja como autenticación HTTP Basic en la solicitud de token (`client_secret_basic`). Pasa `token_endpoint_auth_method="client_secret_post"` para ponerlo en el cuerpo del formulario en su lugar. Algunos servidores de autorización solo aceptan uno de los dos.
!!! tip
Lee `client_secret` del entorno o de un gestor de secretos, nunca del control de versiones.
!!! info
Hay un proveedor más en `mcp.client.auth.extensions.client_credentials`:
**`PrivateKeyJWTOAuthProvider`**, para clientes que se autentican con un JWT en lugar de un
secreto compartido (`private_key_jwt`, la variante de par de claves e identidad de carga de trabajo). Sigue
el mismo patrón: construye uno y ponlo en `auth=`. El mismo módulo incluye
`SignedJWTParameters` y `static_assertion_provider`, dos utilidades que construyen su aserción.
Hay una situación más sin humanos: el cliente pertenece a una empresa cuyo proveedor de identidad, y no el usuario, decide a qué servidores MCP puede acceder. Ese es un grant distinto, con su propio modelo de confianza y su propia página, **[Aserción de identidad](identity-assertion.md)**.
## Cuando falla {#when-it-fails}
Cuando el flujo OAuth sale mal, el proveedor lanza un `OAuthFlowError` de `mcp.client.auth`. Tiene dos subclases. `OAuthRegistrationError` significa que el registro no produjo un cliente que puedas usar: el servidor de autorización se negó a registrarte, o sí te registró pero con credenciales que este flujo no puede usar (por ejemplo, un método de autenticación que no implementa). `OAuthTokenError` significa que no se pudo obtener un token: el endpoint de token dijo que no, o un registro de cliente almacenado lleva un método de autenticación que este cliente no puede aplicar, lo cual se informa al construir la solicitud de token en lugar de enviarse. Un solo `except OAuthFlowError:` cubre descubrimiento, registro, autorización e intercambio.
No todo es un error de flujo. La red todavía puede fallar; esas son excepciones ordinarias de `httpx2` y pasan sin modificar.
## Resumen {#recap}
* `OAuthClientProvider` es un `httpx2.Auth`. Ponlo en un `httpx2.AsyncClient`, pásaselo a `streamable_http_client(url, http_client=...)` y `Client` nunca se entera de que hubo OAuth.
* Aportas cuatro cosas: la URL del servidor, un `OAuthClientMetadata`, un `TokenStorage` y el par de handlers de redirección y callback.
* `TokenStorage` es un `Protocol`: cuatro métodos asíncronos, sin clase base. Persiste `client_info` además de los tokens.
* El descubrimiento, el registro (dinámico o mediante un **Client ID Metadata Document**), PKCE, las comprobaciones de `state` e `iss` y la renovación de tokens son trabajo del proveedor, no tuyo.
* `ClientCredentialsOAuthProvider` es la versión sin humanos: `client_id` + `client_secret`, sin handlers, sin navegador.
* Todo fallo de OAuth es un `OAuthFlowError`; `OAuthRegistrationError` y `OAuthTokenError` son sus subclases.
La otra mitad de este handshake, hacer que tu *servidor* exija el token, es **[Autorización](../run/authorization.md)**.

View file

@ -0,0 +1,87 @@
---
translation:
sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73]
tool: 1
---
# Grupos de sesiones {#session-groups}
Un `Client` se conecta a un servidor. Las aplicaciones reales suelen querer varios (un servidor de búsqueda, un servidor de base de datos, una API interna) y terminan haciendo malabares con una conexión y una lista de herramientas para cada uno.
**`ClientSessionGroup`** es un único objeto que mantiene muchas conexiones y reúne todo lo que exponen en una sola vista.
## Dos servidores {#two-servers}
Empieza con dos servidores normales. No tienen nada que ver entre sí, así que, como es natural, ambos llamaron `search` a su herramienta:
```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"
```
## Un grupo {#one-group}
Crea un `ClientSessionGroup` y llama a **`connect_to_server`** una vez por servidor:
```python title="client.py" hl_lines="10-12"
--8<-- "docs_src/session_groups/tutorial003.py"
```
* `connect_to_server` recibe parámetros de transporte, no un objeto servidor: `StdioServerParameters` (de `mcp`) para lanzar un subproceso, o `StreamableHttpParameters` / `SseServerParameters` (de `mcp.client.session_group`) para un servidor que ya está escuchando en una URL.
* `group.tools` es un `dict[str, Tool]` con las herramientas de todos los servidores conectados. `group.resources` y `group.prompts` tienen la misma forma.
* `group.call_tool(name, arguments)` busca el nombre, encuentra la sesión a la que pertenece y le reenvía la llamada. Nunca indicas qué servidor.
!!! check
Pon `client.py` junto a los dos servidores y ejecútalo. El segundo `connect_to_server` se niega:
```text
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
```
Es un `MCPError`, lanzado antes de que se registre nada del segundo servidor. Un nombre debe
ser único en **todo** el grupo, y dos servidores que no controlas acabarán chocando tarde o temprano.
## `component_name_hook` {#component_name_hook}
Esto se arregla en el grupo, no en los servidores. Pasa una función de `(name, server_info)` y el grupo la ejecuta sobre cada nombre que registra:
```python title="client.py" hl_lines="7-8 15"
--8<-- "docs_src/session_groups/tutorial004.py"
```
Ejecútalo de nuevo. `print(sorted(group.tools))` ahora muestra ambos:
```text
['Library.search', 'Web.search']
```
* La **clave** es tuya. `by_server` la construyó a partir de `server_info.name`, el nombre con el que se creó cada `MCPServer(...)`.
* El `Tool` que contiene queda intacto: `group.tools["Web.search"].name` sigue siendo `"search"`, y ese es el nombre que `call_tool` transmite por el canal. El prefijo nunca sale de tu proceso.
* No son solo las herramientas. El recurso `hours` de la biblioteca se registra como `Library.hours`.
!!! tip
El hook se ejecuta sobre **cada** nombre de **cada** servidor, no solo en los conflictos: no hay un
modo de prefijo solo en caso de colisión. Elige un esquema y deja que se aplique en todas partes.
## Añadir y quitar servidores {#adding-and-removing-servers}
`connect_to_server` devuelve la `ClientSession` que abrió. Guárdala si alguna vez quieres deshacerte de ese servidor: `await group.disconnect_from_server(session)` quita del grupo sus herramientas, recursos y prompts.
Si ya tienes una `ClientSession` conectada (`Client.session` lo es), pásala a `await group.connect_with_session(server_info, session)` en lugar de abrir un transporte nuevo. La agrega de la misma manera. El grupo nunca cierra una sesión que no abrió. `server_info` da nombre al servidor para los prefijos de los componentes; en una conexión de la generación 2026, `client.server_info` puede ser `None` (la identidad es opcional), así que en ese caso pasa tu propio `Implementation(name=..., version=...)`.
## El handshake clásico {#the-classic-handshake}
`ClientSessionGroup` está construido sobre `ClientSession`, no sobre `Client`. Cada `connect_to_server` ejecuta el handshake clásico de `initialize`. Nunca envía el sondeo `server/discover` descrito en **[Versiones del protocolo](../protocol-versions.md)**. Todos los servidores MCP entienden ese handshake, así que esto no te cuesta compatibilidad con nada; solo significa que un grupo toma el camino más antiguo y lento hacia un servidor que podría hacerlo mejor.
## Resumen {#recap}
* `ClientSessionGroup` mantiene muchas conexiones a servidores y reúne sus herramientas, recursos y prompts en un `dict` para cada tipo.
* `connect_to_server(params)` por servidor. Recibe parámetros de transporte, nunca el objeto servidor ni la URL que recibe un `Client`.
* `group.call_tool(name, arguments)` enruta por ti al servidor al que pertenece.
* Los nombres deben ser únicos en todo el grupo; dos servidores con una herramienta `search` no pueden coexistir por sí solos.
* `component_name_hook=` reescribe cada nombre registrado. La clave del dict cambia; el nombre que se transmite por el canal, no.
* `connect_with_session` añade una sesión que ya tienes; `disconnect_from_server` quita una.
El handshake que habla un grupo (y el más rápido que prefiere un `Client`) es el tema de **[Versiones del protocolo](../protocol-versions.md)**.

View file

@ -0,0 +1,91 @@
---
translation:
sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2]
tool: 1
---
# Suscripciones {#subscriptions}
El catálogo de un servidor no es fijo. Las herramientas aparecen en tiempo de ejecución y el contenido detrás de la URI de un recurso cambia. Un cliente se entera a través de `client.listen(...)`: una sola solicitud `subscriptions/listen` cuya respuesta *es* el flujo. Permanece abierta y transporta las notificaciones de cambio que el cliente pidió.
Esta página es el extremo del cliente: abrir el flujo, observarlo junto a tu flujo principal y manejar sus finales. Publicar cambios, filtrar y atender el método son la parte del servidor, que se cuenta en **[Suscripciones](../handlers/subscriptions.md)**, dentro de *Dentro de tu handler*. Los ejemplos de aquí hablan con el servidor del tablero de sprint que se construye allí.
## Observar el flujo {#watching-the-stream}
Una suscripción es un gestor de contexto. Al entrar se envía la solicitud, con tus argumentos nombrados como filtro de la suscripción, y se espera la confirmación del servidor, así que el flujo ya está activo cuando empieza el bloque.
```python title="client.py" hl_lines="15 18 28"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
La iteración produce cuatro eventos tipados: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` y `ResourceUpdated(uri=...)`.
Un evento dice *qué* cambió, nunca *cómo*. Por eso `follow_board` llama a `read_resource` y a `list_tools`: el evento es una señal para volver a pedir los datos. Lee `event.uri` en lugar de suponer qué recurso se movió: un filtro puede nombrar varias URI, y un servidor puede informar de un cambio en un subrecurso de una de ellas.
Los eventos duplicados que esperan a ser consumidos se funden en uno, y al volver a pedir los datos obtienes igualmente el estado actual. Solo se funden los eventos idénticos: dos `ResourceUpdated` para URI distintas son dos eventos.
Dos propiedades más del objeto devuelto:
* `sub.honored` es el filtro que el servidor confirmó: un `SubscriptionFilter` con los campos que pasaste, que se leen como atributos (`sub.honored.prompts_list_changed`). `MCPServer` acepta todos los tipos que pides, así que te devuelve tu solicitud tal cual. Un servidor que admite menos tipos confirma menos, y un tipo aceptado puede no dispararse nunca. Un servidor también puede rechazar la solicitud entera en lugar de confirmarla (consulta [Decidir quién puede observar](../handlers/subscriptions.md#deciding-who-may-watch) en la página del servidor), lo que aparece como el error de la solicitud.
* `sub.subscription_id` es el id de la solicitud de escucha, el que va estampado en cada trama de este flujo. Puede haber varias suscripciones abiertas a la vez, cada una demultiplexada por su propio id.
## Observar sin bloquear {#watching-without-blocking}
`follow_board` se ejecuta hasta que el servidor cierra el flujo, lo que puede no ocurrir nunca, así que por sí solo se adueña de tu programa. Los clientes reales quieren el observador *junto* al flujo principal: un agente llama a herramientas mientras un observador mantiene al día una caché o una interfaz.
Abre primero la suscripción, luego inicia el observador y sigue con tu trabajo.
=== "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` importa `BOARD` y `read_board` del primer ejemplo, que este repositorio guarda como
`tutorial003.py`. Si guardas los archivos renderizados uno junto al otro como `client.py` y `app.py`,
escribe `from client import BOARD, read_board` en su lugar. El ejemplo `watch.py` de más abajo
importa `read_board` de la misma manera.
El orden es la clave. No se reenvía nada, así que un evento publicado antes de que existiera tu flujo se pierde. Entrar en `client.listen(...)` espera la confirmación, así que cada cambio a partir de ese momento llega a tu observador, y la instantánea que tomas dentro del bloque no puede perderse ninguno.
Las solicitudes se ejecutan libremente junto a un flujo abierto, desde la tarea del observador o desde cualquier otra, en el mismo cliente. Como los eventos *duplicados* sin consumir se funden, un flujo principal ocupado puede producir una sola recarga en lugar de tres. Los eventos distintos no se funden: un filtro que nombra muchas URI encola un evento pendiente por URI.
Para dejar de observar, sal del bloque: no hay ninguna llamada `unsubscribe`. Cancelar la tarea propietaria del bloque lo hace por ti, y el SDK cancela la solicitud de escucha como espera el transporte: en Streamable HTTP, cerrando el flujo de esa solicitud. Un observador que se ejecuta durante toda la vida de tu app nunca vuelve por sí solo, así que cancélalo, o cancela el alcance de su grupo de tareas, al apagar.
## Los flujos terminan {#streams-end}
Un flujo termina de una de dos maneras, y ambas son flujo de control normal. Un cierre ordenado del servidor termina el `async for`; una caída abrupta lanza `SubscriptionLost`.
La diferencia es de diagnóstico, no de qué hacer después: el flujo ya no está, no se reenvió nada, y un observador al que todavía le importa vuelve a escuchar y vuelve a pedir los datos.
```python title="watch.py" hl_lines="16 20"
--8<-- "docs_src/subscriptions/tutorial005.py"
```
Los servidores cierran flujos de forma ordenada por sus propias razones, entre ellas deshacerse de un suscriptor cuyo atraso creció demasiado, así que un final limpio no es una señal para dejar de observar. Espera un poco antes de volver a escuchar.
`SubscriptionLost` también tiene una causa local. El cliente retiene como máximo 1024 eventos sin consumir, y un consumidor que se atrasa tanto pierde la suscripción en lugar de crecer sin límite. Mantén corto el cuerpo del `async for` y haz el trabajo lento en otro sitio.
`keep_following` captura solo `SubscriptionLost`. Entrar en `listen()` también puede lanzar `MCPError` (la conexión falló o el servidor no atiende el método), `TimeoutError` (no llegó ninguna confirmación) y `ListenNotSupportedError` (una conexión anterior a 2026). Decide cuáles de ellas debe reintentar tu observador: la última nunca se recupera.
## Resumen {#recap}
* Entra con `async with client.listen(...)`; al entrar se espera la confirmación, así que no se pierde nada publicado después.
* Itera con `async for event in sub`. Los eventos son señales para volver a pedir los datos, nunca cargas de datos.
* Abre la suscripción, luego ejecuta el observador como tarea, y las llamadas a herramientas siguen fluyendo junto a él.
* Un final limpio detiene el bucle; una caída lanza `SubscriptionLost`. En ambos casos: vuelve a escuchar, vuelve a pedir los datos y, antes, espera un poco.
* Salir del bloque es darse de baja.
Publicar estos eventos, acotar el filtro y escalar más allá de un proceso son la parte del servidor: **[Suscripciones](../handlers/subscriptions.md)**. Estos mismos eventos también mantienen fiable una caché del lado del cliente, y **[Caché](caching.md)** es la página siguiente.

View file

@ -0,0 +1,128 @@
---
translation:
sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7]
tool: 1
---
# Transportes del cliente {#client-transports}
Cada `Client` habla con su servidor a través de un **transporte**: lo que realmente lleva los mensajes.
Nunca configuras uno por separado. `Client` recibe un único argumento posicional y deduce el transporte a partir de su tipo.
El lado del *servidor* de cada uno (lo que hace `mcp.run()` y lo que despliegas) está en **[Ejecutar el servidor](../run/index.md)**.
## En memoria {#in-memory}
Pasa el propio objeto del servidor:
```python title="client.py" hl_lines="14"
--8<-- "docs_src/client_transports/tutorial001.py"
```
Sin subproceso, sin puerto, sin bytes por ningún canal. El cliente y el servidor son dos objetos en el mismo proceso, y aun así la llamada pasa por la capa real del protocolo: `search_books` se lista, se valida y se invoca exactamente igual que por HTTP.
Eso lo convierte en dos cosas a la vez:
* **Un arnés de pruebas.** Todos los ejemplos de esta documentación se ejercitan así, y la página **[Pruebas](../get-started/testing.md)** construye todo el patrón en torno a ello.
* **Una API de integración.** Una aplicación que construye el servidor no necesita un salto de red para llamar a sus herramientas.
## Streamable HTTP {#streamable-http}
Pasa una cadena con una URL y obtienes **Streamable HTTP**, el transporte con el que despliegas:
```python title="client.py" hl_lines="5"
--8<-- "docs_src/client_transports/tutorial002.py"
```
Ese es todo el cliente de producción. `Client` envuelve la URL en `streamable_http_client(...)` por ti, encima de un `httpx2.AsyncClient` configurado como MCP necesita: `follow_redirects=True`, un timeout de 30 segundos para connect/write/pool y un timeout de lectura de 300 segundos, porque el servidor puede mantener abierto un flujo de respuesta.
!!! check
Un `Client` que has construido **no** está conectado. La construcción solo elige el transporte;
`async with` es lo que lo abre. Intenta usar la conexión antes de entrar y el SDK te lo dice:
```text
RuntimeError: Client must be used within an async context manager
```
No se resolvió, se obtuvo ni se lanzó nada cuando escribiste `Client("http://...")`. Esa línea es gratis.
### Trae tu propio `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient}
En cuanto necesites un encabezado `Authorization`, una cookie, un proxy, mTLS o un timeout distinto, construye tú mismo el `httpx2.AsyncClient` y entrégaselo a `streamable_http_client`:
```python title="client.py" hl_lines="8-14"
--8<-- "docs_src/client_transports/tutorial003.py"
```
Dos cosas que notar:
* El `httpx2.AsyncClient` es tuyo, así que **tú** entras y sales de él. El SDK nunca cierra un cliente que no creó.
* `streamable_http_client(url, http_client=...)` devuelve un transporte, y `Client(transport)` lo acepta como cualquier otra cosa.
Una nota sobre TLS: `httpx2` verifica los certificados contra el almacén de confianza del sistema operativo (mediante
[`truststore`](https://pypi.org/project/truststore/)), no contra una lista de CA incluida. En un entorno sin
un almacén de CA del sistema utilizable (algunos contenedores mínimos), configura las variables de entorno
estándar `SSL_CERT_FILE`/`SSL_CERT_DIR` o pasa un `verify=ssl_context` explícito a tu `httpx2.AsyncClient`
(el contexto está en
[`httpx` y `httpx-sse` sustituidos por `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)).
!!! warning
`streamable_http_client` antes aceptaba `headers=` y `timeout=` directamente. Ya no:
sus únicos parámetros son `url`, `http_client` y `terminate_on_close`. Usa `headers=` por
costumbre y obtienes:
```text
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
```
Todo lo que tiene forma de HTTP vive ahora en el único `httpx2.AsyncClient` que pasas.
!!! info
`httpx2` conserva la API conocida de `httpx`, así que si conoces `httpx` ya sabes cómo hacer la autenticación,
los proxies, los event hooks, los reintentos y los límites de conexión aquí. El SDK no añade nada encima ni quita
nada. También es donde se conecta OAuth:
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Todo ese flujo está en **[Clientes OAuth](oauth-clients.md)**.
## stdio {#stdio}
Un servidor **stdio** es un subproceso. El cliente lo lanza, escribe JSON-RPC en su stdin y lee JSON-RPC de su stdout. Así es como un host de escritorio ejecuta un servidor en tu máquina: un host *es* este código más una interfaz de usuario, y **[Conectar a un host real](../get-started/real-host.md)** es la misma relación vista desde el lado del host, como archivo de configuración.
Describe el proceso con `StdioServerParameters`, conviértelo en un transporte con `stdio_client` y entrega *eso* a `Client`:
```python title="client.py" hl_lines="4-8 12"
--8<-- "docs_src/client_transports/tutorial004.py"
```
`Client` no acepta el objeto de parámetros por sí solo. `StdioServerParameters` es configuración; `stdio_client(server)` es el transporte que sabe lanzar un proceso a partir de ella. Envuélvelo siempre.
Salir del bloque `async with` también cierra el subproceso: cierra stdin, espera y lo mata si se queda. Nunca lo limpias tú.
!!! warning
El proceso hijo **no** hereda tu entorno. Recibe una lista de permitidos mínima (`HOME`, `LOGNAME`,
`PATH`, `SHELL`, `TERM` y `USER` en POSIX) para que nada sensible se filtre a un proceso que quizá
no hayas escrito tú.
Un servidor que necesita una clave de API no la encontrará ahí. Pásala explícitamente con `env=`; esas
variables se fusionan encima de la lista de permitidos. Eso es lo que hace `BOOKSHOP_API_KEY` arriba.
## SSE {#sse}
`sse_client(url)`, de `mcp.client.sse`, es el transporte HTTP al que reemplazó Streamable HTTP. Envuélvelo igual, `Client(sse_client("http://localhost:8000/sse"))`, para hablar con un servidor que todavía lo usa, y no construyas nada nuevo sobre él.
## El protocolo `Transport` {#the-transport-protocol}
Para `Client`, todo lo anterior es lo mismo.
Un **transporte** es cualquier gestor de contexto asíncrono que produce un par `(read, write)` de flujos de mensajes: formalmente, el protocolo `Transport` de `mcp.client`. `Client` resuelve su argumento por tipo: un objeto de servidor se conecta dentro del proceso, un `str` se convierte en `streamable_http_client(url)` y cualquier otra cosa se entra directamente como transporte. Esa última regla es la razón por la que `stdio_client(...)`, `streamable_http_client(...)` y `sse_client(...)` encajan todos en el mismo hueco, y por la que puedes escribir el tuyo.
## Resumen {#recap}
* `Client(mcp)` (el objeto del servidor) se conecta en memoria. Úsalo para pruebas y para integración.
* `Client("http://.../mcp")` (una URL) se conecta por Streamable HTTP, el transporte de producción.
* Los encabezados, la autenticación, los proxies y los timeouts van en un `httpx2.AsyncClient` que pasas a `streamable_http_client(url, http_client=...)`. No existe el argumento nombrado `headers=`.
* stdio es `Client(stdio_client(StdioServerParameters(...)))`, nunca el objeto de parámetros solo.
* El subproceso recibe un entorno con lista de permitidos, no el tuyo; `env=` se añade a él.
* Un transporte es cualquier cosa con la que puedas hacer `async with x as (read, write)`. `Client` entrega directamente a ese protocolo todo lo que no sea un objeto de servidor ni una URL.
* Construir un `Client` elige el transporte. `async with` lo abre.
Una vez abierto el transporte, los dos lados tienen que acordar una versión del protocolo. Normalmente nunca piensas en ello; cuando lo hagas, **[Versiones del protocolo](../protocol-versions.md)** es la página.