Build a Claude agent with MCP¶
Build a small agent that uses the Claude Agent SDK with one custom MCP server. The agent will read files, query a SQLite database, and answer natural-language questions about both. Inline code; should run in ~30 minutes.
For the underlying mechanics see Agentic loops, MCP explained, and Tool use.
Architecture¶
flowchart LR
U[User question] --> A[Claude agent]
A <-->|tool calls| FS[MCP server: filesystem]
A <-->|tool calls| SQL[MCP server: sqlite custom]
A --> R[Answer + audit trail] The agent runs the Claude Agent SDK loop. The filesystem MCP server is off-the-shelf. The sqlite MCP server is custom and demonstrates how to write your own.
Prerequisites¶
- Python 3.11+
- Node.js 20+ (for the official filesystem MCP server)
- An Anthropic API key
- ~30 minutes
Step 1: Project layout¶
mkdir mcp-agent && cd mcp-agent
mkdir data
sqlite3 data/orders.db <<'SQL'
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL, status TEXT);
INSERT INTO orders VALUES (1, 'alice', 199.50, 'shipped');
INSERT INTO orders VALUES (2, 'bob', 45.00, 'pending');
INSERT INTO orders VALUES (3, 'alice', 89.95, 'shipped');
INSERT INTO orders VALUES (4, 'carol', 320.00, 'cancelled');
SQL
Step 2: Write a custom MCP server (sqlite query tool)¶
Save as sqlite_mcp.py:
"""A minimal MCP server exposing a SQLite query tool over stdio."""
import sqlite3
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("sqlite-orders")
DB_PATH = "data/orders.db"
@mcp.tool()
def list_tables() -> str:
"""List all tables in the orders database."""
with sqlite3.connect(DB_PATH) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
return "\n".join(r[0] for r in rows)
@mcp.tool()
def describe_table(table: str) -> str:
"""Return the schema for a given table."""
with sqlite3.connect(DB_PATH) as conn:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return "\n".join(f"{r[1]} {r[2]}" for r in rows)
@mcp.tool()
def query(sql: str) -> str:
"""Run a read-only SQL query and return up to 50 rows as text.
Reject anything that isn't a SELECT to keep the model from being
able to mutate the database.
"""
if not sql.strip().lower().startswith("select"):
return "ERROR: only SELECT queries are allowed."
with sqlite3.connect(DB_PATH) as conn:
try:
rows = conn.execute(sql).fetchmany(50)
except sqlite3.Error as e:
return f"ERROR: {e}"
cols = [d[0] for d in conn.execute(sql).description]
header = " | ".join(cols)
body = "\n".join(" | ".join(str(c) for c in r) for r in rows)
return f"{header}\n{body}"
if __name__ == "__main__":
mcp.run()
Install the MCP Python lib:
pip install "mcp[cli]"
Test the server alone:
mcp dev sqlite_mcp.py
# In the inspector UI, try: list_tables, describe_table('orders'), query('SELECT * FROM orders')
Step 3: Configure the filesystem MCP server¶
The official filesystem MCP server runs via npx. We'll point it at the data/ dir:
# No installation needed - npx will fetch on first use:
npx -y @modelcontextprotocol/server-filesystem ./data
You don't need to actually start it now - the agent will spawn it.
Step 4: Write the agent¶
Save as agent.py:
import os
import asyncio
from anthropic import Anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
SYSTEM = """You are a helpful data assistant.
You have access to two tool servers:
1. sqlite-orders: query a SQLite database of customer orders.
2. filesystem: read files from a sandboxed data directory.
Strategy:
- For questions about orders, use the sqlite tools.
- For questions about files in data/, use the filesystem tools.
- If unsure, list tables / list files first.
- Always cite your evidence.
"""
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
async def collect_tools(session: ClientSession):
tools = await session.list_tools()
return [{
"name": t.name,
"description": t.description or "",
"input_schema": t.inputSchema,
} for t in tools.tools]
async def run(question: str, max_steps: int = 10):
sqlite_params = StdioServerParameters(command="python", args=["sqlite_mcp.py"])
fs_params = StdioServerParameters(
command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "./data"]
)
async with stdio_client(sqlite_params) as (sqlite_r, sqlite_w), \
ClientSession(sqlite_r, sqlite_w) as sqlite_sess, \
stdio_client(fs_params) as (fs_r, fs_w), \
ClientSession(fs_r, fs_w) as fs_sess:
await sqlite_sess.initialize()
await fs_sess.initialize()
tools_sqlite = await collect_tools(sqlite_sess)
tools_fs = await collect_tools(fs_sess)
# Tag each tool with the server it lives on so we can dispatch later.
all_tools = (
[{**t, "_server": "sqlite"} for t in tools_sqlite] +
[{**t, "_server": "fs"} for t in tools_fs]
)
servers = {"sqlite": sqlite_sess, "fs": fs_sess}
# Strip _server before sending to Claude
api_tools = [{k: v for k, v in t.items() if not k.startswith("_")} for t in all_tools]
messages = [{"role": "user", "content": question}]
for step in range(max_steps):
resp = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
system=SYSTEM,
tools=api_tools,
messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason == "end_turn":
# Final text answer
for block in resp.content:
if hasattr(block, "text"):
print(block.text)
return
if resp.stop_reason == "tool_use":
tool_results = []
for block in resp.content:
if block.type == "tool_use":
# Find which server owns this tool
owner = next(t["_server"] for t in all_tools if t["name"] == block.name)
print(f"[tool] {owner}::{block.name}({block.input})")
result = await servers[owner].call_tool(block.name, block.input)
text = "\n".join(
c.text for c in result.content if hasattr(c, "text")
)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": text,
})
messages.append({"role": "user", "content": tool_results})
continue
print(f"Unexpected stop reason: {resp.stop_reason}")
return
print("Hit max_steps without finishing.")
if __name__ == "__main__":
import sys
question = " ".join(sys.argv[1:]) or "Who has spent the most across all orders, and what's their total?"
asyncio.run(run(question))
Step 5: Run it¶
export ANTHROPIC_API_KEY=sk-ant-...
python agent.py "Who has spent the most across all orders, and what's their total?"
Expected (approximate) output:
[tool] sqlite::list_tables({})
[tool] sqlite::describe_table({'table': 'orders'})
[tool] sqlite::query({'sql': 'SELECT customer, SUM(amount) ... GROUP BY customer ORDER BY ...'})
Alice has spent the most, with a total of $289.45 across two shipped orders.
The model navigated from "what tables exist" to "what columns" to "the right query" without you hand-coding the path.
Verification¶
You know it worked when:
- The agent prints
[tool]lines showing it called sqlite tools (and possibly filesystem tools) - A correct natural-language answer comes out at the end
- The final answer can be traced back to the tool calls in the log
Extensions¶
- Add LLM observability for production tracing
- Add a confirmation step before destructive tools (none in this demo, but you'd want it)
- Replace stdio transport with HTTP for a fleet of agents sharing servers
- Wrap your RAG pipeline as an MCP server and let this agent use it
- Use the Claude Agent SDK directly instead of rolling the loop yourself - it brings persistence, hooks, and richer permissioning
Cross-references¶
- Concepts: Agentic loops, MCP explained, Tool use, Structured outputs
- Topic: LLMs and GenAI
- Comparisons: Agent frameworks, GenAI platforms
- Anthropic certs: Architect Foundations, Developer Foundations