mcp-corp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-corpcheck the health status"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-corp
Implementación de referencia de un servidor MCP corporativo en Python. Este repositorio es la plantilla base que se clonará para cada fuente de datos concreta; por eso esta fase prioriza claridad y solidez del andamiaje sobre velocidad de entrega.
Estado actual: Fase 6 — correcciones de campo.
Fases 1 a 5 cerradas (andamiaje base, capa de conectores + Postgres,
conector HTTP + tools MCP + auditoría, identificadores venezolanos y
escalado horizontal verificado bajo carga, artefactos de despliegue para
entorno pre-productivo — ver deploy/README.md). La
Fase 6 corrige tres problemas reales encontrados en un despliegue de
verdad: el registro de tools era todo-o-nada en vez de por-fuente (ver
"Registro de tools por fuente disponible" abajo), el servidor moría al
arrancar si una fuente estaba caída en vez de arrancar en modo degradado
(ver "Arranque en modo degradado"), y las sesiones MCP no sobreviven a
más de una réplica detrás de un balanceador sin sticky sessions (ver
"Sesiones MCP con múltiples réplicas" — investigado y documentado, no
implementado esta fase).
Qué es esto
Un servidor MCP construido con FastMCP 3.x, expuesto sobre transporte Streamable HTTP, diseñado para correr stateless y escalar horizontalmente detrás de un balanceador — hoy Docker Swarm + Traefik (Portainer), con OpenShift/Kubernetes como destino final.
Cómo levantarlo en local con uv
Requisitos: Python 3.12+ y uv instalado.
# Instala dependencias (incluye extras de desarrollo/test)
uv sync --extra dev
# Copia la config de ejemplo y ajústala si hace falta
cp .env.example .env
# Levanta el servidor
uv run python -m mcp_corpPor defecto escucha en http://0.0.0.0:8000. El endpoint MCP
(Streamable HTTP) queda expuesto en /mcp.
Cómo probar /health y /ready
curl -i http://localhost:8000/health # liveness: el proceso está vivo
curl -i http://localhost:8000/ready # readiness: listo para tráficoAmbos responden 200 con un JSON {"status": "...", "service": "mcp-corp"}
cuando todo está bien; /ready responde 503 mientras el server arranca o
se está apagando. Ninguno de los dos refleja la salud de los conectores
(Postgres, APIs, etc.) — para eso está /diagnostics (ver más abajo).
Cómo probar /diagnostics
curl -s http://localhost:8000/diagnostics | jqDevuelve, por cada conector registrado, el estado del circuit breaker
(closed / open / half_open), operaciones en curso, el límite de
concurrencia configurado, el resultado de la última verificación de salud y
estadísticas crudas del pool. Ejemplo con el conector Postgres habilitado:
{
"connectors": {
"postgres": {
"source": "postgres",
"circuit_state": "closed",
"in_flight": 0,
"max_concurrency": 10,
"healthy": true,
"pool": {"pool_size": 1, "pool_available": 1, "requests_num": 1, "...": "..."}
}
}
}Este endpoint es solo para observabilidad/alertas manuales: a diferencia de
/health y /ready, nunca debe conectarse al healthcheck del orquestador
(ver "¿Por qué /ready nunca se acopla a los conectores?" más abajo).
Cómo correrlo en Docker
docker build -t mcp-corp:latest .
docker run --rm -p 8000:8000 --env-file .env mcp-corp:latestEl HEALTHCHECK de la imagen apunta a /health. Build, arranque y
respuesta HTTP real (curl /health devolviendo 200 desde dentro del
contenedor) fueron verificados en un entorno con Docker Desktop.
Cómo levantar la infraestructura de desarrollo (Postgres + stub de saldos)
Para probar los conectores de las Fases 2 y 3 localmente (fuera de los tests de integración, que ya lo hacen por su cuenta):
docker compose -f docker-compose.dev.yml up -d
# En tu .env:
# MCP_CORP_POSTGRES__ENABLED=true
# MCP_CORP_POSTGRES__DSN=postgresql://mcp_corp:mcp_corp@localhost:5432/mcp_corp
# MCP_CORP_SALDO_API__ENABLED=true
# MCP_CORP_SALDO_API__BASE_URL=http://localhost:8080
uv run python -m mcp_corp
curl -s http://localhost:8000/diagnostics | jq
docker compose -f docker-compose.dev.yml down -vdocker compose ... down -v es importante si cambiaste el seed: Postgres
solo corre docker-entrypoint-initdb.d en un volumen de datos vacío, así
que sin el -v una recreación del contenedor NO vuelve a sembrar la base
(la tabla clientes nueva de esta fase no aparecía hasta hacer el -v).
Ver docker-compose.dev.yml, el seed de
Postgres en deploy/dev/postgres-seed.sql
y el stub de saldos en
deploy/dev/saldo_api_stub.py. Esto es
solo para desarrollo local; no es el stack de despliegue (eso sigue siendo
deploy/swarm/).
Registro de tools por fuente disponible (Fase 6, Bug 1)
Antes de esta fase, register_tools() registraba las tres tools solo si
ambos conectores (postgres y saldo_api) estaban en el registro —
un residuo de cuando resumen_cliente era la única tool que importaba.
En un despliegue real con Postgres sano pero saldo_api deshabilitado a
propósito (ver "Recomendación: desplegar en dos etapas" en
deploy/README.md), esto registraba cero tools,
incluida consultar_cliente, que no necesita saldo_api para nada.
Ahora cada tool, el Resource y el Prompt se registran si y solo si sus propias fuentes están disponibles:
Registrable | Requiere | Se omite si falta |
|
|
|
|
|
|
|
| cualquiera de los dos |
Resource |
|
|
Prompt |
| cualquiera de los dos |
Al arrancar, un log tools_registration_summary deja explícito qué se
registró y qué se omitió y por qué:
{"event": "tools_registration_summary", "registradas": ["consultar_cliente"], "omitidas": {"consultar_saldo": "requiere el conector 'saldo_api', que no está disponible", "resumen_cliente": "requiere ambos conectores; falta(n): saldo_api"}}Verificado contra el protocolo MCP real, no inspeccionando variables
internas. Los tests en
tests/tools/test_tools_registration.py
usan fastmcp.Client(mcp).list_tools() (y list_resources() /
list_prompts()) contra las cuatro combinaciones posibles de conectores
disponibles, incluyendo el caso exacto del bug en producción (Postgres
sano, saldo_api deshabilitado). Este tipo de test — contra el protocolo
tal como lo ve un cliente MCP real, no contra el estado interno del
server — debió existir desde la Fase 3; no existía, y por eso el bug
llegó a producción sin que la suite lo detectara.
Arranque en modo degradado (Fase 6, Bug 2)
Antes de esta fase, ConnectorRegistry.connect_all() dejaba que
cualquier excepción de connector.connect() se propagara sin atajar
hasta el lifespan del server — si Postgres no respondía a tiempo
(psycopg_pool.PoolTimeout: pool initialization incomplete after 10.0 sec), el proceso entero moría antes de servir una sola petición. En
Swarm/Kubernetes esto se manifiesta como un bucle de reinicio: la réplica
nueva vuelve a fallar contra el mismo Postgres caído, se reinicia, falla
de nuevo.
Ahora una fuente caída al arrancar nunca tumba el proceso:
ConnectorRegistry.connect_all()atrapa cualquier excepción deconnect()por conector, registraconnector_startup_failedy fuerza el circuito de esa fuente aopen(ResilientExecutor.force_open(), nuevo) — el registro sigue con las demás fuentes.El conector de Postgres tiene además su propio manejo específico: si
pool.open(wait=True, timeout=...)agota el timeout,psycopg_poollevantaPoolTimeouty cierra el pool (no se puede reabrir). En vez de propagar esa excepción,PostgresConnector.connect()descarta ese pool y abre uno nuevo en modo no bloqueante (wait=False), que arranca sus workers en background sin esperar ni lanzar nada — el conector queda con un pool "vivo pero vacío" en vez de sin pool.Las tools que dependen solo de fuentes sanas funcionan con normalidad desde el primer segundo. Las que dependen de la fuente caída fallan limpio con el mismo
ToolErrorde negocio que ya existía para cualquier circuito abierto (nunca un stack trace ni un crash).Recuperación automática, sin reiniciar el proceso. No hace falta ningún bucle de reintento propio: en cuanto una operación real contra esa fuente tiene éxito, el circuit breaker ya existente (medio-abierto tras
reset_timeout_seconds, cierre trassuccess_thresholdéxitos) la cierra sola. Verificado tanto con tests automatizados (tests/connectors/test_degraded_startup.py,tests/connectors/test_postgres_degraded_connect.py) como manualmente contra un Postgres real en Docker: arranque con Postgres apagado → el proceso sigue vivo → se enciende Postgres → dos llamadas reales a la tool bastan para ver el circuito volver acloseden/diagnostics, sin ninguna intervención manual.PoolTimeoutno hereda deTimeoutError/OSError/ConnectionErrorde la stdlib (hereda depsycopg.OperationalError) — sin el ajuste aPOSTGRES_INFRA_EXCEPTIONSenmain.py(ausente antes de esta fase), el circuit breaker nunca la habría contado como fallo de infraestructura y el error se habría propagado sin traducir haciatools.py.
¿Por qué no se registran/desregistran tools dinámicamente al recuperarse una fuente?
Se evaluó usar notifications/tools/list_changed (FastMCP lo dispara al
llamar add_tool() / enable() / disable() / remove_tool()) para
que, por ejemplo, resumen_cliente apareciera recién cuando ambas
fuentes estuvieran sanas. Se descartó por dos razones: (1) esas
notificaciones solo se emiten dentro de un request MCP activo — un
conector que se recupera en segundo plano (sin que haya ninguna petición
en curso en ese instante) no tiene un contexto de request desde el cual
disparar la notificación, así que no encaja de forma natural con una
recuperación asíncrona; (2) con el criterio del Bug 1, una tool ya se
registra desde el arranque si su fuente está configurada y habilitada,
sin importar si en ese momento está sana — "sana" es un estado que
cambia con el circuit breaker, no con el registro de la tool. Una tool
cuya fuente cae simplemente empieza a fallar limpio (circuito abierto) y
vuelve a funcionar sola en cuanto el breaker cierra, sin que haga falta
quitarla ni volver a añadirla del catálogo. Registro dinámico queda como
opción a reconsiderar solo si en el futuro se necesita ocultar del todo
una tool (no solo hacerla fallar limpio) mientras su fuente está caída.
Con la infraestructura de desarrollo arriba (Postgres + stub de saldos) y
el server corriendo con ambos conectores habilitados, cualquier cliente
MCP puede invocar lo siguiente contra http://localhost:8000/mcp:
Las tres tools
Tool | Fuente | Parámetro | Devuelve |
| PostgreSQL ( |
|
|
| API REST de saldos (stub) |
|
|
| ambas, en paralelo |
| ver "Política de resultado parcial" abajo |
consultar_cliente y consultar_saldo fallan limpio (ToolError, mensaje
de negocio) si el identificador no existe o si su fuente no está
disponible. resumen_cliente es la tool compuesta preferida cuando se
necesitan ambos datos: una sola llamada, ambas fuentes consultadas en
paralelo con asyncio.TaskGroup.
Identificadores de prueba (ver el seed): V16760320 y V16760321 existen
en ambas fuentes (caso feliz) — pueden escribirse también como
16760320, 16.760.320, V-16.760.320, etc. (ver "Identificadores
venezolanos" abajo); V16760322 existe solo en Postgres (para probar el
resultado parcial); V90000001 existe en Postgres y hace que el stub de
saldos responda 500 (para probar el circuit breaker con un fallo real
de infraestructura, no un simple "no encontrado").
Identificadores venezolanos: formatos aceptados (Fase 4)
Las tres tools aceptan cédula o RIF venezolano en cualquier formato común
— la normalización vive en identifiers.py,
no en el esquema de la tool ni en el modelo. El modelo no necesita limpiar
la entrada del usuario antes de pasarla.
Formatos equivalentes (todos normalizan al mismo valor canónico):
Cédula: 16760320 16.760.320 V16760320
V-16760320 V-16.760.320 v16760320
RIF: J-167603200 J16.760.320-0 J-16760320-0Con o sin puntos de millar, con o sin guiones, con o sin letra de prefijo, mayúscula o minúscula.
Prefijos aceptados: V, E, J, G, P — verificados contra
fuentes (ver "Decisiones de diseño" para el detalle y las fuentes
consultadas). C (comunas/consejos comunales) existe pero es ambiguo
entre fuentes; queda deshabilitado por defecto, activable con
MCP_CORP_IDENTIFIERS__INCLUIR_PREFIJO_C=true.
La letra I NO es un prefijo válido y se rechaza explícitamente —
varias librerías y regex publicados la incluyen por error; el registro
oficial del SENIAT no la contempla.
Dígito verificador del RIF: cuando el identificador trae uno (un RIF
completo, no solo una cédula), se valida contra la fórmula módulo 11 del
SENIAT ANTES de tocar cualquier conector — un identificador mal tipeado
se rechaza sin gastar un slot del semáforo ni abrir una conexión.
Desactivable con MCP_CORP_IDENTIFIERS__VALIDAR_DIGITO_VERIFICADOR=false
si alguna vez hiciera falta (ver "Decisiones de diseño" para cómo se
verificó el algoritmo).
Un identificador con formato irreconocible, con un prefijo no válido, o
(si la validación está activa) con un dígito verificador que no coincide,
produce un ToolError de negocio ("el identificador no tiene un formato
reconocido" / "el dígito verificador no es válido") — nunca un error
técnico, y nunca después de haber tocado Postgres o la API de saldos.
Política de resultado parcial de resumen_cliente
Si una de las dos fuentes no está disponible (circuito abierto, timeout, fallo de infraestructura), la tool no falla entera: devuelve lo que sí pudo obtener y marca explícitamente qué falta y por qué.
{
"identificador": "V16760322",
"cliente": {"disponible": true, "datos": {"...": "..."}, "motivo": null},
"saldo": {"disponible": false, "datos": null, "motivo": "el servicio de saldos no está disponible en este momento"},
"resumen_completo": false
}Un modelo que consuma esta tool debe revisar siempre cliente.disponible
/ saldo.disponible antes de usar datos — un dato ausente NUNCA es cero
ni un valor válido. El Prompt atencion_cliente (ver abajo) codifica esta
regla explícitamente para el flujo de atención al cliente.
Resource: catálogo de estados de cliente
data://clientes/estados — un diccionario JSON de solo lectura con el
significado de cada código de estado (activo, moroso, inactivo)
que puede traer consultar_cliente / resumen_cliente.
Diferencia entre Tool y Resource: una Tool es una ACCIÓN que el modelo
decide invocar (con efectos — en este caso, consultar una fuente externa
en el momento). Un Resource es CONTEXTO de solo lectura que la aplicación
cliente puede cargar sin que el modelo gaste un turno de tool-call — p.
ej. para mostrarlo en una UI, o para que el modelo lo tenga ya disponible
al interpretar el campo estado de un cliente.
Prompt: flujo de atención al cliente
atencion_cliente(cedula) — una plantilla de workflow reutilizable que le
dice al modelo, en orden: usar resumen_cliente primero, revisar los
campos disponible antes de reportar nada, no inventar datos faltantes, y
cuándo usar las tools individuales o el Resource de estados en su lugar.
Probar todo de punta a punta
docker compose -f docker-compose.dev.yml up -d
# .env con MCP_CORP_POSTGRES__ENABLED=true y MCP_CORP_SALDO_API__ENABLED=true
uv run python -m mcp_corpCon el server corriendo, usando el cliente de fastmcp (incluido como
dependencia transitiva):
import asyncio
from fastmcp import Client
async def main():
async with Client("http://localhost:8000/mcp") as client:
print(await client.list_tools())
print(await client.call_tool("resumen_cliente", {"identificador": "V-16.760.320"}))
asyncio.run(main())O ejecuta directamente uv run pytest tests/tools/ (unitarios sin
infraestructura + integración contra el stack real, se saltan solos si no
está levantado).
Cómo desplegarlo en Docker Swarm
Hay dos archivos, para dos propósitos distintos:
deploy/swarm/docker-compose.yml— demo mínima de la Fase 4, HTTP plano, pensada para probar el escalado horizontal rápido en local.deploy/swarm/mcp-corp-stack.yml— stack completo para el entorno pre-productivo (Fase 5): secretos de Swarm montados como archivo, HTTPS con Let's Encrypt, límites de recursos, logging acotado. Usa este para cualquier cosa que no sea una prueba rápida en tu laptop.
docker stack deploy -c deploy/swarm/mcp-corp-stack.yml mcp-corpEl procedimiento completo — construir y transportar la imagen sin
registro, crear los secretos, verificar el despliegue, escalar, hacer
rollback y troubleshooting — está en
deploy/README.md, el runbook operativo de esta
fase. Léelo antes de desplegar en un entorno real; el stack file por sí
solo no basta, tiene varios TODO que hay que completar primero (ver los
comentarios de cabecera del propio archivo).
Cómo desplegarlo en OpenShift / Kubernetes
Esqueleto de manifiestos en deploy/openshift/ —
Deployment con sondas de liveness (/health) y readiness (/ready)
separadas, Service, Route y ConfigMap/Secret. No se probó contra un
cluster real (no había uno disponible en este entorno); ver el
README de esa carpeta para qué cambia
al migrar desde Swarm (solo la capa de orquestación) y qué no (el
código del server).
Prueba de carga y verificaciones de escalado horizontal (Fase 4, Parte B)
deploy/dev/load_test.py es un script simple
(sin hey ni locust, solo fastmcp.Client) que abre N sesiones MCP
concurrentes y mide throughput/latencias:
uv run python deploy/dev/load_test.py \
--url http://localhost:8000/mcp \
--tool resumen_cliente \
--identificador V16760320 \
--concurrency 20 \
--requests-per-worker 20Lo que se verificó bajo carga real (no teoría) y los números medidos
Todo lo de abajo se corrió contra Postgres y el stub de saldos reales de
docker-compose.dev.yml, con réplicas reales del server (procesos/
contenedores independientes, no simulados) generando y sirviendo tráfico
concurrente real.
1. Fórmula de capacidad (límite por réplica = techo de la fuente ÷ nº de réplicas).
Se levantaron 3 réplicas reales, cada una con MAX_POOL_SIZE=3 y
MAX_CONCURRENCY=3 para Postgres, y se les generó carga concurrente
simultánea a las tres (10 sesiones × 5 peticiones por réplica). Se
monitoreó pg_stat_activity en Postgres cada segundo durante toda la
corrida: el conteo de conexiones se mantuvo estable en 9 conexiones de
aplicación (3 réplicas × pool de 3) durante todo el pico de carga,
nunca más. /diagnostics de cada réplica confirmó pool_size: 3 (el
máximo configurado) en las tres al mismo tiempo. La fórmula se sostiene:
9 = 3 × 3, ni una conexión más.
2. Presupuesto de conexiones y el umbral de PgBouncer.
El Postgres de desarrollo (postgres:16-alpine, default de fábrica) tiene
max_connections = 100. Con superuser_reserved_connections (3 por
defecto) reservadas, quedan ~97 conexiones utilizables para la
aplicación. Con la fórmula de arriba, el umbral para necesitar
PgBouncer (u otro pooler externo) es cuando nº_de_réplicas × max_pool_size
se acerca a ese número — por ejemplo, con max_pool_size=5 por
réplica, el límite práctico ronda las 19 réplicas (19 × 5 = 95) antes
de agotar max_connections; con max_pool_size=10, el límite baja a
~9 réplicas. Este número es específico de esta instancia de Postgres
(el max_connections real de producción puede ser mayor o menor, y hay
que restarle lo que usen otros clientes — pgAdmin, otros servicios); la
fórmula general es: nº_réplicas_máximo ≈ (max_connections − reservadas − otros_clientes) ÷ max_pool_size.
3. Comportamiento al saturar el semáforo: espera o falla limpio, nunca encola infinito.
Con max_concurrency=2 y acquire_timeout_seconds generoso (3s), 20
sesiones concurrentes contra una réplica completaron 50/50 peticiones
exitosas (0 fallos) — todas esperaron su turno y fueron atendidas,
con latencia p95 de ~2.03s (el costo real de encolarse detrás de un
límite de 2). Con el mismo max_concurrency=2 pero
acquire_timeout_seconds=0.05 (deliberadamente agresivo), la misma carga
de 20 sesiones concurrentes produjo 13/60 peticiones rechazadas
limpiamente (ToolError: "La base de datos de clientes no está
disponible en este momento") en vez de acumularse indefinidamente —
confirmando las dos rutas posibles del diseño: esperar (si el timeout lo
permite) o fallar limpio (si no), nunca una cola sin fin. El circuito
permaneció closed durante toda esta prueba: saturación de
concurrencia NO cuenta como fallo de infraestructura para el breaker (es
la distinción documentada en la Fase 2).
4. /ready bajo carga y con una fuente caída: nunca se degrada.
Con el stub de saldos apagado (docker stop) — el conector con
healthy: false y el circuito de esa fuente open — y simultáneamente
saturando el semáforo de Postgres con 20 sesiones concurrentes contra un
límite de 2, se sondeó /ready continuamente: 45/45 respuestas fueron
200, ni una sola 503. /ready siguió respondiendo únicamente sobre
la salud del proceso, exactamente como se diseñó desde la Fase 2 — nunca
se acopla a la salud de los conectores, sin importar cuánta carga o cuántas
fuentes estén caídas.
5. El circuit breaker con múltiples réplicas: independiente por diseño, confirmado.
Con dos réplicas reales corriendo, se detuvo el stub de saldos y se le
mandaron 6 peticiones fallidas seguidas SOLO a la réplica A. Resultado en
/diagnostics de cada una, al mismo tiempo:
Réplica A (recibió las 6 peticiones fallidas): circuit_state = "open"
Réplica B (no recibió tráfico hacia saldo_api): circuit_state = "closed", healthy = falseCada réplica descubre la fuente caída de forma completamente independiente, con su propio conteo de fallos — exactamente la decisión de diseño de la Fase 2, ahora confirmada con réplicas reales en vez de en teoría. En la práctica esto implica que, con N réplicas, una fuente que se recupera recibe hasta N sondeos independientes en medio-abierto (uno por réplica que la tenía marcada como caída), no uno solo coordinado.
6. Graceful shutdown durante una rotación de réplicas — con matices reales, no solo el camino feliz.
Se corrió el server en un contenedor Docker real (Linux; ver el porqué
más abajo) con carga concurrente sostenida (3 sesiones, cientos de
peticiones) y se ejecutó docker stop (SIGTERM real vía el PID 1 del
contenedor) a mitad de la corrida. Resultado, leído directamente de los
logs:
470/470 tool-calls que ya habían arrancado se completaron con éxito (
tool_invocation_completedconresult: successpara cadaCallToolRequestprocesado) — ninguna quedó a medias.El apagado completo (
Shutting down→Application shutdown complete) tomó ~130 ms, muy por debajo delMCP_CORP_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDSconfigurado (8s): uvicorn no necesitó agotar ese margen porque las peticiones request/response ya estaban resueltas.Hallazgo no anticipado: el stream SSE de larga duración que usa el transporte Streamable HTTP de MCP para cada sesión SÍ se corta de forma abrupta al recibir SIGTERM (
ASGI callable returned without completing responseen los logs de uvicorn), en vez de esperar el timeout de gracia completo. El cliente ve unReadErroral intentar cerrar ordenadamente su sesión. La garantía que sí se sostiene — y es la que importa — es que ninguna tool call queda inconclusa; lo que se corta es la conexión de transporte de la sesión, no una operación de negocio en curso. Documentado como hallazgo real, no maquillado: el diseño cumple su promesa central (no tools a medias), pero con un matiz de transporte que vale la pena conocer si se opera esto en producción.
¿Por qué en un contenedor Docker (Linux) y no como proceso nativo de Windows? Ver "Qué NO se pudo verificar" abajo — Windows no entrega SIGTERM real a un proceso hijo desde este sandbox; un contenedor Linux sí, y es exactamente el mismo mecanismo que usarían Swarm/Kubernetes en producción (ambos corren contenedores Linux y envían SIGTERM real al apagar una réplica).
Qué NO se pudo verificar por límites del entorno
Un Swarm o cluster OpenShift/Kubernetes real. Solo Docker Desktop (un único daemon, sin múltiples nodos ni Traefik real corriendo). Las verificaciones de arriba se hicieron con réplicas reales (procesos y contenedores independientes) apuntando a la misma infraestructura compartida, que es el mecanismo real detrás de "una réplica" — pero sin un balanceador real (Traefik/Service de Kubernetes) al frente redirigiendo tráfico entre ellas.
Graceful shutdown enviando SIGTERM a un proceso nativo de Windows. Windows no invoca los manejadores de señal que instala uvicorn cuando se envía SIGTERM a un proceso hijo desde otro proceso —
os.kill(pid, SIGTERM)ytaskkill(sin/F) terminan el proceso abruptamente sin darle oportunidad de drenar. Se confirmó el rechazo explícito de Windows (taskkillsin/F: "Este proceso se puede terminar solo de forma forzada"). Por eso la verificación #6 se hizo contra un contenedor Docker (Linux real dentro), que si recibe y maneja SIGTERM correctamente — el mismo mecanismo que usa un Swarm o Kubernetes reales en producción.Manifiestos de OpenShift contra un cluster real. Sintaxis validada (YAML bien formado, coherente con la documentación de la API de Kubernetes/OpenShift), pero nunca aplicados con
oc apply/kubectl applycontra un cluster.
Modo producción y fail-closed del HMAC (Fase 4, Parte B)
Pendiente heredado de la Fase 3: antes, si MCP_CORP_AUDIT_HMAC_SECRET
venía vacía, el server solo registraba un warning y arrancaba igual — un
HMAC con clave vacía es determinista y públicamente reproducible (el
mismo problema que motivó abandonar el hash plano), así que ese
enmascaramiento no protegía nada.
Ahora, Settings (en config.py) tiene un validador que revisa la
combinación al construirse: si MCP_CORP_ENVIRONMENT=production y
MCP_CORP_AUDIT_HMAC_SECRET está vacía, el proceso no arranca —
pydantic.ValidationError desde el primer momento, antes de abrir
ningún conector. En cualquier otro entorno (local, staging), una
clave vacía sigue siendo aceptable y solo produce el warning de
server.py — apto para desarrollo, no para el entorno cuyos logs sí se
auditan de verdad.
Configuración
Toda la configuración viene de variables de entorno (prefijo MCP_CORP_),
cargadas con pydantic-settings en src/mcp_corp/config.py.
Ver .env.example para la lista completa con valores de
ejemplo. Nunca commitees un .env real: está excluido en .gitignore.
Secretos como archivo (Docker Swarm / Kubernetes, Fase 5)
Docker Swarm monta cada secreto como un ARCHIVO en /run/secrets/<nombre>,
no como variable de entorno — y pydantic-settings solo sabe leer variables
de entorno. config.py resuelve esto con el patrón <VAR>_FILE (el mismo
que usan las imágenes oficiales de Postgres/MySQL/Redis en Docker Hub):
# En vez de exportar el valor directamente...
export MCP_CORP_AUDIT_HMAC_SECRET_FILE=/run/secrets/audit_hmac_secret
export MCP_CORP_POSTGRES__DSN_FILE=/run/secrets/postgres_dsnAl arrancar, get_settings() lee cada archivo referenciado por una
variable MCP_CORP_*_FILE y su contenido (recortado) pasa a ocupar la
variable sin el sufijo — MCP_CORP_AUDIT_HMAC_SECRET_FILE=/run/secrets/x
termina resolviendo MCP_CORP_AUDIT_HMAC_SECRET como si se hubiera
exportado directamente. Funciona igual para CUALQUIER variable con
prefijo MCP_CORP_, incluidas las anidadas (MCP_CORP_POSTGRES__DSN_FILE)
— no hace falta enumerar cuáles son sensibles ni tocar el código cuando se
agregue una nueva.
Precedencia (de mayor a menor): variable de entorno real explícita >
archivo referenciado por _FILE > .env de desarrollo > default del
campo. Si MCP_CORP_X ya existe como variable real, la variante _FILE
se ignora en silencio — por eso el flujo de desarrollo local con .env
sigue funcionando exactamente igual que antes de esta fase, sin ningún
cambio.
¿Por qué este patrón y no secrets_dir nativo de pydantic-settings?
Ver el docstring de _load_file_secrets_into_environ en config.py para
el detalle: secrets_dir resuelve nombres de archivo a partir del nombre
de cada campo, y su comportamiento con modelos anidados (postgres.dsn,
saldo_api.*) no está bien cubierto en la documentación. El patrón
_FILE opera sobre el nombre de la variable de entorno ya resuelta por
pydantic-settings (prefijo + delimitador __ incluidos), así que cubre
cualquier campo, anidado o no, sin depender de ese comportamiento interno.
Estructura del proyecto
src/mcp_corp/
├── main.py # entrypoint: python -m mcp_corp
├── server.py # FastMCP server: /health, /ready, /diagnostics, tools/resource/prompt, lifecycle
├── config.py # configuración vía pydantic-settings (Postgres + saldo_api)
├── logging_setup.py # logging JSON estructurado a stdout
├── audit.py # auditoría por invocación de tool (correlation id + enmascaramiento)
├── tools.py # las 3 tools de negocio + Resource + Prompt (Fase 3)
├── identifiers.py # normalización/validación de cédula-RIF venezolano (Fase 4)
└── connectors/
├── base.py # protocolo Connector: connect/close/health/run
├── resilience.py # capa genérica: semáforo + timeout + circuit breaker
├── registry.py # ciclo de vida y diagnóstico agregado de conectores
├── postgres.py # conector concreto: psycopg3 + psycopg_pool
└── http.py # conector concreto: httpx.AsyncClient (Fase 3)
tests/
├── test_audit.py # unitarios de auditoría: enmascaramiento, forma del log
├── test_identifiers.py # unitarios de normalización/checksum (Fase 4)
├── test_config.py # fail-closed del HMAC en modo producción (Fase 4)
├── connectors/
│ ├── test_resilience.py # unitarios de la capa de resiliencia (conector falso)
│ ├── test_postgres_integration.py # integración contra Postgres real
│ └── test_http.py # unitarios del conector HTTP (transporte falso)
└── tools/
├── test_tools_logic.py # unitarios de las tools: parcial, fuente caída, sin internals
└── test_tools_integration.py # integración contra Postgres + stub de saldos reales
deploy/
├── README.md # runbook operativo de despliegue (Fase 5)
├── swarm/
│ ├── docker-compose.yml # demo mínima HTTP plano (Fase 4)
│ ├── mcp-corp-stack.yml # stack pre-productivo completo: secretos, HTTPS, recursos (Fase 5)
│ └── saldo-api-stub-stack.yml # stub de saldos opcional, solo para demo (Fase 5)
├── dev/ # postgres-seed.sql + saldo_api_stub.py + load_test.py
└── openshift/ # Deployment/Service/Route/Ingress/HPA/ConfigMap + Secret de ejemploDecisiones de diseño
¿Por qué stateless por invocación? El proceso no guarda ningún estado de negocio en memoria ni en disco entre requests. Esto permite correr N réplicas idénticas detrás de un balanceador sin sticky sessions: cualquier réplica puede atender cualquier request. Es la base para escalar horizontalmente en Swarm hoy y en OpenShift/Kubernetes después, simplemente ajustando el número de réplicas.
¿Por qué /health y /ready separados?
Son preguntas distintas. /health (liveness) responde "¿el proceso sigue
vivo?" — si falla repetidamente, el orquestador debe reiniciar el
contenedor. /ready (readiness) responde "¿debo recibirle tráfico nuevo
ahora mismo?" — puede ser false durante el arranque o el apagado sin que
eso implique que el proceso está roto. Docker Swarm hoy solo usa un
healthcheck (mapeado a /health), pero OpenShift/Kubernetes exige sondas de
liveness y readiness independientes; separarlos desde ahora evita una
migración dolorosa después.
¿Por qué /ready nunca se acopla a la salud de los conectores?
Corrección respecto a la Fase 1: el README original decía que /ready se
ampliaría para reflejar la salud de los conectores cuando existieran. Se
decidió lo contrario al construir la Fase 2. Razón: si el breaker de UNA
fuente se abre (p. ej. Postgres cae) y eso tumbara /ready, el balanceador
sacaría la réplica entera de rotación — incluidas las tools que no
dependen de esa fuente y que seguirían funcionando perfectamente. Eso
empeora el incidente en vez de contenerlo. /ready sigue siendo,
exclusivamente, "¿el proceso terminó de arrancar y no se está apagando?".
El estado de los conectores se expone aparte, en /diagnostics, pensado
para observabilidad y alertas — nunca para el healthcheck del orquestador.
¿Por qué logging JSON estructurado a stdout, con correlation id?
12-factor: el proceso no sabe ni le importa a dónde van sus logs; los emite
a stdout y el agregador de la plataforma (Docker/Portainer hoy, el stack de
logging de OpenShift después) los recolecta. El formato JSON los hace
parseables por esas plataformas sin heurísticas de texto libre. El
correlation_id (vía contextvars) se deja preparado desde ya en
logging_setup.py aunque todavía no hay tools que lo usen: la
infraestructura de auditoría de un server que va a manejar datos
corporativos debe existir desde el andamiaje, no añadirse como parche
cuando ya haya tráfico de negocio.
¿Por qué usuario no-root en el contenedor?
Requisito de seguridad estándar en entornos bancarios/corporativos: si el
proceso es comprometido, un usuario sin privilegios limita el radio de daño
dentro del contenedor. El Dockerfile crea un usuario de sistema dedicado
(mcpcorp, uid/gid 1000) y nunca ejecuta como root.
Fase 2 — capa de conectores
¿Por qué dos capas separadas (resiliencia genérica + conectores concretos) y no un "conector Postgres" con su propia resiliencia adentro?
Porque cuando sumemos la fuente número 20, 30 o 50, agregarla debe ser
"escribir cómo le hablo" y nada más. Si cada conector reimplementara su
semáforo, su timeout y su breaker, cada uno sería una superficie distinta
para el mismo bug, y arreglarlo en una fuente no arreglaría las demás. La
capa de resiliencia (connectors/resilience.py) no sabe nada de Postgres
ni de HTTP: solo envuelve cualquier objeto que cumpla el protocolo
Connector (connectors/base.py) — connect, close, health, run.
Un conector nuevo (REST, un sistema legacy) solo implementa esos cuatro
métodos y hereda concurrencia, timeout y circuit breaker gratis.
¿Por qué psycopg3 (psycopg[binary]==3.3.4 + psycopg_pool==3.3.1) y no asyncpg?
Por compatibilidad con PgBouncer. Cuando escalemos réplicas, es previsible
que Postgres quede detrás de un PgBouncer en modo transaction pooling, y
ese modo no soporta prepared statements por conexión. asyncpg usa
prepared statements automáticamente y sin forma sencilla de desactivarlo
por completo, lo que produce DuplicatePreparedStatementError — un fallo
que solo aparece bajo presión real del pool, no en desarrollo ni en
tests ligeros, y que pasaría cualquier suite de pruebas hasta explotar en
producción. psycopg3 tiene un prepare_threshold adaptativo pensado
justamente para convivir con poolers externos. La diferencia de
rendimiento entre ambos drivers no es nuestro cuello de botella.
¿Por qué circuit breaker propio y no una librería de terceros?
Las semánticas (cerrado / abierto / medio-abierto, umbral de fallos, tiempo
de reset) son simples y acotadas — la implementación completa en
resilience.py no llega a 100 líneas. Las alternativas de PyPI para esto
suelen ser proyectos de un solo mantenedor. Este código corre en el camino
crítico de un sistema bancario: cada dependencia de terceros que no
sumamos es superficie de supply chain que no tenemos que auditar ni
defender. Código propio, tipado y con tests, es más barato de mantener que
una dependencia externa para algo de este tamaño.
¿Por qué el estado del circuit breaker es por réplica (no compartido)?
Decisión explícita, no un descuido: cada réplica mantiene su propio
CircuitBreaker en memoria de proceso. Cada réplica descubre una fuente
caída de forma independiente, con su propio conteo de fallos y su propio
reset. Coordinar el estado entre réplicas (p. ej. vía Redis) añadiría una
dependencia compartida más — y un punto de fallo más — para un beneficio
que en esta fase no justifica el costo. Queda como opción futura si la
detección coordinada se vuelve necesaria (p. ej. para no volver a golpear
una fuente que ya varias réplicas saben que está caída).
¿Por qué asyncio.BoundedSemaphore (no Semaphore) y uno por fuente?
BoundedSemaphore levanta ValueError si se hace release() de más que
acquire() — atrapa bugs de over-release en vez de corromper el contador
en silencio. Es uno por fuente, no global, para que el backpressure de
cada fuente sea independiente: si el core bancario se satura, eso no debe
robarle capacidad de espera a las consultas contra Postgres.
¿Por qué asyncio.timeout() y no asyncio.wait_for?
Es la API estructurada recomendada desde Python 3.11: compone mejor con
cancelación y no envuelve la corrutina en una Task adicional como hace
wait_for. Por la misma razón, si en el futuro hace falta ejecutar varias
operaciones concurrentes relacionadas, la herramienta es
asyncio.TaskGroup, no asyncio.gather — gather deja tareas huérfanas
corriendo si una de las otras falla; TaskGroup cancela las hermanas.
Fórmula de capacidad: límite por réplica = techo de la fuente ÷ número de réplicas.
max_concurrency (y, cuando exista, el límite de tasa) se configuran por
réplica, no en total. Si Postgres tolera 40 conexiones concurrentes desde
este servicio y corremos 4 réplicas, cada una debe configurarse con
MCP_CORP_POSTGRES__MAX_CONCURRENCY=10 (40 ÷ 4), no con 40. Configurar 40
en cada una de las 4 réplicas permitiría hasta 160 conexiones simultáneas
en el peor caso y ahogaría la fuente exactamente cuando más tráfico hay.
Esta cuenta hay que rehacerla cada vez que cambia el número de réplicas.
¿Por qué el semáforo no limita tasa (req/s), solo concurrencia?
Hueco conocido, señalado a propósito y no resuelto en esta fase (ver
rate_limit_per_second en ResilienceConfig y en PostgresSettings, hoy
sin efecto). Un semáforo acota cuántas operaciones están en vuelo al
mismo tiempo, pero no cuántas por segundo se disparan — una fuente puede
declarar un techo en req/s en vez de en conexiones simultáneas (típico de
APIs REST con rate limiting). Si eso ocurre, hará falta un token bucket por
encima del semáforo; no existe todavía.
¿Por qué /diagnostics es un endpoint separado de /health y /ready?
Para poder observar y alertar sobre el estado de los conectores (breaker
abierto, pool agotado) sin que eso saque la réplica de rotación — ver
"¿Por qué /ready nunca se acopla a la salud de los conectores?" arriba.
¿Por qué apagado graceful ante SIGTERM controlando uvicorn explícitamente?
main.py construye el uvicorn.Server directamente (en vez de usar
mcp.run()) para poder fijar timeout_graceful_shutdown desde la
configuración (MCP_CORP_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS). Uvicorn
instala los signal handlers: al recibir SIGTERM deja de aceptar conexiones
nuevas, espera a que las conexiones en curso terminen (hasta ese timeout) y
solo entonces dispara el shutdown del lifespan del server, donde marcamos
ready = False y lo dejamos registrado en el log. Esto se probó
manualmente enviando SIGTERM al proceso y confirmando en los logs la
secuencia shutdown_initiated → Application shutdown complete.
¿Por qué fastmcp pineado a una versión exacta (==3.4.4)?
FastMCP 3.0 fue una reescritura mayor de la librería; pinear la versión
exacta (en vez de un rango) evita que una actualización de terceros rompa
el comportamiento del server sin que nos demos cuenta. Lo mismo aplica al
resto de dependencias directas en pyproject.toml — incluidas
psycopg[binary]==3.3.4 y psycopg-pool==3.3.1 de la Fase 2 — y
uv.lock fija además todo el árbol transitivo para builds reproducibles.
¿Por qué pydantic-settings para la configuración?
Da validación de tipos y defaults declarativos sobre variables de entorno
con muy poco código, y es el enfoque estándar en el ecosistema FastMCP/
Pydantic. Toda variable nueva que se necesite en fases futuras se añade
como un campo más en Settings, reflejado también en .env.example.
Fase 3 — conector HTTP y tools de negocio
El conector HTTP valida la abstracción de la Fase 2 contra algo que no es Postgres.
connectors/http.py implementa el mismo protocolo Connector (connect,
close, health, run) y se envuelve con el mismo ResilientExecutor,
sin que la capa de resiliencia sepa que esta vez el recurso subyacente es
un httpx.AsyncClient en vez de un pool de psycopg. No hizo falta tocar
resilience.py ni base.py para nada: la abstracción de la Fase 2 no
resultó forzada — de hecho salió MÁS simple que el conector de Postgres,
porque no hay un pool que administrar a mano (httpx.AsyncClient ya trae
el suyo internamente). Eso es justamente la señal de que el diseño de la
Fase 2 estaba bien encontrado, no una casualidad.
¿Por qué httpx==0.28.1 como dependencia directa (y no solo transitiva)?
Ya era una dependencia transitiva de fastmcp/mcp desde la Fase 1 (por
eso la versión coincide exactamente); pasa a dependencia directa en esta
fase porque connectors/http.py la usa en tiempo de ejecución, no solo en
tests. Se quita de dev en pyproject.toml para no declararla dos veces.
¿Qué cuenta como fallo de infraestructura para una fuente HTTP?
HTTP_INFRA_EXCEPTIONS = (httpx.TransportError, httpx.HTTPStatusError) en
connectors/http.py. TransportError cubre problemas reales de red/
conexión/timeout de transporte. HTTPStatusError solo se levanta cuando
el código de la tool llama a response.raise_for_status() — y las tools
de esta fase manejan el 404 ("cédula no encontrada") como caso de
NEGOCIO antes de llegar ahí, devolviendo None en vez de lanzar. Así que
lo que efectivamente cuenta como fallo de infraestructura es un 5xx real
del servicio, no un simple "no encontrado".
Tools por intención de negocio, no una por endpoint.
Tres tools (consultar_cliente, consultar_saldo, resumen_cliente), no
una por cada endpoint de cada fuente. Un server con demasiadas tools
parecidas degrada la capacidad del modelo de elegir bien cuál usar — este
es el motivo documentado de que se evitó deliberadamente una tool por
endpoint. Las descripciones de cada tool son la interfaz real que lee el
modelo: dicen explícitamente cuándo usar esa tool y cuándo usar otra en su
lugar (p. ej. "si también necesitas el saldo, usa resumen_cliente en su
lugar").
Resultado parcial explícito en resumen_cliente, nunca fallo total.
Ver "Política de resultado parcial" arriba. La razón de fondo: un modelo
que recibe un error genérico cuando una de dos fuentes falla no puede
decirle al usuario "tengo el dato del cliente pero no el saldo ahora
mismo" — se queda sin nada que podía haber tenido. Ambas ramas de
_resumen_cliente_logic atrapan sus propios ConnectorError (nunca dejan
escapar una excepción "esperada"); solo un bug real y no contemplado
propagaría una excepción fuera de la tool, y ahí sí queremos que falle
fuerte en vez de fingir un resultado parcial.
¿Por qué asyncio.TaskGroup y no asyncio.gather en la tool compuesta?
Misma razón que en la Fase 2: gather deja tareas huérfanas corriendo si
una falla; TaskGroup las cancela. Aquí, además, ninguna de las dos tareas
internas deja escapar una excepción esperada (la atrapan y la traducen a
disponible=False), así que TaskGroup casi nunca tiene que cancelar
nada en la práctica — pero es la primitiva correcta igual, para el día en
que sí aparezca un bug real que deba cancelar a la tarea hermana.
Errores hacia el modelo: ToolError + mask_error_details=True, dos capas.
Cada tool atrapa ConnectorError (ya sanitizado por resilience.py: su
mensaje nunca incluye el DSN, el SQL ni el string de la excepción
original) y lo traduce a un ToolError con un mensaje de negocio propio,
escrito a mano — nunca str(excepción_original). Como defensa en
profundidad adicional, el server se crea con
FastMCP(..., mask_error_details=True): cualquier excepción que NO sea un
ToolError (es decir, un bug no contemplado) se enmascara automáticamente
hacia el cliente en vez de reenviar el traceback completo.
Criterio de enmascaramiento en el log de auditoría (audit.py).
Cada invocación de tool queda en el log JSON con: correlation_id (nuevo
por invocación, reutilizando el mecanismo que ya existía desde la Fase 1),
nombre de la tool, duration_ms, y result (success / partial /
failure, con reason si falló). Lo que NUNCA se registra es el VALOR de
los parámetros de negocio: una cédula, nombre o saldo en claro en un log
que viaja a un agregador externo es, en sí mismo, un problema de
cumplimiento. Lo único que se conserva del identificador principal (la
cédula) es HMAC-SHA256(clave, cédula) truncado a 12 hex — permite
correlacionar invocaciones del mismo cliente entre líneas de log sin poder
recuperar el valor original a partir del log.
¿Por qué HMAC-SHA256 y no un sha256(cédula) plano?
Un hash plano NO es irreversible en este caso concreto: el espacio de
cédulas (6 a 10 dígitos) es pequeño y enumerable — calcular sha256 de
los ~10 mil millones de valores posibles y armar una tabla arcoíris toma
segundos en cualquier laptop. Cualquiera con acceso al log (el agregador
externo, un auditor, un atacante que lo filtre) podría revertir el
identificador sin necesitar ningún secreto — el "enmascaramiento" no
protegería nada. HMAC(clave, cédula) corta ese ataque: sin conocer
audit_hmac_secret (nueva variable en .env.example, nunca un valor real
en el repo), ni siquiera se puede empezar a precomputar la tabla, porque
el HMAC de cada cédula depende de una clave que no está en el log.
Sigue siendo determinista (misma cédula + misma clave = mismo hash, ver
_mask en audit.py) y por lo tanto correlacionable — pero deja de ser
reversible por fuerza bruta desde fuera del server.
Qué pasa con la correlación histórica si audit_hmac_secret rota.
HMAC(clave_nueva, cédula) ≠ HMAC(clave_vieja, cédula) para la misma
cédula — es el comportamiento esperado, no un defecto. Rotar la clave
rompe la correlación entre logs de ANTES y DESPUÉS de la rotación para el
mismo cliente: dos invocaciones de la misma cédula, una a cada lado de la
rotación, quedan con hashes distintos y no se pueden enlazar mirando solo
el log. Es exactamente el trade-off deseable al rotar por sospecha de
compromiso de la clave: invalida la posibilidad de correlacionar hacia
atrás usando la clave filtrada. Si en el futuro se necesita continuidad de
correlación durante una rotación planificada (no por incidente), la única
forma es calcular el hash con AMBAS claves durante una ventana de
transición — no implementado en esta fase.
Clave igual en todas las réplicas, no por-réplica.
A diferencia del estado del circuit breaker (que sí es por réplica, ver
Fase 2), audit_hmac_secret debe ser IDÉNTICA en todas las réplicas: si
cada una tuviera su propia clave, la misma cédula produciría hashes
distintos según qué réplica atendió la invocación, y dejarías de poder
correlacionar al mismo cliente entre logs de réplicas diferentes — el
caso de uso exactamente contrario al del breaker.
Hallazgo importante: el JSONFormatter de la Fase 1 ignoraba extra={}.
Al validar el log de auditoría de punta a punta se descubrió que
logging_setup.JSONFormatter (desde la Fase 1) solo incluía
correlation_id en el JSON — cualquier otro campo pasado vía
logger.info(msg, extra={...}) se registraba en el LogRecord pero
JAMÁS se escribía en la línea de log final. Esto afectaba TODOS los logs
estructurados desde la Fase 2 (source, circuit_state, etc.), no solo
los de esta fase; se corrigió volcando cualquier atributo del LogRecord
que no sea uno de los estándar de logging. Verificado manualmente
comparando el log antes/después del fix contra el mismo flujo de tools.
Prompt y Resource: mismo patrón de registro que las tools, sin resiliencia.
Ninguno de los dos toca una fuente externa en el momento (el Resource es
un diccionario estático en memoria; el Prompt es una plantilla de texto),
así que no pasan por ResilientExecutor — no tienen de qué protegerse.
Fase 4, Parte A — identificadores venezolanos
¿Por qué normalizar en el servidor y no exigirle el formato limpio al modelo?
Un usuario le escribe al agente "consúltame la cédula V-16.760.320", no
"16760320". Si la tool exige dígitos limpios, el modelo tiene que adivinar
cómo limpiar la entrada — y si adivina distinto de cómo lo hace nuestro
código, la tool falla antes de tocar ninguna fuente por una razón que no
tiene nada que ver con si el cliente existe. La normalización es
responsabilidad del servidor, no del modelo: identifiers.py acepta el
formato tal como lo escribe una persona.
¿Por qué un módulo dedicado (identifiers.py) y no dentro de tools.py?
Es lógica de dominio (reglas del SENIAT) sin ninguna dependencia de
FastMCP, de un conector ni de resiliencia — se puede probar y razonar
sobre ella de forma completamente aislada. tools.py la importa y la usa,
pero no la conoce por dentro.
El algoritmo del dígito verificador: cómo se verificó.
Se cruzaron tres implementaciones independientes y no relacionadas entre
sí — un gist de la lista python-venezuela, el paquete joseayram/utils
en PHP, y la librería django-localflavor-ve (usada en producción por
proyectos Django venezolanos) — y las tres coinciden EXACTAMENTE en la
fórmula: peso por posición (3, 2, 7, 6, 5, 4, 3, 2) sobre los 8 dígitos
del número, más un valor base por letra (V=4, E=8, J=12, P=16, G=20),
todo módulo 11. Como confirmación final, se encontró un ejemplo real
citado como correcto en una fuente independiente
(V-13222105-3, documento "Cálculo Dígito verificador RIF Venezuela",
marcado "Rif correcto") y se reprodujo el cálculo exacto con esta
implementación: suma = 63, residuo = 8, verificador = 11 − 8 = 3. Con esa
triple coincidencia de código más un ejemplo real verificado, se activó
el checksum por defecto (validar_digito_verificador=True) — no quedó
detrás de un flag apagado, porque sí se logró la confianza que pedía el
encargo. Sigue siendo desactivable (MCP_CORP_IDENTIFIERS__VALIDAR_DIGITO_VERIFICADOR=false)
por si en producción aparece un caso real que la fórmula no contemple.
La letra I no es un prefijo válido — trampa conocida, verificada y evitada.
Ninguna de las fuentes oficiales ni las tres implementaciones cruzadas
incluye I como prefijo. Sí aparece en algunos regex y librerías de
validación de terceros, heredado de un error que se propaga por copia
entre proyectos. identifiers.py solo acepta V, E, J, G, P por defecto,
y hay un test de regresión explícito (test_letra_i_es_rechazada_no_existe_en_el_seniat)
para que nadie la reintroduzca sin darse cuenta.
El prefijo C: activable, no cableado.
Existe desde un anuncio oficial de 2015 para comunas, consejos comunales
y organizaciones del Poder Popular. Pero a diferencia de V/E/J/G/P, no
se encontró consenso entre las fuentes consultadas sobre si sigue vigente
en el set que valida el portal actual del SENIAT, ni una fórmula de
dígito verificador confirmada por más de una fuente para esta letra (solo
joseayram/utils la documenta, compartiendo el valor de J). Por esa
doble incertidumbre queda deshabilitada por defecto
(MCP_CORP_IDENTIFIERS__INCLUIR_PREFIJO_C=false) y solo se activa
explícitamente.
Relación cédula/RIF: no son dos números independientes.
Para personas naturales (prefijo V), los 8 dígitos del RIF SON el
número de cédula — no hay dos identificadores distintos que reconciliar.
Por eso IdentidadFiscal guarda un solo numero de 8 dígitos y expone
.cedula (forma corta, sin verificador) y .rif (forma completa, exige
verificador) como dos VISTAS del mismo dato, no como dos campos separados.
Relleno con cero.
Un identificador con menos de 8 dígitos (cédulas antiguas más cortas) se
completa con ceros a la izquierda hasta 8 — identidad.numero.zfill(8).
"123456" normaliza a "00123456".
Cada conector adapta la forma canónica a lo que necesita su fuente.
tools.py normaliza una sola vez (identidad = normalizar(...)) y pasa
identidad.cedula a Postgres y a la API de saldos — en esta fase ambas
fuentes usan la misma forma corta, pero el punto de extensión ya existe:
un conector futuro que necesite el RIF completo con verificador usaría
identidad.rif en su lugar, sin que el resto del código cambie.
Rechazo sin tocar ninguna fuente: la razón de ser en el diseño de resiliencia.
_resolve_identidad() corre ANTES de cualquier ResilientExecutor.run().
Un identificador mal tipeado nunca reserva un slot del semáforo ni abre
una conexión del pool — es el filtro más barato posible, y en un sistema
donde cada fuente tiene un techo de concurrencia finito, filtrar temprano
importa.
Fase 6 — correcciones de campo
Sesiones MCP con múltiples réplicas (Bug 3): investigado, NO implementado esta fase.
Con una sola réplica todo funciona. Con dos o más réplicas detrás de
Traefik sin sticky sessions, initialize crea la sesión en la réplica
que atendió esa petición; la siguiente petición con ese
Mcp-Session-Id puede caer en otra réplica y responder "Session not found". Esto contradice la premisa "stateless" heredada de la Fase 1 —
cierta para las tools (cada invocación es petición/respuesta, sin
estado propio) pero falsa para el transporte: el
StreamableHTTPSessionManager del SDK de MCP guarda el estado de cada
sesión (streams SSE, tareas en curso) en memoria del proceso que la
creó, sin ningún backend compartido. Es el mismo hallazgo, visto desde
otro ángulo, que el de la Fase 4: el stream SSE se corta abruptamente
ante SIGTERM durante una rotación de réplicas (ver "Graceful shutdown
durante una rotación de réplicas" arriba) — en ambos casos, lo que se
rompe es la sesión de transporte de larga duración, no una tool call.
Tres opciones evaluadas, ninguna implementada:
1. Estado de sesión compartido (Redis).
Es la respuesta "correcta" en teoría para escalado horizontal, pero
bloqueada hoy por la arquitectura del propio SDK de MCP: el
StreamableHTTPSessionManager no tiene soporte nativo para un backend
externo — lo que guarda por sesión no es un diccionario serializable,
son streams SSE y tareas asyncio en vuelo, que no se pueden mover de
un proceso a otro tal cual. Hay un issue abierto y sin resolver en el
SDK oficial (modelcontextprotocol/python-sdk#880) pidiendo exactamente
esto para escalado horizontal. Implementarlo hoy significaría, en la
práctica, parchear o reescribir esa capa del SDK — alto esfuerzo, alto
riesgo, y quedaríamos manteniendo un fork. No recomendado mientras el
SDK no lo soporte nativamente; revisar de nuevo cuando ese issue (o
uno equivalente) se resuelva upstream.
2. Sticky sessions en Traefik.
Traefik v2.11 soporta afinidad por cookie de forma nativa
(services.<name>.loadBalancer.sticky.cookie) — NO por header. Afinidad
por header (que sería lo natural aquí, pegando por Mcp-Session-Id) es
un feature request abierto en Traefik (traefik/traefik#1207), no algo
disponible hoy. La opción real es entonces cookie-based: existe un
patrón documentado para exactamente este caso (router dedicado con
PathPrefix(/mcp) + cookie httpOnly/secure/sameSite=strict).
Riesgo real: esto depende de que el CLIENTE MCP conserve y reenvíe la
cookie de afinidad entre peticiones de la misma sesión — cierto para un
cliente HTTP con cookie jar (p. ej. httpx.AsyncClient con
cookies=..., que es lo que usa fastmcp.Client internamente), pero no
garantizado por el protocolo MCP en sí para cualquier implementación de
cliente. Configuración rápida (una etiqueta de Traefik), pero: (a) no
resuelve el corte de SSE en SIGTERM durante una rotación — una sesión
pegada a la réplica que se está apagando se corta igual; (b) degrada el
balanceo real, porque una réplica con muchas sesiones largas pegadas
acumula carga desproporcionada frente a una réplica nueva sin sesiones.
3. Delegar en el futuro gateway de gobierno.
Si el gateway (Fase 7+) es el componente que habla MCP contra este
server — en vez de que cada cliente final lo haga directamente — el
problema no desaparece, pero se concentra en un solo lugar que
controlamos en vez de exigir que cada cliente MCP (actual y futuro)
implemente su propia lógica de reconexión ante "Session not found".
Un gateway que ya sabe reintentar initialize de forma transparente
cuando pierde una sesión resuelve esto una vez, con conocimiento
completo de cuándo está pasando (rotación de réplica, sesión perdida),
en vez de que N clientes distintos tengan que adivinarlo por su cuenta.
Sigue sin arreglar el corte de SSE en sí — el gateway también perdería
su sesión si su réplica destino rota — pero mover el trabajo de "cómo
recuperarse de esto" a una sola pieza de infraestructura, en vez de a
cada consumidor, es lo que hace esta opción atractiva en costo total.
Recomendación: opción 3 como dirección de fondo (ya está en el plan de fases futuras, así que no es trabajo adicional, es orden de prioridad), con la opción 2 (sticky por cookie) como mitigación de corto plazo si hace falta escalar a más de una réplica ANTES de que el gateway exista — aceptando sus dos limitaciones (no soluciona el corte por SIGTERM, degrada el balanceo). La opción 1 (Redis) queda descartada por ahora, no por preferencia sino porque el SDK de MCP no la soporta todavía sin intervención mayor sobre su código interno.
Próximas fases (fuera de alcance aquí)
Fase 7+: gateway de gobierno (candidato natural para resolver Bug 3 de la Fase 6, ver "Sesiones MCP con múltiples réplicas" arriba), más tools de negocio, más conectores (sistemas legacy) sobre el mismo protocolo
Connector/ResilientExecutor.Desplegar
deploy/swarm/mcp-corp-stack.ymlen más de una réplica en el entorno pre-productivo real y decidir si hace falta sticky sessions (opción 2 de "Sesiones MCP con múltiples réplicas") antes de que exista el gateway.Token bucket para límite de tasa (req/s) por fuente — hoy solo hay límite de concurrencia (ver
rate_limit_per_secondenconfig.py).Estado del circuit breaker compartido entre réplicas (hoy es por réplica, a propósito — ver "Decisiones de diseño").
Verificar los manifiestos de OpenShift/Kubernetes contra un cluster real (
deploy/openshift/) — la sintaxis y la estructura están listas, pero nunca se aplicaron conoc/kubectl.PgBouncer (u otro pooler externo) delante de Postgres, cuando el número de réplicas se acerque al umbral documentado en "Verificaciones bajo carga".
Continuidad de correlación del HMAC de auditoría durante una rotación de clave planificada (hoy rotar rompe la correlación histórica a propósito; ver Fase 3).
This server cannot be installed
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/alvaradojuanm/mcp-corp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server