How to Build an MCP Server in Python: Complete Beginner's Guide

Model Context Protocol (MCP) is an open standard that lets AI assistants like Claude, GPT, and Cursor talk to your tools, databases, and APIs through a single interface. Instead of writing custom integrations for every AI client, you build one MCP server and any compatible client can use it.

This guide walks you through building a working MCP server in Python — from installation to testing with a real AI client. No prior MCP experience needed.

1. What Is MCP and Why It Matters

Anthropic introduced MCP in late 2024 as an open protocol for connecting AI models to external data sources and tools. Think of it as USB-C for AI: one standard connector that works with any device.

Before MCP, if you wanted Claude to query your database and GPT to read your files, you wrote two separate integrations. With MCP, you build one server that exposes your tools, and any MCP-compatible client can discover and use them.

Without MCPWith MCP
Custom integration per AI clientOne server, all clients
Vendor-locked tool definitionsOpen standard (JSON-RPC)
Manual auth for each connectionStandardized auth flow
No tool discoveryClients auto-discover tools
MCP uses JSON-RPC 2.0 as its transport layer. Servers expose tools (executable functions), resources (readable data), and prompts (reusable templates). Clients call what they need.

2. Prerequisites and Installation

You need Python 3.10+ and the official MCP Python SDK. Create a virtual environment first:

# Create and activate virtual environment
python -m venv mcp-env

# Activate (Windows)
mcp-env\Scripts\activate

# Activate (macOS/Linux)
source mcp-env/bin/activate

# Install the MCP SDK
pip install mcp

# Verify installation
python -c "import mcp; print(mcp.__version__)"

The mcp package includes everything you need: the server framework, type definitions, and a test client for development.

3. Your First MCP Server

Let us build a server that exposes a simple calculator tool and a greeting resource. This covers the two most common MCP primitives.

# server.py
from mcp.server.fastmcp import FastMCP

# Create the server instance
mcp = FastMCP("my-first-server")

# Define a TOOL — something the AI can execute
@mcp.tool()
def calculate(expression: str) -> str:
    """Evaluate a math expression and return the result.

    Args:
        expression: A math expression like '2 + 3 * 4'
    """
    try:
        result = eval(expression, {"__builtins__": {}}, {})
        return f"Result: {result}"
    except Exception as e:
        return f"Error: {e}"

# Define a RESOURCE — readable data the AI can access
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
    """Generate a personalized greeting."""
    return f"Hello, {name}! Welcome to MCP."

# Run the server
if __name__ == "__main__":
    mcp.run()

That is a complete MCP server. The FastMCP class handles all the JSON-RPC protocol details. You just define Python functions and decorate them.

Terminal showing an MCP server starting and registering tools
Running the server: tools register automatically and the client connects over stdio.
⚠ Security note: The eval() call above is for demonstration only. In production, use ast.literal_eval() or a dedicated math parser like numexpr to avoid code injection.

4. Designing Real-World Tools

Tools are the core of most MCP servers. A well-designed tool has a clear name, a descriptive docstring, and typed parameters. The AI uses the docstring to decide when to call your tool.

import json
import sqlite3
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("database-server")

@mcp.tool()
def query_database(db_path: str, sql: str) -> str:
    """Run a read-only SQL query against a SQLite database.

    Args:
        db_path: Path to the .sqlite or .db file
        sql: A SELECT query (writes are blocked)

    Returns:
        JSON array of result rows, or an error message
    """
    # Block any write operations
    sql_stripped = sql.strip().upper()
    if not sql_stripped.startswith("SELECT"):
        return "Error: Only SELECT queries are allowed."

    try:
        conn = sqlite3.connect(db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.execute(sql)
        rows = [dict(row) for row in cursor.fetchall()]
        conn.close()
        return json.dumps(rows, indent=2)
    except Exception as e:
        return f"Query error: {e}"

@mcp.tool()
def list_tables(db_path: str) -> str:
    """List all table names in a SQLite database.

    Args:
        db_path: Path to the .sqlite or .db file
    """
    try:
        conn = sqlite3.connect(db_path)
        cursor = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
        )
        tables = [row[0] for row in cursor.fetchall()]
        conn.close()
        return json.dumps(tables)
    except Exception as e:
        return f"Error: {e}"

Notice the pattern: each tool does one thing, validates input, and returns a string. The AI client reads the docstring to understand what the tool does and when to use it.

5. Resources vs Tools: When to Use Which

MCP servers expose two main primitives. Understanding the difference is critical:

  • Tools — Functions the AI can call to perform actions. They may have side effects (write to a file, send an API request, modify data). The AI decides when to call them based on the user's request.
  • Resources — Data the AI can read. They are read-only and identified by URIs. Resources are passive — the AI fetches them when it needs context, not to perform actions.
AspectToolsResources
PurposeExecute actionsProvide context
Side effectsYes (allowed)No (read-only)
IdentificationBy nameBy URI
Examplesend_email()file:///report.txt

6. Building a File System Server

A common use case: let an AI read files from a specific directory. Here is a safe implementation:

import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("file-server")

# Restrict access to this directory only
ALLOWED_DIR = Path("/home/user/projects").resolve()

@mcp.tool()
def read_file(relative_path: str) -> str:
    """Read the contents of a file within the allowed project directory.

    Args:
        relative_path: Path relative to the project root (e.g., 'src/main.py')
    """
    target = (ALLOWED_DIR / relative_path).resolve()

    # Prevent path traversal attacks
    if not str(target).startswith(str(ALLOWED_DIR)):
        return "Error: Access denied — path outside allowed directory."

    if not target.is_file():
        return f"Error: File not found: {relative_path}"

    return target.read_text(encoding="utf-8")

@mcp.tool()
def list_files(directory: str = ".") -> str:
    """List files and directories at the given path.

    Args:
        directory: Subdirectory within the project root (default: root)
    """
    target = (ALLOWED_DIR / directory).resolve()

    if not str(target).startswith(str(ALLOWED_DIR)):
        return "Error: Access denied."

    if not target.is_dir():
        return "Error: Not a directory."

    entries = []
    for item in sorted(target.iterdir()):
        prefix = "[DIR] " if item.is_dir() else "[FILE] "
        entries.append(f"{prefix}{item.name}")
    return "\n".join(entries)

@mcp.resource("file://{relative_path}")
def file_resource(relative_path: str) -> str:
    """Expose project files as readable resources."""
    target = (ALLOWED_DIR / relative_path).resolve()
    if not str(target).startswith(str(ALLOWED_DIR)):
        return "Access denied."
    if target.is_file():
        return target.read_text(encoding="utf-8")
    return "File not found."

7. Testing Your MCP Server

The MCP SDK includes a built-in inspector tool for testing. Run it against your server:

# Install the inspector (one-time)
pip install mcp-cli

# Run your server with the inspector
mcp dev server.py

# Or test programmatically
python -c "
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def test():
    server_params = StdioServerParameters(
        command='python',
        args=['server.py'],
    )
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # List available tools
            tools = await session.list_tools()
            print('Available tools:')
            for tool in tools.tools:
                print(f'  - {tool.name}: {tool.description}')

            # Call a tool
            result = await session.call_tool(
                'calculate',
                {'expression': '2 + 3 * 4'}
            )
            print(f'Result: {result.content[0].text}')

asyncio.run(test())
"

The inspector opens a web UI where you can list tools, call them with custom arguments, and see raw JSON-RPC responses. Use it during development to debug tool definitions and responses.

8. Connecting to AI Clients

Once your server works, connect it to an AI client. Each client has its own config format:

Claude Desktop

Edit claude_desktop_config.json (found in Claude's settings):

{
  "mcpServers": {
    "my-database-server": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"],
      "env": {
        "DB_PATH": "/data/app.db"
      }
    }
  }
}

Restart Claude Desktop and your tools appear in the chat. Claude automatically calls them when relevant.

Cursor IDE

In Cursor settings, add the server under Settings › Features › MCP:

{
  "mcpServers": {
    "file-server": {
      "command": "python",
      "args": ["/path/to/file_server.py"]
    }
  }
}

9. Common Mistakes and Pitfalls

9.1. Vague docstrings

The AI relies entirely on your tool's docstring to decide when to call it. """Does stuff""" is useless. Write docstrings that explain what the tool does, what each parameter means, and what it returns.

# BAD — the AI has no idea when to use this
@mcp.tool()
def process(data: str) -> str:
    """Process data."""
    ...

# GOOD — the AI knows exactly what this does
@mcp.tool()
def summarize_text(text: str, max_sentences: int = 3) -> str:
    """Summarize a block of text into a concise summary.

    Args:
        text: The input text to summarize (any length)
        max_sentences: Maximum number of sentences in the summary (default: 3)

    Returns:
        A summary string
    """
    ...

9.2. No input validation

The AI can pass unexpected values. Always validate inputs — paths, SQL queries, file sizes — before processing. Block path traversal, limit query results, and sanitize file paths.

9.3. Returning unstructured data

Return strings, not raw Python objects. If your tool returns a dict, serialize it to JSON first. The AI reads the string response, so format it clearly.

9.4. Tools that do too much

One tool should do one thing. Instead of manage_database(operation, table, data), split it into list_tables(), query_database(sql), and describe_table(name). The AI can compose small tools better than it can navigate one giant tool.

9.5. Forgetting to use absolute paths

AI clients run your server from their own working directory, not yours. Always use absolute paths in args and inside your server code. Relative paths will break.

10. Deployment Options

For local development, stdio transport (the default) is fine. For production or remote access, use SSE (Server-Sent Events) transport:

# For stdio (local, default)
mcp.run()

# For SSE (remote, network-accessible)
mcp.run(transport="sse", host="0.0.0.0", port=8080)

SSE transport lets you host your MCP server on a remote machine. Clients connect over HTTP, which means you can share one server across multiple machines or team members.

⚠ Production tip: When using SSE transport, add authentication. The MCP spec supports OAuth 2.1 — implement it before exposing your server to the internet.

Frequently Asked Questions

Do I need to install Claude to use MCP?

No. MCP is an open protocol. Any client that implements the MCP spec can connect to your server — Claude Desktop, Cursor, Windsurf, Cline, or your own custom client built with the MCP SDK.

Can I write an MCP server in JavaScript instead of Python?

Yes. Anthropic maintains official SDKs for both Python and TypeScript. The protocol is language-agnostic — a Python server works with a TypeScript client and vice versa.

How is MCP different from regular function calling?

Function calling is tied to a specific model's API (OpenAI, Anthropic, etc.). MCP is a standard protocol that works across all compatible clients. You write your tool once, and any MCP client can use it without changes.

Can MCP servers have side effects like sending emails?

Yes. Tools can perform any action Python can do — API calls, file writes, database updates. But always validate inputs carefully, since the AI decides when to call tools. Consider adding confirmation prompts for destructive actions.