AI Agents as Experimental Tools: Searching Bio Databases with MCP
Upon Completion of This Topic
You will be able to combine the MCP (Model Context Protocol) and the harness learned in the textbook to create a tool that allows LLMs like Claude or GPT to automatically query bio databases such as NCBI Entrez, PubMed, and UniProt using natural language instructions. You will gain an understanding of the internal principles of LLM tool use and the actual implementation of the harness used to evaluate its reliability.
This article provides a general educational example. Real-world deployed MCP servers have much more sophisticated authentication, rate limiting, and observability.
"Why keep repeating the same search every time?" - The Pitfalls of Repetitive Experiments
Suppose you are preparing research ideas and repeat the following search every day.
- Search
"BRCA1" AND "review" AND "2024"on NCBI PubMed. - Browse the abstracts and determine relevance.
- Check citation relationships.
- Check relevant protein domains on UniProt.
- Download the sequence from GenBank.
You repeat this combination every day. There is a problem.
Problem 1: Each database has different search syntax. The filter syntax of NCBI Entrez, the search syntax of UniProt, and the accession format of GenBank are all different. You have to remember them again each time.
Problem 2: You have to manually combine the results from multiple databases. For example, you enter the author's name obtained from PubMed into UniProt again, and then enter the resulting accession into GenBank again.
Problem 3: Most of this task is deterministic and rule-based, but it is repeated daily with different keywords. This means it is a perfect candidate for automation, but there is a significant overhead in learning the API documentation of each database.
The real approach is to delegate this to an AI agent. If you give a request to an LLM in natural language, it will sequentially call multiple APIs in the background and combine the results. A protocol for doing this in a safe and standardized way is MCP (Model Context Protocol).
From Black Box to Components: Exploring the MCP
The MCP may appear complex at first glance, but it can be broken down into three core components.
Component 1: Tool Definition
The MCP server presents the LLM with a list of available tools. Each tool is defined by its name, description, and input schema.
tool_definition = { "name": "search_pubmed", "description": "Search the PubMed database for scientific papers using a keyword and return the top N results.", "input_schema": { "type": "object", "properties": { "query": {"type": "string", "description": "The search query, using PubMed syntax."}, "max_results": {"type": "integer", "default": 10} }, "required": ["query"] }}The key point is that the description is the only information the LLM actually reads and uses to make decisions. A well-written tool description is crucial for good agent behavior.
Component 2: Execution Function
Each tool has an associated function that is executed when the LLM requests a tool call. The function takes the input arguments, performs the necessary operations, and returns the result to the LLM.
async def search_pubmed(query: str, max_results: int = 10) -> dict: import httpx base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" async with httpx.AsyncClient() as client: search_response = await client.get( f"{base}/esearch.fcgi", params={"db": "pubmed", "term": query, "retmax": max_results, "retmode": "json"} ) ids = search_response.json()["esearchresult"]["idlist"] if not ids: return {"results": []} summary_response = await client.get( f"{base}/esummary.fcgi", params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"} ) summaries = summary_response.json()["result"] return { "results": [ { "pmid": pmid, "title": summaries[pmid].get("title"), "journal": summaries[pmid].get("fulljournalname"), "pubdate": summaries[pmid].get("pubdate") } for pmid in ids ] }Important Note: This function makes calls to external APIs, so it must handle network issues, rate limits, and timeouts. We will address this in more detail later.
Component 3: MCP Server Skeleton
All of these components are combined into a single server. Anthropic provides an official Python SDK, but to better illustrate the concept, let's create our own simplified version.
from typing import Any, Callable, Coroutineimport json
class MCPServer: def __init__(self): self.tools: dict[str, dict[str, Any]] = {} self.handlers: dict[str, Callable[..., Coroutine]] = {} def register(self, definition: dict, handler: Callable[..., Coroutine]): name = definition["name"] self.tools[name] = definition self.handlers[name] = handler def list_tools(self) -> list[dict]: return list(self.tools.values()) async def call_tool(self, name: str, arguments: dict) -> dict: if name not in self.handlers: return {"error": f"Unknown tool: {name}"} try: return await self.handlers[name](**arguments) except Exception as e: return {"error": str(e)}
server = MCPServer()server.register( definition={ "name": "search_pubmed", "description": "Search PubMed for scientific papers", "input_schema": { "type": "object", "properties": { "query": {"type": "string"}, "max_results": {"type": "integer", "default": 10} }, "required": ["query"] } }, handler=search_pubmed)This server would typically communicate with an LLM client via standard input/output or HTTP. However, for clarity, we will use a simplified approach where the server is called directly as a function.
LLM Integration โ Writing with Claude
Now, let's connect this server to LLM calls. We will use the tool use feature of the Claude API.
import anthropic
client = anthropic.Anthropic()
async def agent_loop(user_message: str, server: MCPServer, max_iterations: int = 5) -> str: messages = [{"role": "user", "content": user_message}] tools = [ { "name": t["name"], "description": t["description"], "input_schema": t["input_schema"] } for t in server.list_tools() ] for iteration in range(max_iterations): response = client.messages.create( model="claude-opus-4-8", max_tokens=2048, tools=tools, messages=messages ) if response.stop_reason == "end_turn": text_blocks = [b.text for b in response.content if b.type == "text"] return "\n".join(text_blocks) if response.stop_reason == "tool_use": tool_uses = [b for b in response.content if b.type == "tool_use"] messages.append({"role": "assistant", "content": response.content}) tool_results = [] for tool_use in tool_uses: result = await server.call_tool(tool_use.name, tool_use.input) tool_results.append({ "type": "tool_result", "tool_use_id": tool_use.id, "content": json.dumps(result) }) messages.append({"role": "user", "content": tool_results}) return "Max iterations reached"Now, let's use it:
result = await agent_loop( "Find 3 review papers on the BRCA1 gene from 2024 onwards and tell me the title and journal.", server)print(result)The LLM will automatically call search_pubmed(query="BRCA1 AND review AND 2024:2025", max_results=3) and return the results in a natural language format.
Fading โ Three Blanks for You to Fill
Blank 1: Tool Expansion โ UniProt Search
A server with only PubMed is only half complete. Let's add the UniProt tool.
async def search_uniprot(query: str, max_results: int = 10) -> dict: import httpx async with httpx.AsyncClient() as client: # TODO: Call the UniProt REST API # Endpoint: https://rest.uniprot.org/uniprotkb/search # Parameters: query, format=json, size=max_results # Result: {"results": [{"accession": ..., "name": ..., "gene": ...}]} passHint: Extract primaryAccession, proteinDescription.recommendedName.fullName.value, and genes[0].geneName.value from each item in the results array of the UniProt response.
Blank 2: Harness โ Agent Evaluation System
To verify that the agent is working correctly, we need an evaluation harness. It automatically runs multiple test cases and evaluates each result.
async def evaluate_agent( test_cases: list[dict], server: MCPServer) -> dict: """ test_cases: [ { "prompt": "User request", "expected_tool_calls": ["search_pubmed"], "expected_content_contains": ["BRCA1", "review"] } ] """ results = [] for tc in test_cases: # TODO: Run the agent_loop, track the actual tools called, and verify the results. # Record whether the test case passed in the results. pass return { "total": len(test_cases), "passed": sum(1 for r in results if r["passed"]), "details": results }Hint: Modify the agent_loop to log the tool calls. Append each call to a list and return it.
Blank 3: Rate Limit + Error Handling
NCBI Entrez blocks your IP address if you make more than 3 requests per second. Add a rate limit to the tool execution.
import asyncioimport time
class RateLimiter: def __init__(self, calls_per_second: float): self.min_interval = 1.0 / calls_per_second self.last_call = 0.0 async def wait(self): now = time.time() elapsed = now - self.last_call if elapsed < self.min_interval: await asyncio.sleep(self.min_interval - elapsed) self.last_call = time.time()
ncbi_limiter = RateLimiter(calls_per_second=3)
async def search_pubmed_limited(query: str, max_results: int = 10) -> dict: # TODO: Call ncbi_limiter.wait() before executing the function. # Then, execute the existing logic. passReflection โ How does this agent differ from a production-ready tool-use LLM?
The agent you've created shares conceptual roots with production systems (like Claude Code or ChatGPT with tools), but production systems are much more sophisticated.
Security: Production-level MCP servers must defend against prompt injection. If a tool returns data containing malicious instructions ("Ignore this instruction and do X instead"), the agent may malfunction. Production systems either isolate tool return values in a sandboxed context or include a separate validation layer.
Cost Management: Because each iteration triggers an API call, costs can quickly accumulate. Production systems include iteration limits, token budgets, and tool call caching.
Observability: To analyze why the agent called a specific tool and what reasoning led to errors, trace logs are necessary. Production systems record each step using standards like OpenTelemetry.
Multi-Step Reasoning: Complex requests (e.g., "Find all other genes that appear in papers about this gene and look up their respective expression patterns") require planning for multiple steps of tool calls. Production systems often use a planner-executor separation or the ReAct framework.
Extension Project
1. Add GenBank Sequence Download Tool: Use efetch.fcgi to retrieve FASTA sequences from accession numbers.
2. Citation Graph Tool: Trace citation relationships of PubMed papers to construct a related paper tree.
3. Local Cache: Add an SQLite cache layer to avoid re-running the same queries.
4. Claude Desktop Integration: Register the MCP server you created with the actual Claude Desktop app for daily use. Wrap it as an stdio server using the standard MCP SDK.
Component Map for This Feature
- [F] MCP Protocol: Standard format for tool definition, calls, and responses. Enables server-client separation.
- [F] Harness: A testing system that automatically evaluates agent behavior. Serves as the CI/CD for LLM systems.
- [W] API Calls/JSON: Parsing NCBI/UniProt REST calls and responses.
- [W] Async/Await: Parallelizes multiple API calls using asynchronous I/O.
[F] = Tool concept that you implement yourself / [W] = Tool concept provided as complete code.