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.