Custom MCP Server Series — Wrapping 5 Bio Web Services in a Standard Protocol for Team Deployment
In Topic 14 we saw the basic Anthropic Model Context Protocol (MCP) specification and the pipeline in which a Bio-LLM Agent autonomously coordinates multiple MCP servers. This article is an extension of that. It is a hardcore infrastructure article that implements each of 5 bio web services required in real R&D (PubMed · UniProt · PDB · ChEMBL · BLAST) as a complete MCP server and covers Docker containerization · SSE (Server-Sent Events) transport · authentication · rate limit · error handling · observability logging · Anthropic Skills registration · team deployment. This is the final extension and infrastructure peak of the track.
📚 Prerequisite (Strong Recommendation)
This is the infrastructure peak of the AI×Bio hardcore in-depth track. Before entering, we strongly recommend that you first study the following DryBench topics.
- DryBench ai-native #9 Agents and Tool Use
- DryBench ai-native #10 Context Window Management
- DryBench ai-native #14 Claude Code and Cursor
Without the prerequisites, this article proceeds directly from real-world code without re-explaining agent tool orchestration, context-saving strategies, or the practice of deploying Claude Code CLI, so it will be difficult to follow.
What We Learned in DryBench
In DryBench ai-native #9 we learned that agents autonomously handle complex tasks with a tool-calling loop. In #10 we learned the problem of tool-call results eating context and the strategies of summarization/off-loading. In #14 we saw that Claude Code is the tool that implements these principles in CLI practice.
MCP is the infrastructure standardization of these principles. Instead of defining tools anew in each project, we build a shared MCP server so that teams · organizations · open communities can reuse them together. This article is a practical build-out of that infrastructure itself, deploying it at production grade.
Hardcore Problem Definition
Real-world Requirements for an MCP Server Series
When multiple teams in a bio research organization run different projects, a shared MCP server set brings:
- Reusability: A UniProt MCP server built by one team is immediately usable by others.
- Consistency: Multiple projects share the same tool schema · error handling · logging policy.
- Isolated deployment: MCP servers are separate processes, so crashes are isolated from client apps.
- Extensibility: Immediate use of open-source MCP servers (Anthropic official · community).
- Security: Integrated authentication · rate limit · audit log.
- Observability: Latency · success rate · usage dashboards.
Goals of This Article
- Complete implementation of 5 MCP servers: PubMed · UniProt · PDB · ChEMBL · BLAST.
- Expose 4–6 tools per server.
- Common base class: Reuse rate limit · retry · logging · error handling.
- Docker containerization: Independent images per server, integrated execution via docker-compose.
- SSE transport option: stdio default + SSE remote deployment support.
- Anthropic Skills registration: Immediate activation in Claude Desktop · Claude Code CLI.
- Test automation: unit test + integration test per tool.
- Observability infrastructure: Prometheus metrics · structured log · dashboards.
- Documentation: README · API reference · usage examples.
Tool Stack and Infrastructure Requirements
| Tool | Role | License |
|---|---|---|
mcp Python SDK | MCP server framework | MIT |
| Anthropic Claude API | Skills · Agent integration | Commercial |
| Docker · docker-compose | Containerization · orchestration | Apache 2.0 |
| FastAPI + uvicorn (for SSE transport) | Remote deployment | MIT · BSD |
| Biopython | NCBI E-utilities · PDB parsing | Biopython License |
| requests · aiohttp | Each REST API client | Apache 2.0 · MIT |
| pytest · pytest-asyncio | Test framework | MIT |
| structlog · rich | Logging · CLI output | Apache 2.0 · MIT |
| Prometheus client · Grafana | Observability | Apache 2.0 · AGPL |
Infrastructure requirements:
- No GPU. Each server is CPU · network centric.
- Local deployment: Docker Desktop or Docker Engine.
- Remote deployment (optional): small VPS or Cloudflare Workers · AWS Lambda · Kubernetes.
Estimated learner reproduction cost: Local deployment free. Remote VPS 5–10 USD/month. Each public API (PubMed · UniProt · PDB · ChEMBL · BLAST) free.
Pipeline Real-World Implementation
Overall architecture:
Step 1. Common Base Class (Reusable Framework)
Rate limit · retry · logging · error handling · observability shared by all MCP servers.
"""bio_mcp_base.py — Common base class for all bio MCP servers."""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: """Rate limit policy.""" 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 metrics (shared across all servers)TOOL_CALLS = Counter( "mcp_tool_calls_total", "MCP tool call count", ["server", "tool", "status"],)TOOL_LATENCY = Histogram( "mcp_tool_latency_seconds", "MCP tool latency (seconds)", ["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): """Decorator that logs tool-call latency · success rate.""" 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): """Base class for all bio MCP servers."""
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 # skip if port already in use self._register_handlers()
def _register_handlers(self): """Register MCP standard handlers.""" @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 request failed: {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]: """List of tools exposed by this server.""" ...
@abstractmethod async def dispatch_tool(self, name: str, arguments: dict) -> Any: """Dispatch by tool name.""" ...
async def http_get_with_retry( self, url: str, params: dict | None = None, timeout: int = 30, ) -> requests.Response: """GET request with rate limit + retry.""" 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"Max retries exceeded: {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 Server (Topic 14 Extended)
Extending the Topic 14 concepts in more detail. Includes citations · related articles · author search.
"""pubmed_mcp.py — PubMed E-utilities MCP server (production grade)."""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): # 10 req/s with NCBI API key, 3 req/s without. 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="Return PMID list for PubMed search terms. Date range · sort configurable.", 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="Fetch abstract · authors · journal · DOI details for a PMID list.", inputSchema={ "type": "object", "properties": {"pmids": {"type": "array", "items": {"type": "string"}}}, "required": ["pmids"], }, ), Tool( name="find_related", description="Fetch related articles (PubMed similar articles) for a PMID.", inputSchema={ "type": "object", "properties": {"pmid": {"type": "string"}, "top_k": {"type": "integer", "default": 10}}, "required": ["pmid"], }, ), Tool( name="get_citations", description="List of articles citing this PMID (based on PubMed Central).", inputSchema={ "type": "object", "properties": {"pmid": {"type": "string"}}, "required": ["pmid"], }, ), Tool( name="search_by_author", description="Search recent articles by a specific author.", inputSchema={ "type": "object", "properties": { "author_name": {"type": "string", "description": "e.g., '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())Steps 3–6. Other 4 MCP Servers (Summary)
Implement with the same base class pattern. Each server has its own file · its own Docker image.
"""uniprot_mcp.py — UniProt REST MCP server."""
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: none (recommend ≤ 20/s). """ # Extends Topic 14 UniProt example (adds feature · ortholog · domain) # Inherit base class · define dispatch pass
"""pdb_mcp.py — RCSB PDB API MCP server."""
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: none (recommend ≤ 10/s). """ # (Similar pattern to Topic 14 · CIF · PDB file fetch etc.) pass
"""chembl_mcp.py — ChEMBL REST API MCP server."""
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 is the standard open source for drug · activity · target data. """ pass
"""blast_mcp.py — NCBI BLAST QBlast MCP server."""
class BLASTMCPServer(BioMCPServerBase): """tools: submit_blast · poll_blast · get_hits · quick_blast (submit+poll+get integrated). QBlast requires async polling. It is a resource-heavy task so rate limit is strict (1/s). """ passManaging each server as an individual repository makes team collaboration · release management easier.
Step 7. Docker Containerization
Independent Docker image per MCP server.
# Dockerfile.pubmed_mcp
FROM python:3.11-slim
WORKDIR /app
# System dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Source
COPY bio_mcp_base.py pubmed_mcp.py .
# Expose Prometheus metrics port
EXPOSE 9090
# MCP stdin/stdout communication
CMD ["python", "pubmed_mcp.py"]
# Health check (optional)
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" # Prometheus metrics
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
# Observability stack
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:Note: MCP uses stdio communication by default, so using it in Docker requires docker run -i or a stdio-over-network wrapper (e.g. mcp-proxy). The latest MCP SDK also supports SSE (Server-Sent Events) transport, easing remote deployment (Step 8 below).
Step 8. SSE Transport (For Remote Deployment)
stdio is only for local subprocess. Remote servers need SSE.
"""sse_transport_wrapper.py — Expose an MCP server remotely with FastAPI + SSE."""from fastapi import FastAPI, Requestfrom fastapi.responses import StreamingResponsefrom mcp.server.sse import SseServerTransport
async def sse_endpoint_factory(mcp_server_instance): """Expose an MCP server as SSE endpoints.""" 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
# Execution example (remote deployment)# 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 Registration
Package this server set as a reusable Claude Skill [1].
# skills/bio-research/skill.yaml
name: bio-research
version: 1.0.0
description: |
Autonomous bioinformatics research agent.
Integrates 5 services: PubMed · UniProt · PDB · ChEMBL · BLAST.
Claude orchestrates tools autonomously.
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: |
You are a bioinformatics research assistant.
You autonomously call the MCP tools required for the given task and answer based on evidence.
Principles:
1. Do not fabricate facts. All claims must be backed by tool call results.
2. Cite sources (PMID · UniProt accession · PDB ID · ChEMBL ID) in the answer.
3. On tool call failure, try alternative paths + report the failure.
4. Save context: minimize unnecessary tool calls.
5. On rate limits, auto backoff and inform the user of the wait.
capabilities:
- biological literature search
- protein sequence and structure retrieval
- chemical compound and drug activity lookup
- sequence similarity search (BLAST)
- cross-reference between databases
examples:
- "Summarize the last 5 years of papers on human BRCA1, and include related 3D structures and ligands."
- "List candidate target proteins that a given SMILES might bind."
- "Pathways · diseases · latest clinical trials related to this gene."Step 10. Test Automation
pytest integration tests for each MCP server tool.
"""tests/test_pubmed_mcp.py — PubMed MCP integration tests."""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"]) # known valid PMID 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): """Rate limit forces waiting on rapid consecutive requests.""" start = time.time() for _ in range(5): await server._search("test", max_results=1) elapsed = time.time() - start # NCBI policy 3 req/s → 5 requests take at least ~1.3 s (assuming no api_key) assert elapsed >= 1.0, f"Rate limit not respected: {elapsed}"
@pytest.mark.asyncioasync def test_dispatch_tool_unknown(server): with pytest.raises(ValueError, match="Unknown tool"): await server.dispatch_tool("nonexistent_tool", {})
# CI integration# pytest tests/ -v --asyncio-mode=autoStep 11. Observability Dashboard (Grafana)
Visualize Prometheus metrics in 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'Grafana dashboard example metrics:
- Per-server per-tool latency p50 · p95 · p99
- Success rate (successes / total)
- Call rate over time (rate)
- Error type breakdown (429 rate limit · 5xx · parsing failures)
Performance · Cost · Known Failure Cases
Performance Reference (Public Sources Cited)
Each API's standard performance · constraints:
| API | Rate Limit | Average Response | Auth |
|---|---|---|---|
| PubMed E-utilities | 3 req/s (no key), 10 req/s (with key) [2] | 200–800 ms | Free API key |
| UniProt REST | None (recommend ≤ 20/s) | 100–500 ms | None |
| RCSB PDB | None (recommend ≤ 10/s) | 200–1000 ms | None |
| ChEMBL REST | None (recommend ≤ 10/s) | 300–1000 ms | None |
| NCBI BLAST QBlast | 1–3 per second [3] | 30 s–5 min (async) | None |
Estimated Learner Reproduction Cost
- Local execution free.
- Remote deployment: small VPS 5–20 USD/month.
- Claude API: 5–20 USD per skill session.
5 Known Failure Cases (Collected from Community/Papers)
-
Docker stdio MCP communication failure (especially on Windows)
Symptom: On Windows Docker Desktop, the MCP server container's stdio is partially buffered, causing communication failures.
Cause: Docker for Windows' stdio handling peculiarities (Winpty · MSYS etc. layers).
Workaround: (a) Switch to SSE transport (Step 8), (b) run natively via Python subprocess instead of Docker Compose, (c) run inside WSL2, (d) usedocker attachinstead ofdocker exec -it.
Source: MCP GitHub Issues "Windows Docker stdio buffering" [4]. -
Tool name conflicts across multiple MCP servers
Symptom: PubMed MCP'ssearchand UniProt MCP'ssearchname conflict → client fails to dispatch.
Cause: The MCP spec guarantees tool name uniqueness only within a single server.
Workaround: Same as Topic 14. Enforce{server_name}__{tool_name}prefix at the client, or fix the server name as a tool prefix (in this article:pubmed-mcpserver).
Source: MCP Discussions [5]. -
API key management (env vars vs vault)
Symptom: Hardcoding an API key into a Docker image → leaks on image distribution.
Cause: Antipattern of putting secrets into Dockerfile ENV directives.
Workaround: (a) Use${NCBI_API_KEY}env variables in docker-compose (as in this article), (b) Docker Swarm secrets or Kubernetes Secrets, (c) integrate secret management such as HashiCorp Vault · AWS Secrets Manager, (d).envfiles must be.gitignore'd.
Source: Docker best practices [6]. -
BLAST QBlast timeout and retry policy
Symptom: NCBI BLAST QBlast can take 5+ minutes, causing MCP tool timeouts.
Cause: BLAST is a resource-heavy task. Response is delayed based on server load.
Workaround: (a) Set generous MCP tool timeout (10 min+), (b) async polling pattern (submit → return RID → poll), (c) local BLAST execution alternative (blastn/blastp CLI + local genome index), (d) consider replacing large searches with UniProt search.
Source: NCBI BLAST usage guidelines [3]. -
Missing observability metrics make problem diagnosis hard
Symptom: In production, a specific tool's failure rate spikes but there is insufficient log to diagnose.
Cause: Prometheus · structured log · alerting under-provisioned in initial deployment.
Workaround: (a) Require the@instrumentdecorator like the base class in this article (Step 1), (b) build the Grafana dashboard beforehand (Step 11), (c) Alertmanager for automatic alerts when failure-rate thresholds are exceeded, (d) save tool call history in audit log (regulatory compliance).
Source: Prometheus best practices · SRE methodology [7].
Extension Ideas
- Custom MCP server extensions: GEO/SRA (transcriptomics · sequencing) · Ensembl (variant · gene · comparative genomics) · STRING (PPI network) · Reactome (pathway) · KEGG (metabolism).
- Multi-agent collaboration: Separate a research agent + visualization agent + report writing agent into different sessions and share results via MCP.
- Remote MCP server deployment: Run MCP servers 24/7 on Cloudflare Workers · AWS Lambda · Google Cloud Run · Kubernetes to share across many users.
- Wet-lab integration: Expose lab-instrument APIs (e.g., liquid handler robot · plate reader · flow cytometer) as MCP tools to auto-execute experimental plans.
- Authentication · authorization: OAuth2 · JWT · Role-Based Access Control (RBAC) for tool access control.
- Caching layer: Cache frequently requested results (PubMed searches etc.) with Redis · Memcached → save latency · cost.
Next Topics
- Topic 14
bio-mcp-agent: Implement the agent that coordinates this article's server set (revisit Topic 14). - Topic 09
llm-vendor-benchmark: Compare MCP tool-use capability across vendors.
References
- Anthropic Skills documentation:
https://docs.anthropic.com/en/docs/build-with-claude/skills - NCBI E-utilities usage guidelines:
https://www.ncbi.nlm.nih.gov/books/NBK25497/ - NCBI BLAST QBlast:
https://ncbi.github.io/blast-cloud/dev/api.html - MCP GitHub Issues (Windows Docker):
https://github.com/modelcontextprotocol/specification/issues - MCP GitHub Discussions:
https://github.com/modelcontextprotocol/specification/discussions - Docker best practices for secrets:
https://docs.docker.com/engine/swarm/secrets/ - Prometheus best practices:
https://prometheus.io/docs/practices/ - Anthropic Model Context Protocol overview:
https://modelcontextprotocol.io/ - MCP Python SDK:
https://github.com/modelcontextprotocol/python-sdk - MCP TypeScript SDK:
https://github.com/modelcontextprotocol/typescript-sdk - MCP official server registry (community):
https://github.com/modelcontextprotocol/servers - UniProt REST API:
https://www.uniprot.org/help/api - RCSB PDB API:
https://data.rcsb.org/ - ChEMBL REST API:
https://chembl.gitbook.io/chembl-interface-documentation/web-services - MCPmed paper:
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/