Build Custom MCP Servers for Local Development Workflows
Table of Contents
Claude Desktop becomes significantly more useful when it can access your actual project context—your database schema, config files, and Git history. Model Context Protocol (MCP) servers make this possible by exposing local resources to Claude in a structured way.
In this guide, you’ll build a custom MCP server in Python that connects Claude Desktop to your PostgreSQL database, project files, and Git repository. By the end, Claude will understand your specific codebase instead of giving generic answers.
What MCP Servers Actually Do
MCP is Anthropic’s open protocol for connecting AI assistants to external data sources. Instead of copying and pasting context into every prompt, you define “tools” that Claude can call to fetch information on demand.
An MCP server exposes three types of capabilities:
- Tools: Functions Claude can invoke (query database, read file, run command)
- Resources: Static data Claude can reference (config files, documentation)
- Prompts: Pre-defined templates for common tasks
For local development, tools are the most useful. You’ll create functions that let Claude inspect your actual project state.
Step 1: Set Up the MCP Server Skeleton
First, install the MCP SDK and database driver:
pip install mcp psycopg2-binary gitpython
Create a file called dev_server.py:
from mcp.server import Server
from mcp.types import Tool, TextContent
import asyncio
app = Server("dev-context")
@app.list_tools()
async def list_tools():
return [
Tool(
name="query_database",
description="Execute a read-only SQL query against the local PostgreSQL database",
inputSchema={
"type": "object",
"properties": {
"query": {"type": "string", "description": "SQL SELECT query"}
},
"required": ["query"]
}
)
]
if __name__ == "__main__":
asyncio.run(app.run())
This skeleton defines one tool. Claude sees the name, description, and expected input format, then decides when to call it.
Step 2: Expose Your PostgreSQL Database
Add database querying capability. This lets Claude inspect your schema and sample data:
import psycopg2
from psycopg2.extras import RealDictCursor
DB_CONFIG = {
"host": "localhost",
"database": "myproject_dev",
"user": "postgres",
"password": "localdev"
}
@app.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "query_database":
return await handle_database_query(arguments["query"])
async def handle_database_query(query: str):
# Safety check: only allow SELECT statements
if not query.strip().upper().startswith("SELECT"):
return [TextContent(type="text", text="Error: Only SELECT queries allowed")]
try:
conn = psycopg2.connect(**DB_CONFIG)
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query)
rows = cur.fetchmany(100) # Limit results
conn.close()
return [TextContent(type="text", text=str(rows))]
except Exception as e:
return [TextContent(type="text", text=f"Query error: {e}")]
Now Claude can ask questions like “What columns does the users table have?” and actually check your database. If you’re working with Laravel, this pairs well with understanding how RESTful APIs connect to your models.
Step 3: Add File System Access
Claude needs to read your project files—configs, environment files, and source code:
import os
PROJECT_ROOT = "/home/dev/myproject"
ALLOWED_EXTENSIONS = [".py", ".php", ".js", ".json", ".yaml", ".yml", ".env.example", ".md"]
@app.list_tools()
async def list_tools():
return [
# ... previous tool ...
Tool(
name="read_file",
description="Read contents of a project file",
inputSchema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path from project root"}
},
"required": ["path"]
}
),
Tool(
name="list_directory",
description="List files in a project directory",
inputSchema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path from project root"}
},
"required": ["path"]
}
)
]
async def handle_read_file(relative_path: str):
full_path = os.path.join(PROJECT_ROOT, relative_path)
# Security: prevent directory traversal
if not os.path.realpath(full_path).startswith(os.path.realpath(PROJECT_ROOT)):
return [TextContent(type="text", text="Error: Path outside project")]
if not any(relative_path.endswith(ext) for ext in ALLOWED_EXTENSIONS):
return [TextContent(type="text", text="Error: File type not allowed")]
try:
with open(full_path, 'r') as f:
content = f.read(50000) # Limit size
return [TextContent(type="text", text=content)]
except FileNotFoundError:
return [TextContent(type="text", text="File not found")]
Never expose .env files directly—use .env.example patterns. The security checks prevent Claude from accessing files outside your project.
Step 4: Connect Git History for Context
Recent commits tell Claude what you’ve been working on:
from git import Repo
@app.list_tools()
async def list_tools():
return [
# ... previous tools ...
Tool(
name="git_recent_commits",
description="Get recent Git commits with messages and changed files",
inputSchema={
"type": "object",
"properties": {
"count": {"type": "integer", "description": "Number of commits", "default": 10}
}
}
),
Tool(
name="git_diff",
description="Get uncommitted changes in the working directory",
inputSchema={"type": "object", "properties": {}}
)
]
async def handle_git_commits(count: int = 10):
repo = Repo(PROJECT_ROOT)
commits = []
for commit in repo.iter_commits(max_count=count):
commits.append({
"hash": commit.hexsha[:8],
"message": commit.message.strip(),
"author": str(commit.author),
"date": commit.committed_datetime.isoformat(),
"files": list(commit.stats.files.keys())[:10]
})
return [TextContent(type="text", text=str(commits))]
async def handle_git_diff():
repo = Repo(PROJECT_ROOT)
diff = repo.git.diff()
return [TextContent(type="text", text=diff[:20000] if diff else "No uncommitted changes")]
This is especially useful when you ask Claude to continue work from a previous session. If you’re new to Git workflows, check out the Git basics guide first.
Step 5: Configure Claude Desktop
Add your server to Claude Desktop’s config file. On macOS, edit ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"dev-context": {
"command": "python",
"args": ["/path/to/dev_server.py"],
"env": {
"PYTHONPATH": "/path/to/project"
}
}
}
}
On Linux, the config lives at ~/.config/Claude/claude_desktop_config.json.
Restart Claude Desktop. You should see your tools listed when you click the hammer icon in the chat input.
Testing Your MCP Server
Open Claude Desktop and try these prompts:
- “What tables exist in my database? Show me their columns.”
- “Read my config/database.php and explain the connection settings.”
- “What have I committed in the last week? Summarize the changes.”
- “I have uncommitted changes—review them for obvious bugs.”
Claude will call your tools automatically when it needs the information.
Key Takeaways
- MCP servers expose local resources to Claude through a structured tool interface
- Always implement security checks: read-only database queries, path validation, file type restrictions
- Start with three core tools: database queries, file reading, and Git history
- Test incrementally—add one tool at a time and verify it works before adding more
If you’ve already built AI agents with Python and Claude, MCP servers are the natural next step for integrating AI into your actual development workflow.