BioPlayground

🧬
목록으로

커스텀 MCP 서버 시리즈 — 바이오 웹 서비스 5종을 표준 프로토콜로 감싸 팀 배포하기

편 14의 확장. 바이오 웹 서비스 5종(PubMed · UniProt · PDB · ChEMBL · BLAST)을 각각 완결된 MCP 서버로 구현하고 Docker 컨테이너화 · SSE transport · auth · rate limit · 관측성 · Anthropic Skills 등록 · 팀 배포까지 다루는 하드코어 인프라 편. AI Agent 자율 오케스트레이션을 위한 표준 인프라.

심화
|
50
|
검증 완료 (2026-07)
진행률0/15 (0%)

커스텀 MCP 서버 시리즈 — 바이오 웹 서비스 5종을 표준 프로토콜로 감싸 팀 배포하기

편 14에서 우리는 Anthropic Model Context Protocol(MCP)의 기본 규격과 Bio-LLM Agent가 여러 MCP 서버를 자율 조율하는 파이프라인을 봤습니다. 이 편은 그 확장입니다. 실전 R&D에서 필요한 바이오 웹 서비스 5종 (PubMed · UniProt · PDB · ChEMBL · BLAST)을 각각 완결된 MCP 서버로 구현하고, Docker 컨테이너화 · SSE (Server-Sent Events) transport · 인증 · rate limit · 에러 처리 · 관측성 로깅 · Anthropic Skills 등록 · 팀 배포까지 다루는 하드코어 인프라 편입니다. 이 편이 트랙의 확장 마지막이자 인프라 정점입니다.

📚 선수 편 권고 (강력 권고)

이 편은 AI×바이오 하드코어 심화 편의 인프라 정점입니다. 진입 전에 DryBench의 다음 편들을 먼저 듣고·보시길 강력히 권고드립니다.

선수 편 없이 진입할 경우, 이 편에서 다루는 agent tool 오케스트레이션 · 컨텍스트 절약 전략 · Claude Code CLI 배포 실전에 대한 재설명 없이 실전 코드부터 진행하므로 따라가기 어렵습니다.


우리 DryBench에서 이거 배웠잖아

DryBench ai-native #9에서 agent가 tool 호출 loop로 복잡한 태스크를 자율 처리한다는 것을, #10에서 tool 호출 결과가 컨텍스트를 잠식하는 문제와 요약·오프로드 전략을 배웠습니다. #14에서 Claude Code가 이런 원리를 CLI 실전에서 구현하는 도구라는 것을 봤습니다.

MCP는 이 원리들의 인프라 표준화입니다. 개별 프로젝트에서 tool을 매번 새로 정의하지 않고, 공용 MCP 서버를 만들어 팀 · 조직 · 오픈 커뮤니티가 함께 재사용합니다. 이 편은 그 인프라 자체를 만들고 프로덕션급으로 배포하는 실전형입니다.

하드코어 문제 정의

MCP 서버 시리즈의 실전 요구

한 바이오 연구 조직에서 여러 팀이 각자 다른 프로젝트를 진행할 때 공통 MCP 서버 세트가 있으면:

  • 재사용성: 한 팀이 만든 UniProt MCP 서버를 다른 팀이 즉시 활용.
  • 일관성: 여러 프로젝트가 같은 tool 스키마 · 에러 처리 · 로깅 정책 공유.
  • 분리된 배포: MCP 서버는 별도 프로세스라 클라이언트 앱에 크래시 격리.
  • 확장성: 오픈소스 MCP 서버 (Anthropic 공식 · 커뮤니티) 즉시 활용.
  • 보안: 인증 · rate limit · audit log 통합.
  • 관측성: latency · 성공률 · 사용량 대시보드.

이 편의 목표

  • 5개 MCP 서버 완결 구현: PubMed · UniProt · PDB · ChEMBL · BLAST.
  • 각 서버 4~6개 tool 노출.
  • 공통 base class: rate limit · retry · logging · 에러 처리 재사용.
  • Docker 컨테이너화: 각 서버 독립 이미지, docker-compose로 통합 실행.
  • SSE transport 옵션: stdio 기본 + SSE 원격 배포 지원.
  • Anthropic Skills 등록: Claude Desktop · Claude Code CLI에서 즉시 활성화.
  • 테스트 자동화: 각 tool에 대한 unit test + integration test.
  • 관측 인프라: Prometheus 메트릭 · structured log · 대시보드.
  • 문서화: README · API reference · 사용 예시.

도구 스택과 인프라 요구

도구역할라이선스
mcp Python SDKMCP 서버 프레임워크MIT
Anthropic Claude APISkills · Agent 통합상용
Docker · docker-compose컨테이너화 · 오케스트레이션Apache 2.0
FastAPI + uvicorn (SSE transport용)원격 배포MIT · BSD
BiopythonNCBI E-utilities · PDB 파싱Biopython License
requests · aiohttp각 REST API 클라이언트Apache 2.0 · MIT
pytest · pytest-asyncio테스트 프레임워크MIT
structlog · rich로깅 · CLI 출력Apache 2.0 · MIT
Prometheus client · Grafana관측성Apache 2.0 · AGPL

인프라 요구:

  • No GPU. 각 서버는 CPU · 네트워크 중심.
  • 로컬 배포: Docker Desktop 또는 Docker Engine.
  • 원격 배포 (선택): 소형 VPS 또는 Cloudflare Workers · AWS Lambda · Kubernetes.

학습자 재현 예상 비용: 로컬 배포 무료. 원격 VPS 월 5~10 USD. 각 공개 API(PubMed · UniProt · PDB · ChEMBL · BLAST) 무료.

파이프라인 실전 구현

전체 아키텍처:

mermaid

Step 1. 공통 base class (재사용 프레임워크)

모든 MCP 서버가 공유하는 rate limit · retry · logging · 에러 처리 · 관측성.

python
"""bio_mcp_base.py — 모든 바이오 MCP 서버의 공통 base class."""
import asyncio
import json
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import wraps
from typing import Any, Callable
import requests
import structlog
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from prometheus_client import Counter, Histogram, start_http_server
log = structlog.get_logger()
@dataclass
class RateLimitConfig:
"""Rate limit 정책."""
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
# Prometheus 메트릭 (모든 서버 공유)
TOOL_CALLS = Counter(
"mcp_tool_calls_total",
"MCP tool 호출 수",
["server", "tool", "status"],
)
TOOL_LATENCY = Histogram(
"mcp_tool_latency_seconds",
"MCP tool latency (초)",
["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):
"""tool 호출 latency · 성공률 로깅 decorator."""
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):
"""모든 바이오 MCP 서버의 base class."""
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 # 포트 이미 사용 중이면 스킵
self._register_handlers()
def _register_handlers(self):
"""MCP 표준 handler 등록."""
@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"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]:
"""이 서버가 노출하는 tool 리스트."""
...
@abstractmethod
async def dispatch_tool(self, name: str, arguments: dict) -> Any:
"""tool 이름 dispatch."""
...
async def http_get_with_retry(
self,
url: str,
params: dict | None = None,
timeout: int = 30,
) -> requests.Response:
"""Rate limit + retry가 적용된 GET 요청."""
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"최대 재시도 초과: {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())

Step 2. PubMed MCP 서버 (편 14 확장)

편 14의 개념을 더 상세하게. 인용 · 관련 논문 · 저자 검색까지.

python
"""pubmed_mcp.py — PubMed E-utilities MCP 서버 (프로덕션급)."""
import asyncio
import os
from xml.etree import ElementTree as ET
from typing import Any
from mcp.types import Tool
from 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):
# NCBI API key가 있으면 초당 10 req 가능, 없으면 3 req.
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="PubMed 검색어로 논문 PMID 리스트 반환. 날짜 범위·정렬 지정 가능.",
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="PMID 리스트로 초록·저자·저널·DOI 상세 조회.",
inputSchema={
"type": "object",
"properties": {"pmids": {"type": "array", "items": {"type": "string"}}},
"required": ["pmids"],
},
),
Tool(
name="find_related",
description="PMID의 관련 논문 (PubMed similar articles) 조회.",
inputSchema={
"type": "object",
"properties": {"pmid": {"type": "string"}, "top_k": {"type": "integer", "default": 10}},
"required": ["pmid"],
},
),
Tool(
name="get_citations",
description="PMID를 인용한 논문 리스트 (PubMed Central 기반).",
inputSchema={
"type": "object",
"properties": {"pmid": {"type": "string"}},
"required": ["pmid"],
},
),
Tool(
name="search_by_author",
description="특정 저자의 최근 논문 검색.",
inputSchema={
"type": "object",
"properties": {
"author_name": {"type": "string", "description": "예: '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())

Step 3~6. 다른 4개 MCP 서버 (요약)

동일 base class 패턴으로 구현. 각 서버는 별도 파일 · 별도 Docker 이미지.

python
"""uniprot_mcp.py — UniProt REST MCP 서버."""
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/*.
Rate limit: 없음 (권장 초당 20 이하).
"""
# 편 14 UniProt 예시 확장 (feature · ortholog · domain 추가)
# base class 상속 · dispatch 정의
pass
"""pdb_mcp.py — RCSB PDB API MCP 서버."""
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/.
Rate limit: 없음 (권장 초당 10 이하).
"""
# (편 14 유사 패턴 · CIF · PDB 파일 fetch 등)
pass
"""chembl_mcp.py — ChEMBL REST API MCP 서버."""
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은 약물 · 활성 · 표적 데이터의 표준 오픈 소스.
"""
pass
"""blast_mcp.py — NCBI BLAST QBlast MCP 서버."""
class BLASTMCPServer(BioMCPServerBase):
"""tools: submit_blast · poll_blast · get_hits · quick_blast (submit+poll+get 통합).
QBlast는 async 폴링 필요. 리소스 큰 태스크라 rate limit 엄격 (초당 1).
"""
pass

각 서버 개별 리포지토리로 분리 관리하면 팀 협업 · 릴리스 관리 용이.

Step 7. Docker 컨테이너화

각 MCP 서버를 독립 Docker 이미지로.

dockerfile
# Dockerfile.pubmed_mcp
FROM python:3.11-slim

WORKDIR /app

# 시스템 dependency
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

# 소스
COPY bio_mcp_base.py pubmed_mcp.py .

# Prometheus 메트릭 포트 노출
EXPOSE 9090

# stdin/stdout MCP 통신
CMD ["python", "pubmed_mcp.py"]

# Health check (선택)
HEALTHCHECK --interval=30s --timeout=10s \
  CMD curl -f http://localhost:9090/metrics || exit 1
yaml
# 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"  # 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

  # 관측성 스택
  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:

주의: MCP는 기본 stdio 통신이라 Docker에서 활용은 docker run -i 또는 stdio-over-network wrapper (예: mcp-proxy) 필요. 최신 MCP SDK는 SSE (Server-Sent Events) transport도 지원해 원격 배포 용이 (아래 Step 8).

Step 8. SSE Transport (원격 배포용)

stdio는 로컬 subprocess만. 원격 서버는 SSE 필요.

python
"""sse_transport_wrapper.py — FastAPI + SSE로 MCP 서버 원격 노출."""
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from mcp.server.sse import SseServerTransport
async def sse_endpoint_factory(mcp_server_instance):
"""MCP 서버를 SSE endpoint로 노출."""
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
# 실행 예 (원격 배포)
# 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)

Step 9. Anthropic Skills 등록

Claude Skills로 이 서버 세트를 하나의 재사용 skill로 패키징 [1].

yaml
# skills/bio-research/skill.yaml
name: bio-research
version: 1.0.0
description: |
  생명정보학 리서치 자율 에이전트.
  PubMed · UniProt · PDB · ChEMBL · BLAST 5개 서비스 통합.
  Claude가 자율적으로 tool 오케스트레이션.
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: |
  당신은 생명정보학 리서치 어시스턴트입니다.
  주어진 태스크에 필요한 MCP tool을 자율적으로 호출해 증거 기반으로 답변합니다.

  원칙:
  1. 사실 창작 X. 모든 주장은 tool 호출 결과로 뒷받침.
  2. 답변에 출처 (PMID · UniProt accession · PDB ID · ChEMBL ID) 병기.
  3. tool 호출 실패 시 대안 경로 시도 + 실패 보고.
  4. 컨텍스트 절약: 불필요한 tool 호출 최소화.
  5. rate limit 시 backoff 자동, 사용자에게 대기 안내.

capabilities:
  - biological literature search
  - protein sequence and structure retrieval
  - chemical compound and drug activity lookup
  - sequence similarity search (BLAST)
  - cross-reference between databases

examples:
  - "human BRCA1의 최근 5년 논문 요약, 관련 3D 구조·리간드까지"
  - "특정 SMILES가 결합할 가능성 있는 표적 단백질 후보 리스트"
  - "이 유전자와 관련된 pathway · disease · 최신 임상 시험"

Step 10. 테스트 자동화

각 MCP 서버 tool에 대한 pytest 통합 테스트.

python
"""tests/test_pubmed_mcp.py — PubMed MCP 통합 테스트."""
import pytest
import time
from pubmed_mcp import PubMedMCPServer
@pytest.fixture
def server():
return PubMedMCPServer()
@pytest.mark.asyncio
async 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.asyncio
async def test_fetch_abstracts_valid_pmid(server):
results = await server._fetch(["33746851"]) # 알려진 유효 PMID
assert len(results) == 1
assert results[0]["title"]
assert results[0]["abstract"]
@pytest.mark.asyncio
async def test_fetch_abstracts_empty_list(server):
results = await server._fetch([])
assert results == []
@pytest.mark.asyncio
async 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.asyncio
async def test_search_by_author(server):
result = await server._search_by_author("Doudna JA", max_results=5)
assert result["count"] > 0
@pytest.mark.asyncio
async def test_rate_limit_respected(server):
"""빠른 연속 요청 시 rate limit이 대기를 강제."""
start = time.time()
for _ in range(5):
await server._search("test", max_results=1)
elapsed = time.time() - start
# NCBI 정책 3 req/s → 5개는 최소 ~1.3초 (no api_key 가정)
assert elapsed >= 1.0, f"Rate limit 미준수: {elapsed}"
@pytest.mark.asyncio
async def test_dispatch_tool_unknown(server):
with pytest.raises(ValueError, match="Unknown tool"):
await server.dispatch_tool("nonexistent_tool", {})
# CI 통합
# pytest tests/ -v --asyncio-mode=auto

Step 11. 관측 대시보드 (Grafana)

Prometheus 메트릭을 Grafana로 시각화.

yaml
# 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'

Grafana 대시보드 예시 지표:

  • 각 서버 tool별 latency p50 · p95 · p99
  • 성공률 (성공 / 총)
  • 시간별 호출량 (rate)
  • 에러 유형 breakdown (429 rate limit · 5xx · 파싱 실패)

성능·비용·알려진 실패 케이스

성능 참고 (공개 근거 인용)

각 API 표준 성능 · 제약:

APIRate Limit평균 응답인증
PubMed E-utilities3 req/s (no key), 10 req/s (key) [2]200~800ms무료 API key
UniProt REST없음 (권장 초당 20 이하)100~500ms없음
RCSB PDB없음 (권장 초당 10 이하)200~1000ms없음
ChEMBL REST없음 (권장 초당 10 이하)300~1000ms없음
NCBI BLAST QBlast초당 1~3 [3]30초~5분 (async)없음

학습자 재현 예상 비용

  • 로컬 실행 무료.
  • 원격 배포: 소형 VPS 월 5~20 USD.
  • Claude API: Skill 활성 세션당 5~20 USD.

알려진 실패 케이스 5건 (커뮤니티·논문 수집형)

  1. Docker stdio MCP 통신 실패 (특히 Windows)
    증상: Windows Docker Desktop에서 MCP 서버 컨테이너의 stdio가 부분적으로 buffered 되어 통신 실패.
    원인: Docker for Windows의 stdio 처리 특이성 (Winpty · MSYS 등 layer).
    회피: (a) SSE transport로 전환 (Step 8), (b) Docker Compose 대신 native Python subprocess로 로컬 실행, (c) WSL2 안에서 실행, (d) docker exec -it 대신 docker attach.
    출처: MCP GitHub Issues "Windows Docker stdio buffering" [4].

  2. 여러 MCP 서버 tool 이름 충돌
    증상: PubMed MCP의 search와 UniProt MCP의 search 이름 충돌 → 클라이언트가 dispatch 실패.
    원인: MCP 스펙 상 tool 이름 유일성은 서버 내부에서만 보장.
    회피: 편 14와 동일. 클라이언트에서 {server_name}__{tool_name} prefix 강제, 또는 서버 이름을 tool prefix로 고정 (본 편 코드: pubmed-mcp server).
    출처: MCP Discussions [5].

  3. API key 관리 (환경변수 vs vault)
    증상: Docker 이미지에 API key 하드코딩 → 이미지 배포 시 유출.
    원인: Dockerfile ENV 지시자에 secret 넣는 안티패턴.
    회피: (a) docker-compose에서 ${NCBI_API_KEY} env 변수 사용 (본 편 예시), (b) Docker Swarm secrets 또는 Kubernetes Secrets, (c) HashiCorp Vault · AWS Secrets Manager 등 secret 관리 통합, (d) .env 파일은 .gitignore 필수.
    출처: Docker best practices [6].

  4. BLAST QBlast timeout 및 재시도 policy
    증상: NCBI BLAST QBlast는 5분 이상 걸릴 수 있어 MCP tool timeout 발생.
    원인: BLAST는 리소스 큰 태스크. 서버 load에 따라 응답 지연.
    회피: (a) MCP tool timeout을 넉넉히 (10분+), (b) async 폴링 패턴 (submit → RID 반환 → poll), (c) BLAST 로컬 실행 대안 (blastn/blastp CLI + 로컬 게놈 인덱스), (d) 큰 검색은 UniProt search로 대체 검토.
    출처: NCBI BLAST usage guidelines [3].

  5. 관측 메트릭 누락으로 문제 진단 어려움
    증상: 프로덕션에서 특정 tool 실패율이 급증했는데 원인 로그 부족.
    원인: 초기 배포에 Prometheus · structured log · alerting 미비.
    회피: (a) 본 편 base class처럼 @instrument decorator 필수 (Step 1), (b) Grafana 대시보드 사전 구축 (Step 11), (c) Alertmanager로 실패율 임계 초과 시 자동 알림, (d) audit log로 tool 호출 이력 저장 (규제 대응).
    출처: Prometheus best practices · SRE 방법론 [7].

확장 아이디어

  • 커스텀 MCP 서버 확장: GEO/SRA (transcriptomics · sequencing) · Ensembl (variant · gene · comparative genomics) · STRING (PPI network) · Reactome (pathway) · KEGG (metabolism).
  • 다중 에이전트 협업: 리서치 에이전트 + 시각화 에이전트 + 리포트 작성 에이전트를 별도 세션으로 분리하고 MCP로 결과 공유.
  • 원격 MCP 서버 배포: Cloudflare Workers · AWS Lambda · Google Cloud Run · Kubernetes 위에 MCP 서버 상시 가동해 여러 사용자 공유.
  • Wet-lab 통합: 실험 기기 API(예: liquid handler robot · plate reader · flow cytometer)를 MCP tool로 노출해 실험 계획 자동 실행.
  • 인증·권한 관리: OAuth2 · JWT · Role-Based Access Control (RBAC)로 각 tool 접근 통제.
  • 캐싱 layer: Redis · Memcached로 자주 조회되는 결과 (PubMed 검색 등) 캐싱 → latency · 비용 절약.

다음 편

  • 편 14 bio-mcp-agent: 이 편의 서버 세트를 조율하는 agent 구현 (편 14 재방문).
  • 편 09 llm-vendor-benchmark: MCP tool 사용 능력을 벤더별 비교.

참고 문헌

  1. Anthropic Skills documentation: https://docs.anthropic.com/en/docs/build-with-claude/skills
  2. NCBI E-utilities usage guidelines: https://www.ncbi.nlm.nih.gov/books/NBK25497/
  3. NCBI BLAST QBlast: https://ncbi.github.io/blast-cloud/dev/api.html
  4. MCP GitHub Issues (Windows Docker): https://github.com/modelcontextprotocol/specification/issues
  5. MCP GitHub Discussions: https://github.com/modelcontextprotocol/specification/discussions
  6. Docker best practices for secrets: https://docs.docker.com/engine/swarm/secrets/
  7. Prometheus best practices: https://prometheus.io/docs/practices/
  8. Anthropic Model Context Protocol overview: https://modelcontextprotocol.io/
  9. MCP Python SDK: https://github.com/modelcontextprotocol/python-sdk
  10. MCP TypeScript SDK: https://github.com/modelcontextprotocol/typescript-sdk
  11. MCP official server registry (커뮤니티): https://github.com/modelcontextprotocol/servers
  12. UniProt REST API: https://www.uniprot.org/help/api
  13. RCSB PDB API: https://data.rcsb.org/
  14. ChEMBL REST API: https://chembl.gitbook.io/chembl-interface-documentation/web-services
  15. MCPmed 논문: https://academic.oup.com/bib/article/27/1/bbag076/8495038
  16. structlog: https://www.structlog.org/
  17. prometheus_client Python: https://github.com/prometheus/client_python
  18. Grafana: https://grafana.com/
  19. FastAPI: https://fastapi.tiangolo.com/
  20. uvicorn: https://www.uvicorn.org/