docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
10 changed files with 89737 additions and 54 deletions
134
i18n/es/pages/handlers/context.md
Normal file
134
i18n/es/pages/handlers/context.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
translation:
|
||||
sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240]
|
||||
tool: 1
|
||||
---
|
||||
# El Context {#the-context}
|
||||
|
||||
Los argumentos de una herramienta vienen del modelo. Todo lo demás (la solicitud que estás atendiendo, el servidor en el que vives, una forma de responderle al cliente) viene de un solo objeto: el **`Context`**.
|
||||
|
||||
No lo construyes ni lo configuras. Lo pides.
|
||||
|
||||
## Pídelo {#ask-for-it}
|
||||
|
||||
Añade un parámetro anotado con `Context` a cualquier herramienta:
|
||||
|
||||
```python title="server.py" hl_lines="2 8"
|
||||
--8<-- "docs_src/context/tutorial001.py"
|
||||
```
|
||||
|
||||
* El SDK construye un `Context` nuevo para cada solicitud y lo pasa.
|
||||
* El **nombre del parámetro no importa**. `ctx`, `context`, `c`: el SDK lo encuentra por su anotación.
|
||||
* Los recursos y los prompts también pueden declarar uno, de la misma forma.
|
||||
* `ctx.request_id` es el id de la solicitud que tu función está atendiendo en este momento.
|
||||
|
||||
!!! info
|
||||
Si has usado FastAPI, ya has visto esta jugada: declaras un parámetro con el tipo propio del
|
||||
framework (`Request` allí, `Context` aquí) y el framework lo proporciona. Nada que registrar,
|
||||
nada que configurar: la anotación de tipo es todo el mecanismo.
|
||||
|
||||
### Invisible para el modelo {#invisible-to-the-model}
|
||||
|
||||
Esta es la parte que hay que interiorizar. Este es el esquema de entrada que `tools/list` informa para `search_books`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"}
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
Una sola propiedad. `ctx` no es un argumento: nunca aparece en el esquema, al modelo nunca se le habla de él y ningún cliente puede rellenarlo. Es un contrato entre tú y el SDK, que no aparece en lo que se transmite.
|
||||
|
||||
### Pruébalo {#try-it}
|
||||
|
||||
Ejecuta el servidor con el MCP Inspector:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
El formulario de `search_books` tiene un único campo `query`. Llámalo con `dune`:
|
||||
|
||||
```text
|
||||
[request 3] Found 3 books matching 'dune'.
|
||||
```
|
||||
|
||||
El número es el de la solicitud que haya tocado. Vuelve a llamar a la herramienta y cambia: cada solicitud recibe su propio `Context`.
|
||||
|
||||
## Qué te da {#what-it-gives-you}
|
||||
|
||||
El objeto inyectado es pequeño. Además de `request_id`:
|
||||
|
||||
* `await ctx.read_resource(uri)`: lee uno de los recursos **propios** del servidor desde dentro de una herramienta. La siguiente sección.
|
||||
* `await ctx.report_progress(progress, total, message)`: envía el progreso al llamador durante una llamada larga. Todos los detalles están en **[Progreso](progress.md)**.
|
||||
* `await ctx.elicit(message, schema)` y `await ctx.elicit_url(...)`: pausan la herramienta y le hacen una pregunta al usuario. De eso trata **[Elicitación](elicitation.md)** (elicitation).
|
||||
* `ctx.session`: el lado del servidor de la conversación con este cliente. Aquí viven las notificaciones que envías al cliente; la última sección lo usa.
|
||||
* `ctx.headers`: los encabezados de la solicitud que transportó el transporte, o `None` en stdio. Lee un encabezado personalizado con `(ctx.headers or {}).get("x-...")`. Los encabezados son entrada proporcionada por el cliente: valen para una configuración regional o un feature flag, nunca para una identidad.
|
||||
* `ctx.request_context`: el registro bruto por solicitud. El campo que vas a buscar es `lifespan_context`, el objeto que tu código de arranque entregó con yield (consulta **[Lifespan](lifespan.md)**).
|
||||
|
||||
El logging queda fuera de esa lista a propósito. Un servidor registra logs con el módulo `logging` de Python, como cualquier otro programa de Python. **[Logging](logging.md)** es la página breve que explica por qué.
|
||||
|
||||
!!! tip
|
||||
La inyección solo ocurre en la función que registraste. Una función auxiliar a la que llama tu
|
||||
herramienta no recibe su propio `Context`; pásale `ctx` como un argumento normal. No hay un
|
||||
"contexto actual" ambiental que obtener desde otro sitio.
|
||||
|
||||
## Lee tus propios recursos {#read-your-own-resources}
|
||||
|
||||
Los recursos de un servidor no son solo para los clientes. Una herramienta también puede leerlos:
|
||||
|
||||
```python title="server.py" hl_lines="16"
|
||||
--8<-- "docs_src/context/tutorial002.py"
|
||||
```
|
||||
|
||||
`ctx.read_resource` resuelve la URI a través del mismo registro que atiende `resources/read`, así que una herramienta obtiene lo que obtendría un cliente: un iterable de `ReadResourceContents`, uno por bloque de contenido. Para esta URI hay uno:
|
||||
|
||||
```python
|
||||
contents.content # 'fiction, non-fiction, poetry'
|
||||
contents.mime_type # 'text/plain'
|
||||
```
|
||||
|
||||
* `content` es exactamente lo que devolvió `genres()`. Una única fuente de verdad: el cliente explora el recurso, tus herramientas lo consumen, nadie copia la cadena.
|
||||
* El único parámetro de `describe_catalog` es el `Context`, así que su esquema de entrada **no tiene ninguna propiedad**. El modelo la llama con `{}`.
|
||||
|
||||
## Dile al cliente que la lista cambió {#tell-the-client-the-list-changed}
|
||||
|
||||
Lo que ofrece un servidor no queda fijo al importar. Registra una herramienta en tiempo de ejecución y luego díselo al cliente:
|
||||
|
||||
```python title="server.py" hl_lines="15-16"
|
||||
--8<-- "docs_src/context/tutorial003.py"
|
||||
```
|
||||
|
||||
* `mcp.add_tool(recommend_book)` registra una función normal como herramienta: nombre, descripción y esquema se derivan exactamente como lo habría hecho `@mcp.tool()`.
|
||||
* `await ctx.session.send_tool_list_changed()` envía `notifications/tools/list_changed`. Un cliente que la recibe vuelve a llamar a `tools/list` y ve `recommend_book`.
|
||||
|
||||
Los hermanos son `send_resource_list_changed()`, `send_prompt_list_changed()` y `send_resource_updated(uri)` para un cambio en un recurso concreto.
|
||||
|
||||
En una conexión 2026-07-28, los clientes reciben notificaciones de cambio solo en un stream `subscriptions/listen` que hayan abierto, así que los métodos `send_*` de arriba no llegan a esos streams. Los métodos de publicación del `Context` entregan a todos los streams suscritos a la vez: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` y `await ctx.notify_resource_updated(uri)`. Todos los detalles, incluido cómo escalar horizontalmente entre réplicas, están en **[Suscripciones](subscriptions.md)**.
|
||||
|
||||
!!! check
|
||||
Antes de que alguien ejecute `enable_recommendations`, la herramienta que prometes no existe.
|
||||
Llámala de todos modos y el resultado es un error que el modelo puede leer:
|
||||
|
||||
```text
|
||||
Unknown tool: recommend_book
|
||||
```
|
||||
|
||||
Ejecuta `enable_recommendations` y esa misma llamada funciona. La lista de herramientas es
|
||||
realmente dinámica: `tools/list` refleja lo que esté registrado *en este momento*.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Anota un parámetro con `Context` (en una herramienta, un recurso o un prompt) y el SDK lo inyecta. El nombre lo eliges tú.
|
||||
* Es invisible para el modelo: el esquema de entrada solo contiene tus argumentos reales.
|
||||
* `ctx.request_id` identifica la solicitud; `ctx.request_context.lifespan_context` es lo que entregó tu arranque con yield.
|
||||
* `await ctx.read_resource(uri)` permite que una herramienta lea los recursos propios del servidor.
|
||||
* `ctx.session` es el canal de vuelta al cliente: `send_tool_list_changed()` y sus hermanos le indican que vuelva a obtener una lista que cambiaste.
|
||||
* Los informes de progreso y la elicitación también empiezan en el `Context`; cada uno tiene su propia página.
|
||||
|
||||
Los parámetros que el modelo nunca ve, rellenados por tus propias funciones, son las **[Dependencias](dependencies.md)**.
|
||||
168
i18n/es/pages/handlers/dependencies.md
Normal file
168
i18n/es/pages/handlers/dependencies.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
---
|
||||
translation:
|
||||
sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd]
|
||||
tool: 1
|
||||
---
|
||||
# Dependencias {#dependencies}
|
||||
|
||||
Los argumentos de una herramienta vienen del modelo. Algunos valores nunca deberían: un precio consultado en tus registros, una confirmación que solo una persona puede dar, cualquier cosa que el modelo podría estropear inventándosela.
|
||||
|
||||
Las **dependencias** son parámetros que rellenan tus propias funciones. Anotas el parámetro, nombras la función, y el SDK la llama antes de que se ejecute tu herramienta.
|
||||
|
||||
## Declara una {#declare-one}
|
||||
|
||||
Envuelve el tipo del parámetro en `Annotated[...]` y añade `Resolve(fn)`:
|
||||
|
||||
```python title="server.py" hl_lines="18-19 23"
|
||||
--8<-- "docs_src/dependencies/tutorial001.py"
|
||||
```
|
||||
|
||||
* `check_stock` es un **resolutor**: una función normal que el SDK ejecuta antes de `reserve_book`, y cuyo valor devuelto se convierte en el argumento `stock`.
|
||||
* Su parámetro `title` es el propio argumento `title` de la herramienta, emparejado **por nombre**. El resolutor ve exactamente el valor validado que verá el cuerpo de la herramienta.
|
||||
* El cuerpo de la herramienta parte de un `Stock` que ya existe. Nada de código de consulta en la herramienta, nada de preámbulo del tipo "y si falta".
|
||||
|
||||
!!! info
|
||||
Si has usado FastAPI, esto es `Depends`. El mismo mecanismo, por la misma razón: la función declara lo
|
||||
que necesita, el framework lo proporciona, y el cableado vive en la anotación de tipo.
|
||||
|
||||
### Invisible para el modelo {#invisible-to-the-model}
|
||||
|
||||
Este es el esquema de entrada que `tools/list` reporta para `reserve_book`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"title": "Title", "type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"title": "reserve_bookArguments"
|
||||
}
|
||||
```
|
||||
|
||||
Una sola propiedad. Igual que el `Context` en **[El Context](context.md)**, un parámetro resuelto es un contrato entre tú y el SDK: `stock` no está en el esquema, al modelo nunca se le habla de él, y a un cliente que envíe un valor `stock` de todos modos se le ignora. El valor del resolutor es el único que puede recibir tu herramienta.
|
||||
|
||||
Esa última parte es la clave. Un parámetro que el modelo no puede proporcionar es un parámetro que el modelo no puede estropear.
|
||||
|
||||
### Pruébalo {#try-it}
|
||||
|
||||
Ejecuta el servidor con el MCP Inspector:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
El formulario de `reserve_book` tiene un único campo `title`. `stock` no aparece por ningún lado. Llámala con `Dune`:
|
||||
|
||||
```text
|
||||
Reserved 'Dune' (6 copies left).
|
||||
```
|
||||
|
||||
El cuerpo de la herramienta nunca consultó nada: `check_stock` se ejecutó primero, y el `Stock` que devolvió llegó como argumento. Prueba con `Neuromancer` y el mismo resolutor le entrega un cero a la herramienta.
|
||||
|
||||
!!! tip
|
||||
Podrías simplemente llamar a `check_stock(title)` en el cuerpo de la herramienta. Decláralo como
|
||||
dependencia cuando el valor merezca más que una llamada a una función auxiliar: todas las herramientas
|
||||
que necesitan el stock declaran el mismo parámetro, y el SDK ejecuta el resolutor como mucho una vez por
|
||||
llamada, sin importar cuántas lo declaren. Las siguientes secciones añaden el resto: resolutores que
|
||||
dependen unos de otros, y resolutores que preguntan al usuario.
|
||||
|
||||
## Dependencias de dependencias {#dependencies-of-dependencies}
|
||||
|
||||
Un resolutor puede declarar sus propias dependencias, con la misma anotación:
|
||||
|
||||
```python title="server.py" hl_lines="22 29-30"
|
||||
--8<-- "docs_src/dependencies/tutorial002.py"
|
||||
```
|
||||
|
||||
* `estimate_delivery` depende de `check_stock`. El SDK ejecuta el grafo en orden: primero el stock, luego la estimación, luego la herramienta.
|
||||
* Tanto `stock` como `delivery` necesitan `check_stock` en última instancia, pero se ejecuta **una vez por llamada**. Una consulta de inventario, dos consumidores.
|
||||
* No hay nada que registrar. El grafo *son* las anotaciones.
|
||||
|
||||
!!! check
|
||||
No te creas lo de una vez por llamada sin comprobarlo. Pon un `print` en `check_stock` y llama a
|
||||
`order_book` desde el Inspector: una línea por llamada. Dos consumidores, una consulta.
|
||||
|
||||
El SDK analiza el grafo cuando se registra la herramienta, no cuando se llama. Un parámetro que no puede clasificar (ni un `Context`, ni un `Resolve(...)`, ni el nombre de un argumento de la herramienta) y un ciclo de resolutores lanzan ambos `InvalidSignature` al arrancar. El servidor falla antes de que ningún cliente se conecte, con el parámetro o resolutor problemático nombrado en el error.
|
||||
|
||||
Los parámetros de un resolutor se resuelven exactamente igual que los de una herramienta: otro `Resolve(...)`, los argumentos de la propia herramienta por nombre, o el `Context`: `ctx.headers`, el objeto del lifespan, todo.
|
||||
|
||||
!!! warning
|
||||
En los transportes HTTP el `Context` incluye `ctx.headers`. Las cabeceras son **entrada proporcionada
|
||||
por el cliente**, como cualquier argumento de herramienta: bien para una configuración regional o un
|
||||
feature flag, nunca para una identidad. Quién es el que llama viene de tu capa de autorización
|
||||
(**[Autorización](../run/authorization.md)**), no de una cabecera que cualquiera puede establecer.
|
||||
|
||||
!!! tip
|
||||
*Una vez por llamada* significa exactamente eso: el siguiente `tools/call` ejecuta `check_stock` otra
|
||||
vez. Un recurso que debe sobrevivir a una solicitud (un pool de base de datos, un cliente HTTP)
|
||||
pertenece al **[Lifespan](lifespan.md)**, y un resolutor puede llegar a él a través de
|
||||
`ctx.request_context.lifespan_context`.
|
||||
|
||||
## Pregunta cuando debas {#ask-when-you-must}
|
||||
|
||||
Un resolutor no tiene por qué saber la respuesta. Puede devolver `Elicit(message, Model)` y el SDK pregunta al usuario: la maquinaria de **[Elicitación](elicitation.md)** (elicitation), ejecutada por ti:
|
||||
|
||||
```python title="server.py" hl_lines="26-32 39"
|
||||
--8<-- "docs_src/dependencies/tutorial003.py"
|
||||
```
|
||||
|
||||
* Con stock: `confirm_backorder` devuelve un `Backorder` directamente. **Sin pregunta, sin ida y vuelta.** Solo se interrumpe al usuario cuando su respuesta importa.
|
||||
* Sin stock: el SDK envía la elicitación, valida la respuesta contra `Backorder` y la inyecta. Tu resolutor nunca toca el protocolo.
|
||||
* La herramienta lee `backorder.confirm` como cualquier otro argumento. Responder **no** sigue siendo una respuesta: la elicitación se acepta con `confirm=False`, la herramienta se ejecuta y no se hace ningún pedido. Preguntar se convirtió en una precondición, no en fontanería dentro del cuerpo de la herramienta.
|
||||
|
||||
¿Y si el usuario no responde en absoluto, si rechaza la pregunta o la cancela?
|
||||
|
||||
!!! check
|
||||
Ejecuta `order_book` para `Neuromancer` y rechaza la pregunta. Con la anotación escrita como
|
||||
`Annotated[Backorder, Resolve(...)]` el cuerpo de la herramienta nunca se ejecuta; la llamada falla con
|
||||
un resultado de error que el modelo puede leer:
|
||||
|
||||
```text
|
||||
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
|
||||
```
|
||||
|
||||
Ese es el valor por defecto correcto para una precondición: sin respuesta, no hay pedido. Cuando rechazar es un resultado que tu herramienta quiere manejar (omitir el pedido pendiente pero aun así sugerir otro título), anota `ElicitationResult[Backorder]` en su lugar y la herramienta recibe el resultado completo de aceptar/rechazar/cancelar para bifurcar según él. **[Elicitación](elicitation.md)** muestra esa forma, y todo lo demás sobre preguntar: las reglas del esquema, las tres respuestas, el lado del cliente en la conversación.
|
||||
|
||||
!!! info
|
||||
El framework elige el transporte de la pregunta a partir de la versión del protocolo negociada; el
|
||||
código de arriba es idéntico en ambas. En **2026-07-28** y posteriores la pregunta viaja dentro de un
|
||||
`tools/call` de varias idas y vueltas (multi-round-trip): el servidor la devuelve, el
|
||||
`elicitation_callback` del cliente la responde, y el `Client` reintenta la llamada por ti
|
||||
(**[Solicitudes de varias idas y vueltas](multi-round-trip.md)**). En **2025-11-25** y anteriores es
|
||||
una solicitud de elicitación síncrona a mitad de llamada. Cada pregunta se hace exactamente una vez por
|
||||
llamada: una garantía sobre la pregunta, no sobre el resolutor. En la forma de varias idas y vueltas
|
||||
cualquier resolutor puede volver a ejecutarse cada vez que la llamada se reanuda tras una pregunta, así
|
||||
que el código anterior a un `return Elicit(...)` se ejecuta en cada una de esas rondas; la respuesta
|
||||
registrada satisface entonces la pregunta repetida sin volver a preguntar al usuario. Una respuesta
|
||||
registrada solo se consulta cuando el resolutor pregunta; un resolutor que responde *sin* preguntar,
|
||||
como `check_stock`, siempre proporciona su propio valor calculado. Como cada respuesta se empareja con
|
||||
su pregunta, un resolutor que elicita debe derivar su pregunta de forma determinista a partir de los
|
||||
argumentos de la herramienta y las respuestas anteriores. Un valor generado por llamada (un id de
|
||||
`default_factory`, una marca de tiempo) se vuelve a derivar en cada ronda y no debe aparecer en una
|
||||
pregunta a la que la respuesta deba quedar vinculada. Una pregunta construida con datos tan volátiles
|
||||
hace que toda respuesta registrada parezca obsoleta, así que el servidor la vuelve a hacer en cada
|
||||
ronda hasta que el límite de rondas del cliente termina la llamada.
|
||||
|
||||
## Pregunta al cliente, no al usuario {#ask-the-client-not-the-user}
|
||||
|
||||
La elicitación es una de las tres preguntas que puede hacer un resolutor, y el flujo de varias idas y vueltas no permite otras. Las otras dos van al **cliente** en lugar de al usuario: devuelve `Sample(...)` para ejecutar una llamada a un LLM a través del cliente (una solicitud `sampling/createMessage`), o `ListRoots()` para obtener los roots actuales del cliente. Ninguna tiene un resultado de aceptar/rechazar; el consumidor anota el tipo de resultado directamente, `CreateMessageResult` (`CreateMessageResultWithTools` cuando la solicitud lleva `tools` o `tool_choice`) o `ListRootsResult`:
|
||||
|
||||
```python title="server.py" hl_lines="10-15 21"
|
||||
--8<-- "docs_src/dependencies/tutorial004.py"
|
||||
```
|
||||
|
||||
* El framework las enruta exactamente igual que `Elicit`: dentro del `tools/call` de varias idas y vueltas en **2026-07-28**, sobre la solicitud independiente servidor->cliente en **2025-11-25**. Una capacidad no declarada rechaza la llamada con un error de protocolo `-32021` (`sampling`, `roots`, `elicitation` en modo formulario; `sampling.tools` cuando la solicitud lleva `tools` o `tool_choice`).
|
||||
* Todo lo que dice el recuadro informativo de arriba sobre las preguntas se aplica sin cambios: una solicitud `Sample` se empareja con su resultado registrado por su representación exacta, así que constrúyela de forma determinista a partir de los argumentos de la herramienta y las respuestas anteriores; el cliente paga entonces la llamada al LLM una vez por llamada a la herramienta, no una vez por ronda. El resultado registrado viaja en `request_state` durante el resto de la llamada, así que una respuesta del modelo muy grande hace más pesada cada ida y vuelta restante.
|
||||
* Las *funcionalidades* independientes de muestreo (sampling) y roots quedan obsoletas en 2026-07-28 (SEP-2577). Los servidores nuevos que necesitan el modelo del cliente preguntan a través de este mecanismo; los que no, deberían integrarse directamente con un proveedor de LLM. Los valores de `include_context` distintos de `"none"` están ellos mismos obsoletos; evítalos.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* `Annotated[T, Resolve(fn)]` en un parámetro de herramienta: el SDK ejecuta `fn` e inyecta su valor devuelto.
|
||||
* Un parámetro resuelto es invisible para el modelo y un cliente no puede proporcionarlo. Los valores que el modelo no debe inventar (precios, identidades, permisos) van aquí.
|
||||
* Los parámetros de un resolutor se resuelven del mismo modo: el `Context`, otro `Resolve(...)`, o un argumento de la herramienta por nombre. El grafo ejecuta cada resolutor como mucho una vez por ronda, tenga los consumidores que tenga; cada pregunta se hace exactamente una vez, y cualquier resolutor puede volver a ejecutarse cuando una llamada se reanuda tras una pregunta.
|
||||
* Los grafos incorrectos fallan en el registro con `InvalidSignature`, no a mitad de llamada.
|
||||
* Devuelve `Elicit(message, Model)` para preguntar al usuario, solo cuando tengas que hacerlo. Las anotaciones sin envolver abortan al rechazar; `ElicitationResult[T]` permite a la herramienta bifurcar.
|
||||
* Devuelve `Sample(...)` o `ListRoots()` para pedir al cliente una respuesta del modelo o la lista de roots; se inyecta el resultado sin más.
|
||||
|
||||
El estado que tu servidor construye una vez al arrancar, y cómo llega a él un handler, es la página de **[Lifespan](lifespan.md)**.
|
||||
192
i18n/es/pages/handlers/elicitation.md
Normal file
192
i18n/es/pages/handlers/elicitation.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
---
|
||||
translation:
|
||||
sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8]
|
||||
tool: 1
|
||||
---
|
||||
# Elicitación {#elicitation}
|
||||
|
||||
Una herramienta que va por la mitad de su trabajo y a la que le falta una respuesta no tiene por qué fallar.
|
||||
|
||||
La **elicitación** (elicitation) le permite preguntar. En medio de una llamada a la herramienta, el usuario recibe una pregunta y su respuesta vuelve a la misma llamada de función.
|
||||
|
||||
Hay dos modos:
|
||||
|
||||
* **Modo formulario**: necesitas un valor (una confirmación, una fecha, una cantidad). Describes los campos y el cliente muestra el formulario.
|
||||
* **Modo URL**: necesitas que el usuario vaya a otro sitio (una pantalla de consentimiento OAuth, una página de pago). Nada de lo que haga allí pasa por el protocolo.
|
||||
|
||||
Y hay dos formas de preguntar. La que conviene usar es un **resolutor**: cuelgas la pregunta de un parámetro y el SDK pregunta, en cualquier conexión, sea cual sea la generación del protocolo que hable el cliente. La forma directa, `await ctx.elicit(...)`, es una solicitud del *servidor* al *cliente*, un canal que solo existe para un cliente en una conexión heredada (versión de la especificación 2025-11-25 o anterior). Ambas están en esta página; empieza por el resolutor.
|
||||
|
||||
## Preguntar con un resolutor {#ask-with-a-resolver}
|
||||
|
||||
Una pregunta que condiciona toda la herramienta (*¿estás seguro?, ¿cuál de las tres cuentas que coinciden?*) puede sacarse del cuerpo de la herramienta a un **resolutor**, y el framework la hace por ti.
|
||||
|
||||
Un parámetro anotado como `Annotated[T, Resolve(fn)]` se rellena ejecutando `fn` antes del cuerpo de la herramienta. El resolutor devuelve el valor directamente cuando ya lo conoce, o devuelve `Elicit(...)` para que el framework pregunte:
|
||||
|
||||
```python title="server.py" hl_lines="24-30 35-36"
|
||||
--8<-- "docs_src/elicitation/tutorial004.py"
|
||||
```
|
||||
|
||||
* `confirm_delete` lee por nombre el propio argumento `path` de la herramienta, lista la carpeta y **solo pregunta cuando debe**: una carpeta vacía se resuelve a `Confirm(ok=True)` sin ninguna ida y vuelta al cliente.
|
||||
* `delete_folder` anota `ElicitationResult[Confirm]`, así que el framework inyecta el resultado completo y la herramienta usa `match` para cubrir cada caso: aceptar y confirmar, aceptar pero conservar (`ok=False`), rechazar, cancelar.
|
||||
* El parámetro `confirm` nunca aparece en el esquema de entrada de la herramienta: el cliente aporta `path`, el resolutor aporta `confirm`.
|
||||
|
||||
Anota en su lugar el modelo sin envolver (`Annotated[Confirm, Resolve(confirm_delete)]`) cuando la herramienta no necesita bifurcar: recibe el modelo si el usuario acepta y la llamada se interrumpe con un error si rechaza o cancela.
|
||||
|
||||
Un resolutor funciona en **todas** las conexiones. A un cliente en una conexión heredada, el SDK le envía la pregunta directamente; en una conexión **2026-07-28**, el SDK *devuelve* la pregunta desde la llamada y el siguiente intento del cliente lleva la respuesta. Tu resolutor nunca nota la diferencia; lo que ocurre por debajo está en **[Solicitudes de varias idas y vueltas](multi-round-trip.md)** (multi-round-trip).
|
||||
|
||||
Preguntar es solo una de las cosas que puede hacer un resolutor. El mecanismo general (dependencias que calculan sin preguntar, dependencias de dependencias, qué puede aportar el modelo y qué no) está en la página **[Dependencias](dependencies.md)**.
|
||||
|
||||
## Preguntar desde dentro de la herramienta {#ask-from-inside-the-tool}
|
||||
|
||||
Una herramienta también puede detenerse en medio de su propio cuerpo y preguntar.
|
||||
|
||||
!!! warning
|
||||
`ctx.elicit()` y `ctx.elicit_url()` son solicitudes del *servidor* al *cliente*: un
|
||||
canal que solo existe para un cliente en una conexión heredada (versión de la especificación
|
||||
**2025-11-25** o anterior). En una conexión **2026-07-28** no hay solicitudes iniciadas por el
|
||||
servidor, así que estas llamadas fallan. Un resolutor funciona en ambas.
|
||||
**[Versiones del protocolo](../protocol-versions.md)** tiene todos los detalles.
|
||||
|
||||
`await ctx.elicit()` recibe un mensaje y un modelo de Pydantic:
|
||||
|
||||
```python title="server.py" hl_lines="9-11 20-23 25"
|
||||
--8<-- "docs_src/elicitation/tutorial001.py"
|
||||
```
|
||||
|
||||
* El parámetro **`Context`** es lo que te da `ctx.elicit`; cualquier herramienta puede recibir uno. Ese objeto tiene su propia página: **[El Context](context.md)**.
|
||||
* `AlternativeDate` es el **esquema** de la respuesta que quieres.
|
||||
* La herramienta es `async def`. Tiene que serlo: se detiene a mitad y espera a una persona.
|
||||
* En cualquier otra fecha la herramienta devuelve enseguida. Solo pregunta cuando tiene que hacerlo.
|
||||
* La fecha que acepta el usuario vuelve a pasar por el propio `book_table`. Una respuesta es una entrada como cualquier otra: una alternativa que también está completa provoca una nueva pregunta, no se confirma a ciegas.
|
||||
|
||||
### Qué recibe el cliente {#what-the-client-receives}
|
||||
|
||||
El cliente recibe tu mensaje y, junto a él, un JSON Schema generado a partir del modelo:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"accept_alternative": {
|
||||
"description": "Try another date?",
|
||||
"title": "Accept Alternative",
|
||||
"type": "boolean"
|
||||
},
|
||||
"date": {
|
||||
"default": "2025-12-26",
|
||||
"description": "Alternative date (YYYY-MM-DD)",
|
||||
"title": "Date",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["accept_alternative"],
|
||||
"title": "AlternativeDate",
|
||||
"type": "object"
|
||||
}
|
||||
```
|
||||
|
||||
Ese esquema es el formulario. `Field(description=...)` es la etiqueta; un valor por defecto rellena el campo de antemano y lo hace opcional. Es la misma maquinaria de Pydantic a JSON Schema que **[Herramientas](../servers/tools.md)** describe para los argumentos de una herramienta.
|
||||
|
||||
!!! warning
|
||||
Un esquema de elicitación no es tan expresivo como el esquema de entrada de una herramienta.
|
||||
Solo campos planos y primitivos: `str`, `int`, `float`, `bool` o un `Literal` de cadenas (se
|
||||
convierte en un `enum`). Pon un modelo dentro del modelo y `ctx.elicit` lanza una excepción
|
||||
antes de que se envíe nada al cliente:
|
||||
|
||||
```text
|
||||
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
|
||||
```
|
||||
|
||||
Estás interrumpiendo a una persona en plena tarea. Si la respuesta necesita anidamiento,
|
||||
debería haber sido un argumento de la herramienta.
|
||||
|
||||
### Las tres respuestas {#the-three-answers}
|
||||
|
||||
`result.action` te dice qué hizo el usuario, y hay exactamente tres posibilidades:
|
||||
|
||||
* `"accept"`: envió el formulario. `result.data` es una instancia de `AlternativeDate`, ya validada.
|
||||
* `"decline"`: dijo que no.
|
||||
* `"cancel"`: descartó la pregunta sin elegir.
|
||||
|
||||
`result.data` solo existe con `"accept"`, y por eso el ejemplo comprueba primero `result.action`. Tu verificador de tipos impone el orden: después de `result.action == "accept"`, `result.data` es un `AlternativeDate`; antes, no hay ningún `.data`.
|
||||
|
||||
Una negativa no es un error. La herramienta decide qué significa rechazar (aquí, no hay reserva) y responde al modelo con normalidad.
|
||||
|
||||
!!! tip
|
||||
La respuesta se valida contra tu modelo antes de que tu código la vea. Un cliente que envía
|
||||
`"maybe"` para un `bool` no corrompe tu reserva: la llamada falla con un error de
|
||||
discrepancia de esquema y tu `if` nunca se ejecuta.
|
||||
|
||||
## Enviar al usuario a una URL {#send-the-user-to-a-url}
|
||||
|
||||
Algunas cosas no deben pasar por el modelo ni por el cliente: credenciales, números de tarjeta, consentimiento OAuth. Para esas no pides datos; pides al usuario que vaya a algún sitio:
|
||||
|
||||
```python title="server.py" hl_lines="10-14 23"
|
||||
--8<-- "docs_src/elicitation/tutorial002.py"
|
||||
```
|
||||
|
||||
* `ctx.elicit_url()` recibe el mensaje, la **URL** que hay que visitar y un `elicitation_id` que eliges tú: cualquier cadena que identifique esta elicitación dentro de tu servidor.
|
||||
* El resultado tiene una acción y nada más. `"accept"` significa que el usuario aceptó abrir la URL, **no** que haya terminado lo que hay al otro lado.
|
||||
* El pago ocurre fuera de banda, entre el navegador del usuario y tu proveedor de pagos. Ningún contenido vuelve nunca a través de MCP.
|
||||
|
||||
Fíjate en la segunda herramienta. Cuando el servidor se entera de que el flujo fuera de banda terminó (un webhook, un sondeo; aquí se modela como una segunda herramienta), `ctx.session.send_elicit_complete(...)` envía `notifications/elicitation/complete` con el mismo `elicitation_id`. Así es como el cliente sabe que puede dejar de mostrar *"waiting for payment..."*. Sin eso, el cliente solo puede adivinar.
|
||||
|
||||
## El lado del cliente {#the-client-side}
|
||||
|
||||
Los servidores preguntan. Los clientes responden pasando un **`elicitation_callback`** a `Client(...)`:
|
||||
|
||||
```python title="client.py" hl_lines="6-7 18"
|
||||
--8<-- "docs_src/elicitation/tutorial003.py"
|
||||
```
|
||||
|
||||
* Un solo callback maneja ambos modos. `params` es una unión de `ElicitRequestFormParams` y `ElicitRequestURLParams`; `isinstance` es la bifurcación.
|
||||
* Para una URL, muestras `params.url` al usuario y devuelves la acción que eligió. Nunca ningún `content`.
|
||||
* Para un formulario, una aplicación real muestra `params.requested_schema` y devuelve la entrada del usuario como `content`. Este siempre dice que sí con una respuesta predefinida, que es justo el callback que quieres en una prueba.
|
||||
* Pasar el callback es también la **declaración de capacidad**: es como el servidor se entera de que a este cliente se le puede preguntar. Las demás cosas que un cliente puede responder a un servidor están en **[Callbacks del cliente](../client/callbacks.md)**.
|
||||
|
||||
!!! info
|
||||
La elicitación es una solicitud del *servidor* al *cliente*, y esas solo existen en una
|
||||
sesión con handshake clásico, por eso este cliente pasa `mode="legacy"`.
|
||||
En una conexión **2026-07-28**, una herramienta pregunta *devolviendo* la pregunta desde la
|
||||
llamada; ese flujo está en **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**.
|
||||
|
||||
### Pruébalo {#try-it}
|
||||
|
||||
Arranca el `server.py` del modo formulario con `ctx.elicit` (el de `book_table`) sobre Streamable HTTP (**[Ejecutar tu servidor](../run/index.md)** tiene el comando de una línea), luego ejecuta el `main()` del cliente y pide a `book_table` el día de Navidad.
|
||||
|
||||
El callback imprime la pregunta que recibió:
|
||||
|
||||
```text
|
||||
No tables for 2 on 2025-12-25. Would you like to try another date?
|
||||
```
|
||||
|
||||
Responde con `{"accept_alternative": True, "date": "2025-12-27"}`, y la herramienta, que ha estado esperando dentro de `await ctx.elicit(...)` todo este tiempo, termina la reserva:
|
||||
|
||||
```text
|
||||
Booked a table for 2 on 2025-12-27.
|
||||
```
|
||||
|
||||
Ahora cambia al `server.py` del modo URL y apunta el mismo `main()` a `pay_deposit`: el mismo callback toma la otra rama, imprime el enlace de pago y la herramienta vuelve con *"Complete the payment in your browser."* Una ida y vuelta, en mitad de la llamada, en ambos sentidos.
|
||||
|
||||
!!! check
|
||||
Ahora quita `elicitation_callback=` del `Client` y vuelve a llamar a `book_table` para el día
|
||||
de Navidad. Toda la llamada falla con un error de protocolo:
|
||||
|
||||
```text
|
||||
Elicitation not supported
|
||||
```
|
||||
|
||||
Un cliente que no registró ningún callback nunca declaró la capacidad `elicitation`, así que
|
||||
no hay nadie a quien preguntar. Tu herramienta no recibió un `"decline"`; recibió una
|
||||
excepción. Diseña para ello: toda elicitación necesita una respuesta sensata a "¿y si no
|
||||
puedo preguntar?".
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Un parámetro anotado como `Annotated[T, Resolve(fn)]` lo rellena un resolutor, que devuelve `Elicit(...)` cuando tiene que preguntar. Funciona en todas las conexiones.
|
||||
* El esquema es un modelo plano de Pydantic: solo campos primitivos, validados al volver.
|
||||
* `result.action` es `"accept"`, `"decline"` o `"cancel"`; `result.data` solo existe en accept.
|
||||
* `await ctx.elicit(message, schema=Model)` pregunta desde dentro del cuerpo de la herramienta, y `await ctx.elicit_url(message, url, elicitation_id)` es para todo lo que no debe pasar por el modelo (`ctx.session.send_elicit_complete(elicitation_id)` indica que la parte fuera de banda terminó). Ambas son solicitudes del servidor al cliente: necesitan al cliente en una conexión heredada.
|
||||
* El cliente responde con un solo `elicitation_callback`, bifurcando según el tipo de params; registrarlo es lo que declara la capacidad.
|
||||
* En una conexión 2026-07-28 el servidor devuelve la pregunta en lugar de enviarla; el mismo callback se alimenta desde **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**.
|
||||
|
||||
Todo lo que hay debajo de ese retorno (el bucle de reintentos, proteger `requestState`, manejarlo tú mismo) está en **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**.
|
||||
24
i18n/es/pages/handlers/index.md
Normal file
24
i18n/es/pages/handlers/index.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
translation:
|
||||
sections: [424930166c4bc6f3]
|
||||
tool: 1
|
||||
---
|
||||
# Dentro de tu handler {#inside-your-handler}
|
||||
|
||||
Los argumentos de un handler vienen del cliente. Todo lo *demás* que puede leer, y todo lo que puede hacer mientras se ejecuta, está aquí.
|
||||
|
||||
Lo que puede leer:
|
||||
|
||||
* **[El Context](context.md)** es el único parámetro extra que cualquier handler puede pedir: la solicitud en curso, sus cabeceras, su sesión y los verbos de progreso y de notificación de cambios.
|
||||
* **[Dependencias](dependencies.md)** son parámetros que el modelo nunca ve, rellenados por tus propias funciones con `Resolve`.
|
||||
* **[Lifespan](lifespan.md)** cubre el estado que el servidor construye una sola vez al arrancar, y cómo un handler llega a él a través del `Context`.
|
||||
|
||||
Lo que puede hacer mientras se ejecuta:
|
||||
|
||||
* Pedir más datos al usuario con **[Elicitación](elicitation.md)**, y con **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**, el patrón de 2026-07-28 que la transporta.
|
||||
* Pedir al cliente una respuesta de su LLM o sus carpetas de trabajo con **[Muestreo y roots](sampling-and-roots.md)**, obsoletos pero todavía atendidos.
|
||||
* Informar del **[Progreso](progress.md)** de algo lento.
|
||||
* Escribir logs (en el error estándar, para quien opere el servidor) con **[Logging](logging.md)**.
|
||||
* Avisar a los clientes suscritos de que algo cambió con **[Suscripciones](subscriptions.md)**.
|
||||
|
||||
Si todavía no has registrado ningún handler, empieza por **[Herramientas](../servers/tools.md)**. Todas las páginas de esta sección suponen que ya tienes uno.
|
||||
107
i18n/es/pages/handlers/lifespan.md
Normal file
107
i18n/es/pages/handlers/lifespan.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
translation:
|
||||
sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53]
|
||||
tool: 1
|
||||
---
|
||||
# Lifespan {#lifespan}
|
||||
|
||||
La mayoría de los servidores reales mantienen algo durante toda su vida: un pool de conexiones a la base de datos, un cliente HTTP, un modelo cargado.
|
||||
|
||||
No quieres construirlo en cada llamada, y sí quieres cerrarlo limpiamente. Para eso está el **lifespan** (ciclo de vida del servidor).
|
||||
|
||||
## Un lifespan tipado {#a-typed-lifespan}
|
||||
|
||||
Un lifespan es un `@asynccontextmanager` que recibe el servidor y hace `yield` de **un solo objeto**. Lo que sea que entregues queda disponible para todos los handlers mientras el servidor esté en ejecución.
|
||||
|
||||
```python title="server.py" hl_lines="25-31 34 38 40"
|
||||
--8<-- "docs_src/lifespan/tutorial001.py"
|
||||
```
|
||||
|
||||
Léelo de abajo hacia arriba:
|
||||
|
||||
* `app_lifespan` conecta la `Database` **antes** del `yield` y la desconecta **después**, en un `finally`. Eso es el arranque y el apagado.
|
||||
* Entrega un `AppContext`, una dataclass simple que contiene las cosas que configuraste. Un campo hoy, diez mañana.
|
||||
* `MCPServer("Bookshop", lifespan=app_lifespan)` es todo el cableado necesario.
|
||||
* Dentro de la herramienta, el objeto entregado es `ctx.request_context.lifespan_context`.
|
||||
|
||||
El lifespan se ejecuta **una sola vez**. Se entra en él cuando el servidor arranca (antes de la primera solicitud) y se sale cuando el servidor se detiene. Todas las solicitudes intermedias comparten el mismo `AppContext`.
|
||||
|
||||
!!! info
|
||||
Si has escrito un `lifespan` de FastAPI, ya conoces esto. Mismo decorador, mismo `yield`, mismo `finally`.
|
||||
|
||||
### Lo que ve el modelo {#what-the-model-sees}
|
||||
|
||||
Nada nuevo. `ctx` es un parámetro **Context**, así que el SDK lo inyecta y nunca llega al esquema de entrada:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"genre": {"title": "Genre", "type": "string"}
|
||||
},
|
||||
"required": ["genre"],
|
||||
"title": "count_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
`genre` es el único argumento que el modelo puede pasar. El lifespan es asunto de tu servidor.
|
||||
|
||||
Las funciones `@mcp.resource()` y `@mcp.prompt()` también pueden recibir un parámetro `ctx`, escrito como un `Context` a secas por una razón que se explica en la siguiente sección. Todo lo que lleva `ctx` está en **[El Context](context.md)**.
|
||||
|
||||
### De verdad está tipado {#it-really-is-typed}
|
||||
|
||||
Mira de nuevo la anotación: `ctx: Context[AppContext]`.
|
||||
|
||||
Ese único parámetro de tipo es la razón por la que `ctx.request_context.lifespan_context` **es** un `AppContext` para tu verificador de tipos. `.db` se autocompleta; `.dbb` es un error antes de que llegues a ejecutar el servidor.
|
||||
|
||||
Si escribes un `Context` a secas, `lifespan_context` queda tipado como `dict[str, Any]`: el verificador de tipos no tiene forma de saber qué entregó tu lifespan. El objeto sigue ahí en tiempo de ejecución; lo que pierdes es la ayuda.
|
||||
|
||||
!!! warning
|
||||
`Context[AppContext]` es una forma de escribirlo **exclusiva de las herramientas**. Ponla en una
|
||||
función `@mcp.resource()` o `@mcp.prompt()` y todas las llamadas a ese handler fallan. El cliente
|
||||
recibe un error, y el log del servidor muestra por qué:
|
||||
|
||||
```text
|
||||
Context is not available outside of a request
|
||||
```
|
||||
|
||||
En recursos y prompts, escribe `ctx: Context` a secas. El objeto que entregó tu lifespan
|
||||
sigue siendo `ctx.request_context.lifespan_context` en tiempo de ejecución; renuncias al
|
||||
parámetro de tipo, no al objeto.
|
||||
|
||||
!!! tip
|
||||
Siempre hay un lifespan. Si no pasas uno, el lifespan por defecto del SDK entrega un `dict` vacío,
|
||||
así que `ctx.request_context.lifespan_context` es `{}`, nunca `None`. Ese valor por defecto es también
|
||||
la razón por la que un `Context` a secas lo tipa como `dict[str, Any]`.
|
||||
|
||||
## Míralo en acción {#watch-it-happen}
|
||||
|
||||
"El arranque se ejecuta antes de la primera solicitud" es el tipo de frase que no deberías tener que creerte sin más.
|
||||
|
||||
Reduce el servidor al ciclo de vida: dale a `Database` un indicador `connected`, cámbialo en `connect()` y `disconnect()`, y añade una herramienta que informe de su valor.
|
||||
|
||||
```python title="server.py" hl_lines="11 14 17 25 44"
|
||||
--8<-- "docs_src/lifespan/tutorial002.py"
|
||||
```
|
||||
|
||||
`database` vive a nivel de módulo por una sola razón: para que puedas observarla desde *fuera* del servidor.
|
||||
|
||||
!!! check
|
||||
Tres momentos, tres valores:
|
||||
|
||||
* Antes de que el servidor arranque, `database.connected` es `False`. Importar el módulo no conectó nada.
|
||||
* Mientras está en ejecución, llama a `database_status` y el resultado es `"connected"`.
|
||||
* Detén el servidor y se ejecuta el bloque `finally`: `database.connected` es `False` de nuevo.
|
||||
|
||||
El trabajo ocurrió exactamente donde lo pusiste: alrededor del `yield`, no al importar ni en cada solicitud.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* `lifespan=` recibe un `@asynccontextmanager` que recibe el servidor y hace `yield` de un solo objeto.
|
||||
* El código anterior al `yield` es el arranque. El `finally` posterior es el apagado.
|
||||
* Se ejecuta una sola vez, alrededor de toda la vida del servidor, no en cada solicitud.
|
||||
* Lo que sea que entregues con `yield` es `ctx.request_context.lifespan_context` en cada herramienta, recurso y prompt.
|
||||
* `ctx: Context[AppContext]` hace que ese acceso esté completamente tipado en las herramientas. Los recursos y prompts reciben el `Context` a secas.
|
||||
* Sin `lifespan=`, obtienes un `dict` vacío, nunca `None`.
|
||||
|
||||
Un handler que se detiene a mitad de una llamada para preguntarle al usuario algo que solo él sabe es **[Elicitación](elicitation.md)**.
|
||||
87
i18n/es/pages/handlers/logging.md
Normal file
87
i18n/es/pages/handlers/logging.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
translation:
|
||||
sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5]
|
||||
tool: 1
|
||||
---
|
||||
# Registro de logs {#logging}
|
||||
|
||||
Registra logs desde una herramienta igual que desde cualquier otra función de Python: con la biblioteca estándar.
|
||||
|
||||
MCP tiene una **capacidad de logging** a nivel de protocolo: un servidor podía enviar sus mensajes de log al cliente como notificaciones, mediante métodos del objeto `Context`. La revisión 2026-07-28 de la especificación **declara obsoleta esa capacidad y no la sustituye**, así que esta documentación no la enseña. La lista completa de lo que está obsoleto y qué hacer en su lugar está en **[Funcionalidades obsoletas](../deprecated.md)**.
|
||||
|
||||
Lo que haces en su lugar es lo que haces en cualquier otro programa de Python: usar la biblioteca estándar.
|
||||
|
||||
## Una herramienta que registra logs {#a-tool-that-logs}
|
||||
|
||||
```python title="server.py" hl_lines="1 5 13"
|
||||
--8<-- "docs_src/logging/tutorial001.py"
|
||||
```
|
||||
|
||||
* `logging.getLogger(__name__)` te da un logger con el nombre de tu módulo. Créalo una vez, al principio.
|
||||
* Dentro de la herramienta llamas a `logger.info(...)` como en cualquier otra función. Nada que inyectar, nada que esperar con `await`, nada específico de MCP.
|
||||
|
||||
!!! check
|
||||
Llama a la herramienta y mira el resultado completo:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(text="Found 3 books matching 'dune'.")]
|
||||
result.structured_content # {'result': "Found 3 books matching 'dune'."}
|
||||
```
|
||||
|
||||
La línea de log no aparece por ningún lado. El logging es para **ti**, la persona que opera el
|
||||
servidor. El modelo nunca lo ve. Si el modelo debe leer algo, devuélvelo con `return`.
|
||||
|
||||
## A dónde va {#where-it-goes}
|
||||
|
||||
Para un servidor **stdio**, esta pregunta importa más de lo habitual. El host lanzó el servidor como subproceso y lee los mensajes MCP desde su **stdout**. La salida de error estándar es tuya.
|
||||
|
||||
La biblioteca estándar ya hace lo correcto: la salida de log va a `sys.stderr` por defecto. Tus líneas `logger.info(...)` acaban en la terminal (o donde sea que el host recoja el stderr del subproceso), y el flujo del protocolo se mantiene limpio.
|
||||
|
||||
!!! tip
|
||||
No uses `print()` en un servidor stdio. `print` escribe en **stdout**, y stdout pertenece al
|
||||
protocolo. Mientras atiende solicitudes, el SDK desvía a stderr el stdout que realmente se *vacía*
|
||||
(flush), así que no puede corromper el canal. Pero un `print()` en un proceso con búfer por bloques
|
||||
suele quedarse sin vaciar en el búfer de `sys.stdout` hasta que el intérprete lo drena al salir,
|
||||
directamente sobre el flujo del protocolo. Incluso cuando se desvía, la línea llega en bruto entre la
|
||||
salida de log, sin nivel, sin nombre de logger y sin forma de filtrarla.
|
||||
|
||||
`logger.debug("got here")` cuesta la misma línea de esfuerzo y va al lugar correcto.
|
||||
|
||||
## El nivel {#the-level}
|
||||
|
||||
No tienes que llamar a `logging.basicConfig()` tú mismo. Construir un `MCPServer` ya lo hizo, con un handler apuntado a la salida de error estándar, al nivel que pasas como `log_level=`, así que `MCPServer("Bookshop", log_level="DEBUG")` es todo lo que hace falta para ver tus líneas `logger.debug(...)`.
|
||||
|
||||
El valor por defecto es `"INFO"`.
|
||||
|
||||
`logging.basicConfig()` nunca reemplaza handlers que ya existen. Si configuras el logging tú mismo antes de crear el servidor, tu configuración gana.
|
||||
|
||||
## Pruébalo {#try-it}
|
||||
|
||||
Ejecuta el servidor con el MCP Inspector:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
Llama a `search_books` desde la pestaña **Tools**. El Inspector te muestra el resultado: solo el valor devuelto. La línea
|
||||
|
||||
```text
|
||||
Searching for 'dune'
|
||||
```
|
||||
|
||||
fue a la salida de error estándar: a la terminal, no al canal.
|
||||
|
||||
!!! info
|
||||
Si lo que realmente quieres es *trazabilidad* (cada solicitud, cuánto tardó, si falló), no quieres
|
||||
líneas de log, quieres spans. El servidor ya los emite: el SDK traza cada mensaje con OpenTelemetry
|
||||
por defecto. Consulta **[OpenTelemetry](../run/opentelemetry.md)**.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* La capacidad de logging del protocolo MCP queda obsoleta en la especificación 2026-07-28 y no se sustituye. No construyas sobre ella.
|
||||
* `logger = logging.getLogger(__name__)` a nivel de módulo, `logger.info(...)` en la herramienta. Ese es todo el patrón.
|
||||
* La salida de log nunca llega al modelo. Solo lo hace el valor que devuelves con `return`.
|
||||
* La salida de error estándar es tuya; stdout pertenece al protocolo. El SDK desvía a stderr el stdout extraviado que se vacía mientras atiende solicitudes, pero un `print()` sin vaciar todavía puede drenarse sobre el canal al salir, y las líneas desviadas llegan sin etiquetar; usa `logging`, cuyo handler vacía cada registro.
|
||||
* `MCPServer(..., log_level="DEBUG")` fija el nivel, y una configuración de logging que hayas hecho antes se respeta.
|
||||
|
||||
Avisar a los clientes conectados de que algo cambió en el servidor (la lista de herramientas, un recurso) es cosa de **[Suscripciones](subscriptions.md)**.
|
||||
191
i18n/es/pages/handlers/multi-round-trip.md
Normal file
191
i18n/es/pages/handlers/multi-round-trip.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
---
|
||||
translation:
|
||||
sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b]
|
||||
tool: 1
|
||||
---
|
||||
# Solicitudes de varias idas y vueltas {#multi-round-trip-requests}
|
||||
|
||||
A veces una herramienta no puede terminar en una sola ida y vuelta. Necesita algo que solo tiene el usuario: una elección, una confirmación, una credencial.
|
||||
|
||||
Antes de 2026-07-28 el servidor lo conseguía con una llamada **de vuelta**: abría su propia solicitud al cliente (una elicitación (elicitation), una llamada de muestreo (sampling)) mientras atendía la original. La especificación 2026-07-28 retira ese canal de retorno (back-channel).
|
||||
|
||||
En su lugar, el servidor **devuelve** un resultado.
|
||||
|
||||
## Devuelve, no llames de vuelta {#return-dont-call-back}
|
||||
|
||||
El servidor responde a `tools/call` con un **`InputRequiredResult`** en lugar de un `CallToolResult`. Dos de sus campos hacen el trabajo:
|
||||
|
||||
* **`input_requests`**: lo que el servidor aún necesita, como un dict cuyas claves son nombres que eligió el servidor. Cada valor es un `ElicitRequest`, un `CreateMessageRequest` o un `ListRootsRequest`.
|
||||
* **`request_state`**: un token opaco. El cliente lo devuelve tal cual en el reintento. Tu servidor es lo único que lo lee.
|
||||
|
||||
El cliente satisface cada solicitud y luego llama a la **misma herramienta otra vez**, con sus respuestas en `input_responses` y el token en `request_state`. El servidor ya tiene lo que le faltaba y devuelve un `CallToolResult` normal.
|
||||
|
||||
Ese es todo el protocolo. Cada tramo es una solicitud ordinaria del cliente al servidor. Nunca fluye nada en sentido contrario.
|
||||
|
||||
## El lado del servidor {#the-server-side}
|
||||
|
||||
En `@mcp.tool()` rara vez construyes esto a mano: declara una dependencia que pregunta al usuario (`Elicit`), muestrea el LLM del cliente (`Sample`) o lista sus roots (directorios raíz) (`ListRoots`), y el SDK devuelve el `InputRequiredResult` por ti; esa forma es la página **[Dependencias](dependencies.md)**. Las dos formas no se mezclan: una llamada tiene un solo canal `input_responses`/`request_state`, así que una herramienta que usa parámetros `Resolve(...)` no puede además devolver `InputRequiredResult` desde su cuerpo. Un retorno `InputRequiredResult` declarado se rechaza al registrar (`InvalidSignature`), y uno no declarado hace fallar la llamada en tiempo de ejecución. La forma manual es el `Server` de **bajo nivel**, cuyo handler `on_call_tool` puede devolver cualquiera de los dos tipos de resultado:
|
||||
|
||||
```python title="server.py" hl_lines="43-46"
|
||||
--8<-- "docs_src/mrtr/tutorial001.py"
|
||||
```
|
||||
|
||||
* `on_call_tool` tiene el tipo `-> CallToolResult | InputRequiredResult`. Devolver el segundo es toda la API del lado del servidor.
|
||||
* En la primera llamada `params.input_responses` es `None`, así que la guarda se activa y el handler pregunta en lugar de responder.
|
||||
* En el reintento, el `ElicitResult` que envió el cliente está bajo la **misma clave** (`"region"`) que el servidor usó en `input_requests`.
|
||||
|
||||
Todo lo demás en ese archivo (el `input_schema` explícito, el `CallToolResult` construido a mano) es el `Server` de bajo nivel ordinario, que se explica en **[El Server de bajo nivel](../advanced/low-level-server.md)**. Esta página solo añade el segundo tipo de retorno.
|
||||
|
||||
## Más allá de las herramientas {#beyond-tools}
|
||||
|
||||
`tools/call` no es especial: en 2026-07-28 un servidor puede responder a `prompts/get` y `resources/read` de la misma manera. En `MCPServer`, una función `@mcp.prompt()` (o una función de **plantilla** `@mcp.resource()`) devuelve ella misma el `InputRequiredResult` y lee las respuestas del reintento desde el contexto:
|
||||
|
||||
```python title="server.py" hl_lines="20 22 24"
|
||||
--8<-- "docs_src/mrtr/tutorial004.py"
|
||||
```
|
||||
|
||||
* La primera ronda devuelve el `InputRequiredResult`. En el reintento, `ctx.input_responses` contiene las respuestas bajo las mismas claves y la función devuelve su resultado ordinario: mensajes de prompt aquí, contenido de recurso para un recurso de plantilla.
|
||||
* Un `request_state` que fijes se sella antes de transmitirse y se verifica cuando vuelve, como todo lo demás en el servidor; **[Proteger `requestState`](#protecting-requeststate)** más abajo explica qué te da el sello y cuándo necesitas configurar claves.
|
||||
* Una función `@mcp.tool()` puede devolver el resultado directamente de la misma manera, cuando la forma con dependencias no encaja.
|
||||
* Las funciones `@mcp.resource()` estáticas no participan: no reciben `Context`, así que nunca podrían leer el reintento. Solo los recursos de plantilla pueden preguntar.
|
||||
* Las reglas sobre la generación del protocolo de más abajo se aplican sin cambios: devolver un `InputRequiredResult` en una sesión anterior a 2026 es el mismo `-32603` que describe la advertencia.
|
||||
|
||||
## El lado del cliente {#the-client-side}
|
||||
|
||||
`Client` ejecuta el bucle por ti.
|
||||
|
||||
Registra los callbacks que el servidor podría pedir (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) y llama a la herramienta. Cuando llega un `InputRequiredResult`, `Client` despacha cada entrada de `input_requests` al callback correspondiente, reintenta con las respuestas y el `request_state` devuelto, y sigue hasta que vuelve un `CallToolResult`:
|
||||
|
||||
```python title="client.py" hl_lines="11 12"
|
||||
--8<-- "docs_src/mrtr/tutorial003.py"
|
||||
```
|
||||
|
||||
* Ese `elicitation_callback` es el mismo al que habría llegado el `elicitation/create` por canal de retorno de un servidor anterior a 2026. Lo mismo vale para `sampling_callback` con `sampling/createMessage` y `list_roots_callback` con `roots/list`: en 2026-07-28 las RPC independientes de servidor a cliente desaparecen, pero los mismos payloads `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` viajan dentro de `input_requests` y se despachan a los mismos tres callbacks. Un solo conjunto de callbacks sirve a ambas generaciones.
|
||||
* `call_tool` devuelve un `CallToolResult` sin más. Las rondas intermedias son invisibles para quien llama.
|
||||
* `get_prompt` y `read_resource` ejecutan el mismo bucle.
|
||||
|
||||
!!! check
|
||||
Si omites el callback, el bucle falla en la primera ronda: el callback sustituto del SDK
|
||||
responde a toda elicitación con un error, y `call_tool` lanza `MCPError` con el mensaje
|
||||
*"Elicitation not supported"*.
|
||||
|
||||
El bucle está acotado. `Client(..., input_required_max_rounds=10)` es el tope por defecto; un servidor que sigue devolviendo `InputRequiredResult` más allá de él hace que `call_tool` lance una excepción. Si una ronda trae solo `request_state` y ningún `input_requests`, `Client` duerme brevemente (50 ms que se duplican hasta un techo de 250 ms) antes de reintentar, así que a un servidor que solo está diciendo *"todavía no he terminado"* no se le sondea sin parar.
|
||||
|
||||
### Controlar el bucle tú mismo {#driving-the-loop-yourself}
|
||||
|
||||
El bucle automático basta para un cliente de un solo proceso. Hazte cargo del bucle, en cambio, cuando:
|
||||
|
||||
* Tu cliente es **distribuido**: el proceso que muestra la pregunta al usuario no es el proceso que llamó a `call_tool`, así que un worker distinto emite el reintento. `request_state` es el token persistible que llevas a través de esa frontera, mediante tu propio almacenamiento, e `input_responses` es lo que el otro lado envía de vuelta con él.
|
||||
* Quieres **inspeccionar** cada ronda: registrar o auditar cada entrada de `input_requests`, rechazar ciertos tipos de solicitud o aplicar tu propio backoff entre tramos.
|
||||
* Quieres un límite de **tiempo real** en lugar de un límite por número de rondas: envuelve tu propio bucle en `anyio.fail_after(...)` en lugar de depender de `input_required_max_rounds`.
|
||||
|
||||
Baja a la sesión subyacente, donde `allow_input_required=True` te entrega la unión directamente:
|
||||
|
||||
```python title="client.py" hl_lines="12 13 19"
|
||||
--8<-- "docs_src/mrtr/tutorial002.py"
|
||||
```
|
||||
|
||||
* `client.session.call_tool(..., allow_input_required=True)` amplía el tipo de retorno a `CallToolResult | InputRequiredResult`. El `isinstance` es lo que lo vuelve a estrechar.
|
||||
* `request_state` está ahora en tus manos. Guárdalo entre tramos y la conversación puede reanudarse desde un proceso nuevo.
|
||||
* Por cada entrada de `input_requests` pones un `InputResponse` bajo la **misma clave** en `input_responses`. `fulfil` es donde va tu UI; esta fija la respuesta en el código.
|
||||
* Mismo nombre de herramienta, mismos `arguments`, en cada tramo. El reintento es la llamada original realizada de nuevo, no un método nuevo.
|
||||
|
||||
## Proteger `requestState` {#protecting-requeststate}
|
||||
|
||||
Todo lo anterior trata `request_state` como un eco, y en lo que se transmite no es más que eso. Pero el cliente lo conserva entre tramos (guardarlo entre procesos es justo lo que aprobó la sección anterior), así que lo que vuelve es **entrada proporcionada por el cliente**: puede estar modificada, caducada o tomada de otra llamada completamente distinta. La especificación exige que los servidores protejan la integridad de este estado y rechacen la ronda cuando la verificación falla, siempre que el estado pueda influir en la autorización, el acceso a recursos o la lógica de negocio.
|
||||
|
||||
`MCPServer` lo protege por defecto. Todo servidor sella el `requestState` saliente y verifica cada eco (tanto el estado de los resolutores como el construido a mano) con una clave generada al arrancar el proceso. No configuras nada, escribes texto plano y lees texto plano; lo único que se transmite es un token cifrado opaco.
|
||||
|
||||
La clave por defecto vive y muere con el proceso, que es lo único que debes saber antes de desplegar más allá de un solo proceso:
|
||||
|
||||
```python
|
||||
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
|
||||
|
||||
# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each).
|
||||
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
|
||||
```
|
||||
|
||||
* **El valor por defecto (sin configuración)** sirve para un solo proceso: stdio, o exactamente un worker HTTP. Un reintento que cae en otro worker, en otra instancia detrás de un balanceador de carga o en el mismo servidor tras un reinicio está sellado con una clave que ese proceso no tiene: el cliente recibe el rechazo fijo de más abajo y debe empezar el flujo de nuevo.
|
||||
* **`keys=[...]`** es obligatorio siempre que un reintento pueda llegar a una **instancia distinta** (`uvicorn` con varios workers, HTTP con balanceo de carga) o deba sobrevivir a reinicios: cada instancia verifica lo que emitió cualquier instancia hermana. La misma maquinaria, tu secreto en lugar de uno generado.
|
||||
* Para tu propia criptografía, como un KMS o un servicio de tokens existente, pasa `RequestStateSecurity(codec=...)` en lugar de `keys`; **[Trae tu propia criptografía](#bring-your-own-crypto)** más abajo explica el contrato.
|
||||
|
||||
### Qué lleva el sello {#what-the-seal-carries}
|
||||
|
||||
Por defecto o configurado, el `requestState` que se transmite es un token cifrado y autenticado. Tu código nunca lo ve: los handlers y los resolutores escriben texto plano y leen texto plano (`ctx.request_state`); el SDK sella a la salida y verifica a la entrada. Más allá de la integridad, cada token está vinculado a:
|
||||
|
||||
* **Una ventana de tiempo.** Cada ronda vuelve a sellar con una caducidad nueva, así que `RequestStateSecurity(ttl=...)` (600 segundos por defecto) acota el tiempo de reflexión por ronda, no el flujo completo.
|
||||
* **El principal autenticado.** Cuando la solicitud trae un token de acceso OAuth que el SDK validó, el estado queda vinculado al cliente, el emisor y el sujeto del token: el estado emitido para un usuario falla con otro, incluso cuando ambos usuarios comparten un mismo cliente OAuth. Un verificador que no aporta sujeto degrada el vínculo a la sola identidad del cliente, que con ID de cliente basados en URL comparten todos los usuarios de ese software cliente. Cuando la autenticación termina fuera del SDK (un proxy delante), o el transporte no está autenticado, no hay principal al que vincular y esta comprobación queda inerte, salvo que `RequestStateSecurity(bind_principal=...)` aporte uno a partir de tu propia señal de identidad. Sean cuales sean los componentes que aporta tu verificador de tokens, debe aportarlos de forma coherente: un verificador que incluye el sujeto en algunas solicitudes y lo omite en otras cambia el principal a mitad del flujo, y las rondas en curso se rechazan.
|
||||
* **La solicitud de origen.** El método, el nombre de la herramienta o del prompt (o la URI del recurso) y un resumen criptográfico de los argumentos. Un token reproducido contra otra herramienta, otros argumentos u otro método falla.
|
||||
* **La pregunta exacta que se hizo.** Cada respuesta de un resolutor queda fijada a la pregunta tal como se mostró al cliente, tanto en la ronda en que llega por primera vez como cuando una respuesta registrada se reutiliza más tarde. Vuelve a desplegar con un mensaje reformulado o un esquema cambiado y el servidor vuelve a preguntar en lugar de consumir una respuesta obsoleta. La misma fijación funciona en el otro sentido: deriva los mensajes de los argumentos de la herramienta, no de datos propios de cada llamada. Un mensaje construido a partir de una marca de tiempo o de una cotización en vivo se muestra distinto en cada ronda, así que toda respuesta registrada parece obsoleta y el servidor vuelve a preguntar hasta que el límite de rondas del cliente termina la llamada.
|
||||
|
||||
Todo eso es trabajo del SDK, no tuyo, ni del códec si traes el tuyo.
|
||||
|
||||
### Rotación de claves {#rotating-keys}
|
||||
|
||||
`keys[0]` sella el estado nuevo; todas las claves de la lista verifican. La rotación sin tiempo de inactividad tiene tres fases, cada una desplegada por completo antes de la siguiente:
|
||||
|
||||
```python
|
||||
RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints
|
||||
RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying
|
||||
RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD
|
||||
```
|
||||
|
||||
Nunca promuevas primero la clave emisora: emitir con una clave que alguna instancia aún no puede verificar pierde rondas en curso a mitad del despliegue.
|
||||
|
||||
Las claves tienen como ámbito un solo servicio. El sobre sellado lleva también el nombre del servidor como claim de audiencia, así que un token emitido por otro servicio que casualmente comparte un secreto se rechaza de todos modos. El claim es tan distintivo como lo sea el nombre, así que un servidor con una política explícita debe tener un nombre real o fijar `RequestStateSecurity(audience=...)`: uno sin nombre lanza una excepción al construirse. `audience=` también sirve para topologías deliberadas de varios servicios donde un servicio debe aceptar estado que emitió otro. (El valor por defecto sin configuración queda exento: su clave nunca sale del proceso, así que el claim de audiencia no tiene nada que añadir.)
|
||||
|
||||
### Trae tu propia criptografía {#bring-your-own-crypto}
|
||||
|
||||
`RequestStateSecurity(codec=...)` acepta cualquier cosa con `seal(bytes) -> str` y `unseal(str) -> bytes` que lance `InvalidRequestState` para cualquier token que no haya emitido. La forma clásica es el cifrado de sobre contra un KMS, donde desenvuelves una clave de datos una vez al arrancar y mantienes local la criptografía por token:
|
||||
|
||||
```python title="server.py" hl_lines="12 26-27 34-35 38"
|
||||
--8<-- "docs_src/mrtr/tutorial005.py"
|
||||
```
|
||||
|
||||
El TTL, el vínculo con el principal y el vínculo con la solicitud **no** son trabajo del códec: el SDK los graba en el payload antes de `seal` y los vuelve a verificar después de `unseal`, para todo códec. Las únicas obligaciones de un códec son la integridad (manipulado significa lanzar una excepción) y, a ser posible, la confidencialidad.
|
||||
|
||||
### Cuando falla la verificación {#when-verification-fails}
|
||||
|
||||
Todo fallo entrante, ya sea por manipulación, caducidad, reproducción contra otra solicitud u otro principal, o por estar sellado con una clave que este servidor no conoce, recibe la misma respuesta:
|
||||
|
||||
```json
|
||||
{"code": -32602, "message": "Invalid or expired requestState"}
|
||||
```
|
||||
|
||||
Un único mensaje fijo para todas las causas, de modo que lo que se transmite nunca revela qué comprobación falló; el motivo real va al log del servidor. Se comprueba todo `requestState` entrante en `tools/call`, `prompts/get` y `resources/read`, incluido uno que llegue para un handler que nunca emite estado. El rechazo más común en la práctica no es un atacante: es la clave por defecto, local al proceso, que se topa con un reintento anterior a un reinicio o procedente de otra instancia; el cliente reinicia el flujo, y `keys=[...]` es la solución cuando eso importa.
|
||||
|
||||
### Estado construido a mano {#hand-built-state}
|
||||
|
||||
Un `request_state` que fijas tú mismo (al devolver `InputRequiredResult` desde una función de herramienta, de prompt o de plantilla de recurso) lo sella y verifica la misma maquinaria que el estado de los resolutores, sin ningún cambio de código: escribe texto plano, lee texto plano, y se aplican todos los vínculos anteriores.
|
||||
|
||||
Lo único que el SDK no puede fijar por ti, incluso configurado, es la identidad de la pregunta: no sabe a cuál de *tus* preguntas pertenece una respuesta de tu estado. Si guardas respuestas con la pregunta como clave, incluye tu propio identificador de pregunta en el estado y compruébalo en el reintento.
|
||||
|
||||
El `Server` de bajo nivel es el nivel sin pilas incluidas: a diferencia de `MCPServer`, no se sella nada hasta que añades tú mismo esa frontera, y tu `request_state` se transmite exactamente como lo escribiste hasta que lo haces. La activación de una sola línea se muestra en **[El Server de bajo nivel](../advanced/low-level-server.md#the-other-handlers)**.
|
||||
|
||||
## Un resultado de 2026-07-28 {#a-2026-07-28-result}
|
||||
|
||||
`InputRequiredResult` solo existe en la versión del protocolo **2026-07-28**. El `Client(server)` en memoria la negocia por ti; a través del canal, `mode="auto"` la descubre. Tras conectar, `client.protocol_version` te dice qué obtuviste.
|
||||
|
||||
!!! warning
|
||||
Una sesión anterior a 2026 no tiene dónde poner un `InputRequiredResult`. Devuelve uno desde tu
|
||||
handler en una conexión `mode="legacy"` y el ejecutor no puede serializarlo a la versión negociada;
|
||||
el cliente recibe un error `-32603` *"Handler returned an invalid result"*. Un servidor que atiende
|
||||
ambas generaciones debe comprobar `ctx.protocol_version` antes de recurrir a él.
|
||||
|
||||
!!! info
|
||||
**La elicitación en modo URL** usa exactamente este mecanismo en una conexión 2026. La entrada en
|
||||
`input_requests` es un `ElicitRequest` cuyos params son `ElicitRequestURLParams`; el usuario
|
||||
termina el flujo fuera de banda y tu cliente reintenta la llamada. El mismo bucle, ninguna API
|
||||
nueva. La mitad del servidor de alto nivel está en **[Elicitación](elicitation.md)**.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* En 2026-07-28 un servidor que necesita datos a mitad de una llamada **devuelve** un `InputRequiredResult`. Nunca abre una solicitud al cliente.
|
||||
* `input_requests` es lo que necesita. `request_state` es un token opaco de reanudación que solo lee el servidor.
|
||||
* `Client` ejecuta el bucle de reintentos por ti: registra `elicitation_callback` / `sampling_callback` / `list_roots_callback` y `call_tool` devuelve un `CallToolResult` sin más. `input_required_max_rounds` (10 por defecto) lo acota.
|
||||
* Para inspeccionar o persistir rondas, usa `client.session.call_tool(..., allow_input_required=True)` y hazte cargo tú mismo del bucle `while isinstance(result, InputRequiredResult)`.
|
||||
* En `@mcp.tool()`, una dependencia que pregunta al usuario produce este resultado por ti (**[Dependencias](dependencies.md)**); el `Server` de **bajo nivel** es la forma manual.
|
||||
* Los prompts y los recursos también participan: una función `@mcp.prompt()` o `@mcp.resource()` de plantilla devuelve ella misma el `InputRequiredResult` y lee `ctx.input_responses` en el reintento.
|
||||
* `requestState` vuelve como entrada proporcionada por el cliente, así que `MCPServer` lo sella por defecto (tanto el estado de los resolutores como el construido a mano) con una clave local al proceso; los despliegues de varias instancias pasan `RequestStateSecurity(keys=[...])` (o un códec propio) para que cada instancia pueda verificar lo que emitió una instancia hermana. El sello vincula cada token a una ventana de tiempo, a la solicitud de origen y al principal autenticado cuando la solicitud trae autenticación que el SDK validó o `bind_principal=` aporta tu propia señal de identidad (**[Proteger `requestState`](#protecting-requeststate)**).
|
||||
|
||||
Este es el mecanismo que sustituye al muestreo iniciado por el servidor y al resto del canal de retorno de tipo push; consulta **[Funcionalidades obsoletas](../deprecated.md)**.
|
||||
122
i18n/es/pages/handlers/progress.md
Normal file
122
i18n/es/pages/handlers/progress.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
translation:
|
||||
sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007]
|
||||
tool: 1
|
||||
---
|
||||
# Progreso {#progress}
|
||||
|
||||
Una herramienta que tarda treinta segundos y no dice nada durante treinta segundos parece rota.
|
||||
|
||||
Las **notificaciones de progreso** lo solucionan. La herramienta informa de cuánto lleva avanzado; el cliente decide qué dibujar con eso: una barra, un indicador giratorio, una línea de log.
|
||||
|
||||
## Repórtalo desde la herramienta {#report-it-from-the-tool}
|
||||
|
||||
Acepta un parámetro **`Context`** y llama a `report_progress`:
|
||||
|
||||
```python title="server.py" hl_lines="8 11"
|
||||
--8<-- "docs_src/progress/tutorial001.py"
|
||||
```
|
||||
|
||||
Tres argumentos, y tú decides qué significan:
|
||||
|
||||
* `progress`: cuánto llevas avanzado. La especificación exige que **aumente** con cada reporte; nunca repitas un valor ni retrocedas.
|
||||
* `total`: cuánto hay en total, si lo sabes. Opcional.
|
||||
* `message`: una línea legible para humanos sobre *este* paso. Opcional.
|
||||
|
||||
`ctx` se inyecta por su anotación de tipo y el modelo nunca lo ve: el esquema de entrada de `import_catalog` tiene una sola propiedad, `urls`. La página **[El Context](context.md)** trata por completo de ese objeto; el progreso es una de las cosas que te da.
|
||||
|
||||
## Escúchalo desde el cliente {#listen-for-it-from-the-client}
|
||||
|
||||
El cliente lo activa **por llamada**, pasando `progress_callback=` a `call_tool`:
|
||||
|
||||
```python title="client.py" hl_lines="7 16"
|
||||
import anyio
|
||||
from mcp import Client
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
async def show(progress: float, total: float | None, message: str | None) -> None:
|
||||
print(f"{message} ({progress}/{total})")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"import_catalog",
|
||||
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
|
||||
progress_callback=show,
|
||||
)
|
||||
print(result.structured_content)
|
||||
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
El callback es una función `async` que recibe exactamente lo que reportó el servidor: `progress`, `total`, `message`.
|
||||
|
||||
!!! info
|
||||
`Client(mcp)` se conecta directamente al objeto servidor, en memoria; es el mismo cliente sobre el que se construye la página **[Pruebas](../get-started/testing.md)**.
|
||||
`progress_callback` es el mismo parámetro sea cual sea el transporte que use el `Client`;
|
||||
los *tiempos* que vas a ver son los de la conexión en memoria. Ejecuta tu callback
|
||||
de forma directa, así que cada reporte llega antes de que `call_tool` devuelva. Con un transporte real,
|
||||
las notificaciones compiten con el resultado, y un callback lento puede seguir ejecutándose después de que `call_tool`
|
||||
haya devuelto.
|
||||
|
||||
### Pruébalo {#try-it}
|
||||
|
||||
Pon `client.py` junto a `server.py` y ejecútalo:
|
||||
|
||||
```console
|
||||
python client.py
|
||||
```
|
||||
|
||||
```text
|
||||
Imported https://example.com/a.json (1/2)
|
||||
Imported https://example.com/b.json (2/2)
|
||||
{'result': 'Imported 2 records.'}
|
||||
```
|
||||
|
||||
Cada `await ctx.report_progress(...)` en el servidor se convirtió en una llamada a `show` en el cliente, en orden, y ambas líneas se imprimieron **antes** de que `call_tool` devolviera. El progreso no va empaquetado en el resultado; se transmite mientras la herramienta sigue trabajando.
|
||||
|
||||
!!! warning
|
||||
`progress_callback` pertenece a la **llamada**, no al `Client`. No hay un argumento del constructor
|
||||
para él, porque llamadas distintas quieren callbacks distintos: una maneja una barra de descarga, la siguiente
|
||||
una línea de log.
|
||||
|
||||
!!! check
|
||||
Ahora borra `progress_callback=show` y ejecútalo de nuevo:
|
||||
|
||||
```text
|
||||
{'result': 'Imported 2 records.'}
|
||||
```
|
||||
|
||||
Ningún error, ningún aviso, el mismo resultado. `report_progress` **no hace nada cuando quien llama no pidió
|
||||
progreso**, así que reportas sin condiciones y nunca tienes que preguntarte si alguien está
|
||||
escuchando.
|
||||
|
||||
## Cuando no conoces el total {#when-you-dont-know-the-total}
|
||||
|
||||
`total` es para cuando conoces el denominador. A menudo no es así: estás vaciando un feed, recorriendo un cursor, descargando algo sin cabecera de longitud.
|
||||
|
||||
Omítelo:
|
||||
|
||||
```python title="server.py" hl_lines="20"
|
||||
--8<-- "docs_src/progress/tutorial002.py"
|
||||
```
|
||||
|
||||
El callback recibe `total=None`. Un cliente todavía puede mostrar *actividad* ("3 imported so far...") pero no puede mostrar un porcentaje. No te inventes un total para conseguir una barra más bonita.
|
||||
|
||||
!!! tip
|
||||
`progress` no tiene por qué contar nada en particular. Bytes, filas, páginas: elige la unidad que el
|
||||
usuario reconocería, y promete solo un `total` que puedas cumplir.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* `await ctx.report_progress(progress, total=None, message=None)` desde cualquier herramienta que reciba un `Context`.
|
||||
* El cliente pasa `progress_callback=` a `call_tool`: por llamada, nunca en el `Client`.
|
||||
* El callback es `async (progress, total, message) -> None` y se dispara mientras la herramienta sigue ejecutándose.
|
||||
* Si la llamada no lleva callback, `report_progress` no hace nada. Reporta sin condiciones.
|
||||
* Omite `total` cuando no lo conozcas; el callback recibe `None`.
|
||||
|
||||
El progreso es lo que una herramienta en ejecución le muestra al *usuario*. Las líneas que registra para *ti*, la persona que opera el servidor, van por otro canal: **[Logging](logging.md)**.
|
||||
51
i18n/es/pages/handlers/sampling-and-roots.md
Normal file
51
i18n/es/pages/handlers/sampling-and-roots.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
translation:
|
||||
sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9]
|
||||
tool: 1
|
||||
---
|
||||
# Muestreo y roots {#sampling-and-roots}
|
||||
|
||||
Un handler puede pedirle al cliente conectado dos cosas más: una respuesta del propio modelo del cliente, el **muestreo** (sampling), y las carpetas del espacio de trabajo del cliente, los **roots** (directorios raíz).
|
||||
|
||||
Ambos siguen funcionando, en todas las versiones del protocolo que habla el SDK. Pero lee la advertencia antes de diseñar en torno a ellos:
|
||||
|
||||
!!! warning "Obsoletos según la especificación 2026-07-28"
|
||||
El muestreo y los roots están obsoletos a partir de `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Siguen siendo plenamente funcionales y permanecen en la especificación al menos doce meses antes de poder ser eliminados, pero las implementaciones nuevas no deberían basarse en ellos. Las migraciones sugeridas: integra directamente la API de tu proveedor de LLM en lugar del muestreo, y pasa los directorios mediante parámetros de herramientas, URI de recursos o la configuración del servidor en lugar de los roots. La lista completa del SDK está en **[Funcionalidades obsoletas](../deprecated.md)**.
|
||||
|
||||
## Muestreo: toma prestado el modelo del cliente {#sampling-borrow-the-clients-model}
|
||||
|
||||
Un resolutor devuelve `Sample(...)` y la herramienta recibe la respuesta del modelo, a través del mismo mecanismo de dependencias que ejecuta `Elicit` en **[Dependencias](dependencies.md)**:
|
||||
|
||||
```python title="server.py" hl_lines="10-15 19"
|
||||
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
|
||||
```
|
||||
|
||||
* `Sample(messages, max_tokens=...)` refleja los parámetros de `sampling/createMessage`. El valor inyectado es el `CreateMessageResult` del cliente; pasa `tools` o `tool_choice` y en su lugar será un `CreateMessageResultWithTools`.
|
||||
* El cliente debe haber declarado la capacidad `sampling` (`sampling.tools` si pasas `tools` o `tool_choice`). Si no lo hizo, la llamada falla con un error de protocolo `-32021` en lugar de enviar una solicitud que el cliente no puede manejar. Una sesión anterior a 2026 sin canal de retorno (back-channel) falla con su error habitual de falta de canal de retorno, ya que no hay nada por donde enviarla.
|
||||
* En `2026-07-28` la solicitud se entrega dentro del flujo de varias idas y vueltas (**[Solicitudes de varias idas y vueltas](multi-round-trip.md)**, multi-round-trip); en `2025-11-25` es una solicitud independiente al cliente. El código es el mismo en ambos casos, pero ten en cuenta la regla de las varias idas y vueltas: la solicitud debe generarse idéntica en cada ronda de reintento, así que constrúyela solo a partir de los argumentos de la herramienta y otros datos estables.
|
||||
* Deja `include_context` tal cual: los valores distintos de `"none"` están a su vez obsoletos (SEP-2596) y necesitan una capacidad que casi ningún cliente declara.
|
||||
|
||||
## Roots: ¿dónde va esto? {#roots-where-should-this-go}
|
||||
|
||||
Los roots son las carpetas sobre las que, según el cliente, el servidor puede operar. Son una orientación informativa, no un mecanismo de control de acceso. Un resolutor devuelve `ListRoots()`:
|
||||
|
||||
```python title="server.py" hl_lines="10-11 15"
|
||||
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
|
||||
```
|
||||
|
||||
* El `ListRootsResult` inyectado lleva una lista de objetos `Root`: un URI `file://` y un nombre para mostrar opcional.
|
||||
* La condición es la misma que para el muestreo: sin una capacidad `roots` declarada, la llamada falla con `-32021` en lugar de enviar la solicitud.
|
||||
|
||||
Al otro lado del canal, el cliente responde ambas solicitudes con los callbacks que ya tiene: `sampling_callback` y `list_roots_callback`, que se tratan en **[Callbacks del cliente](../client/callbacks.md)**.
|
||||
|
||||
## En conexiones de la generación 2025 {#on-2025-era-connections}
|
||||
|
||||
`ctx.session.create_message(...)` y `ctx.session.list_roots()` siguen existiendo para el código que maneja la sesión directamente. Solo funcionan donde existe un canal de retorno (conexiones de la generación 2025 que no sean sin estado), y llamarlos lanza un aviso de obsolescencia. Los marcadores de resolutor de arriba son la forma admitida: eligen la entrega según la versión negociada y no emiten ningún aviso.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Devuelve `Sample(...)` o `ListRoots()` desde un resolutor; la herramienta recibe el `CreateMessageResult` o el `ListRootsResult` como cualquier otra dependencia.
|
||||
* El cliente debe declarar la capacidad correspondiente o la llamada falla con `-32021` en lugar de enviarse una solicitud.
|
||||
* Ambas funcionalidades están obsoletas en `2026-07-28`: plenamente funcionales por ahora, equivocadas para diseños nuevos. Prefiere las API del proveedor frente al muestreo y los parámetros explícitos frente a los roots.
|
||||
|
||||
Informar cuánto lleva avanzado una herramienta lenta: **[Progreso](progress.md)**.
|
||||
152
i18n/es/pages/handlers/subscriptions.md
Normal file
152
i18n/es/pages/handlers/subscriptions.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
translation:
|
||||
sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883]
|
||||
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 del URI de un recurso cambia.
|
||||
|
||||
Las **suscripciones** son la forma en que un cliente se entera. El cliente envía una solicitud `subscriptions/listen`, y la respuesta a esa solicitud *es* el flujo: queda abierto y transporta las notificaciones de cambio que el cliente pidió.
|
||||
|
||||
## Publícalo desde la herramienta {#publish-it-from-the-tool}
|
||||
|
||||
Tu parte es una sola línea: publicar el cambio.
|
||||
|
||||
```python title="server.py" hl_lines="20 32"
|
||||
--8<-- "docs_src/subscriptions/tutorial001.py"
|
||||
```
|
||||
|
||||
* `await ctx.notify_resource_updated("board://sprint")` llega a cada flujo abierto que se suscribió a ese URI. A nadie más.
|
||||
* `await ctx.notify_tools_changed()` llega a cada flujo que pidió los cambios en la lista de herramientas. Un cliente que lo recibe vuelve a llamar a `tools/list`, y ahora ve `sprint_report`.
|
||||
* Los métodos hermanos son `notify_prompts_changed()` y `notify_resources_changed()`.
|
||||
* Sin suscriptores, sin trabajo. Publicar en un servidor inactivo no hace nada, así que nunca compruebas si alguien está escuchando. Declaras qué cambió.
|
||||
|
||||
`MCPServer` atiende `subscriptions/listen` por ti. Las obligaciones del canal (el acuse de recibo como primera trama, el filtrado por flujo, el id de suscripción en cada trama) son trabajo del SDK.
|
||||
|
||||
!!! check
|
||||
En el canal, un flujo cuyo filtro nombró `board://sprint` se ve así después de que se ejecuta `complete_task`:
|
||||
|
||||
```json
|
||||
{"method": "notifications/subscriptions/acknowledged",
|
||||
"params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
|
||||
|
||||
{"method": "notifications/resources/updated",
|
||||
"params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
|
||||
```
|
||||
|
||||
Fíjate en lo que la actualización *no* lleva: el tablero. Cada trama lleva el id JSON-RPC de la solicitud listen bajo `_meta`, y ese id es el id de suscripción. Lo genera el cliente: el `Client` de Python usa cadenas como `"listen-1"`; otros clientes pueden usar enteros.
|
||||
|
||||
## Solo lo que se pidió {#only-what-was-asked-for}
|
||||
|
||||
El filtro es un contrato. Un flujo que solicitó los cambios en la lista de herramientas y un URI de recurso recibe esos dos tipos y nada más. Publica un cambio de prompt y ese flujo se queda en silencio.
|
||||
|
||||
`MCPServer` compara los URI de recurso como cadenas exactas, así que un flujo que nombró `board://sprint` no se entera de nada sobre `board://sprint/tasks/1`. La especificación permite que un servidor informe de un cambio en un subrecurso de un URI suscrito; `MCPServer` nunca lo hace, pero los clientes están construidos para esperarlo.
|
||||
|
||||
Dos cosas que el flujo *no* es:
|
||||
|
||||
* **No es un registro de repetición.** Un flujo caído se pierde, y los eventos publicados mientras nadie estaba conectado no se encolan. Los clientes vuelven a escuchar y vuelven a consultar.
|
||||
* **No es la vía de 2025.** A los clientes que llamaron a `resources/subscribe` los atiende `ctx.session.send_resource_updated(uri)`. Los métodos `notify_*` llegan solo a los flujos de `subscriptions/listen`.
|
||||
|
||||
## Decidir quién puede observar {#deciding-who-may-watch}
|
||||
|
||||
Por defecto se acepta cada tipo y URI solicitado: cualquier llamador puede observar cualquier URI que publiques. Nada consulta tu handler de lectura, porque nadie está leyendo: un llamador al que tu handler `files://{name}` rechazaría puede igualmente abrir un flujo sobre `files://payroll.csv` y enterarse de que cambió, y cuándo. Nunca conoce el contenido, y no puede sondear qué existe, porque un URI desconocido también se acepta y simplemente nunca se dispara. Acotado pero real, así que contrólalo antes de publicar URI por usuario desde un servidor multiinquilino.
|
||||
|
||||
El control es un middleware. Ve la solicitud `subscriptions/listen` antes de que el SDK la acuse y la rechaza cuando el llamador pide algo que no puede leer:
|
||||
|
||||
```python title="server.py" hl_lines="19-26 29"
|
||||
--8<-- "docs_src/subscriptions/tutorial006.py"
|
||||
```
|
||||
|
||||
* `ctx.params` es la solicitud en bruto, así que el propio middleware la valida como `SubscriptionsListenRequestParams` y lee el filtro que pidió el cliente.
|
||||
* El rechazo es un `MCPError` lanzado antes de `call_next(ctx)`: el cliente recibe ese error y ningún flujo, y la conexión sigue. Mantén el mensaje uniforme, sin nombrar ningún URI, para que un rechazo nunca confirme qué URI están protegidos.
|
||||
* Un único `can_access(user, uri)` responde ambas preguntas. El handler del recurso lo consulta en `resources/read`; el middleware lo consulta en `subscriptions/listen`. Cambia la tabla por una base de datos o por tu sistema RBAC y ambos siguen coordinados.
|
||||
* La decisión vale durante toda la vida del flujo. No hay nueva comprobación por evento, así que si el acceso de un llamador puede caducar a mitad del flujo (un token que expira), termina la conexión de ese llamador cuando ocurra.
|
||||
|
||||
El contrato completo del middleware, incluido qué más envuelve y por qué está marcado como provisional, está en **[Middleware](../advanced/middleware.md)**.
|
||||
|
||||
## El lado del cliente {#the-client-end}
|
||||
|
||||
Aquí tienes un cliente al otro lado de ese flujo, siguiendo el tablero:
|
||||
|
||||
```python title="client.py" hl_lines="15"
|
||||
--8<-- "docs_src/subscriptions/tutorial003.py"
|
||||
```
|
||||
|
||||
Entrar en `client.listen(...)` envía la solicitud y espera tu acuse de recibo, así que el flujo está activo cuando empieza el bloque, y cada evento tipado es una señal para volver a consultar, nunca un payload. Ese es todo el contrato en una pantalla. Todo lo demás sobre el lado del cliente vive en su propia página: observar junto a un flujo principal, finales de flujo y volver a escuchar. Consulta **[Suscripciones](../client/subscriptions.md)** en *Clientes*.
|
||||
|
||||
## Escalar más allá de un proceso {#scaling-past-one-process}
|
||||
|
||||
Las publicaciones viajan desde tu handler hasta los flujos abiertos a través de un `SubscriptionBus`. El valor por defecto es en memoria: un proceso, con todos los flujos dentro. Esa es la respuesta correcta hasta que ejecutas réplicas detrás de un balanceador de carga, porque entonces el flujo de un cliente queda fijado a una réplica, y una publicación en otra réplica tiene que llegar hasta él.
|
||||
|
||||
Esa pieza te toca implementarla a ti: dos métodos sobre tu backend de pub/sub.
|
||||
|
||||
```python
|
||||
from collections.abc import Callable
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class
|
||||
|
||||
|
||||
class RedisSubscriptionBus:
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
|
||||
|
||||
async def publish(self, event: ServerEvent) -> None:
|
||||
await self._redis.publish("mcp-events", encode(event)) # to every replica
|
||||
|
||||
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
||||
token = object()
|
||||
self._listeners[token] = listener
|
||||
|
||||
def unsubscribe() -> None:
|
||||
self._listeners.pop(token, None)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
|
||||
```
|
||||
|
||||
`encode` es tuyo, y también lo es la tarea lectora de cada réplica que decodifica los mensajes que llegan y llama a cada listener registrado. Los listeners son síncronos, no deben lanzar excepciones y se ejecutan en el bucle de eventos del servidor.
|
||||
|
||||
El bus transporta valores `ServerEvent` tipados, cuatro dataclasses pequeñas, nunca JSON-RPC. El marcado, el filtrado y los ciclos de vida de los flujos se quedan en el SDK, así que una implementación del bus no puede romper el protocolo. Solo puede mover eventos entre procesos.
|
||||
|
||||
Para publicar desde fuera de una solicitud, construye el bus tú mismo para conservar la referencia. `MCPServer` crea uno internamente cuando no pasas nada, y no lo expone.
|
||||
|
||||
```python
|
||||
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
|
||||
|
||||
bus = InMemorySubscriptionBus()
|
||||
mcp = MCPServer("Sprint Board", subscriptions=bus)
|
||||
|
||||
|
||||
async def tools_reloaded() -> None:
|
||||
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
|
||||
```
|
||||
|
||||
## La composición de bajo nivel {#the-low-level-composition}
|
||||
|
||||
Abajo, en el `Server` de bajo nivel, no hay nada preconectado, y las mismas piezas se ensamblan en tres líneas:
|
||||
|
||||
```python title="server.py" hl_lines="8-9 47"
|
||||
--8<-- "docs_src/subscriptions/tutorial002.py"
|
||||
```
|
||||
|
||||
* El bus es tuyo, así que publicas en él directamente: `await bus.publish(ResourceUpdated(uri=...))`. Ponlo donde tus handlers puedan alcanzarlo: el ámbito del módulo aquí, el lifespan en una app más grande.
|
||||
* `ListenHandler(bus)` es el mismo handler que registra `MCPServer`, y `on_subscriptions_listen=` es una ranura de handler común y corriente. Pon tu propio callable en esa ranura para otra semántica, y las obligaciones de la especificación pasan a ti: acusar recibo primero, marcar cada trama con el id de suscripción, no entregar nada fuera del filtro.
|
||||
* `ListenHandler.close()` termina cada flujo abierto de forma ordenada. Cada uno recibe el resultado de la solicitud listen como trama final, que es la forma que tiene la especificación de decir que el servidor terminó la suscripción a propósito. Devuelve antes de que esos flujos acaben de vaciarse, así que dales un momento antes de desmontar el transporte. Sin él, los flujos terminan cuando el cliente se desconecta.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Un cliente se apunta con una solicitud `subscriptions/listen`, y la respuesta es el flujo. Atenderla viene integrado.
|
||||
* Publicas con `ctx.notify_*`, y el SDK hace el trabajo de marcado, filtrado y ciclo de vida.
|
||||
* Los eventos son señales, no payloads. Ambos extremos vuelven a consultar.
|
||||
* El lado del cliente es `async with client.listen(...)`: **[Suscripciones](../client/subscriptions.md)** en *Clientes* tiene todos los detalles.
|
||||
* En el `Server` de bajo nivel ensamblas tú mismo las mismas piezas: un bus, `ListenHandler(bus)`, la ranura `on_subscriptions_listen`.
|
||||
* Escalar horizontalmente significa implementar `SubscriptionBus`, dos métodos, y pasarlo como `MCPServer(subscriptions=...)`.
|
||||
|
||||
Ejecutar el servidor que atiende todo esto, detrás de una réplica o de veinte, es **[Desplegar y escalar](../run/deploy.md)**.
|
||||
Loading…
Reference in a new issue