Building Local Model Context Protocol (MCP) Servers
The Model Context Protocol (MCP) bridges Large Language Models (LLMs) and real-world execution environments by standardizing how tools, resources, and prompts are exposed to AI clients. Rather than relying on rigid UI forms or isolated scripts, developers can expose databases, business logic, and backend services to LLM clients (such as Claude Desktop) using local stdio communication.
1. Problem Statement & The Ecosystem Dilemma
Managing daily tasks such as expense tracking traditionally forces users into restrictive graphical user interfaces, manual form inputs, and predefined dashboard views. When interacting with an LLM, users expect natural language input—stating a financial transaction in conversational terms and receiving structured parsing, persistent storage, and statistical analysis on demand.
However, writing an MCP server from scratch exposes developers to a fragmented library landscape:
Developers encounter two main choices when implementing an MCP server in Python: the Official Anthropic MCP SDK and FastMCP.
The Library Dilemma: MCP SDK vs. FastMCP
- Anthropic's Official MCP SDK (
mcp): Released late 2023. Contains three primary sub-libraries:mcp.server: Low-level server primitive implementation.mcp.client: Client integration libraries.mcp.cli: Developer CLI for runtime inspection.- The Problem: Requires deep knowledge of JSON-RPC message framing, direct event loops, and manual transport handling. Adding two numbers requires dozens of lines of scaffolding.
- FastMCP (Created by Jeremiah Lowin, CEO of Prefect): Developed to eliminate low-level boilerplate. Similar to how Keras brought an intuitive API on top of raw TensorFlow, or how Flask simplified WSGI applications, FastMCP wraps low-level MCP primitives inside clean, Pythonic decorators (
@mcp.tool,@mcp.resource). - The 2025 Convergence & Split: Anthropic initially integrated FastMCP v1 directly into the official SDK under
mcp.server.fastmcp. Over time, the FastMCP creator expanded the library's scope into an independent ecosystem, releasing FastMCP 2.0 (pip install fastmcp). Today, developers can build either with the official SDK's built-in FastMCP v1 or with FastMCP 2.0 directly.
2. Core Mental Model & Environment Setup
Building a local MCP server involves linking a local process via standard input and output streams (stdio) directly to an LLM client.
Environment Initialization Using uv
uv is an extremely fast Python package manager written in Rust, recommended by FastMCP for managing dependencies and sub-shells:
# 1. Install uv globally
pip install uv
# 2. Create the project directory and initialize
mkdir expense-tracker-mcp-server
cd expense-tracker-mcp-server
uv init .
# 3. Add FastMCP dependency
uv add fastmcp
# 4. Verify installation and toolchain environment
fastmcp version
fastmcp version inspects the operating platform, Python runtime path, and package bindings (confirming FastMCP 2.x+).
3. Step 0: Minimal Demo Server & Tool Verification
To validate client-server communication before adding database complexity, construct a minimal prototype exposing mathematical and randomized operations.
# main.py
import random
from fastmcp import FastMCP
# Instantiate the server with an explicit name identifier
mcp = FastMCP("Demo Server")
@mcp.tool()
def roll_dice(num_dice: int = 1) -> list[int]:
"""Roll a specified number of 6-sided dice and return the results."""
return [random.randint(1, 6) for _ in range(num_dice)]
@mcp.tool()
def add_numbers(a: float, b: float) -> float:
"""Add two numbers together and return the sum."""
return a + b
if __name__ == "__main__":
mcp.run()
Running the MCP Inspector for Direct JSON-RPC Debugging
Never connect an unvalidated MCP server directly to an LLM client. Use Anthropic's interactive browser-based developer harness, the MCP Inspector:
uv run fastmcp dev main.py
This inspection harness visualizes message envelopes, capability handshakes, JSON-RPC schemas, and tool execution frames prior to client configuration.
4. Claude Desktop Integration & Path Troubleshooting
To configure an MCP server inside Claude Desktop, install the server manifest into Claude's configuration file:
uv run fastmcp install claude-desktop main.py
The Missing Executable Pitfall & Path Resolution
On macOS and Linux, GUI applications such as Claude Desktop do not inherit user shell session paths ($PATH defined in .zshrc or .bashrc). Consequently, launching the command uv within Claude will fail with connection errors.
To fix this:
- Identify the absolute path to your
uvbinary:BASHwhich uv # Output example: /Users/username/.local/bin/uv or /opt/homebrew/bin/uv - Open Claude Desktop's settings: Settings -> Developer -> Edit Config.
- In
claude_desktop_config.json, replace the bareuvreference with its absolute path:
{
"mcpServers": {
"demo_server": {
"command": "/Users/username/.local/bin/uv",
"args": [
"run",
"--with",
"fastmcp",
"fastmcp",
"run",
"/absolute/path/to/expense-tracker-mcp-server/main.py"
]
}
}
}
Relaunch Claude Desktop. The hammer/plug icon will light up, indicating that roll_dice and add_numbers are live and discoverable.
5. Building the Expense Tracker: Iteration 1 (Add & List)
We now evolve the prototype into a SQLite-backed financial ledger.
SQLite Schema Definition and Initialization
Create an isolated SQLite database file (expenses.db) in the root directory. Initialize the database schema on server startup:
import sqlite3
from pathlib import Path
from fastmcp import FastMCP
mcp = FastMCP("Expense Tracker")
DB_PATH = Path(__file__).parent / "expenses.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
amount REAL NOT NULL,
category TEXT NOT NULL,
subcategory TEXT,
note TEXT
)
""")
conn.commit()
conn.close()
# Initialize schema on module import
init_db()
Defining Tools: add_expense and list_expenses
@mcp.tool()
def add_expense(date: str, amount: float, category: str, subcategory: str = "", note: str = "") -> str:
"""Add a new expense entry into the database.
Args:
date: The date of transaction in YYYY-MM-DD format.
amount: The total expenditure numeric value.
category: Primary category classification.
subcategory: Secondary category classification (optional).
note: Descriptive contextual detail regarding the transaction.
"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO expenses (date, amount, category, subcategory, note) VALUES (?, ?, ?, ?, ?)",
(date, amount, category, subcategory, note)
)
conn.commit()
conn.close()
return f"Successfully recorded transaction: {amount} on {date} under {category}."
@mcp.tool()
def list_expenses() -> list[dict]:
"""Retrieve all recorded expense entries from the database sorted chronologically."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT id, date, amount, category, subcategory, note FROM expenses ORDER BY date ASC")
rows = cursor.fetchall()
conn.close()
return [
{
"id": row[0],
"date": row[1],
"amount": row[2],
"category": row[3],
"subcategory": row[4],
"note": row[5]
}
for row in rows
]
if __name__ == "__main__":
mcp.run()
6. Iteration 2: Temporal Filtering with Date Ranges
Retrieving every row on every query wastes context window tokens and degrades LLM reasoning. In this iteration, we replace the unbounded retrieval tool with parameterized window bounds:
@mcp.tool()
def list_expenses(start_date: str, end_date: str) -> list[dict]:
"""Retrieve expense entries within an inclusive date range.
Args:
start_date: Beginning boundary date in YYYY-MM-DD format.
end_date: Ending boundary date in YYYY-MM-DD format.
"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, date, amount, category, subcategory, note
FROM expenses
WHERE date BETWEEN ? AND ?
ORDER BY date ASC
""",
(start_date, end_date)
)
rows = cursor.fetchall()
conn.close()
return [
{
"id": row[0],
"date": row[1],
"amount": row[2],
"category": row[3],
"subcategory": row[4],
"note": row[5]
}
for row in rows
]
When a user asks: "List my expenses from the first week of September in a table," the LLM maps the conversational window ("first week of September") to concrete boundary parameters: start_date="2025-09-01", end_date="2025-09-07".
7. Iteration 3: Group-By Category Aggregation
While filtering by date ranges helps narrow down transactions, deriving category totals manually across large token windows is error-prone. Offload numerical aggregations directly to SQLite via an optimized summarize tool:
@mcp.tool()
def summarize_expenses(start_date: str, end_date: str, category: str = None) -> list[dict]:
"""Summarize aggregated expenditures grouped by category within a specified date range.
Args:
start_date: Beginning boundary date in YYYY-MM-DD format.
end_date: Ending boundary date in YYYY-MM-DD format.
category: Specific category filter. If omitted, computes aggregations for all categories.
"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
base_query = """
SELECT category, SUM(amount) as total_amount
FROM expenses
WHERE date BETWEEN ? AND ?
"""
params = [start_date, end_date]
if category:
base_query += " AND category = ?"
params.append(category)
base_query += " GROUP BY category ORDER BY category ASC"
cursor.execute(base_query, tuple(params))
rows = cursor.fetchall()
conn.close()
return [{"category": row[0], "total_amount": row[1]} for row in rows]
Relieving LLM Computational Burden
Pushing computations to SQL prevents arithmetic hallucinations:
8. Iteration 4: Schema Control Using MCP Resources
When LLMs infer categorization parameters without constraints, variations quickly pollute the database. For example:
- One transaction may be logged under
"Transport", another under"transportation", and another under"Cab". - Subcategories may be ignored entirely if not explicitly prompted.
To maintain a consistent schema across queries, expose a rigid taxonomy via an MCP Resource.
Static Configuration: categories.json
{
"Transport": ["Cab", "Metro", "Flight", "Fuel"],
"Food": ["Groceries", "Restaurant", "Snacks"],
"Education": ["Books", "Online Courses", "Tuition"],
"Entertainment": ["Movies", "Streaming Platforms", "Events"],
"Utilities": ["Electricity", "Water", "Internet"]
}
Exposing the Taxonomy via @mcp.resource
import json
CATEGORIES_FILE = Path(__file__).parent / "categories.json"
@mcp.resource("categories://data")
def get_categories() -> str:
"""Provides a standardized taxonomy of primary expense categories and valid subcategories."""
with open(CATEGORIES_FILE, "r") as f:
return f.read()
When interacting with Claude Desktop, the user attaches this resource (via the UI paperclip/plus icon or contextual prompt injection). Claude reads the allowed JSON taxonomy before calling add_expense, ensuring consistent classifications across all records.
9. FastAPI to FastMCP: Zero-Code Conversion Pattern
Modern enterprise architectures often already have a central REST backend built with FastAPI. Rewriting these endpoints into separate MCP tools creates redundant maintenance overhead.
FastMCP provides an adapter pattern—FastMCP.from_fastapi(...)—that automatically converts standard FastAPI endpoint definitions and Pydantic schemas into an MCP server.
1. Existing Production FastAPI Architecture (main.py)
from fastapi import FastAPI
from pydantic import BaseModel
import sqlite3
app = FastAPI(title="Expense API Engine")
class ExpenseItem(BaseModel):
date: str
amount: float
category: str
subcategory: str = ""
note: str = ""
@app.post("/expenses")
def add_expense(item: ExpenseItem):
"""Add a new transaction record."""
conn = sqlite3.connect("expenses.db")
c = conn.cursor()
c.execute(
"INSERT INTO expenses (date, amount, category, subcategory, note) VALUES (?, ?, ?, ?, ?)",
(item.date, item.amount, item.category, item.subcategory, item.note)
)
conn.commit()
conn.close()
return {"status": "success", "data": item.model_dump()}
@app.get("/expenses")
def get_expenses():
"""Retrieve all transactions."""
conn = sqlite3.connect("expenses.db")
c = conn.cursor()
c.execute("SELECT id, date, amount, category, subcategory, note FROM expenses")
records = c.fetchall()
conn.close()
return {"expenses": records}
2. Auto-Generating the MCP Server (server.py)
Instead of re-declaring the @mcp.tool signatures manually, instantiate the MCP server directly from the FastAPI application object:
# server.py
from fastmcp import FastMCP
from main import app as fastapi_app
# Convert the entire FastAPI routing table into exposed MCP tools in one line
mcp = FastMCP.from_fastapi(fastapi_app, name="Enterprise Expense MCP Server")
if __name__ == "__main__":
mcp.run()
Inspect the generated server using the MCP Inspector:
uv run fastmcp dev server.py
FastMCP parses the Pydantic type signatures, docstrings, and URL routes, generating corresponding JSON-RPC tools (add_expense, get_expenses) that can be installed directly into Claude Desktop.
10. Complete Code Implementation
Here is the complete code for the SQLite-backed Expense Tracker MCP server, combining tools, resources, and persistent storage in a single file:
# main.py
import sqlite3
import json
from pathlib import Path
from fastmcp import FastMCP
# 1. Server & Storage Initialization
mcp = FastMCP("Personal Expense Tracker Server")
BASE_DIR = Path(__file__).parent
DB_PATH = BASE_DIR / "expenses.db"
CATEGORIES_PATH = BASE_DIR / "categories.json"
def setup_database():
"""Ensure persistence layer schema exists."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
amount REAL NOT NULL,
category TEXT NOT NULL,
subcategory TEXT,
note TEXT
)
""")
conn.commit()
conn.close()
setup_database()
# 2. Resources: Controlled Categorization
@mcp.resource("categories://data")
def list_categories() -> str:
"""Provides valid category and subcategory hierarchies to eliminate data pollution."""
if not CATEGORIES_PATH.exists():
fallback_taxonomies = {
"Transport": ["Cab", "Metro", "Flight"],
"Food": ["Groceries", "Restaurant"],
"Education": ["Courses", "Books"],
"Utilities": ["Electricity", "Internet"]
}
with open(CATEGORIES_PATH, "w") as f:
json.dump(fallback_taxonomies, f, indent=2)
with open(CATEGORIES_PATH, "r") as f:
return f.read()
# 3. Tool: Record Financial Transaction
@mcp.tool()
def add_expense(date: str, amount: float, category: str, subcategory: str = "", note: str = "") -> str:
"""Inserts a confirmed expenditure entry into persistent storage.
Args:
date: ISO transaction date string (YYYY-MM-DD).
amount: Numeric transaction value.
category: High-level classification label.
subcategory: Granular classification identifier.
note: Conversational context notes.
"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO expenses (date, amount, category, subcategory, note) VALUES (?, ?, ?, ?, ?)",
(date, amount, category, subcategory, note)
)
conn.commit()
conn.close()
return f"Successfully saved: ${amount:.2f} under '{category}' ('{subcategory}') on {date}."
# 4. Tool: Windowed Temporal Querying
@mcp.tool()
def list_expenses(start_date: str, end_date: str) -> list[dict]:
"""Retrieves all expense records strictly bounded inside an inclusive date interval.
Args:
start_date: Boundary start date (YYYY-MM-DD).
end_date: Boundary end date (YYYY-MM-DD).
"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, date, amount, category, subcategory, note
FROM expenses
WHERE date BETWEEN ? AND ?
ORDER BY date ASC
""",
(start_date, end_date)
)
rows = cursor.fetchall()
conn.close()
return [
{
"id": r[0],
"date": r[1],
"amount": r[2],
"category": r[3],
"subcategory": r[4],
"note": r[5]
}
for r in rows
]
# 5. Tool: Category Aggregation Engine
@mcp.tool()
def summarize_expenses(start_date: str, end_date: str, category: str = None) -> list[dict]:
"""Computes categorical totals for an inclusive date range directly in SQL.
Args:
start_date: Boundary start date (YYYY-MM-DD).
end_date: Boundary end date (YYYY-MM-DD).
category: Optional categorical filter. If omitted, computes aggregations across all categories.
"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
query = """
SELECT category, SUM(amount)
FROM expenses
WHERE date BETWEEN ? AND ?
"""
params = [start_date, end_date]
if category:
query += " AND category = ?"
params.append(category)
query += " GROUP BY category ORDER BY category ASC"
cursor.execute(query, tuple(params))
records = cursor.fetchall()
conn.close()
return [{"category": r[0], "total_spent": r[1]} for r in records]
if __name__ == "__main__":
mcp.run()
11. Debugging Workflows & Production Pitfalls
When developing local MCP servers, failures often occur across process boundaries and standard I/O channels. Below are common pitfalls and how to fix them.
Key Debugging Techniques
- Avoid standard print statements: MCP uses standard output (
stdout) for JSON-RPC message framing. Callingprint("Database connected")writes plain text directly into the protocol channel, corrupting the message stream and crashing the client connection. Usesys.stderr.write(...)or the standardloggingmodule instead:PYTHONimport sys import logging logging.basicConfig(stream=sys.stderr, level=logging.INFO) - Restart Claude Desktop completely after code changes: Claude launches a dedicated background subshell to communicate with local MCP servers. Editing
main.pyon disk does not hot-reload the running process. Always restart Claude Desktop (viaCmd+Qon macOS or ending the process in Task Manager on Windows) to pick up code changes. - Validate first in the MCP Inspector: Before attaching a modified server to Claude Desktop, always verify that your tools run cleanly in the MCP Inspector:
BASH
uv run fastmcp dev main.py
## Summary and Key Takeaways
- The Model Context Protocol (MCP) standardizes how Large Language Models discover and run local code, query resources, and call parameterized tools across clean JSON-RPC protocol boundaries.
- FastMCP vs. MCP SDK: Anthropic's low-level Python SDK handles base JSON-RPC framing and protocol specifics. FastMCP provides an ergonomic, decorator-driven developer layer (similar to Flask or FastAPI) that significantly reduces boilerplate.
- Local Stdio Transport: Local MCP servers communicate with clients like Claude Desktop over standard I/O (
stdio). This requires using absolute binary paths in configuration files to avoid runtime environment issues. - Pre-Flight Protocol Debugging: Use the MCP Inspector (
uv run fastmcp dev <file>.py) to test tools, inspect JSON-RPC payloads, and debug responses before integrating with an LLM client. - Token-Efficient Tool Design: Avoid dumping unbounded database tables into the context window. Provide structured boundaries (such as
start_dateandend_date) and push group-by aggregations directly to the database engine. - Taxonomy Control via Resources: Use
@mcp.resourceto expose fixed taxonomies (likecategories.json) to the LLM, preventing arbitrary category labels and data corruption. - Zero-Overhead FastAPI Migration: FastMCP can automatically convert existing production FastAPI applications into complete MCP servers using
FastMCP.from_fastapi(app), eliminating redundant code and maintenance. - Safe Diagnostic Logging: Because local MCP servers communicate over
stdout, never use plainprint()statements in server logic. Direct all logs and debugging output tostderrto keep JSON-RPC frames clean.