A year ago I wrote about building a tiny AI agent that uses MCP tools. It’s still the most-read thing I’ve published.
But there was a question I kept getting, and kept dodging: “Cool, but where do the tools come from?”
Honest answer? Someone else wrote them. I plugged in a server from GitHub, it worked, I moved on.
That’s fine until you want your AI to touch your stuff. Your leads. Your client list. The messy SQLite file only you understand. Nobody has written that server for you. So last week I wrote one.
It’s about 80 lines. It runs on my laptop. And now I can open Claude and ask “who do I need to follow up with today?” and get a real answer from my real data.
Here’s exactly how I built it, so you can build yours this weekend.
I run a small marketing agency. Leads come from everywhere: cold email replies, referrals, a DM on LinkedIn at 11pm.
I tried the big CRMs. They’re built for sales teams of fifty. I’m a team of a few, and I mostly need three things: who is this, where are we, when do I ping them next.
A SQLite file does that. What it doesn’t do is talk to my AI assistant. That’s the gap MCP fills.
MCP (Model Context Protocol) is a standard way to give an AI model tools. You write a small program called a server. It says “here are my tools, here’s what each one does.” An AI app like Claude Desktop, Cursor, or your own agent connects as a client and calls them.
The nice part: you write the server once. Every MCP-aware app can use it. No custom plugin for each one.
That’s it. No magic. A server is just functions with good descriptions.
You need Python 3.10 or newer. Then:
mkdir leads-mcp && cd leads-mcppython -m venv .venv && source .venv/bin/activatepip install "mcp[cli]"
That’s the official MCP Python SDK. It ships with FastMCP, which does all the protocol work for you.
Create leads_server.py. I'll show the whole thing, then walk through the parts that matter.
import sqlite3from datetime import datefrom pathlib import Path
python
from mcp.server.fastmcp import FastMCP
DB = Path(__file__).parent / "leads.db"mcp = FastMCP("leads")
python
def db(): conn = sqlite3.connect(DB) conn.row_factory = sqlite3.Row conn.execute( """CREATE TABLE IF NOT EXISTS leads ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, company TEXT, status TEXT DEFAULT 'new', follow_up TEXT, notes TEXT DEFAULT '' )""" ) return conn
python
@mcp.tool()def add_lead(name: str, company: str = "", follow_up: str = "", notes: str = "") -> str: """Add a new lead. follow_up is a date like 2026-10-08.""" with db() as conn: cur = conn.execute( "INSERT INTO leads (name, company, follow_up, notes) VALUES (?, ?, ?, ?)", (name, company, follow_up or None, notes), ) return f"Added lead #{cur.lastrowid}: {name} ({company})"
php
@mcp.tool()def list_leads(status: str = "") -> list[dict]: """List leads. Optionally filter by status: new, contacted, won, lost.""" with db() as conn: if status: rows = conn.execute("SELECT * FROM leads WHERE status = ?", (status,)) else: rows = conn.execute("SELECT * FROM leads") return [dict(r) for r in rows]
php
@mcp.tool()def due_follow_ups() -> list[dict]: """Leads whose follow-up date is today or earlier and are still open.""" today = date.today().isoformat() with db() as conn: rows = conn.execute( "SELECT * FROM leads WHERE follow_up <= ? AND status IN ('new', 'contacted')", (today,), ) return [dict(r) for r in rows]
python
@mcp.tool()def update_lead(lead_id: int, status: str = "", follow_up: str = "", note: str = "") -> str: """Update a lead's status, follow-up date, or append a note.""" with db() as conn: lead = conn.execute("SELECT * FROM leads WHERE id = ?", (lead_id,)).fetchone() if not lead: return f"No lead with id {lead_id}" conn.execute( "UPDATE leads SET status = ?, follow_up = ?, notes = ? WHERE id = ?", ( status or lead["status"], follow_up or lead["follow_up"], (lead["notes"] + "\n" + note).strip() if note else lead["notes"], lead_id, ), ) return f"Updated lead #{lead_id}"
if __name__ == "__main__": mcp.run()
Three things are doing the heavy lifting here.
@mcp.tool() turns a function into a tool. FastMCP reads the type hints to build the input schema. lead_id: int means the model has to send a number. You don't write any JSON schema by hand.
The docstring is the prompt. This is the part I underestimated. The model never sees your code. It only sees the tool name, the arguments, and that one line of docstring. “Leads whose follow-up date is today or earlier and are still open” tells it exactly when to reach for that tool.
mcp.run() talks over stdio by default. The client starts your script as a subprocess and they chat through stdin and stdout. No ports, no server to host. (Remember this. It bites later.)
Notice I wrote due_follow_ups as its own tool instead of making the model filter list_leads itself. Small, specific tools beat one clever tool. The model makes fewer mistakes when the right answer is one call away.
This step saved me the most time. Don’t plug a fresh server straight into Claude. If something’s broken, you’ll just see the model shrug.
Run this instead:
mcp dev leads_server.py
It opens the MCP Inspector in your browser (you’ll need Node installed). You see your four tools, fill in arguments, hit run, and watch the raw response. Add a fake lead. List it. Mark it contacted. If it works here, it’ll work everywhere.
Open Claude Desktop’s config file. On a Mac it’s ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows it's %APPDATA%\Claude\claude_desktop_config.json.
Add your server:
{ "mcpServers": { "leads": { "command": "/full/path/to/leads-mcp/.venv/bin/python", "args": ["/full/path/to/leads-mcp/leads_server.py"] } }}
Use full paths for both. Claude Desktop doesn’t know about your virtual environment or your shell’s PATH. Plain python will either fail or pick the wrong one.
Restart Claude Desktop. Now try talking to it like a person:
“Add Dr. Mehta from Smile Studio, follow up next Tuesday. She asked about pricing.”
“Who do I need to follow up with today?”
“Mark lead 3 as won and add a note: signed the 3-month plan.”
The first time Claude called due_follow_ups on its own and gave me a short list with context from my notes, I laughed out loud. Eighty lines. That's all it took.
This is where it connects back to my earlier post. The server doesn’t care who’s calling it. Here’s a LangGraph-based agent using the exact same file:
pip install langchain langchain-mcp-adapters langchain-anthropic
python
import asynciofrom langchain.agents import create_agentfrom langchain_mcp_adapters.client import MultiServerMCPClient
python
async def main(): client = MultiServerMCPClient({ "leads": { "command": "python", "args": ["leads_server.py"], "transport": "stdio", } }) tools = await client.get_tools()
agent = create_agent( "anthropic:claude-sonnet-4-5", # any tool-calling model works tools, system_prompt="You manage my sales pipeline. Be brief.", )
result = await agent.ainvoke({ "messages": [{"role": "user", "content": "Who do I need to follow up with today?"}] }) print(result["messages"][-1].content)
asyncio.run(main())
create_agent is LangChain's agent builder, and it runs on LangGraph under the hood. MultiServerMCPClient starts your server, reads its tools, and hands them over as normal LangChain tools.
Why bother when Claude Desktop already works? Because code can run on a schedule and Claude Desktop can’t. Put this in a cron job and every morning it checks follow-ups for you. Add an email tool and it can draft the messages too. That’s what I’m wiring up next.
1. I used print() to debug. Remember stdio? Your server's stdout is the conversation with the client. One stray print("got here") and you've injected garbage into the protocol. The connection just dies, with a useless error. Log to stderr instead, or use Python's logging module, which writes to stderr by default.
2. I wrote lazy docstrings. My first version of update_lead said "Updates a lead." Claude kept guessing which fields it could change. When I listed them (status, follow-up date, note), the guessing stopped. Write docstrings like you're briefing a new intern.
3. I gave it a delete tool on day one. Don’t. Start read-only, then add writes, then think hard before deletes. An AI misreading “drop the Patel lead” is funny exactly once.
Once you’ve built one, you start seeing servers everywhere. A few I’m eyeing:
The pattern is always the same. Take something you already keep somewhere. Wrap it in three or four small tools. Write good docstrings. Done.
The tools aren’t the hard part anymore. Knowing which ones you need is.
What would you wrap first: your leads, your notes, or something weirder? Tell me in the responses. If enough of you pick the same thing, that’s my next tutorial.
If this was useful, follow me. I write about building small, practical AI tools for real businesses.
Built My Own MCP Server in 80 Lines of Python. Now Claude Knows My Sales Pipeline. was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.