Introducción
El Model Context Protocol (MCP) es un protocolo abierto que estandariza la comunicación entre
LLMs y herramientas externas. En lugar de construir integraciones ad-hoc para cada agente,
MCP define una interfaz única: el agente descubre tools, las invoca con JSON-RPC 2.0, y el
servidor ejecuta la lógica.
En este artículo explico cómo construimos un MCP server que integra cuatro servicios
complementarios para proporcionar a cualquier LLM (Claude, GPT, Gemini, etc.) capacidades de búsqueda, navegación web interactiva, extracción de contenido y OCR mediante una única interfaz MCP:
- SearXNG: metabúsqueda privada y autogestionada
- Firecrawl: scraping, crawling y extracción estructurada
- Camofox: navegación headless con anti-detección
- OCR iOS: reconocimiento de texto local vía Apple Vision Framework
Arquitectura general
El servidor MCP actúa como un proxy unificado entre el LLM y los servicios. No expone
los servicios directamente, sino que ofrece tools que el LLM invoca según necesidad.

Cada tool del MCP server llama a su servicio vía HTTP. El LLM nunca ve los servicios
directamente, solo ve las tools con sus nombres, descripciones y parámetros.
Decisiones de diseño
1. FastMCP vs MCP SDK raw
Usamos FastMCP del SDK oficial (mcp.server.fastmcp). FastMCP es una capa sobre el
protocolo MCP que elimina boilerplate: genera automáticamente el JSON Schema de cada tool
a partir de type hints y docstrings, maneja el lifecycle del protocolo, y soporta múltiples
transportes sin configuración adicional.
FastMCP abstrae el protocolo, lo que es bueno para productividad pero limita
el control fino sobre mensajes JSON-RPC individuales. Para nuestro caso (tools simples sin
streaming) parece una decisión correcta.
2. Transporte: stdio vs Streamable HTTP
Soportamos ambos. stdio es ideal cuando el cliente MCP (Claude Desktop, Cursor) lanza el
server como subproceso local. streamable-http es necesario cuando el server corre en
una máquina remota.
ata el ciclo de vida del server al del cliente. HTTP permite servir a múltiples clientes,
pero requiere configuración de red, firewalls y posiblemente autenticación.
En una topología con el LLM en 10.0.1.5, los servicios en 10.0.1.8 y el MCP server en otra máquina, ambas opciones son viables. La elección depende principalmente de la infraestructura disponible y de si el servidor será utilizado por uno o varios clientes.
- Opción A (SSH): el cliente en
10.0.1.5lanzassh user@mcp-server python server.py
como comando stdio. No requiere abrir puertos. - Opción B (HTTP): el server corre con
--transport streamable-http --port 8001y el cliente se conecta ahttp://mcp-server:8001/mcp. Requiere el puerto accesible.
3. Cliente HTTP global vs por request
Una de las primeras correcciones que hicimos fue centralizar el cliente HTTP:
http_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=30.0),
limits=httpx.Limits(max_keepalive_connections=20, max_connections=100),
)
Antes cada request creaba su propio AsyncClient, lo que implicaba:
- Negociación TCP en cada llamada (handshake + TLS)
- Sin reuso de conexiones
- Mayor latencia y carga en los servicios
Después con un cliente global con pool de conexiones keep-alive:
- Las conexiones se reusan entre requests
- El límite
max_connections=100permite controlar el número máximo de conexiones simultáneas abiertas por el cliente HTTP. El valor adecuado dependerá de la capacidad de los servicios de destino y de la carga esperada. - Se define un timeout granular por operación (connect, read, write, pool)
El cliente global complica el shutdown (hay que cerrarlo explícitamente al
salir). Lo resolvemos con un try/finally en el bloque __main__.
4. SSRF protección resolviendo DNS
Aceptar URLs arbitrarias del LLM implica riesgo de Server-Side Request Forgery (SSRF).
Un LLM malicioso o comprometido podría intentar acceder a http://169.254.169.254/latest/meta-data/
(metadata cloud) o a servicios internos.
Nuestra validación:
- Verifica esquema HTTP/HTTPS solamente
- Resuelve el hostname a IP con
socket.gethostbyname() - Bloquea IPs privadas, loopback y link-local con
ipaddressmodule
def _es_ip_privada(host):
ip = ipaddress.ip_address(host)
return ip.is_private or ip.is_loopback or ip.is_link_local
def _resolver_y_validar(host):
ip = socket.gethostbyname(host)
if _es_ip_privada(ip):
raise ValueError(f"Host {host} resuelve a IP privada {ip}")
Tradeoff: Resolver DNS en cada llamada añade una pequeña latencia adicional, cuyo impacto dependerá de la caché DNS del sistema operativo, la red y el resolvedor utilizado. Validar únicamente el formato del hostname no es suficiente frente a determinados escenarios como DNS rebinding. Resolver el nombre antes de comprobar la IP reduce significativamente la superficie de ataque frente a una validación basada únicamente en el hostname, aunque no elimina todos los escenarios de SSRF (por ejemplo DNS rebinding o cambios de resolución posteriores a la validación).
5. Múltiples servicios unificados vs separados
Podríamos haber creado 4 servidores MCP independientes (uno por servicio). En su lugar
los unificamos en un solo server con tools nombradas por prefijo searxng_*, firecrawl_*,camofox_*, ocr_*).
Ventajas de unificarlos:
- Una sola configuración para el usuario
- Un solo proceso que gestionar
- El LLM ve todas las tools disponibles sin cambiar de «contexto»
- Las tools pueden combinarse en un mismo flujo de razonamiento
Desventajas:
- Si un servicio falla (ej. Camofox caído), todas las tools aparecen como disponibles
pero pueden fallar - Mayor acoplamiento: actualizar la lógica de un servicio requiere tocar el mismo archivo
- Un solo punto de fallo
En despliegues donde distintos equipos gestionan cada servicio o donde se requiere aislamiento entre componentes, separar los servidores puede simplificar la operación. En otros escenarios, especialmente para uso personal o equipos pequeños, un único MCP resulta más sencillo de administrar.
6. Retries automáticos
Inicialmente configuramos httpx.AsyncHTTPTransport(retries=2) a nivel de transporte.
Lo eliminamos al descubrir que retry automático en POST requests puede causar efectos
secundarios graves: doble crawl, doble extracción, doble navegación.
Decisión final: sin retries automáticos. Cada tool maneja sus errores específicos
(timeout, HTTP error, conexión) con mensajes claros para el LLM, que puede decidir
reintentar o no. Esto no significa que los retries sean siempre una mala idea. En operaciones idempotentes (por ejemplo, muchas peticiones GET) pueden mejorar la resiliencia del sistema. En nuestro caso preferimos deshabilitarlos globalmente para evitar efectos secundarios en operaciones que modifican estado.
7. OCR: URL vs Base64
El servidor OCR expone un endpoint HTTP sencillo (POST /upload) que recibe imágenes mediante una petición multipart.
ocr_imagen_url: descarga la imagen desde una URL y la reenvía al OCRocr_imagen_base64: recibe la imagen como base64 directamente
URL es más eficiente (no pasa datos binarios por MCP), pero requiere que
la imagen sea accesible públicamente. Base64 funciona para cualquier imagen (capturas
locales, fotos recién tomadas, imágenes generadas por el LLM), pero puede saturar
el contexto si la imagen es grande. Nuestro límite práctico serán ~20MB.
8. Observabilidad
Cada herramienta envuelve sus llamadas HTTP con:
- Request ID (UUID hex de 8 chars) para correlacionar logs
- Timing con
time.monotonic()para medir latencia - Logging estructurado con niveles (info para éxito, error para fallos)
log.info("[%s] GET %s → %s (%dms)", rid, url, resp.status_code, int(elapsed * 1000))
El logging detallado agrega overhead de I/O. En nuestro escenario de uso, donde predominan pocas peticiones relativamente costosas (navegación, OCR o scraping), el coste adicional del logging resulta pequeño frente al tiempo total de ejecución. En sistemas de mayor volumen podrían ser preferibles estrategias como logging asíncrono, muestreo o agregación de eventos.
9. Truncado de respuestas
Aunque el protocolo MCP no impone un límite estricto para las respuestas de las herramientas, en la práctica el tamaño útil está condicionado por el cliente MCP, el modelo utilizado y la ventana de contexto disponible. Por ello implementamos dos estrategias de truncado:
- Por caracteres (
truncar_por_caracteres): corta a 8000 chars. Ideal para texto
plano (OCR, contenido de páginas). - Por líneas (
truncar_por_lineas): corta a 80 líneas. Ideal para listas de
resultados (búsquedas, mapas de sitio).
El truncado incluye un mensaje «[Truncado — respuesta demasiado larga]» para que el LLM
sepa que la respuesta está incompleta y pueda pedir más si es necesario.
10. Excepciones específicas vs genéricas
Evolucionamos de except Exception as e (que oculta bugs de lógica y filtra información
interna al usuario) a manejo granular:
except httpx.TimeoutException:
return "Error: el servicio no respondió a tiempo"
except httpx.HTTPStatusError as e:
log.error("HTTP %s: %s", e.response.status_code, e.response.text[:300])
return "Error: el servicio respondió con un código de error"
except httpx.HTTPError as e:
log.error("Error de conexión: %s", e)
return "Error: no se pudo conectar con el servicio"
Principio: el usuario ve mensajes genéricos y accionables; los detalles técnicos
(IPs, stack traces, cuerpos de respuesta) van solo a los logs.
Lecciones aprendidas
- Nombrar el archivo
mcp.pyshadowea el paquete. Suena obvio, pero es el error
más común al empezar con MCP en Python. El archivo debe llamarse cualquier otra cosa. - FastMCP tiene dos implementaciones habituales: la del SDK oficial (
mcp.server.fastmcp) y la
standalonefastmcpde Prefect). Tienen APIs distintas. La oficial no tieneon_shutdown, la de Prefect sí. - El LLM no entiende de infraestructura. El modelo no dispone de conocimiento específico sobre la infraestructura de despliegue. Por ello las herramientas deben describirse en términos funcionales («buscar», «extraer», «navegar») y no mediante detalles internos de implementación.
- El contexto MCP es limitado. Las respuestas de tools deben ser concisas.
Un resultado de OCR con 5000 caracteres de texto es más útil que uno con 50000
que nunca llega al LLM porque excede el límite del protocolo. - Protección SSRF. Si un MCP server acepta URLs proporcionadas por el LLM, conviene incorporar protección frente a SSRF desde el principio. Resolver el hostname antes de validar la IP evita una clase importante de ataques y representa una mejora significativa frente a validar únicamente el formato de la URL, aunque no sustituye otras medidas de defensa que puedan ser necesarias en entornos especialmente sensibles.
Conclusión
Construir un MCP server que integra búsqueda, navegación web mediante un navegador real, extracción de contenido y OCR combina infraestructura, protocolos y diseño de herramientas orientadas a agentes. La clave está en las decisiones de borde: cómo truncar, cómo autenticar, cómo validar, cuándo retry, qué loggear.
El código fuente completo está en el repositorio. Las tools expuestas son:
| Servicio | Tools |
|---|---|
| SearXNG | searxng_buscar |
| Firecrawl | firecrawl_obtener, firecrawl_buscar, firecrawl_mapa, firecrawl_rastrear, firecrawl_extraer |
| Camofox | camofox_navegar, camofox_obtener_instantanea, camofox_hacer_click, camofox_escribir, camofox_capturar_pantalla, camofox_transcribir_youtube, camofox_buscar |
| OCR iOS | ocr_imagen_url, ocr_imagen_base64, ocr_documento_url, ocr_documento_base64 |
En conjunto, las 16 herramientas proporcionan a cualquier LLM una interfaz unificada para buscar información, navegar por sitios web, interactuar con aplicaciones web, extraer contenido estructurado y realizar OCR, ampliando significativamente las capacidades del modelo para automatizar tareas sobre la web.
