docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
6 changed files with 89737 additions and 54 deletions
146
i18n/es/pages/run/asgi.md
Normal file
146
i18n/es/pages/run/asgi.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
---
|
||||
translation:
|
||||
sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3]
|
||||
tool: 1
|
||||
---
|
||||
# Añadir a una app existente {#add-to-an-existing-app}
|
||||
|
||||
`mcp.run("streamable-http")` arranca un servidor web por ti. A veces no es lo que quieres: el servidor MCP es una pieza de una aplicación web más grande, o ya tienes un despliegue ASGI.
|
||||
|
||||
Para eso, `mcp.streamable_http_app()` devuelve una **aplicación Starlette**.
|
||||
|
||||
Una app Starlette es una app ASGI, así que cualquier cosa que aloje ASGI (uvicorn, Hypercorn, otra Starlette, FastAPI) puede alojar el servidor MCP.
|
||||
|
||||
## La app {#the-app}
|
||||
|
||||
```python title="server.py" hl_lines="12"
|
||||
--8<-- "docs_src/asgi/tutorial001.py"
|
||||
```
|
||||
|
||||
`app` es una aplicación ASGI corriente. Pásala a cualquier servidor ASGI:
|
||||
|
||||
```console
|
||||
uvicorn server:app
|
||||
```
|
||||
|
||||
El endpoint MCP está en `/mcp`, así que un cliente se conecta a `http://127.0.0.1:8000/mcp`.
|
||||
|
||||
La app ya trae dos cosas:
|
||||
|
||||
* Una ruta, `/mcp`: el endpoint Streamable HTTP.
|
||||
* Un **lifespan** (ciclo de vida del servidor) que arranca `mcp.session_manager`, el objeto que se encarga del trabajo en segundo plano de cada sesión activa.
|
||||
|
||||
Ejecuta la app por sí sola (`uvicorn server:app`) y nunca tendrás que pensar en ninguna de las dos.
|
||||
|
||||
!!! tip
|
||||
`streamable_http_app()` acepta los mismos argumentos nombrados que `mcp.run("streamable-http", ...)`,
|
||||
menos `port`: el puerto es cosa de lo que sirva la app. `host` se sigue aceptando, pero aquí
|
||||
no enlaza nada; **[Desplegar y escalar](deploy.md)** explica qué controla realmente.
|
||||
**[Ejecutar el servidor](index.md)** cubre las opciones en sí.
|
||||
|
||||
`mcp.sse_app()` hace lo mismo para el transporte SSE, ya reemplazado.
|
||||
|
||||
## Solo localhost, hasta que digas lo contrario {#localhost-only-until-you-say-otherwise}
|
||||
|
||||
Por defecto, la app responde **solo** a las solicitudes dirigidas a localhost. `streamable_http_app()`
|
||||
no puede saber detrás de qué nombre de host se va a servir, así que activa la protección contra DNS
|
||||
rebinding con la lista de permitidos más segura posible; en tu máquina eso es justo lo correcto.
|
||||
Desplegada detrás de un nombre de host real, significa que **toda solicitud se rechaza con
|
||||
`421 Misdirected Request`** hasta que le pases a `transport_security=` una lista de permitidos con
|
||||
lo que realmente sirves. Nada de lo que construiste llega siquiera a consultarse antes. Esa lista
|
||||
de permitidos, y todo lo demás que hay entre una app que funciona y un nombre de host real, está en
|
||||
**[Desplegar y escalar](deploy.md)**.
|
||||
|
||||
## Montarla {#mounting-it}
|
||||
|
||||
En cuanto el servidor MCP es *parte* de una aplicación más grande, metes la app dentro de un `Mount`. Y en cuanto haces eso, el lifespan pasa a ser tu problema:
|
||||
|
||||
```python title="server.py" hl_lines="18-21 25-26"
|
||||
--8<-- "docs_src/asgi/tutorial002.py"
|
||||
```
|
||||
|
||||
* `Mount("/", ...)` junto con el path por defecto `/mcp` mantiene el endpoint en `/mcp`. Starlette prueba las rutas en orden y `Mount("/")` coincide con **cualquier** path, así que tus propias rutas van *antes* que él en la lista. Todo lo que quede después es inalcanzable.
|
||||
* La función `lifespan` entra en `mcp.session_manager.run()` durante toda la vida de la app **anfitriona**. Esta es la línea que todo el mundo olvida.
|
||||
* `mcp.session_manager` solo existe *después* de llamar a `streamable_http_app()`. Por eso las rutas se construyen en el ámbito del módulo y el gestor solo se toca dentro del lifespan.
|
||||
|
||||
La ruta `Host` de Starlette funciona igual: cambia `Mount("/", ...)` por `Host("mcp.example.com", ...)` para enrutar por nombre de host en lugar de por path. La regla del lifespan no cambia, y la de la seguridad del transporte tampoco. Una ruta `Host("mcp.example.com", ...)` solo recibe solicitudes dirigidas a ese nombre de host, pero la lista de permitidos de Host del propio transporte (**[Desplegar y escalar](deploy.md)**) sigue ejecutándose primero. Sin `"mcp.example.com"` en ella, esa ruta responde a todas con un `421`.
|
||||
|
||||
!!! warning "La app anfitriona es dueña del lifespan"
|
||||
`streamable_http_app()` conecta `session_manager.run()` al lifespan de la Starlette que
|
||||
devuelve, pero **el lifespan de una subaplicación montada nunca se ejecuta**. Monta la app y
|
||||
ese lifespan integrado es código muerto. La app que esté en la cima de tu pila ASGI, sea cual
|
||||
sea, debe entrar en `mcp.session_manager.run()` en su propio lifespan.
|
||||
|
||||
!!! check
|
||||
Borra la línea `lifespan=lifespan` y arranca el servidor. Arranca. La ruta se resuelve.
|
||||
Luego la primera solicitud a `/mcp` falla con:
|
||||
|
||||
```text
|
||||
RuntimeError: Task group is not initialized. Make sure to use run().
|
||||
```
|
||||
|
||||
Nada arranca el gestor de sesiones salvo su `run()`.
|
||||
|
||||
## Dos servidores, una app {#two-servers-one-app}
|
||||
|
||||
Cada `MCPServer` es su propia app con su propio gestor de sesiones. Monta tantos como quieras; entra en todos los gestores desde el lifespan de la app anfitriona, que es uno solo:
|
||||
|
||||
```python title="server.py" hl_lines="27-30 35-36"
|
||||
--8<-- "docs_src/asgi/tutorial003.py"
|
||||
```
|
||||
|
||||
* `AsyncExitStack` entra en ambos gestores; arrancan juntos y se cierran en orden inverso.
|
||||
* Los endpoints son `/notes/mcp` y `/tasks/mcp`: el prefijo de montaje más el path por defecto.
|
||||
|
||||
## Cambiar el path {#changing-the-path}
|
||||
|
||||
Ese `/mcp` final es `streamable_http_path`. Ponlo en `"/"` y el prefijo de montaje pasa a ser el path público completo:
|
||||
|
||||
```python title="server.py" hl_lines="25"
|
||||
--8<-- "docs_src/asgi/tutorial004.py"
|
||||
```
|
||||
|
||||
Ahora los clientes se conectan a `/notes`, no a `/notes/mcp`.
|
||||
|
||||
## CORS para clientes de navegador {#cors-for-browser-clients}
|
||||
|
||||
Un cliente basado en navegador necesita dos permisos de tu parte: **enviar** sus encabezados de solicitud MCP y **leer** el que MCP devuelve. Ambos son configuración CORS de la app anfitriona, y la lista de permitidos de seguridad del transporte de arriba tiene que concordar con ella:
|
||||
|
||||
```python title="server.py" hl_lines="27-30 33 35-49"
|
||||
--8<-- "docs_src/asgi/tutorial005.py"
|
||||
```
|
||||
|
||||
* `allow_headers` es la mitad que todo el mundo olvida. Un navegador hace un **preflight** de cada solicitud MCP, porque `Content-Type: application/json` y los encabezados de solicitud `Mcp-*` no están en la lista segura de CORS, y un encabezado que el preflight no concede es una solicitud que el navegador nunca envía. (`allow_headers=["*"]` también funciona: Starlette responde a un preflight con lo que sea que haya pedido.)
|
||||
* `expose_headers=["Mcp-Session-Id"]` es la mitad de lectura. Streamable HTTP devuelve el ID de sesión en ese encabezado de respuesta, y los navegadores ocultan los encabezados de respuesta a JavaScript salvo que CORS los exponga por nombre. Sin él, el cliente nunca puede hacer su segunda solicitud.
|
||||
* `allow_origins` es decisión tuya, no de MCP. Sé preciso y refléjalo en `allowed_origins=` arriba: el navegador hace cumplir CORS, pero el servidor comprueba `Origin` por su cuenta, y un origen en el que el transporte no confía recibe un `403` incluso tras un preflight limpio.
|
||||
* `allow_methods` enumera los tres métodos que usa Streamable HTTP: `POST` para enviar mensajes, `GET` para abrir el flujo de servidor a cliente, `DELETE` para terminar la sesión.
|
||||
|
||||
## Rutas personalizadas {#custom-routes}
|
||||
|
||||
`@mcp.custom_route()` registra un endpoint HTTP simple en la misma app, para las cosas que todo servicio desplegado necesita y que no tienen nada que ver con MCP: una comprobación de estado, un callback de OAuth.
|
||||
|
||||
```python title="server.py" hl_lines="15-17"
|
||||
--8<-- "docs_src/asgi/tutorial006.py"
|
||||
```
|
||||
|
||||
* El handler es Starlette puro: una función `async` de `Request` a `Response`.
|
||||
* `streamable_http_app()` recoge todas las rutas personalizadas. `app.routes` es ahora `/mcp` y `/health`.
|
||||
* `GET /health` responde `{"status": "ok"}` sin rastro de MCP.
|
||||
|
||||
!!! warning
|
||||
Las rutas personalizadas **nunca se autentican**, aunque el resto del servidor sí. Es
|
||||
deliberado: las comprobaciones de estado y los callbacks de OAuth tienen que ser accesibles
|
||||
antes de que exista ningún token. No pongas nada privado detrás de una.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* `mcp.streamable_http_app()` devuelve una app Starlette con una ruta, `/mcp`. Cualquier servidor ASGI puede ejecutarla.
|
||||
* Por defecto, la app responde solo a las solicitudes dirigidas a localhost, y detrás de un nombre de host real lo rechaza todo con un `421` hasta que le pases a `transport_security=` una lista de permitidos. **[Desplegar y escalar](deploy.md)** se ocupa de eso y del resto del camino a producción.
|
||||
* `Mount` (o `Host`) la mete dentro de una app Starlette o FastAPI más grande.
|
||||
* **Montar desactiva el lifespan integrado.** El lifespan de la app anfitriona debe entrar en `mcp.session_manager.run()`, o la primera solicitud falla.
|
||||
* Varios servidores en una app significa varios montajes y un solo lifespan que entra en todos los gestores de sesiones.
|
||||
* `streamable_http_path="/"` mueve el endpoint al propio prefijo de montaje.
|
||||
* Los clientes de navegador necesitan CORS: `allow_headers` para los encabezados de solicitud `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` para la respuesta.
|
||||
* `@mcp.custom_route()` añade endpoints HTTP simples, sin autenticación, junto a `/mcp`.
|
||||
|
||||
Una vez que el servidor es accesible en una URL real, **[El cliente](../client/index.md)** se conecta a él con esa URL en lugar de con un objeto servidor.
|
||||
130
i18n/es/pages/run/authorization.md
Normal file
130
i18n/es/pages/run/authorization.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
translation:
|
||||
sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31]
|
||||
tool: 1
|
||||
---
|
||||
# Autorización {#authorization}
|
||||
|
||||
Sobre Streamable HTTP, tu servidor MCP es un servicio web común y corriente, y lo proteges igual que proteges cualquier servicio web: con tokens bearer de OAuth 2.1.
|
||||
|
||||
En términos de OAuth, el servidor es un **servidor de recursos**. Nunca inicia la sesión de nadie y nunca emite un token. Hace una sola cosa: mirar el header `Authorization` de cada solicitud y decidir si el token que trae es válido.
|
||||
|
||||
Esta página es el lado del servidor. Un cliente que descubre tu servidor de autorización y obtiene el token está en **[Clientes OAuth](../client/oauth-clients.md)**.
|
||||
|
||||
## Las tres partes {#the-three-parties}
|
||||
|
||||
* El **servidor de autorización** inicia la sesión de las personas y emite tokens de acceso. Esto no lo escribes tú. Es tu proveedor de identidad (Auth0, Keycloak, Entra, el tuyo propio).
|
||||
* El **servidor de recursos** es tu servidor MCP. Verifica el token en cada solicitud.
|
||||
* El **cliente** descubre en qué servidor de autorización confías, obtiene de él un token y te lo envía de vuelta como `Authorization: Bearer <token>`.
|
||||
|
||||
Ese es todo el triángulo. Todo lo que hay en esta página es el punto del medio.
|
||||
|
||||
## Un verificador de tokens {#a-token-verifier}
|
||||
|
||||
El SDK no opina sobre cómo debe ser un token válido. Se lo dices tú, implementando **`TokenVerifier`**:
|
||||
|
||||
```python title="server.py" hl_lines="12-14 19-24"
|
||||
--8<-- "docs_src/authorization/tutorial001.py"
|
||||
```
|
||||
|
||||
* `TokenVerifier` es un protocolo con un solo método asíncrono. `verify_token` recibe el token en bruto del header `Authorization` y devuelve un **`AccessToken`** si es válido, `None` si no lo es. No hay nada más que implementar.
|
||||
* Este busca el token en una tabla. Uno real verifica la firma de un JWT o llama al endpoint de introspección de tokens del servidor de autorización. Ese código es tuyo; el SDK solo lo llama.
|
||||
* `token_verifier=` y `auth=` siempre van juntos. Pasa uno sin el otro y `MCPServer(...)` lanza un `ValueError` antes de atender ninguna solicitud.
|
||||
|
||||
`AuthSettings` es la cara pública de tu servidor de recursos:
|
||||
|
||||
* `issuer_url`: el servidor de autorización que emite tus tokens.
|
||||
* `resource_server_url`: la URL pública de este endpoint MCP. Indica *para qué* recurso es un token y es donde vive el documento de descubrimiento.
|
||||
* `required_scopes`: todo token debe traerlos todos.
|
||||
|
||||
!!! tip
|
||||
`examples/servers/simple-auth/` en el repositorio del SDK tiene un `IntrospectionTokenVerifier` que llama
|
||||
al endpoint [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) de un servidor de autorización real. Es la forma que toman la mayoría de los verificadores en producción.
|
||||
|
||||
## Lo que obtienes sobre HTTP {#what-you-get-over-http}
|
||||
|
||||
La autorización vive en los headers HTTP, así que solo existe en los transportes HTTP. Ejecútala en el que despliegues: `mcp.run(transport="streamable-http")` la pone en `http://127.0.0.1:8000/mcp`, y **[Ejecutar el servidor](index.md)** tiene el resto. La app ahora tiene dos rutas:
|
||||
|
||||
```text
|
||||
/mcp
|
||||
/.well-known/oauth-protected-resource/mcp
|
||||
```
|
||||
|
||||
Registraste una herramienta. La segunda ruta es del SDK.
|
||||
|
||||
### Descubrimiento {#discovery}
|
||||
|
||||
Haz un `GET` a esa ruta well-known y obtienes los **Protected Resource Metadata de [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)**, construidos directamente a partir de tu `AuthSettings`:
|
||||
|
||||
```json
|
||||
{
|
||||
"resource": "http://127.0.0.1:8000/mcp",
|
||||
"authorization_servers": ["https://auth.example.com/"],
|
||||
"scopes_supported": ["notes:read"],
|
||||
"bearer_methods_supported": ["header"]
|
||||
}
|
||||
```
|
||||
|
||||
Este documento es la forma en que un cliente que nunca ha oído hablar de tu servidor encuentra la entrada: lee `authorization_servers` y va ahí a buscar un token. No escribiste nada de él.
|
||||
|
||||
!!! check
|
||||
Llama a `/mcp` sin token (o con uno para el que tu verificador devolvió `None`) y la solicitud
|
||||
se detiene en la puerta:
|
||||
|
||||
```text
|
||||
HTTP/1.1 401 Unauthorized
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
|
||||
|
||||
{"error": "invalid_token", "error_description": "Authentication required"}
|
||||
```
|
||||
|
||||
No se analizó nada ni se ejecutó ninguna herramienta. Y ese puntero `resource_metadata` en `WWW-Authenticate` es
|
||||
lo que hace automático el descubrimiento: 401 -> documento de metadatos -> servidor de autorización -> token -> reintento.
|
||||
|
||||
!!! warning
|
||||
Nada de esto protege a `stdio`. Una tubería no tiene header `Authorization`, así que ahí nunca se
|
||||
consulta `token_verifier`. La frontera de seguridad de un servidor `stdio` es el proceso que lo lanzó. Lo mismo
|
||||
vale para el `Client(mcp)` en memoria que usas en las pruebas: se conecta directamente al objeto servidor
|
||||
y se salta la capa HTTP, autorización incluida.
|
||||
|
||||
## La identidad de quien llama {#the-callers-identity}
|
||||
|
||||
Dentro de cualquier handler, **`get_access_token()`** es el `AccessToken` que tu verificador devolvió para la solicitud actual:
|
||||
|
||||
```python title="server.py" hl_lines="4 32-35"
|
||||
--8<-- "docs_src/authorization/tutorial002.py"
|
||||
```
|
||||
|
||||
* Funciona en herramientas, recursos y prompts, y no hay nada que pasar de un lado a otro: el middleware de autenticación lo guarda en una variable de contexto por solicitud.
|
||||
* Recibes el **mismo objeto que construyó tu verificador**: `client_id`, `scopes`, `subject`, `expires_at` y cualquier `claims` extra que hayas añadido. Ese es el punto de enganche para reglas por herramienta: lee los scopes y rechaza.
|
||||
* Fuera de una solicitud HTTP autenticada devuelve `None`. En memoria y sobre `stdio` siempre es `None`.
|
||||
|
||||
Llama a `whoami` con `Authorization: Bearer alice-token` y el modelo lee:
|
||||
|
||||
```text
|
||||
alice (scopes: notes:read)
|
||||
```
|
||||
|
||||
## La mitad que el SDK no hace {#the-half-the-sdk-doesnt-do}
|
||||
|
||||
El SDK te da la mitad del servidor de recursos: verificar, anunciar, rechazar. No te da una página de inicio de sesión, una pantalla de consentimiento ni un token.
|
||||
|
||||
Para ver a las tres partes en movimiento, ejecuta `examples/servers/simple-auth/` del repositorio del SDK (un pequeño servidor de autorización y un servidor de recursos configurado exactamente como en esta página) y luego apunta `examples/clients/simple-auth-client/` hacia él para ver el recorrido completo de descubrimiento y token.
|
||||
|
||||
!!! info
|
||||
Hay un segundo argumento del constructor, `auth_server_provider=`, que incrusta un servidor de autorización
|
||||
completo dentro de tu servidor MCP. Es anterior a la separación AS/RS sobre la que se construye la especificación
|
||||
de autorización de MCP. Los servidores nuevos no deberían recurrir a él.
|
||||
|
||||
Un servidor de autorización también puede aceptar la aserción firmada de un proveedor de identidad empresarial en lugar de que un usuario haga clic en una pantalla de consentimiento, y el SDK admite los dos lados de ese intercambio. El grant, y el cliente que lo presenta, están en **[Aserción de identidad](../client/identity-assertion.md)**.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Sobre Streamable HTTP tu servidor es un **servidor de recursos** de OAuth 2.1: verifica tokens, nunca los emite.
|
||||
* `TokenVerifier` es toda la superficie de integración: un método asíncrono, entra un token, sale `AccessToken | None`.
|
||||
* `token_verifier=` y `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` siempre van juntos.
|
||||
* El SDK publica los Protected Resource Metadata de [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) en `/.well-known/oauth-protected-resource/...` y responde a las solicitudes no autenticadas con un 401 cuyo header `WWW-Authenticate` apunta a ellos. Ese es todo el mecanismo de descubrimiento.
|
||||
* `get_access_token()` en cualquier handler te dice quién llama.
|
||||
* La autorización es un asunto de HTTP. `stdio` y el cliente en memoria nunca la ven.
|
||||
|
||||
La mitad del cliente (descubrir tu servidor de autorización y obtener el token por ti) está en **[Clientes OAuth](../client/oauth-clients.md)**. Y un cliente que *afirma* una identidad en lugar de pedírsela a un usuario está en **[Aserción de identidad](../client/identity-assertion.md)**.
|
||||
179
i18n/es/pages/run/deploy.md
Normal file
179
i18n/es/pages/run/deploy.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
---
|
||||
translation:
|
||||
sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a]
|
||||
tool: 1
|
||||
---
|
||||
# Desplegar y escalar {#deploy-scale}
|
||||
|
||||
El servidor funciona. Ahora necesita un nombre de host real y más de un worker detrás.
|
||||
|
||||
Casi nada de eso es asunto de MCP. Tú pones el servidor ASGI, el gestor de procesos y el balanceador de carga. Lo que tiene esta página es la lista corta de cosas que *sí* son asunto de MCP: un ajuste que condiciona cualquier despliegue y los dos puntos donde "más de un worker" cambia lo que hace el SDK.
|
||||
|
||||
## Antes que nada: la lista de hosts permitidos {#before-anything-else-the-host-allowlist}
|
||||
|
||||
`streamable_http_app()` no puede saber detrás de qué nombre de host se va a servir, así que supone la respuesta más segura: localhost. Sin `transport_security=`, la app activa la **protección contra DNS rebinding** y acepta una solicitud solo si su encabezado `Host` es `127.0.0.1:<port>`, `localhost:<port>` o `[::1]:<port>`. El encabezado `Origin`, cuando lo hay, tiene que ser la forma `http://` del mismo. En tu máquina eso es exactamente lo correcto: impide que una página web maliciosa maneje tu servidor local a través de un nombre DNS que volvió a apuntar a `127.0.0.1`.
|
||||
|
||||
Desplegada detrás de un nombre de host real, esa misma configuración por defecto rechaza **todas las solicitudes** hasta que indiques lo contrario. La comprobación se ejecuta antes que cualquier cosa con forma de MCP, así que nada de lo que construiste llega siquiera a consultarse:
|
||||
|
||||
```text
|
||||
421 Misdirected Request Invalid Host header the Host is not in the allowlist
|
||||
403 Forbidden Invalid Origin header the Origin is not in the allowlist
|
||||
```
|
||||
|
||||
`transport_security=` es la solución. Permite lo que realmente sirves:
|
||||
|
||||
```python title="server.py" hl_lines="2 13-17"
|
||||
--8<-- "docs_src/deploy/tutorial001.py"
|
||||
```
|
||||
|
||||
* Las entradas de `allowed_hosts` son cadenas exactas: `"mcp.example.com"` coincide con un encabezado `Host` sin puerto y `"mcp.example.com:*"` coincide con cualquier puerto. Incluye ambas.
|
||||
* `allowed_origins` solo importa para los navegadores, porque nada más envía `Origin`. Es el gemelo del lado del servidor de la configuración CORS de **[Añadir a una app existente](asgi.md)**.
|
||||
* Detrás de un proxy inverso que ya controla el encabezado `Host`, desactivar la comprobación es la configuración honesta: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
|
||||
* Pasar un `host=` que no sea localhost (por ejemplo `host="mcp.example.com"`) **no** añade ese nombre de host a la lista permitida. Solo evita que el valor por defecto de localhost arme la protección, lo que deja cualquier Host y Origin aceptados. Di lo que quieres decir con `transport_security=`.
|
||||
|
||||
!!! check
|
||||
Borra el argumento `transport_security=security` y despliega la app de todos modos. Arranca, `/mcp`
|
||||
enruta, y cada solicitud (incluso desde un simple `curl`) vuelve así:
|
||||
|
||||
```text
|
||||
HTTP/1.1 421 Misdirected Request
|
||||
|
||||
Invalid Host header
|
||||
```
|
||||
|
||||
No encontrarás esas palabras del lado del cliente. Un `421` es una respuesta HTTP en texto plano, no un
|
||||
error JSON-RPC, así que el cliente MCP lanza un error de transporte genérico; el nombre de host que
|
||||
no le gustó aparece solo en el log del **servidor**, como una única advertencia. Un servidor recién
|
||||
desplegado que rechaza todas las conexiones es una lista de hosts permitidos hasta que se demuestre lo contrario.
|
||||
**[Solución de problemas](../troubleshooting.md)** también empieza por aquí.
|
||||
|
||||
## Workers, y quién tiene que ser sticky {#workers-and-who-has-to-be-sticky}
|
||||
|
||||
Una vez que el nombre de host responde, pon más de un worker detrás. No hay ningún ajuste del SDK para eso; una app Starlette se escala como cualquier app ASGI, entregando el objeto a algo que sepa hacer fork:
|
||||
|
||||
```console
|
||||
uvicorn server:app --workers 4
|
||||
```
|
||||
|
||||
Cuatro procesos, un socket. Y ahora la pregunta que todo despliegue tiene que responder: **¿una solicitud tiene que llegar al worker que vio la anterior?**
|
||||
|
||||
Para un cliente que habla el protocolo **2026-07-28**, no. Una solicitud moderna es un único POST autocontenido: sin handshake `initialize` antes, sin `Mcp-Session-Id` en la respuesta, nada *a lo que* una segunda solicitud pueda volver. Enrútala a cualquier worker.
|
||||
|
||||
Eso no es un modo que activas. `stateless_http=True` parece que debería serlo, pero el transporte enruta según el encabezado de solicitud `MCP-Protocol-Version`, entrega una solicitud moderna al handler moderno y **devuelve**. La línea que lee `stateless_http` viene *después* de ese retorno. No es que el flag se ignore en la ruta 2026-07-28; es que nunca se alcanza. `stateless_http` es un ajuste solo para el tramo **heredado**, y la ruta moderna no tiene sesión por construcción.
|
||||
|
||||
Para un cliente heredado en la versión de especificación 2025-11-25 o anterior, la respuesta depende de ese flag:
|
||||
|
||||
| Versión de protocolo del cliente | Sesión | Lo que debe hacer el balanceador de carga |
|
||||
| --- | --- | --- |
|
||||
| **2026-07-28** | Ninguna. `Mcp-Session-Id` nunca se establece. | Nada. Cualquier worker atiende cualquier solicitud. |
|
||||
| **2025-11-25 y anteriores** (por defecto) | `Mcp-Session-Id`, guardado en la memoria de un worker. | **Sesiones sticky.** Una solicitud siguiente que llega a otro worker recibe un `404` *"Session not found"*. |
|
||||
| **2025-11-25 y anteriores**, con `stateless_http=True` | Ninguna. | Nada. El costo es el canal de retorno (back-channel) del servidor al cliente (muestreo (sampling), elicitación (elicitation) por push, `roots/list`) y la capacidad de reanudar. |
|
||||
|
||||
Las sesiones sticky y lo que cuesta el tramo heredado tienen su propia página, **[Atender clientes heredados](legacy-clients.md)**; las dos generaciones en sí están en **[Versiones del protocolo](../protocol-versions.md)**. Lo que importa aquí es la forma de la respuesta: *en 2026-07-28 ya eres stateless, sin nada que configurar.*
|
||||
|
||||
El resto de esta página son las dos cosas que ser stateless **no** te resuelve.
|
||||
|
||||
## `requestState` entre workers {#requeststate-across-workers}
|
||||
|
||||
Una herramienta **[de varias idas y vueltas (multi-round-trip)](../handlers/multi-round-trip.md)** necesita algo que el cliente tiene que ir a buscar (una confirmación, una elección, una credencial), así que devuelve una pregunta en lugar de una respuesta y termina en el reintento. Entre las dos rondas el cliente guarda un token opaco `request_state` que acuñó el servidor. En el reintento el servidor tiene que volver a abrir ese token.
|
||||
|
||||
*¿Sellado con qué clave?* Por defecto, una que el servidor generó con `os.urandom(32)` al construirse. Con `--workers 4` eso son cuatro construcciones, en cuatro procesos: cuatro claves distintas, nunca escritas en ningún lado, nunca compartidas, perdidas al reiniciar.
|
||||
|
||||
Aquí tienes una herramienta que pregunta antes de actuar, en un servidor que no configura nada:
|
||||
|
||||
```python title="server.py" hl_lines="14 20"
|
||||
--8<-- "docs_src/deploy/tutorial002.py"
|
||||
```
|
||||
|
||||
La primera ronda llega al worker A. El worker A sella `refund:120` con **su** clave y devuelve el token. El cliente le pone la pregunta delante a una persona, recibe un sí y reintenta. El reintento es una solicitud HTTP completamente nueva.
|
||||
|
||||
!!! check
|
||||
Deja que ese reintento llegue al worker B. B intenta abrir un token que no acuñó, no puede, y rechaza la
|
||||
ronda entera. Nunca se llama a `refund`; el cliente recibe un error JSON-RPC:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "Invalid or expired requestState",
|
||||
"data": {"reason": "invalid_request_state"}
|
||||
}
|
||||
```
|
||||
|
||||
Ese mensaje está **congelado**. Expirado, manipulado, reutilizado contra otros argumentos o (la causa
|
||||
más común con diferencia en un despliegue real) sellado por un worker hermano: al cliente se le dice
|
||||
lo mismo cada vez, así que lo que se transmite nunca revela qué comprobación falló. El motivo real es un
|
||||
`WARNING` en el log del servidor:
|
||||
|
||||
```text
|
||||
requestState rejected on tools/call: unknown key
|
||||
```
|
||||
|
||||
Una herramienta de varias idas y vueltas que funcionaba con un worker y empezó a fallar *a veces* con
|
||||
dos es esto. Las dos rondas siguen teniendo que llegar al mismo proceso, así que falla exactamente tan
|
||||
a menudo como tu balanceador de carga las separa.
|
||||
|
||||
Las dos rondas son dos solicitudes HTTP independientes, y varias cosas ordinarias las separan: un proxy que balancea por solicitud, una conexión que se cayó entre medias, un despliegue o un reinicio, un cliente que persistió `request_state` y está reanudando desde un proceso completamente distinto (**[Dirigir el bucle tú mismo](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Cualquiera de ellas es "un worker distinto".
|
||||
|
||||
La solución es un argumento. Tiene **dos** mitades.
|
||||
|
||||
```python title="server.py" hl_lines="1 12 14"
|
||||
--8<-- "docs_src/deploy/tutorial003.py"
|
||||
```
|
||||
|
||||
* **`keys=[...]`** es la mitad que todo el mundo encuentra. Dale a cada instancia el mismo secreto (al menos 32 bytes) y cada instancia puede abrir lo que acuñó cualquier hermana. `keys[0]` sella y todas las claves de la lista abren, que es el anillo de rotación; **[Rotar claves](../handlers/multi-round-trip.md#rotating-keys)** explica cómo girarlo sin tiempo de inactividad.
|
||||
* **El nombre del servidor** es la mitad que casi nadie encuentra, y la razón por la que los reintentos entre instancias siguen fallando después de compartir la clave. Cada token sellado lleva el `name` del servidor como **claim de audiencia**, comprobado de forma estricta al volver a entrar. Dos instancias construidas a partir del mismo código tienen el mismo nombre y nunca lo notan. Nómbralas distinto (`MCPServer(f"billing-{POD}")` parece buena higiene de observabilidad) y cada reintento entre instancias se rechaza exactamente como arriba, con clave compartida o sin ella. El log dice `audience` en lugar de `unknown key`; el cliente no puede notar la diferencia.
|
||||
|
||||
Acuña el secreto una vez y entrega el mismo valor a cada instancia. Este es el comando que el propio mensaje de error del SDK te dice que ejecutes si le pasas menos de 32 bytes:
|
||||
|
||||
```console
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
!!! warning "Las mismas claves, *y* el mismo nombre"
|
||||
Un despliegue de varias instancias debe compartir ambos. Si los nombres por instancia son imprescindibles
|
||||
para ti, dale a la flota una única audiencia explícita: `RequestStateSecurity(keys=[...], audience="billing")`.
|
||||
Cada instancia acuña y acepta entonces bajo `"billing"` sin importar cómo se llame.
|
||||
|
||||
Todo lo demás sobre el sello está en **[Proteger `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: qué vincula, el `ttl` por ronda (600 segundos por defecto), traer tu propio códec, por qué el valor por defecto sin configurar es exactamente lo correcto en `stdio`. Toda la aportación de esta página es una lista de comprobación de dos puntos: *las mismas claves, el mismo nombre.*
|
||||
|
||||
!!! info
|
||||
Estás en esta ruta aunque nunca hayas escrito `InputRequiredResult`. Una herramienta cuyos parámetros
|
||||
usan `Resolve(...)` (**[Dependencias](../handlers/dependencies.md)**) es una herramienta de varias idas y vueltas,
|
||||
y el SDK acuña y sella su `request_state` por ella. La misma clave por defecto, el mismo fallo entre
|
||||
workers, la misma solución.
|
||||
|
||||
## Notificaciones de cambio entre réplicas {#change-notifications-across-replicas}
|
||||
|
||||
El stream `subscriptions/listen` de un cliente es una única respuesta de larga duración, así que queda fijado a una réplica durante toda su vida. Un `ctx.notify_resource_updated(...)` publicado en una réplica **distinta** tiene que llegarle.
|
||||
|
||||
La unión entre las dos es el `SubscriptionBus`. El bus que le des a un servidor es al que va cada publicación y el que escucha cada stream abierto, así que entrega el mismo bus a cada réplica:
|
||||
|
||||
```python title="server.py" hl_lines="2 7 9"
|
||||
--8<-- "docs_src/deploy/tutorial004.py"
|
||||
```
|
||||
|
||||
A nada del fan-out le importa a qué objeto servidor está conectado un stream. Dos servidores que comparten un `InMemorySubscriptionBus` ya se comportan así: abre un stream de escucha en uno, ejecuta `edit_note` en el otro, y el stream se entera. Ese bus en memoria solo abarca objetos servidor dentro de un mismo proceso, lo que lo convierte en el modelo, no en el despliegue:
|
||||
|
||||
* Entre procesos reales, **el SDK no trae ningún bus que pueda ayudarte.** `SubscriptionBus` es un `Protocol` de dos métodos (`publish` y `subscribe`) que implementas sobre tu propio backend pub/sub (Redis, NATS, lo que ya ejecutes) y pasas como `MCPServer(subscriptions=...)`. **[Suscripciones](../handlers/subscriptions.md#scaling-past-one-process)** tiene el esbozo y el contrato.
|
||||
* El bus transporta cuatro eventos tipados pequeños, nunca JSON-RPC. El acuse de recibo, el filtrado y el ciclo de vida de los streams se quedan en el SDK, así que tu bus no puede romper el protocolo; solo puede mover eventos entre procesos.
|
||||
* Los streams **no** se pueden reanudar y los eventos **no** se vuelven a reproducir. Perder una réplica descarta sus streams; los clientes vuelven a escuchar y vuelven a obtener los datos. No hay almacén de eventos que compartir ni nada más que configurar. Este es el único lugar donde escalar horizontalmente es de verdad solo más de lo mismo.
|
||||
|
||||
## Lo que el SDK no te da {#what-the-sdk-does-not-give-you}
|
||||
|
||||
Un `MCPServer` es una implementación del protocolo, no un servidor de aplicaciones. Los ajustes de despliegue que vas a buscar a continuación faltan a propósito:
|
||||
|
||||
* **Sin `workers=`.** `mcp.run("streamable-http")` arranca exactamente un proceso uvicorn, y eso es todo lo que arrancará jamás. Multiproceso es `streamable_http_app()` entregado a lo que ya uses para desplegar ASGI: `uvicorn --workers`, gunicorn, el gestor de procesos de tu plataforma. Esta página deliberadamente no es un tutorial de ninguno de ellos; su documentación es mejor de lo que sería una copia aquí.
|
||||
* **Sin ruta de health check.** `@mcp.custom_route("/health", methods=["GET"])` es toda la respuesta, y nunca se autentica aunque el resto del servidor sí. Eso es lo correcto para una sonda de vida, incorrecto para cualquier cosa privada. **[Añadir a una app existente](asgi.md#custom-routes)** muestra una.
|
||||
* **Sin objeto de configuración de producción.** No hay ningún lugar en `MCPServer` donde anotar timeouts, TLS, apagado ordenado o límites de conexión, porque ninguno de esos es su trabajo. Pertenecen a tu servidor ASGI, y los configuras allí. **[Ejecutar tu servidor](index.md)** cubre el puñado de ajustes que el constructor *sí* acepta.
|
||||
* **Sin `EventStore` incluido, y en 2026-07-28 sin uso para uno.** La capacidad de reanudar es una funcionalidad del tramo heredado con estado; un intercambio moderno es un POST, una respuesta y nada que reanudar.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Por defecto, la app responde solo a las solicitudes dirigidas a localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` es la puerta de salida a producción: hasta que lo pases, cada solicitud detrás de un nombre de host real es un `421` y el motivo solo está en el log del servidor.
|
||||
* En 2026-07-28 no hay sesión ni nada sobre lo que un balanceador de carga pueda ser sticky. `stateless_http=True` es un ajuste solo para lo heredado porque una solicitud moderna se enruta y se responde antes de que ese flag llegue a leerse.
|
||||
* La clave por defecto de `requestState` es `os.urandom(32)`, acuñada por proceso. Un reintento de varias idas y vueltas que llega a otro worker falla con `-32602` *"Invalid or expired requestState"*.
|
||||
* La solución es `RequestStateSecurity(keys=[...])` **y** el mismo nombre de servidor en cada instancia. El nombre es el claim de audiencia por defecto del token. Las mismas claves, el mismo nombre.
|
||||
* Las notificaciones de cambio cruzan réplicas a través de un `SubscriptionBus` compartido. La única implementación del SDK es dentro del proceso; el `Protocol` de dos métodos sobre tu propio pub/sub te toca escribirlo a ti.
|
||||
* No hay `workers=`, ni ruta de health check, ni objeto de configuración de producción. Trae tu propio servidor ASGI.
|
||||
|
||||
Lo otro que un nombre de host real necesita delante es un token: **[Autorización](authorization.md)**.
|
||||
156
i18n/es/pages/run/index.md
Normal file
156
i18n/es/pages/run/index.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
translation:
|
||||
sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0]
|
||||
tool: 1
|
||||
---
|
||||
# Ejecutar el servidor {#running-your-server}
|
||||
|
||||
`mcp.run()` inicia el servidor.
|
||||
|
||||
La única decisión que tomas es el **transporte**: cómo se mueven realmente los bytes entre el servidor y su cliente.
|
||||
|
||||
## Elige un transporte {#pick-a-transport}
|
||||
|
||||
| Transporte | Qué es | Cuándo |
|
||||
|---|---|---|
|
||||
| `stdio` | El host lanza tu archivo como subproceso y se comunica a través de su stdin y stdout. | Servidores locales. El valor por defecto. |
|
||||
| `streamable-http` | Un servidor HTTP real que escucha en un puerto. | Cualquier cosa que despliegues. |
|
||||
| `sse` | El transporte HTTP antiguo. | No lo uses. |
|
||||
|
||||
!!! warning
|
||||
SSE quedó reemplazado por Streamable HTTP en la revisión del protocolo 2025-03-26.
|
||||
`mcp.run(transport="sse")` sigue funcionando, con sus propias opciones `sse_path=` y `message_path=`,
|
||||
pero existe para los clientes que no han migrado. No construyas nada nuevo sobre él.
|
||||
|
||||
## `mcp.run()` {#mcprun}
|
||||
|
||||
```python title="server.py" hl_lines="12-13"
|
||||
--8<-- "docs_src/run/tutorial001.py"
|
||||
```
|
||||
|
||||
* `run()` es síncrono. Bloquea durante toda la vida del servidor.
|
||||
* Sin argumentos, el transporte es `stdio`.
|
||||
* Está bajo `if __name__ == "__main__":` porque todo lo que carga el servidor (`mcp dev`, `mcp run`, `mcp install`, tus pruebas) **importa** este archivo. La guarda evita que una importación se convierta en un servidor en ejecución.
|
||||
|
||||
### stdio {#stdio}
|
||||
|
||||
No hay nada que configurar. El host inicia tu archivo como proceso hijo, escribe las solicitudes en su stdin y lee las respuestas de su stdout.
|
||||
|
||||
Ejecútalo tú mismo y verás la consecuencia:
|
||||
|
||||
```console
|
||||
python server.py
|
||||
```
|
||||
|
||||
No imprime nada y no termina. Está esperando en stdin a que un host hable primero.
|
||||
|
||||
Eso también significa que stdout **es el canal**. Mientras sirve, el SDK mueve el canal a un descriptor privado y desvía a stderr la salida que se *vacía* (flush) hacia stdout (un subproceso que escribe en el stdout heredado, un `print()` con flush), donde no puede corromper el flujo. La salida que se vacía hacia stdout *antes* de que empiece a servir (un script envoltorio que hace echo, un print sin búfer al importar) sigue llegando al canal, igual que un `print()` que queda en el búfer hasta que el intérprete lo vacía al salir. Para la salida que de verdad quieres, el módulo `logging` es la herramienta adecuada: su handler vacía cada registro a stderr en cuanto ocurre. Todos los detalles están en **[Logging](../handlers/logging.md)**.
|
||||
|
||||
### Pruébalo {#try-it}
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
El Inspector hace exactamente lo que hace un host real: lanza `server.py` como subproceso y se conecta a él por stdio.
|
||||
|
||||
Nunca le diste un puerto. No hay ninguno.
|
||||
|
||||
## Streamable HTTP {#streamable-http}
|
||||
|
||||
Para poner el mismo servidor en un puerto, nombra el transporte (y sus opciones) en `run()`:
|
||||
|
||||
```python title="server.py" hl_lines="13"
|
||||
--8<-- "docs_src/run/tutorial002.py"
|
||||
```
|
||||
|
||||
Esa única línea construye una app de Starlette y la sirve con uvicorn. Los clientes se conectan a `http://127.0.0.1:3001/mcp`.
|
||||
|
||||
Cada transporte tiene sus propios argumentos nombrados, todos en `run()`:
|
||||
|
||||
* `host` / `port`: dónde escuchar. Por defecto `127.0.0.1` y `8000`.
|
||||
* `streamable_http_path`: dónde vive el endpoint MCP. Por defecto `/mcp`.
|
||||
* `json_response=True`: responde a cada POST con un único cuerpo JSON en lugar de un flujo SSE. Ese cuerpo tiene sitio para la respuesta y nada más, así que una herramienta que llama de vuelta al cliente a mitad de solicitud (`ctx.elicit()`, muestreo (sampling)) lanza `NoBackChannelError` en este tramo, y las notificaciones ligadas a la llamada en curso (el progreso de `ctx.report_progress()`, los mensajes de log por llamada) se descartan; el flujo `GET` independiente sigue llevando las que no están relacionadas.
|
||||
* `stateless_http=True`: un transporte nuevo por solicitud, sin seguimiento de sesión.
|
||||
* `max_request_body_size`: el cuerpo POST más grande que se acepta, en bytes. Es 4 MiB por defecto; las solicitudes mayores
|
||||
reciben HTTP 413 antes del análisis o de la creación de la sesión. Súbelo solo cuando los mensajes MCP legítimos
|
||||
superen ese tamaño.
|
||||
* `event_store`, `retry_interval`, `transport_security`: reanudabilidad y protección contra DNS rebinding. Pueden esperar hasta que despliegues en algún lugar que no sea localhost; **[Desplegar y escalar](deploy.md)** cubre `transport_security`.
|
||||
|
||||
!!! warning
|
||||
Las opciones de transporte van a `run()`, **no** a `MCPServer(...)`. El constructor describe lo que
|
||||
el servidor *es*: nombre, versión, instrucciones. `run()` describe cómo se sirve. Si lo haces
|
||||
al revés, Python responde antes de que MCP entre siquiera en juego:
|
||||
|
||||
```text
|
||||
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
|
||||
```
|
||||
|
||||
`run()` es el camino corto. En cuanto necesitas más (el servidor montado dentro de una app existente, dos servidores en un proceso, CORS para clientes de navegador), construyes la app ASGI tú mismo y se la pasas a cualquier host ASGI. Eso es **[Añadir a una app existente](asgi.md)**.
|
||||
|
||||
## Ajustes del servidor {#server-settings}
|
||||
|
||||
Un par de cosas sobre la ejecución no tienen que ver con el transporte. Son argumentos del constructor:
|
||||
|
||||
```python title="server.py" hl_lines="3"
|
||||
--8<-- "docs_src/run/tutorial003.py"
|
||||
```
|
||||
|
||||
* `log_level`: se pasa a `logging.basicConfig()` en el momento en que se construye `MCPServer(...)`. Eso configura el logger **raíz**, así que fija el nivel también para tus propios loggers, no solo para los del SDK. Por defecto `"INFO"`.
|
||||
* `debug`: se reenvía a la app de Starlette que construyen los transportes HTTP. Por defecto `False`.
|
||||
|
||||
Ambos acaban en `mcp.settings`, que puedes leer en tiempo de ejecución.
|
||||
|
||||
## El comando `mcp` {#the-mcp-command}
|
||||
|
||||
El extra `[cli]` instala una pequeña herramienta de línea de comandos alrededor de todo esto.
|
||||
|
||||
`mcp dev` ejecuta el servidor bajo el **MCP Inspector**:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
uv run mcp dev server.py --with pandas --with numpy
|
||||
uv run mcp dev server.py --with-editable .
|
||||
```
|
||||
|
||||
`--with` añade paquetes al entorno que construye; `--with-editable` instala tu propio paquete en él. Necesita `npx` en tu `PATH`: el Inspector es una app de Node.js.
|
||||
|
||||
`mcp run` importa el archivo, encuentra el objeto servidor (un `mcp`, `server` o `app` a nivel de módulo) y llama a `run()` sobre él:
|
||||
|
||||
```console
|
||||
uv run mcp run server.py
|
||||
uv run mcp run server.py:bookshop
|
||||
```
|
||||
|
||||
El sufijo `:` nombra el objeto cuando no se llama `mcp`, `server` ni `app`.
|
||||
|
||||
Tu bloque `if __name__ == "__main__":` nunca se ejecuta aquí: `mcp run` llama a `run()` por su cuenta, y la única opción que reenvía es `--transport`.
|
||||
|
||||
`mcp install` registra el servidor en **Claude Desktop**, de modo que la app lo lanza por ti:
|
||||
|
||||
```console
|
||||
uv run mcp install server.py --name "Bookshop"
|
||||
uv run mcp install server.py -v API_KEY=abc123 -f .env
|
||||
```
|
||||
|
||||
`-v KEY=VALUE` y `-f .env` guardan variables de entorno en esa entrada. Claude Desktop inicia el servidor en su propio proceso. El entorno de tu shell no está ahí.
|
||||
|
||||
Claude Desktop es el único host que `mcp install` conoce. Todos los demás hosts (Claude Code, Cursor, VS Code) aceptan el mismo comando de lanzamiento en su propio archivo de configuración, y **[Conectar con un host real](../get-started/real-host.md)** tiene cada uno.
|
||||
|
||||
`mcp version` imprime la versión del SDK instalada.
|
||||
|
||||
!!! tip
|
||||
`mcp dev` y `mcp run` solo entienden `MCPServer`. Si construyes con el `Server` de bajo nivel,
|
||||
lo ejecutas tú mismo. Consulta **[El Server de bajo nivel](../advanced/low-level-server.md)**.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Un **transporte** es cómo llegan los bytes al servidor: `stdio` para un subproceso local, `streamable-http` para un puerto. SSE está reemplazado.
|
||||
* `mcp.run()` elige el transporte. Sin argumentos es `stdio`, y bloquea.
|
||||
* Cada opción de transporte (`host`, `port`, `streamable_http_path`, ...) es un argumento de `run()`, nunca de `MCPServer(...)`.
|
||||
* Mantén `run()` bajo `if __name__ == "__main__":`. Todo lo que carga el servidor importa primero el archivo.
|
||||
* `log_level=` y `debug=` son argumentos del constructor; acaban en `mcp.settings`.
|
||||
* `mcp dev` para el Inspector, `mcp run` para ejecutar un archivo, `mcp install` para Claude Desktop, `mcp version` para la versión.
|
||||
* El transporte nunca cambia lo que el servidor *es*: los tres archivos de esta página exponen la misma herramienta.
|
||||
|
||||
Cuando el límite es `run()` mismo (el servidor dentro de una app que ya existe), es **[Añadir a una app existente](asgi.md)**. Un nombre de host real y más de un worker es **[Desplegar y escalar](deploy.md)**. Y si algunos de tus clientes siguen en la versión de la especificación 2025-11-25 o anterior, **[Atender clientes heredados](legacy-clients.md)** es la buena noticia.
|
||||
134
i18n/es/pages/run/legacy-clients.md
Normal file
134
i18n/es/pages/run/legacy-clients.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
translation:
|
||||
sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c]
|
||||
tool: 1
|
||||
---
|
||||
# Atender clientes heredados {#serving-legacy-clients}
|
||||
|
||||
MCP tiene dos generaciones de protocolo: la generación del handshake `initialize`, hasta la versión de la especificación `2025-11-25`, y la generación moderna, `2026-07-28`. **[Versiones del protocolo](../protocol-versions.md)** es la página dedicada a esa división.
|
||||
|
||||
Esta página trata del lado del servidor de esa división, y la respuesta cabe en una frase: **el `streamable_http_app()` que ya despliegas atiende a ambas.**
|
||||
|
||||
El SDK enruta cada solicitud según su encabezado `MCP-Protocol-Version`. Una solicitud que indica `2026-07-28` va al handler moderno. Una solicitud que indica una versión de la generación del handshake, o que no trae ningún encabezado (que es como llega el `initialize` de un cliente anterior a 2026), va al transporte que esos clientes esperan: handshake `initialize`, sesiones y todo lo demás. Ocurre por solicitud, antes de tu código, en una sola app.
|
||||
|
||||
Así que un cliente heredado (legacy) no es algo *para* lo que construyes. Es algo que se conecta *al* servidor que ya escribiste. No configuras nada.
|
||||
|
||||
!!! note
|
||||
Nada, literalmente. No hay una opción `legacy=`, ni una lista de versiones permitidas, ni forma
|
||||
de rechazar o desactivar una generación: ni en `streamable_http_app()`, ni en `run()`, ni en el
|
||||
gestor de sesiones. Ambas generaciones están siempre activas. Lo más parecido a un interruptor
|
||||
por generación en esa firma es `stateless_http`, y ocupa la mayor parte de esta página.
|
||||
|
||||
## Un handler, ambas generaciones {#one-handler-both-eras}
|
||||
|
||||
Aquí tienes una herramienta que necesita preguntarle algo al usuario, y clientes de ambas generaciones que la llaman:
|
||||
|
||||
```python title="server.py" hl_lines="24 37-38"
|
||||
--8<-- "docs_src/legacy_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
`reserve` necesita una cosa que el modelo no proporcionó: cuántos ejemplares. `Annotated[..., Resolve(ask_quantity)]` es la forma en que una herramienta lo declara (**[Dependencias](../handlers/dependencies.md)** tiene todos los detalles). Nada en `reserve` nombra una versión, comprueba una capacidad ni se bifurca.
|
||||
|
||||
Los dos clientes están abiertos **al mismo tiempo**, sobre el mismo objeto `mcp`. `mode="legacy"` ejecuta el handshake `initialize`: exactamente la conexión que abre un cliente anterior a 2026. El otro toma el valor por defecto y queda en `2026-07-28`.
|
||||
|
||||
```text
|
||||
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
|
||||
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
|
||||
```
|
||||
|
||||
Mismo servidor, mismo handler, misma respuesta. Esa es toda la funcionalidad.
|
||||
|
||||
Vale la pena detenerse en el *cómo*, porque a los dos clientes se les hizo la misma pregunta por dos canales completamente distintos. La conexión `2026-07-28` no tiene un canal por el que el servidor pueda enviar una solicitud, así que `Resolve` devolvió la pregunta dentro del resultado de la herramienta y el cliente reintentó la llamada con la respuesta (**[Solicitudes de varias idas y vueltas](../handlers/multi-round-trip.md)**). La conexión `2025-11-25` no tiene nada de eso; ahí, `Resolve` envió una solicitud `elicitation/create` en vivo a mitad de la llamada y esperó. No escribiste ninguna de las dos cosas. `Resolve` lee la versión negociada de la conexión y elige; el cuerpo de tu herramienta ve un `AcceptedElicitation` en ambos casos.
|
||||
|
||||
!!! tip
|
||||
Esa portabilidad entre generaciones es la *razón* por la que `Resolve` es la API sobre la que
|
||||
conviene construir. Su hermano mayor, `ctx.elicit()` (**[Elicitación](../handlers/elicitation.md)**),
|
||||
solo envía `elicitation/create`, así que solo funciona en una conexión heredada. En una
|
||||
`2026-07-28` la llamada falla. Si una herramienta todavía lo usa, la solución es la que ves
|
||||
arriba, no una comprobación de versión.
|
||||
|
||||
## Lo que te cuesta una sesión heredada {#what-a-legacy-session-costs-you}
|
||||
|
||||
El enrutamiento es gratis. La sesión no.
|
||||
|
||||
Una conexión `2026-07-28` **no tiene sesión**: cada solicitud es independiente, y el handler moderno nunca emite un `Mcp-Session-Id`. Una conexión heredada es lo contrario. En el momento en que un cliente anterior a 2026 envía `initialize`, el SDK genera un `Mcp-Session-Id`, lo devuelve en un encabezado de respuesta y mantiene detrás de él un registro vivo que las solicitudes posteriores del cliente deben encontrar: la versión negociada, los streams abiertos, una tarea en segundo plano que mueve la sesión.
|
||||
|
||||
Ese registro es **un simple `dict` dentro del proceso**. No hay un almacén de sesiones distribuido ni forma de conectar uno.
|
||||
|
||||
Con un solo worker eso no se nota. Con dos, es todo el problema: una solicitud que trae un `Mcp-Session-Id` y cae en un worker que no lo generó no encuentra nada en ese dict, y la respuesta es un `404` (`Session not found`), no el resultado de la herramienta. Así que en cuanto ejecutas más de un worker, **los clientes heredados necesitan enrutamiento sticky (afinidad de sesión)**: cada solicitud de una sesión tiene que llegar al proceso que la inició. Los clientes modernos nunca lo necesitan; no tienen una sesión a la que mantenerse pegados. **[Desplegar y escalar](deploy.md)** cubre la afinidad y todo lo demás sobre ejecutar más de uno de estos.
|
||||
|
||||
!!! warning
|
||||
`event_store=` parece la solución y no lo es. Es **reanudabilidad** (reenviar los eventos SSE
|
||||
perdidos a un cliente que se reconecta a la *misma* sesión), no un almacén de sesiones. Nunca
|
||||
hace que una sesión sea alcanzable desde otro proceso.
|
||||
|
||||
## El único ajuste: `stateless_http` {#the-one-knob-stateless_http}
|
||||
|
||||
Si la afinidad es un costo que te niegas a pagar, hay exactamente una cosa que puedes cambiar.
|
||||
|
||||
```python title="server.py" hl_lines="28"
|
||||
--8<-- "docs_src/legacy_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
Es el servidor del principio de la página más un argumento nombrado. `stateless_http=True` hace que el tramo heredado construya en su lugar una sesión desechable, por solicitud: no se emite ningún `Mcp-Session-Id`, no se recuerda nada entre solicitudes, así que cualquier worker puede atender cualquier solicitud y el balanceador de carga puede hacer lo que quiera.
|
||||
|
||||
Dos cosas sobre él importan más que lo que hace.
|
||||
|
||||
**Solo afecta al tramo heredado.** Las solicitudes se enrutan según el encabezado de versión *antes* de que se lea `stateless_http`, así que la ruta moderna nunca lo ve. Una conexión `2026-07-28` ya no tiene sesión y es exactamente igual con cualquiera de los dos valores.
|
||||
|
||||
**Cuesta los dos canales de servidor a cliente en ese tramo.** Una sesión que vive lo que dura un `POST` no tiene un stream por el que el servidor pueda enviar una solicitud ni un stream independiente por el que enviar notificaciones. Cada solicitud iniciada por el servidor lanza `NoBackChannelError`: `ctx.elicit()`, las llamadas retiradas de muestreo (sampling) y roots (**[Funcionalidades obsoletas](../deprecated.md)**) y, sí, `Resolve` cuando le hace su pregunta a un cliente *heredado*. Las notificaciones ni siquiera reciben un error; se descartan en silencio.
|
||||
|
||||
!!! note
|
||||
`json_response=True` no es ese ajuste, pero asume la mitad del mismo costo en *cada* sesión
|
||||
heredada: un `POST` respondido con un único cuerpo JSON no tiene stream para el canal ligado a
|
||||
la solicitud, así que un `ctx.elicit()` a mitad de solicitud lanza el mismo `NoBackChannelError`
|
||||
y las notificaciones ligadas a la solicitud se descartan. El stream independiente de la sesión no
|
||||
se toca: las notificaciones no relacionadas siguen llegando.
|
||||
|
||||
!!! check
|
||||
Haz lo incorrecto. `reserve` es exactamente la herramienta que acaba de atender a ambos clientes.
|
||||
Despliégala con `stateless_http=True`, conecta los mismos dos clientes por HTTP y llámala desde
|
||||
cada uno.
|
||||
|
||||
El cliente moderno sigue recibiendo `Reserved 2 of 'Dune'.` El tramo moderno no cambió.
|
||||
|
||||
La llamada del cliente heredado no vuelve como un resultado `is_error` que el modelo pueda leer.
|
||||
La solicitud entera falla, como un error de protocolo de nivel superior:
|
||||
|
||||
```text
|
||||
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
|
||||
```
|
||||
|
||||
`Resolve` no te salvó. En una conexión `2025-11-25` *tiene* que enviar `elicitation/create`,
|
||||
y el canal que necesita es justo lo que `stateless_http=True` entregó. El código portable entre
|
||||
generaciones no es código libre de canal de retorno (back-channel).
|
||||
|
||||
Así que es una concesión real, y solo existe en el tramo heredado: **con sesión y afinidad, o sin estado y en una sola dirección.** Si tus herramientas nunca llaman de vuelta al cliente, `stateless_http=True` es gratis y deberías usarlo. Si lo hacen, conserva las sesiones y mantén el enrutamiento con afinidad.
|
||||
|
||||
## Dónde se bifurca realmente tu código {#where-your-code-actually-forks}
|
||||
|
||||
Casi en ningún sitio.
|
||||
|
||||
Herramientas, recursos, prompts, salida estructurada, progreso, errores: a ninguno le importa qué generación hizo la llamada. El handshake `initialize`, el `Mcp-Session-Id`, el stream independiente, el `DELETE` que termina una sesión: el SDK se encarga de todo, y un handler nunca ve nada de eso. La entrada interactiva es *el* lugar donde las generaciones difieren de verdad en lo que se transmite, y `Resolve` existe para que no sea tu problema: acabas de ver a una sola herramienta atender a ambas.
|
||||
|
||||
Queda exactamente una cosa, y son las **notificaciones de cambio**, porque las dos generaciones escuchan por conductos distintos:
|
||||
|
||||
* Un cliente `2026-07-28` abre un stream `subscriptions/listen` y lee el bus de suscripciones. `ctx.notify_resource_updated()` (y `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) publican ahí, y *solo* ahí. **[Suscripciones](../handlers/subscriptions.md)** es esa página.
|
||||
* Un cliente heredado lee el stream independiente que su sesión mantiene abierto. `ctx.session.send_resource_updated()` (y `send_tool_list_changed()` y compañía) escriben en la *conexión* que trajo la solicitud: para una sesión heredada, ese es su stream independiente. Una conexión moderna no tiene dónde ponerlo: por HTTP no existe ese canal, y por stdio los cuatro tipos de notificación de cambio viajan solo por streams `subscriptions/listen`, así que en una conexión moderna la notificación se descarta en silencio.
|
||||
|
||||
Por HTTP, ninguna de las dos llamadas alcanza a los clientes de la otra generación. Para avisar a todos, llama a ambas:
|
||||
|
||||
```python title="server.py" hl_lines="19-20"
|
||||
--8<-- "docs_src/legacy_clients/tutorial003.py"
|
||||
```
|
||||
|
||||
Dos líneas, sin `if`, sin comprobación de versión, y listo. Esa es la lista completa de cosas que un handler hace distinto porque existe un cliente heredado.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Un solo `streamable_http_app()` atiende a ambas generaciones del protocolo. El SDK enruta cada solicitud según su encabezado `MCP-Protocol-Version`; no hay nada que configurar ni ningún ajuste de generación que buscar.
|
||||
* Un cliente heredado te cuesta una sesión: un registro `Mcp-Session-Id` dentro del proceso sin ningún almacén distribuido detrás. Más de un worker significa **enrutamiento sticky**, o el worker equivocado responde `404 Session not found`. **[Desplegar y escalar](deploy.md)** tiene todos los detalles sobre varios workers.
|
||||
* `stateless_http=True` es el único ajuste, y es **solo para el tramo heredado**. Compra balanceo de carga gratis para los clientes heredados al precio de los dos canales de servidor a cliente en ese tramo: las solicitudes iniciadas por el servidor lanzan `NoBackChannelError` (un error de nivel superior en el cliente, no un resultado `is_error`), y las notificaciones se descartan.
|
||||
* Una conexión `2026-07-28` no tiene sesión en ningún caso. `stateless_http` nunca la toca.
|
||||
* El código de tu handler se bifurca por generación en exactamente un lugar: las notificaciones de cambio. `ctx.notify_*` llega a los clientes de `subscriptions/listen`; `ctx.session.send_*` llega a las sesiones heredadas. Llama a ambas.
|
||||
* Todo lo demás (incluido pedirle datos al usuario, mediante `Resolve`) es portable entre generaciones por construcción. Escribe la versión moderna una sola vez.
|
||||
113
i18n/es/pages/run/opentelemetry.md
Normal file
113
i18n/es/pages/run/opentelemetry.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
---
|
||||
translation:
|
||||
sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607]
|
||||
tool: 1
|
||||
---
|
||||
# OpenTelemetry {#opentelemetry}
|
||||
|
||||
Tu servidor ya está trazado. No tienes que añadir nada.
|
||||
|
||||
Cada servidor que creas emite un span de [OpenTelemetry](https://opentelemetry.io/) por cada
|
||||
mensaje que maneja. No lo escribiste ni lo importas. Está ahí desde el momento en que
|
||||
llamas a `MCPServer(...)`.
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/opentelemetry/tutorial001.py"
|
||||
```
|
||||
|
||||
Ese es un servidor completo y trazado. Llama a `search_books` y se crea un span para esa llamada.
|
||||
Lo mismo vale para el `Server` de bajo nivel: el trazado vive en ambos.
|
||||
|
||||
## Qué obtienes {#what-you-get}
|
||||
|
||||
Cada mensaje entrante se convierte en un span `SERVER` con el nombre del método y su destino. Así,
|
||||
un `tools/call` para `search_books` es el span `tools/call search_books`, y un `tools/list` a secas
|
||||
es simplemente `tools/list`.
|
||||
|
||||
Cada span lleva unos cuantos atributos:
|
||||
|
||||
* `mcp.method.name` y `mcp.protocol.version`, en todos los spans.
|
||||
* `jsonrpc.request.id`, en una solicitud (una notificación no tiene).
|
||||
* Un handler que lanza una excepción marca el estado del span como error. Lo mismo hace un resultado de herramienta con `is_error=True`.
|
||||
|
||||
Y como trazar una llamada a herramienta es algo que se quiere muy a menudo, los spans de `tools/call`
|
||||
siguen las [convenciones semánticas GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/) de OpenTelemetry:
|
||||
|
||||
* `gen_ai.operation.name`, con el valor `"execute_tool"`.
|
||||
* `gen_ai.tool.name`, con el nombre de la herramienta que se llama.
|
||||
|
||||
Un span de `prompts/get` recibe `gen_ai.prompt.name` con la misma idea. Los métodos de listado no
|
||||
llevan claves `gen_ai.*`, porque no hay nada que nombrar.
|
||||
|
||||
!!! tip
|
||||
Esos atributos GenAI son la razón por la que una interfaz de trazas agrupa tus llamadas a
|
||||
herramientas igual que agrupa las de cualquier otro agente. Obtienes esa agrupación gratis,
|
||||
sin código extra.
|
||||
|
||||
## No cuesta nada hasta que lo quieras {#it-costs-nothing-until-you-want-it}
|
||||
|
||||
Esta es la parte que hace que "activado por defecto" sea un valor por defecto cómodo.
|
||||
|
||||
El SDK depende solo de `opentelemetry-api`, la mitad ligera de OpenTelemetry. Sin un SDK ni un
|
||||
exportador instalados, crear un span es una operación nula. Así que los spans que tu servidor está
|
||||
emitiendo ahora mismo no te cuestan casi nada, y nadie los está recolectando.
|
||||
|
||||
El día que quieras *verlos*, instalas la otra mitad y la apuntas a algún sitio:
|
||||
|
||||
```console
|
||||
uv add opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
```
|
||||
|
||||
Configura un exportador como se hace normalmente en OpenTelemetry, y cada span que el SDK ha estado
|
||||
creando en silencio se enciende. El código de tu servidor no cambia. Ni una línea.
|
||||
|
||||
!!! info
|
||||
[Pydantic Logfire](https://logfire.pydantic.dev/) es uno de esos backends, y hace la
|
||||
configuración por ti: `pip install logfire`, `logfire.configure()`, y tus spans de MCP aparecen
|
||||
en la vista en vivo. Está construido sobre OpenTelemetry, así que todo lo que sigue también se aplica a él.
|
||||
|
||||
## Trazas que cruzan el canal {#traces-that-cross-the-wire}
|
||||
|
||||
Una traza es más útil cuando sigue una solicitud desde el cliente hasta el servidor, en una sola
|
||||
imagen conectada.
|
||||
|
||||
Cuando el cliente y el servidor ejecutan ambos el SDK, esa conexión es automática. El cliente inyecta
|
||||
el [contexto de traza W3C](https://www.w3.org/TR/trace-context/) en la solicitud, y el servidor lo
|
||||
lee de vuelta, de modo que el span del servidor queda anidado bajo el span del cliente en la misma
|
||||
traza. Esto es [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), y lo
|
||||
obtienes sin pedirlo.
|
||||
|
||||
Si el mensaje entrante no lleva contexto de traza, por ejemplo una solicitud de un cliente que no es
|
||||
el SDK, el span del servidor simplemente toma como padre el span que ya esté activo en el servidor,
|
||||
en lugar de iniciar una traza huérfana nueva.
|
||||
|
||||
## Desactivarlo {#turning-it-off}
|
||||
|
||||
El trazado es un middleware, el primero de la lista de tu servidor. Si de verdad quieres un servidor
|
||||
que no emita spans, quítalo:
|
||||
|
||||
```python
|
||||
from mcp.server._otel import OpenTelemetryMiddleware
|
||||
|
||||
mcp._lowlevel_server.middleware[:] = [
|
||||
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
|
||||
]
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Ese import lleva un guion bajo inicial, y es a propósito. La clase es provisional, igual que
|
||||
[`Server.middleware`](../advanced/middleware.md) es provisional, así que debes contar con que la
|
||||
ruta de importación cambie. Casi nunca necesitas esto: sin un exportador instalado los spans son
|
||||
gratis, así que la respuesta habitual es dejarlos activados y no instalar un exportador.
|
||||
|
||||
## Resumen {#recap}
|
||||
|
||||
* Cada `MCPServer` y cada `Server` de bajo nivel emite un span `SERVER` por mensaje entrante, por
|
||||
defecto. No escribes nada.
|
||||
* Los spans llevan `mcp.method.name` y `mcp.protocol.version`; `tools/call` y `prompts/get` también
|
||||
llevan atributos GenAI para que tus llamadas a herramientas se agrupen como las de cualquier otro agente.
|
||||
* No cuesta nada hasta que instalas un SDK de OpenTelemetry y un exportador, y entonces se enciende
|
||||
sin ningún cambio en tu servidor.
|
||||
* El contexto de traza de cliente a servidor se propaga automáticamente cuando ambos lados ejecutan el SDK.
|
||||
|
||||
Lo que decide si una solicitud se ejecuta o no es la **[Autorización](authorization.md)**.
|
||||
Loading…
Reference in a new issue