The release of the Model Context Protocol (MCP) by Anthropic has fundamentally changed how we integrate LLMs into local development environments. While Claude is exceptionally capable at reasoning, it is architecturally isolated from your local data—your internal APIs, SQLite databases, and local log files.
Developers attempting to bridge this gap often hit a wall of complexity: managing JSON-RPC handshakes, defining schemas manually, and handling transport layers. If you are struggling with the boilerplate required to connect Claude Desktop to your local tools, you are not alone.
This guide provides a rigorous, production-ready implementation of a custom MCP server using Python. We will build a Secure SQLite Inspector that allows Claude to safely query and analyze a local database directly from the chat interface.
The Root Cause: Why Simple Scripts Don't Work
Before writing code, it is critical to understand why standard Python scripts fail to interact with Claude Desktop.
Large Language Models (LLMs) are stateless text processors. They do not have intrinsic input/output (I/O) capabilities. To "use" a tool, the model needs three things:
- Schema Awareness: A strict definition of available functions and their arguments.
- Transport Layer: A standardized way to send the request and receive the result.
- State Management: A system to maintain context between tool calls.
The friction in MCP development usually stems from Standard Input/Output (Stdio) interference. MCP relies on stdin and stdout to transmit JSON-RPC messages. If your Python script uses print() for debugging, it corrupts the protocol stream, causing Claude to disconnect silently. Furthermore, Claude cannot infer your function signatures automatically; they must be serialized into a specific JSON schema format.
We will solve this by using the mcp Python SDK and the FastMCP interface, which abstracts the protocol negotiation while strictly enforcing type safety.
The Fix: Building a SQLite MCP Server
We will build a server that exposes a local SQLite database to Claude. We will use uv for dependency management, as it is currently the standard recommended for high-performance Python tooling.
Step 1: Environment Setup
Create a new directory and initialize your project. We require Python 3.10 or higher.
# Create project directory
mkdir claude-sqlite-mcp
cd claude-sqlite-mcp
# Initialize a new project with uv
uv init
# Create a virtual environment
uv venv
# Install the MCP SDK and strict typing extensions
uv add mcp pydantic
Step 2: The Server Implementation
Create a file named server.py.
This code uses the FastMCP class. This is a modern, decorator-based approach (similar to FastAPI) that inspects your Python type hints to automatically generate the MCP JSON schema.
Critical Note: Notice the use of pydantic for argument validation and the specific way we handle database connections to prevent locking issues.
import sqlite3
from typing import List, Dict, Any, Optional
from mcp.server.fastmcp import FastMCP
from pydantic import Field
# Initialize the FastMCP server
# name: Identifies the server in logs
mcp = FastMCP("local-sqlite-inspector")
DB_PATH = "my_data.db"
def get_db_connection():
"""Establishes a connection to the SQLite database."""
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # Return rows as dictionaries
return conn
except sqlite3.Error as e:
raise RuntimeError(f"Database connection failed: {e}")
@mcp.tool()
def list_tables() -> List[str]:
"""
Retrieves a list of all tables in the database.
Use this first to understand the database structure.
"""
conn = get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row["name"] for row in cursor.fetchall()]
return tables
except sqlite3.Error as e:
return [f"Error querying schema: {str(e)}"]
finally:
conn.close()
@mcp.tool()
def describe_table(table_name: str) -> List[Dict[str, Any]]:
"""
Returns the schema information for a specific table.
Args:
table_name: The name of the table to inspect.
"""
conn = get_db_connection()
cursor = conn.cursor()
try:
# Parameterized query not needed here as table names can't be bound,
# but we validate existence to prevent injection in a real scenario.
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [dict(row) for row in cursor.fetchall()]
return columns
except sqlite3.Error as e:
# Return error as data so Claude can interpret it
return [{"error": str(e)}]
finally:
conn.close()
@mcp.tool()
def read_query(query: str = Field(..., description="A read-only SQL query")) -> List[Dict[str, Any]]:
"""
Executes a SELECT query and returns the results.
Only SELECT statements are allowed for safety.
"""
if not query.strip().upper().startswith("SELECT"):
raise ValueError("Only SELECT queries are permitted.")
conn = get_db_connection()
cursor = conn.cursor()
try:
cursor.execute(query)
results = [dict(row) for row in cursor.fetchall()]
return results[:100] # Limit results to prevent context window overflow
except sqlite3.Error as e:
return [{"error": str(e)}]
finally:
conn.close()
if __name__ == "__main__":
# This entry point runs the MCP server over stdio
mcp.run()
Step 3: Create Dummy Data
To test this, create a simple database in the same directory. Run this python snippet once:
import sqlite3
conn = sqlite3.connect("my_data.db")
cursor = conn.cursor()
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
cursor.execute("INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')")
cursor.execute("INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com')")
conn.commit()
conn.close()
Step 4: Configuring Claude Desktop
Claude Desktop needs to know how to launch your Python script. It uses a configuration file typically located at:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Open (or create) this file and add your server configuration. We use uv run to ensure the script runs within the virtual environment context we created earlier.
Note: You must use the absolute path to your project directory.
{
"mcpServers": {
"sqlite-inspector": {
"command": "uv",
"args": [
"run",
"server.py"
],
"cwd": "/Users/username/projects/claude-sqlite-mcp"
}
}
}
Replace /Users/username/projects/claude-sqlite-mcp with your actual absolute path.
Step 5: Verification
- Restart Claude Desktop completely.
- Look for the electrical plug icon (🔌) in the top right of the chat input.
- You should see
sqlite-inspectorlisted with 3 tools:list_tables,describe_table, andread_query. - Type into Claude: "Check the database, list the tables, and tell me who the users are."
Deep Dive: How the Protocol Works Here
Understanding the flow ensures you can debug future servers.
- Initialization: When Claude starts, it reads the config and spawns your
uvcommand as a subprocess. - Capabilities Negotiation: Claude sends a
initializeJSON-RPC message over Stdin. Yourmcp.run()loop receives this and responds with server capabilities (e.g., "I provide tools"). - Tool Discovery: Claude sends
tools/list.FastMCPinspects the functions decorated with@mcp.tool(). It converts the Python type hints (str,int,pydantic.Field) into a JSON Schema. This is why strict typing in Python is mandatory for MCP. - Execution: When you ask Claude to query data, it does not execute the SQL itself. It generates a JSON payload containing the arguments for
read_query. Your Python script executes the logic and returns the result via Stdout.
Common Pitfalls and Edge Cases
1. The "Print" Statement Trap
If you add print("Debug: Query running") inside your functions, the server will crash.
- Why: MCP uses Stdout for protocol communication. Random text breaks the JSON structure.
- Fix: Use
mcp.log.info()or write tosys.stderrfor debugging. Claude logsstderroutput to its internal logs without breaking the connection.
2. Environment Variables
The subprocess spawned by Claude does not automatically inherit your shell's .env file or specialized paths.
- Fix: Pass environment variables explicitly in the
claude_desktop_config.json:
"env": {
"DATABASE_URL": "postgresql://...",
"API_KEY": "12345"
}
3. Context Window Overflows
Returning 10,000 rows from a database will exceed Claude's context window or cause a timeout.
- Fix: Always implement hard limits in your Python code (as seen in the
results[:100]line in the code above).
Conclusion
By wrapping standard Python logic in the FastMCP interface, you transform isolated scripts into native AI tools. This setup allows you to keep your data local and secure while leveraging Claude's reasoning capabilities to write SQL, analyze logs, or manage internal state.
The key to stability is strict adherence to the request/response cycle over Stdio: utilize type hints for schema generation and avoid polluting the standard output stream.