Serie de servidores MCP personalizados: encapsular cinco servicios web de bio para el despliegue en equipo mediante un protocolo estándar
En la parte 14, vimos las especificaciones básicas del Protocolo de contexto del modelo Anthropic (MCP) y la canalización mediante la cual un agente Bio-LLM orquesta de forma autónoma varios servidores MCP. Esta parte es una extensión de lo anterior. Se trata de una parte práctica de la infraestructura que implementa cinco servicios web de bio necesarios para la investigación y el desarrollo reales (PubMed, UniProt, PDB, ChEMBL y BLAST) como servidores MCP completos, y que abarca la contenedorización de Docker, el transporte SSE (Eventos enviados por el servidor), la autenticación, el límite de velocidad, el manejo de errores, el registro de observabilidad, el registro de habilidades de Anthropic y el despliegue en equipo. Esta parte es la última de la ampliación de la serie y el punto álgido de la infraestructura.
📚 Recomendación de partes previas (muy recomendable)
Esta parte es el punto álgido de la infraestructura de la serie avanzada de IA y bio. Le recomendamos encarecidamente que vea y escuche primero las siguientes partes de DryBench antes de empezar.
- DryBench ai-native #9: uso de agentes y herramientas
- DryBench ai-native #10: administración de la ventana de contexto
- DryBench ai-native #14: Claude Code y Cursor
Si empieza sin haber visto las partes previas, le resultará difícil seguir el ritmo, ya que esta parte avanza directamente al código práctico sin volver a explicar la orquestación de herramientas de agentes, las estrategias de ahorro de contexto y la implementación práctica de Claude Code CLI.
Ya aprendimos esto en DryBench
En DryBench ai-native #9, aprendimos que un agente puede procesar de forma autónoma tareas complejas mediante un bucle de llamadas a herramientas, y en #10, aprendimos sobre el problema de que los resultados de las llamadas a herramientas consuman el contexto y las estrategias de resumen y descarga. En #14, vimos que Claude Code es una herramienta que implementa estos principios en la práctica a través de la CLI.
MCP es la estandarización de la infraestructura de estos principios. En lugar de definir herramientas de nuevo en cada proyecto individual, se crea un servidor MCP común para que los equipos, las organizaciones y la comunidad de código abierto lo reutilicen. Esta parte es una guía práctica para crear esta infraestructura y desplegarla a nivel de producción.
Definición del problema práctico
Requisitos prácticos de la serie de servidores MCP
Cuando varios equipos de una organización de investigación biológica llevan a cabo diferentes proyectos, tener un conjunto común de servidores MCP puede:
- Reutilización: Un equipo crea un servidor UniProt MCP y otros equipos lo utilizan inmediatamente.
- Consistencia: Varios proyectos comparten el mismo esquema de herramientas, manejo de errores y políticas de registro.
- Despliegue aislado: El servidor MCP es un proceso independiente, lo que aísla los fallos del cliente.
- Escalabilidad: Se pueden utilizar inmediatamente servidores MCP de código abierto (oficiales de Anthropic y de la comunidad).
- Seguridad: Integración de autenticación, limitación de velocidad y registro de auditoría.
- Observabilidad: Paneles de control de latencia, tasa de éxito y uso.
Objetivos de este proyecto
- Implementación completa de 5 servidores MCP: PubMed, UniProt, PDB, ChEMBL y BLAST.
- Exposición de 4 a 6 herramientas por servidor.
- Clase base común: Reutilización de la limitación de velocidad, reintentos, registro y manejo de errores.
- Contenerización con Docker: Cada servidor tiene su propia imagen y se ejecuta de forma integrada con docker-compose.
- Opción de transporte SSE: stdio como opción predeterminada y soporte para despliegue remoto con SSE.
- Registro en Anthropic Skills: Activación inmediata en Claude Desktop y Claude Code CLI.
- Automatización de pruebas: Pruebas unitarias y de integración para cada herramienta.
- Infraestructura de observabilidad: Métricas de Prometheus, registro estructurado y paneles de control.
- Documentación: README, referencia de la API y ejemplos de uso.
Pila de herramientas y requisitos de infraestructura
| Herramienta | Función | Licencia |
|---|---|---|
mcp Python SDK | Marco de trabajo del servidor MCP | MIT |
| Anthropic Claude API | Integración de Skills y Agente | Comercial |
| Docker y docker-compose | Contenerización y orquestación | Apache 2.0 |
| FastAPI + uvicorn (para el transporte SSE) | Despliegue remoto | MIT y BSD |
| Biopython | Utilidades NCBI E y análisis PDB | Licencia Biopython |
| requests y aiohttp | Clientes de cada API REST | Apache 2.0 y MIT |
| pytest y pytest-asyncio | Marco de pruebas | MIT |
| structlog y rich | Registro y salida en la CLI | Apache 2.0 y MIT |
| Cliente de Prometheus y Grafana | Observabilidad | Apache 2.0 y AGPL |
Requisitos de infraestructura:
- No se requiere GPU. Cada servidor se centra en la CPU y la red.
- Implementación local: Docker Desktop o Docker Engine.
- Implementación remota (opcional): VPS pequeño o Cloudflare Workers, AWS Lambda o Kubernetes.
Coste estimado para que el alumno pueda replicar el proyecto: Implementación local gratuita. VPS remoto, 5 a 10 USD al mes. Cada API pública (PubMed, UniProt, PDB, ChEMBL, BLAST) es gratuita.
Implementación práctica de la canalización
Arquitectura general:
Paso 1. Clase base común (marco reutilizable)
Limitación de velocidad, reintentos, registro, manejo de errores y observabilidad, elementos que todos los servidores MCP comparten.
"""bio_mcp_base.py — clase base común de todos los servidores MCP de biología."""import asyncioimport jsonimport timefrom abc import ABC, abstractmethodfrom dataclasses import dataclassfrom functools import wrapsfrom typing import Any, Callable
import requestsimport structlogfrom mcp.server import Serverfrom mcp.server.stdio import stdio_serverfrom mcp.types import Tool, TextContentfrom prometheus_client import Counter, Histogram, start_http_server
log = structlog.get_logger()
@dataclassclass RateLimitConfig: """Política de límite de solicitudes.""" max_requests_per_second: float = 3.0 max_requests_per_hour: int | None = None burst: int = 5
class RateLimiter: """Token bucket rate limiter (async)."""
def __init__(self, config: RateLimitConfig): self.config = config self.tokens = float(config.burst) self.last_refill = time.time() self.lock = asyncio.Lock()
async def acquire(self) -> None: async with self.lock: now = time.time() elapsed = now - self.last_refill self.tokens = min( self.config.burst, self.tokens + elapsed * self.config.max_requests_per_second, ) self.last_refill = now if self.tokens < 1.0: wait_time = (1.0 - self.tokens) / self.config.max_requests_per_second await asyncio.sleep(wait_time) self.tokens = 0.0 else: self.tokens -= 1.0
# Métricas de Prometheus (compartidas por todos los servidores)TOOL_CALLS = Counter( "mcp_tool_calls_total", "Número de llamadas a herramientas MCP", ["server", "tool", "status"],)TOOL_LATENCY = Histogram( "mcp_tool_latency_seconds", "Latencia de herramientas MCP (segundos)", ["server", "tool"], buckets=[0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],)
def instrument(server_name: str, tool_name: str): """Decorador que registra la latencia y la tasa de éxito de las llamadas a herramientas.""" def decorator(func: Callable): @wraps(func) async def wrapper(*args, **kwargs): logger = log.bind(server=server_name, tool=tool_name) start = time.perf_counter() try: result = await func(*args, **kwargs) elapsed = time.perf_counter() - start TOOL_CALLS.labels(server=server_name, tool=tool_name, status="success").inc() TOOL_LATENCY.labels(server=server_name, tool=tool_name).observe(elapsed) logger.info("tool_success", latency_ms=elapsed * 1000) return result except Exception as e: elapsed = time.perf_counter() - start TOOL_CALLS.labels(server=server_name, tool=tool_name, status="failure").inc() TOOL_LATENCY.labels(server=server_name, tool=tool_name).observe(elapsed) logger.error("tool_failure", latency_ms=elapsed * 1000, error=str(e)) raise return wrapper return decorator
class BioMCPServerBase(ABC): """Clase base de todos los servidores MCP de biología."""
def __init__( self, server_name: str, rate_limit_config: RateLimitConfig, max_retries: int = 3, prometheus_port: int | None = 9090, ): self.server = Server(server_name) self.server_name = server_name self.rate_limiter = RateLimiter(rate_limit_config) self.max_retries = max_retries if prometheus_port: try: start_http_server(prometheus_port) except OSError: pass # Omite si el puerto ya está en uso self._register_handlers()
def _register_handlers(self): """Registra los handlers estándar de MCP.""" @self.server.list_tools() async def _list() -> list[Tool]: return self.tools
@self.server.call_tool() async def _call(name: str, arguments: dict) -> list[TextContent]: try: result = await self.dispatch_tool(name, arguments) return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))] except requests.RequestException as e: log.error("api_request_failed", tool=name, error=str(e)) return [TextContent(type="text", text=json.dumps({"error": f"Falló la solicitud a la API: {e}"}))] except Exception as e: log.error("tool_execution_error", tool=name, error=str(e), exc_info=True) return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
@property @abstractmethod def tools(self) -> list[Tool]: """Lista de herramientas que expone este servidor.""" ...
@abstractmethod async def dispatch_tool(self, name: str, arguments: dict) -> Any: """Despacha por nombre de herramienta.""" ...
async def http_get_with_retry( self, url: str, params: dict | None = None, timeout: int = 30, ) -> requests.Response: """Solicitud GET con límite de solicitudes y reintentos.""" for attempt in range(self.max_retries): await self.rate_limiter.acquire() try: resp = requests.get(url, params=params, timeout=timeout) if resp.status_code == 429: # Rate limit hit wait_time = 2 ** attempt log.warning("rate_limited", wait=wait_time, attempt=attempt, url=url) await asyncio.sleep(wait_time) continue resp.raise_for_status() return resp except requests.Timeout: if attempt == self.max_retries - 1: raise await asyncio.sleep(2 ** attempt) raise RuntimeError(f"Se superó el máximo de reintentos: {url}")
async def run_stdio(self): """stdio transport.""" async with stdio_server() as (read, write): await self.server.run(read, write, self.server.create_initialization_options())Paso 2. Servidor PubMed MCP (expansión de la versión 14)
Profundice en los conceptos de la versión 14. Incluye la búsqueda de citas, artículos relacionados y autores.
"""pubmed_mcp.py — servidor MCP de PubMed E-utilities preparado para producción."""import asyncioimport osfrom xml.etree import ElementTree as ETfrom typing import Any
from mcp.types import Toolfrom bio_mcp_base import BioMCPServerBase, RateLimitConfig, instrument
EUTILS_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
class PubMedMCPServer(BioMCPServerBase): def __init__(self, api_key: str | None = None): # Con una clave de API de NCBI admite 10 solicitudes/s; sin ella, 3 solicitudes/s. rate = 10.0 if api_key else 3.0 super().__init__( server_name="pubmed-mcp", rate_limit_config=RateLimitConfig(max_requests_per_second=rate, burst=int(rate)), ) self.api_key = api_key
@property def tools(self) -> list[Tool]: return [ Tool( name="search_pubmed", description="Devuelve una lista de PMID a partir de una consulta de PubMed; permite indicar intervalo de fechas y orden.", inputSchema={ "type": "object", "properties": { "query": {"type": "string"}, "max_results": {"type": "integer", "default": 20}, "date_range_years": {"type": "integer", "default": 5}, "sort": {"type": "string", "enum": ["relevance", "pub_date"], "default": "relevance"}, }, "required": ["query"], }, ), Tool( name="fetch_abstracts", description="Consulta resúmenes, autores, revistas y DOI a partir de una lista de PMID.", inputSchema={ "type": "object", "properties": {"pmids": {"type": "array", "items": {"type": "string"}}}, "required": ["pmids"], }, ), Tool( name="find_related", description="Consulta artículos relacionados con un PMID mediante PubMed Similar Articles.", inputSchema={ "type": "object", "properties": {"pmid": {"type": "string"}, "top_k": {"type": "integer", "default": 10}}, "required": ["pmid"], }, ), Tool( name="get_citations", description="Lista artículos que citan un PMID, basada en PubMed Central.", inputSchema={ "type": "object", "properties": {"pmid": {"type": "string"}}, "required": ["pmid"], }, ), Tool( name="search_by_author", description="Busca publicaciones recientes de un autor concreto.", inputSchema={ "type": "object", "properties": { "author_name": {"type": "string", "description": "Por ejemplo: 'Doudna JA'"}, "max_results": {"type": "integer", "default": 20}, }, "required": ["author_name"], }, ), ]
async def dispatch_tool(self, name: str, arguments: dict) -> Any: dispatch_map = { "search_pubmed": self._search, "fetch_abstracts": self._fetch, "find_related": self._find_related, "get_citations": self._get_citations, "search_by_author": self._search_by_author, } if name not in dispatch_map: raise ValueError(f"Unknown tool: {name}") instrumented = instrument("pubmed-mcp", name)(dispatch_map[name]) return await instrumented(**arguments)
async def _search( self, query: str, max_results: int = 20, date_range_years: int = 5, sort: str = "relevance", ) -> dict: params = { "db": "pubmed", "term": query, "retmax": max_results, "reldate": date_range_years * 365, "datetype": "pdat", "retmode": "json", "sort": sort, } if self.api_key: params["api_key"] = self.api_key resp = await self.http_get_with_retry(f"{EUTILS_BASE}/esearch.fcgi", params) pmids = resp.json().get("esearchresult", {}).get("idlist", []) return {"query": query, "count": len(pmids), "pmids": pmids}
async def _fetch(self, pmids: list[str]) -> list[dict]: if not pmids: return [] params = { "db": "pubmed", "id": ",".join(pmids), "rettype": "abstract", "retmode": "xml", } if self.api_key: params["api_key"] = self.api_key resp = await self.http_get_with_retry(f"{EUTILS_BASE}/efetch.fcgi", params, timeout=60) root = ET.fromstring(resp.content) articles = [] for art in root.findall(".//PubmedArticle"): articles.append({ "pmid": art.findtext(".//PMID"), "title": art.findtext(".//ArticleTitle") or "", "abstract": " ".join(t.text or "" for t in art.findall(".//AbstractText")), "authors": [ f"{a.findtext('LastName') or ''} {a.findtext('Initials') or ''}".strip() for a in art.findall(".//Author") ][:10], "journal": art.findtext(".//Journal/Title") or "", "year": art.findtext(".//PubDate/Year") or "", "doi": next(( id_.text for id_ in art.findall(".//ArticleId") if id_.get("IdType") == "doi" ), None), "mesh_terms": [m.findtext(".//DescriptorName") for m in art.findall(".//MeshHeading")][:10], }) return articles
async def _find_related(self, pmid: str, top_k: int = 10) -> dict: params = { "dbfrom": "pubmed", "db": "pubmed", "id": pmid, "linkname": "pubmed_pubmed", "retmode": "json", } if self.api_key: params["api_key"] = self.api_key resp = await self.http_get_with_retry(f"{EUTILS_BASE}/elink.fcgi", params) links = resp.json().get("linksets", [{}])[0].get("linksetdbs", []) related = [] for link in links: if link.get("linkname") == "pubmed_pubmed": related = [l["id"] for l in link.get("links", [])][:top_k] break return {"source_pmid": pmid, "related_pmids": related}
async def _get_citations(self, pmid: str) -> dict: params = { "dbfrom": "pubmed", "db": "pmc", "id": pmid, "linkname": "pubmed_pmc_refs", "retmode": "json", } if self.api_key: params["api_key"] = self.api_key resp = await self.http_get_with_retry(f"{EUTILS_BASE}/elink.fcgi", params) return {"source_pmid": pmid, "cited_by": resp.json()}
async def _search_by_author(self, author_name: str, max_results: int = 20) -> dict: query = f"{author_name}[Author]" return await self._search(query, max_results=max_results, sort="pub_date")
if __name__ == "__main__": server = PubMedMCPServer(api_key=os.environ.get("NCBI_API_KEY")) asyncio.run(server.run_stdio())Paso 3 a 6. Los otros 4 servidores MCP (resumen)
Implementado con el mismo patrón de clase base. Cada servidor tiene un archivo y una imagen de Docker separados.
"""uniprot_mcp.py — servidor MCP para la API REST de UniProt."""
class UniProtMCPServer(BioMCPServerBase): """tools: search_protein · get_protein_details · get_sequence · get_features · get_orthologs · get_domains.
UniProt REST endpoint: https://rest.uniprot.org/uniprotkb/*. Límite de solicitudes: ninguno; se recomiendan como máximo 20 por segundo. """ # Amplía el ejemplo de UniProt de la parte 14 con features, ortólogos y dominios # Hereda de la clase base y define el despacho pass
"""pdb_mcp.py — servidor MCP para la API de RCSB PDB."""
class PDBMCPServer(BioMCPServerBase): """tools: search_structure · get_structure_details · fetch_pdb_file · get_ligands · search_by_sequence · get_experimental_method · get_related_structures.
RCSB PDB REST API: https://data.rcsb.org/. Límite de solicitudes: ninguno; se recomiendan como máximo 10 por segundo. """ # Patrón similar al de la parte 14: descarga de archivos CIF y PDB, etc. pass
"""chembl_mcp.py — servidor MCP para la API REST de ChEMBL."""
class ChEMBLMCPServer(BioMCPServerBase): """tools: search_compound · get_compound_details · get_target_activity · smiles_to_chembl_id · get_bioactivities · get_similar_compounds.
ChEMBL REST: https://www.ebi.ac.uk/chembl/api/data/. ChEMBL es una fuente abierta estándar de datos sobre fármacos, actividad y dianas. """ pass
"""blast_mcp.py — servidor MCP para NCBI BLAST QBlast."""
class BLASTMCPServer(BioMCPServerBase): """Herramientas: submit_blast, poll_blast, get_hits y quick_blast (integra envío, sondeo y obtención).
QBlast requiere sondeo asíncrono. Como consume muchos recursos, el límite es estricto: una solicitud por segundo. """ passSi se gestionan los servidores individuales como repositorios separados, se facilita la colaboración en equipo y la gestión de las versiones.
Paso 7. Contenerización con Docker
Cada servidor MCP como una imagen Docker independiente.
# Dockerfile.pubmed_mcp
FROM python:3.11-slim
WORKDIR /app
# Dependencias del sistema
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Python dependency
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Código fuente
COPY bio_mcp_base.py pubmed_mcp.py .
# Expone el puerto de métricas de Prometheus
EXPOSE 9090
# Comunicación MCP mediante stdin/stdout
CMD ["python", "pubmed_mcp.py"]
# Comprobación de estado (opcional)
HEALTHCHECK --interval=30s --timeout=10s \
CMD curl -f http://localhost:9090/metrics || exit 1# docker-compose.yml
version: "3.9"
services:
pubmed-mcp:
build:
context: .
dockerfile: Dockerfile.pubmed_mcp
container_name: pubmed-mcp
environment:
- NCBI_API_KEY=${NCBI_API_KEY}
- LOG_LEVEL=INFO
ports:
- "9091:9090" # Métricas de Prometheus
stdin_open: true
tty: true
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
uniprot-mcp:
build: {context: ., dockerfile: Dockerfile.uniprot_mcp}
container_name: uniprot-mcp
ports: ["9092:9090"]
stdin_open: true
tty: true
restart: unless-stopped
pdb-mcp:
build: {context: ., dockerfile: Dockerfile.pdb_mcp}
container_name: pdb-mcp
ports: ["9093:9090"]
stdin_open: true
tty: true
restart: unless-stopped
chembl-mcp:
build: {context: ., dockerfile: Dockerfile.chembl_mcp}
container_name: chembl-mcp
ports: ["9094:9090"]
stdin_open: true
tty: true
restart: unless-stopped
blast-mcp:
build: {context: ., dockerfile: Dockerfile.blast_mcp}
container_name: blast-mcp
ports: ["9095:9090"]
stdin_open: true
tty: true
restart: unless-stopped
# Stack de observabilidad
prometheus:
image: prom/prometheus:latest
container_name: prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports: ["9099:9090"]
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: grafana
ports: ["3001:3000"]
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-storage:/var/lib/grafana
restart: unless-stopped
volumes:
grafana-storage:Advertencia: Dado que MCP utiliza la comunicación stdio por defecto, para su uso en Docker se requiere docker run -i o un wrapper stdio-over-network (por ejemplo, mcp-proxy). El SDK de MCP más reciente también admite el transporte SSE (Server-Sent Events), lo que facilita el despliegue remoto (véase el paso 8).
Paso 8. Transporte SSE (para despliegue remoto)
stdio solo funciona para subprocesos locales. Para servidores remotos, se requiere SSE.
"""sse_transport_wrapper.py — expone remotamente un servidor MCP mediante FastAPI y SSE."""from fastapi import FastAPI, Requestfrom fastapi.responses import StreamingResponsefrom mcp.server.sse import SseServerTransport
async def sse_endpoint_factory(mcp_server_instance): """Expone el servidor MCP en un endpoint SSE.""" app = FastAPI() transport = SseServerTransport("/messages")
@app.get("/sse") async def sse_endpoint(request: Request): async with transport.connect_sse(request.scope, request.receive, request._send) as (read, write): await mcp_server_instance.server.run( read, write, mcp_server_instance.server.create_initialization_options(), ) return StreamingResponse(iter([]), media_type="text/event-stream")
@app.post("/messages") async def messages_endpoint(request: Request): return await transport.handle_post_message(request.scope, request.receive, request._send)
return app
# Ejemplo de ejecución (despliegue remoto)# from pubmed_mcp import PubMedMCPServer# import uvicorn# server = PubMedMCPServer(api_key=os.environ.get("NCBI_API_KEY"))# app = await sse_endpoint_factory(server)# uvicorn.run(app, host="0.0.0.0", port=8000)Paso 9. Registrar las habilidades de Anthropic
Empaquete este conjunto de servidores como una habilidad reutilizable en Claude Skills [1].
# skills/bio-research/skill.yaml
name: bio-research
version: 1.0.0
description: |
Agente autónomo de investigación bioinformática.
Integra cinco servicios: PubMed, UniProt, PDB, ChEMBL y BLAST.
Claude orquesta las herramientas de forma autónoma.
authors: ["Your Lab"]
license: MIT
mcp_servers:
- name: pubmed
command: docker
args: ["run", "-i", "--rm", "-e", "NCBI_API_KEY", "bio-mcp/pubmed:latest"]
env: ["NCBI_API_KEY"]
- name: uniprot
command: docker
args: ["run", "-i", "--rm", "bio-mcp/uniprot:latest"]
- name: pdb
command: docker
args: ["run", "-i", "--rm", "bio-mcp/pdb:latest"]
- name: chembl
command: docker
args: ["run", "-i", "--rm", "bio-mcp/chembl:latest"]
- name: blast
command: docker
args: ["run", "-i", "--rm", "bio-mcp/blast:latest"]
system_prompt: |
Eres un asistente de investigación bioinformática.
Llama de forma autónoma a las herramientas MCP necesarias y responde basándote en evidencia.
Principios:
1. No inventes hechos. Toda afirmación debe estar respaldada por resultados de herramientas.
2. Incluye las fuentes: PMID, accession de UniProt, ID de PDB o ID de ChEMBL.
3. Si falla una herramienta, intenta una ruta alternativa e informa del fallo.
4. Ahorra contexto y minimiza las llamadas innecesarias.
5. Ante un límite de solicitudes, aplica backoff automáticamente e informa al usuario de la espera.
capabilities:
- biological literature search
- protein sequence and structure retrieval
- chemical compound and drug activity lookup
- sequence similarity search (BLAST)
- cross-reference between databases
examples:
- "Resume las publicaciones de los últimos cinco años sobre BRCA1 humano, incluidas estructuras 3D y ligandos relacionados"
- "Enumera proteínas diana candidatas a las que podría unirse un SMILES concreto"
- "Busca vías, enfermedades y ensayos clínicos recientes relacionados con este gen"Paso 10. Automatización de pruebas
Pruebas de integración pytest para cada herramienta del servidor MCP.
"""tests/test_pubmed_mcp.py — pruebas de integración del MCP de PubMed."""import pytestimport timefrom pubmed_mcp import PubMedMCPServer
@pytest.fixturedef server(): return PubMedMCPServer()
@pytest.mark.asyncioasync def test_search_returns_pmids(server): result = await server._search("BRCA1", max_results=5) assert result["count"] > 0 assert all(p.isdigit() for p in result["pmids"])
@pytest.mark.asyncioasync def test_fetch_abstracts_valid_pmid(server): results = await server._fetch(["33746851"]) # PMID válido conocido assert len(results) == 1 assert results[0]["title"] assert results[0]["abstract"]
@pytest.mark.asyncioasync def test_fetch_abstracts_empty_list(server): results = await server._fetch([]) assert results == []
@pytest.mark.asyncioasync def test_find_related(server): result = await server._find_related("33746851", top_k=5) assert "related_pmids" in result assert isinstance(result["related_pmids"], list)
@pytest.mark.asyncioasync def test_search_by_author(server): result = await server._search_by_author("Doudna JA", max_results=5) assert result["count"] > 0
@pytest.mark.asyncioasync def test_rate_limit_respected(server): """El límite de solicitudes fuerza una espera ante llamadas rápidas consecutivas.""" start = time.time() for _ in range(5): await server._search("test", max_results=1) elapsed = time.time() - start # Política de NCBI: 3 solicitudes/s; cinco requieren al menos ~1,3 s sin clave de API assert elapsed >= 1.0, f"Límite de solicitudes incumplido: {elapsed}"
@pytest.mark.asyncioasync def test_dispatch_tool_unknown(server): with pytest.raises(ValueError, match="Unknown tool"): await server.dispatch_tool("nonexistent_tool", {})
# Integración continua# pytest tests/ -v --asyncio-mode=autoPaso 11. Panel de control de supervisión (Grafana)
Visualice las métricas de Prometheus en Grafana.
# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'mcp-servers'
static_configs:
- targets:
- 'pubmed-mcp:9090'
- 'uniprot-mcp:9090'
- 'pdb-mcp:9090'
- 'chembl-mcp:9090'
- 'blast-mcp:9090'Ejemplos de métricas para el panel de Grafana:
- Latencia p50, p95 y p99 por herramienta y servidor
- Tasa de éxito (éxitos / total)
- Volumen de llamadas por hora (tasa)
- Distribución de tipos de error (límite de tasa 429, 5xx, error de análisis)
Rendimiento, costo y casos de error conocidos
Referencia de rendimiento (citando fuentes públicas)
Rendimiento y limitaciones estándar de cada API:
| API | Límite de tasa | Tiempo de respuesta promedio | Autenticación |
|---|---|---|---|
| PubMed E-utilities | 3 solicitudes/s (sin clave), 10 solicitudes/s (con clave) [2] | 200–800 ms | Clave de API gratuita |
| UniProt REST | Ninguno (se recomienda menos de 20 por segundo) | 100–500 ms | Ninguno |
| RCSB PDB | Ninguno (se recomienda menos de 10 por segundo) | 200–1000 ms | Ninguno |
| ChEMBL REST | Ninguno (se recomienda menos de 10 por segundo) | 300–1000 ms | Ninguno |
| NCBI BLAST QBlast | 1–3 por segundo [3] | 30 segundos–5 minutos (asíncrono) | Ninguno |
Costo estimado para la reproducción por parte del alumno
- Ejecución local: gratuita.
- Implementación remota: VPS pequeño, 5–20 USD al mes.
- API de Claude: 5–20 USD por sesión activa de Skill.
Cinco casos de error conocidos (recopilados de la comunidad y de artículos)
-
Error de comunicación MCP de Docker stdio (especialmente en Windows) Síntoma: En Windows Docker Desktop, el stdio del contenedor del servidor MCP se almacena en búfer parcialmente, lo que provoca un error de comunicación. Causa: Particularidad del manejo de stdio de Docker para Windows (capas como Winpty y MSYS). Solución: (a) Cambiar al transporte SSE (Paso 8), (b) Ejecutar localmente con un subproceso Python nativo en lugar de Docker Compose, (c) Ejecutar dentro de WSL2, (d) Usar
docker attachen lugar dedocker exec -it. Fuente: Problemas de GitHub de MCP, "Almacenamiento en búfer de stdio de Docker en Windows" [4]. -
Conflicto de nombres de herramientas entre varios servidores MCP Síntoma: Conflicto de nombres entre
searchde PubMed MCP ysearchde UniProt MCP, lo que provoca que el cliente no pueda realizar el envío. Causa: La especificación de MCP solo garantiza la singularidad de los nombres de las herramientas dentro del servidor. Solución: Igual que en la sección 14. Forzar el prefijo{server_name}__{tool_name}en el cliente o fijar el nombre del servidor como prefijo de la herramienta (código de esta sección: servidorpubmed-mcp). Fuente: MCP Discussions [5]. -
Gestión de claves API (variables de entorno vs. bóveda) Síntoma: Clave API codificada en la imagen de Docker, lo que provoca su filtración al implementar la imagen. Causa: Patrón anti-patrón que consiste en colocar secretos en la directiva ENV del archivo Docker. Solución: (a) Utilizar la variable de entorno
${NCBI_API_KEY}en docker-compose (como en este ejemplo), (b) Docker Swarm secrets o Kubernetes Secrets, (c) Integrar la gestión de secretos con HashiCorp Vault o AWS Secrets Manager, (d) El archivo.enves.gitignoreobligatorio. Fuente: Docker best practices [6]. -
Política de tiempo de espera y reintento de BLAST QBlast Síntoma: NCBI BLAST QBlast puede tardar más de 5 minutos, lo que provoca un tiempo de espera excesivo de la herramienta MCP. Causa: BLAST es una tarea que consume muchos recursos. El tiempo de respuesta puede variar según la carga del servidor. Solución: (a) Aumentar el tiempo de espera de la herramienta MCP (10 minutos o más), (b) Utilizar un patrón de sondeo asíncrono (enviar, recibir RID, sondear), (c) Considerar la posibilidad de ejecutar BLAST localmente (blastn/blastp CLI + índice de genoma local), (d) Para búsquedas grandes, considerar la posibilidad de utilizar la búsqueda de UniProt. Fuente: NCBI BLAST usage guidelines [3].
-
Dificultad para diagnosticar problemas debido a la falta de métricas de observación Síntoma: En producción, la tasa de fallos de una herramienta específica aumenta repentinamente, pero faltan registros para determinar la causa. Causa: Falta de Prometheus, registros estructurados y alertas en la implementación inicial. Solución: (a) Utilizar un decorador
@instrumentobligatorio, como en la clase base de este ejemplo (Paso 1), (b) Crear previamente un panel de control de Grafana (Paso 11), (c) Utilizar Alertmanager para enviar notificaciones automáticas cuando la tasa de fallos supere un umbral, (d) Guardar el historial de llamadas a la herramienta en un registro de auditoría (para cumplir con las normativas). Fuente: Prometheus best practices, metodología SRE [7].
Ideas de ampliación
- Ampliación del servidor MCP personalizado: GEO/SRA (transcriptómica, secuenciación), Ensembl (variante, gen, genómica comparada), STRING (red de interacción proteína-proteína), Reactome (vía), KEGG (metabolismo).
- Colaboración multiagente: Separar el agente de investigación, el agente de visualización y el agente de generación de informes en sesiones independientes y compartir los resultados a través de MCP.
- Despliegue remoto del servidor MCP: Ejecución continua del servidor MCP en Cloudflare Workers, AWS Lambda, Google Cloud Run o Kubernetes para que lo compartan varios usuarios.
- Integración de laboratorio húmedo: Exposición de las API de los equipos de laboratorio (por ejemplo, robots de manipulación de líquidos, lectores de placas, citómetros de flujo) como herramientas MCP para la ejecución automatizada de planes de experimentos.
- Gestión de autenticación y autorización: Control del acceso a cada herramienta mediante OAuth2, JWT y control de acceso basado en roles (RBAC).
- Capa de caché: Almacenamiento en caché de los resultados consultados con frecuencia (por ejemplo, búsquedas en PubMed) con Redis o Memcached para reducir la latencia y los costos.
Próximo episodio
- Episodio 14
bio-mcp-agent: Implementación de un agente que coordina el conjunto de servidores de este episodio (se volverá a visitar el episodio 14). - Episodio 09
llm-vendor-benchmark: Comparación de las capacidades de uso de las herramientas MCP por proveedor.
Referencias
- Documentación de Anthropic Skills:
https://docs.anthropic.com/en/docs/build-with-claude/skills - Directrices de uso de NCBI E-utilities:
https://www.ncbi.nlm.nih.gov/books/NBK25497/ - NCBI BLAST QBlast:
https://ncbi.github.io/blast-cloud/dev/api.html - Problemas de MCP en GitHub (Windows Docker):
https://github.com/modelcontextprotocol/specification/issues - Debates de MCP en GitHub:
https://github.com/modelcontextprotocol/specification/discussions - Mejores prácticas de Docker para secretos:
https://docs.docker.com/engine/swarm/secrets/ - Mejores prácticas de Prometheus:
https://prometheus.io/docs/practices/ - Descripción general del protocolo de contexto del modelo de Anthropic:
https://modelcontextprotocol.io/ - SDK de Python de MCP:
https://github.com/modelcontextprotocol/python-sdk - SDK de TypeScript de MCP:
https://github.com/modelcontextprotocol/typescript-sdk - Registro del servidor oficial de MCP (comunidad):
https://github.com/modelcontextprotocol/servers - API REST de UniProt:
https://www.uniprot.org/help/api - API de RCSB PDB:
https://data.rcsb.org/ - API REST de ChEMBL:
https://chembl.gitbook.io/chembl-interface-documentation/web-services - Artículo de MCPmed:
https://academic.oup.com/bib/article/27/1/bbag076/8495038 - structlog:
https://www.structlog.org/ - prometheus_client Python:
https://github.com/prometheus/client_python - Grafana:
https://grafana.com/ - FastAPI:
https://fastapi.tiangolo.com/ - uvicorn:
https://www.uvicorn.org/