•6 min read

Building Your First MCP Server from Scratch: The Complete Python & Claude Guide

Building Your First MCP Server from Scratch: The Complete Python & Claude Guide

For years, connecting Large Language Models to internal databases, local filesystems, and developer tools required writing bespoke function-calling schemas for each LLM provider. Every time you switched models or updated an agent framework, you had to re-implement tool definitions, authorization boundaries, and serialization logic.

The Model Context Protocol (MCP), introduced by Anthropic and adopted broadly across Claude Desktop, Cursor, and modern AI developer environments, solves this fragmentation. MCP is the universal, open standard—essentially the "USB-C of AI integrations"—that enables LLMs to discover, inspect, and invoke local and remote tools through a standardized JSON-RPC interface.

In this guide, we break down the architecture of MCP, build a production-grade Python MCP server from scratch using FastMCP, implement secure tools and dynamic resources, and wire it directly into Claude Desktop.


Audio Briefing
0:00 / 0:00

The MCP Architecture: How Hosts, Clients, and Servers Connect

To understand how MCP functions, examine the three core participants in the protocol:

┌────────────────────────────────────────────────────────┐
│ MCP Host (Claude Desktop / Cursor IDE)                 │
│                                                        │
│   ┌────────────────────────────────────────────────┐   │
│   │ MCP Client (Protocol Adapter & Security Layer) │   │
│   └───────────────────────┬────────────────────────┘   │
└───────────────────────────┼────────────────────────────┘
                            │ (JSON-RPC 2.0 over Stdio or SSE)
                            ▼
┌────────────────────────────────────────────────────────┐
│ MCP Server (Your Python / TypeScript Application)      │
│                                                        │
│  ├── Tools:     Callable functions (read, write, API)  │
│  ├── Resources: Read-only context (logs, DB rows)      │
│  └── Prompts:   Pre-engineered prompt templates        │
└────────────────────────────────────────────────────────┘
  1. MCP Host: The application running the AI model interface (e.g., Claude Desktop, Zed editor).
  2. MCP Client: The internal protocol driver inside the host that negotiates capabilities, enforces security permissions, and forwards tool calls.
  3. MCP Server: A standalone lightweight process (or remote service) that advertises its available capabilities and executes requested operations.

Transports: Stdio vs Server-Sent Events (SSE)

  • Stdio Transport: The host spawns your Python server as a child process and communicates via standard input/output (stdin/stdout). This is the standard, zero-overhead mode for local developer machines.
  • SSE Transport: The host connects to an external HTTP server over Server-Sent Events for streaming updates. This mode is used for cloud-hosted microservices and enterprise SaaS connectors.

Advertisement

1. Setting Up Your Python Environment with uv

We'll use uv, the blazing-fast Python package manager, to initialize our project and install the official Python MCP SDK:

# Create project directory
mkdir local-dev-mcp && cd local-dev-mcp

# Initialize Python workspace
uv init
uv add mcp pydantic

2. Building the Server with FastMCP

The Python SDK provides FastMCP, a high-level framework modeled after FastAPI. It automatically extracts parameter schemas, type hints, and documentation strings into valid JSON-RPC tool schemas.

Create server.py:

# server.py: Production Local Developer MCP Server
import os
from pathlib import Path
from pydantic import BaseModel, Field
from mcp.server.fastmcp import FastMCP

# Initialize the MCP Server with identity and version
mcp = FastMCP("Developer-Workspace-Tools", version="1.0.0")

# Define a safe base directory to prevent directory traversal attacks
SAFE_WORKSPACE = Path(os.getenv("MCP_WORKSPACE_DIR", "./workspace")).resolve()
SAFE_WORKSPACE.mkdir(parents=True, exist_ok=True)

def resolve_safe_path(relative_path: str) -> Path:
    """Guarantees file operations remain strictly inside the authorized workspace."""
    resolved = (SAFE_WORKSPACE / relative_path).resolve()
    if not str(resolved).startswith(str(SAFE_WORKSPACE)):
        raise PermissionError(f"Access denied: '{relative_path}' attempts path traversal.")
    return resolved

# ====================================================================
# Primitive 1: Tools (Callable Actions)
# ====================================================================

@mcp.tool()
def save_code_snippet(filename: str, code: str, language: str = "python") -> str:
    """
    Saves a code snippet to the authorized local workspace.
    
    Args:
        filename: Name of the file including extension (e.g., 'auth_utils.py')
        code: Full code content to write
        language: Programming language identifier for metadata
    """
    target_path = resolve_safe_path(filename)
    target_path.parent.mkdir(parents=True, exist_ok=True)
    
    with open(target_path, "w", encoding="utf-8") as f:
        f.write(code)
        
    return f"Successfully wrote {len(code)} bytes to {filename} (Language: {language})"

@mcp.tool()
def list_workspace_files() -> list[dict]:
    """Lists all files in the developer workspace with file size and modified timestamps."""
    files = []
    for p in SAFE_WORKSPACE.rglob("*"):
        if p.is_file():
            stat = p.stat()
            files.append({
                "relative_path": str(p.relative_to(SAFE_WORKSPACE)),
                "size_bytes": stat.st_size,
                "modified_timestamp": stat.st_mtime,
            })
    return files

# ====================================================================
# Primitive 2: Resources (Context Streams)
# ====================================================================

@mcp.resource("workspace://logs/{log_name}")
def get_system_log(log_name: str) -> str:
    """Reads a designated log file from the workspace for debugging context."""
    log_path = resolve_safe_path(f"logs/{log_name}.log")
    if not log_path.exists():
        return f"Log file '{log_name}' not found."
        
    with open(log_path, "r", encoding="utf-8") as f:
        # Return the last 50 lines to conserve context window
        lines = f.readlines()
        return "".join(lines[-50:])

if __name__ == "__main__":
    # Runs the Stdio transport event loop
    mcp.run()

3. Configuring Claude Desktop

To allow Claude Desktop to discover and invoke your new MCP server, configure its JSON configuration file:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server definition under mcpServers:

{
  "mcpServers": {
    "developer-workspace": {
      "command": "uv",
      "args": [
        "run",
        "--directory",
        "/absolute/path/to/local-dev-mcp",
        "server.py"
      ],
      "env": {
        "MCP_WORKSPACE_DIR": "/absolute/path/to/workspace"
      }
    }
  }
}

Restart Claude Desktop. You will notice a new 🔌 hammer/tool icon appear in the bottom-right corner of the chat input box. Clicking it displays your registered tools: save_code_snippet and list_workspace_files.


Advertisement

4. Testing and Debugging with MCP Inspector

Before connecting to Claude Desktop, you can interactively test and debug your MCP server using the official browser-based MCP Inspector:

# Launch the interactive MCP debugger
npx @modelcontextprotocol/inspector uv run server.py

The inspector launches a local web UI where you can:

  • Inspect auto-generated JSON schemas for all tools and resources.
  • Execute tools with custom JSON arguments and view raw JSON-RPC responses.
  • Verify error handling and path traversal rejections.

Security Best Practices for Production MCP Servers

  1. Path Traversal Protection: Always resolve file paths against an explicit base directory using pathlib.Path.resolve(). Never trust relative path strings passed directly by an LLM.
  2. Read-Only Enclaves: If a tool only needs to read telemetry or database records, connect with a database role that has strict SELECT-only privileges.
  3. Stdio Cleanliness: Never use print() statements for debugging in an MCP server. Because standard output is reserved strictly for JSON-RPC messages, arbitrary text output will corrupt the wire protocol. Use standard Python logging configured to write to sys.stderr.

Frequently Asked Questions

Can an MCP server call external cloud APIs?

Yes. An MCP server is simply standard code. You can use httpx or requests inside your tool implementations to call GitHub APIs, Slack webhooks, Jira tickets, or internal Kubernetes clusters.

How does Claude decide when to call an MCP tool?

Claude inspects the function signature and docstrings advertised by your MCP server. Writing descriptive docstrings (explaining what the tool does, what parameters mean, and when it should be called) is the single most important factor for reliable tool calling.

What is the difference between an MCP Tool and an MCP Resource?

  • Tools are actions with side effects (writing files, executing commands, querying external APIs) that require explicit user approval in the host UI.
  • Resources are passive, read-only data streams (like attaching a file or feeding database logs into context) that the host can read without side-effect risks.

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement