DEV Community

Serhii Kalyna
Serhii Kalyna

Posted on • Originally published at kalyna.pro

How to Build an MCP Server with Claude: Complete Tutorial (2026)

The Model Context Protocol (MCP) lets you expose custom tools and data sources to Claude in a standardized way. Instead of hardcoding tool definitions in every app, you build an MCP server once and connect it to Claude Desktop, Claude Code, or any MCP-compatible client. In this tutorial you’ll build a working MCP server in Python from scratch, add real tools, and connect it to Claude.


Prerequisites

  • Python 3.10+
  • An Anthropic API key — console.anthropic.com
  • Claude Desktop or Claude Code installed
  • Basic Python knowledge (functions, async)
  • Read What is MCP? first (optional but recommended)

What Is an MCP Server, Exactly?

An MCP server is a lightweight process that exposes tools, resources, and prompts to an LLM client over a standard protocol. Think of it as a plugin system for Claude:

  • Tools — functions Claude can call (search, read files, query a database)
  • Resources — data Claude can read (files, API responses, live data)
  • Prompts — reusable prompt templates with arguments

Claude decides when to call your tools based on the user’s message — you just define what’s available. The MCP protocol handles the transport (stdio or HTTP), so you focus only on your tool logic.


Step 1: Install the MCP SDK

Install the official Python MCP SDK:

pip install mcp
Enter fullscreen mode Exit fullscreen mode

This installs the mcp package with everything you need to build and run a server.


Step 2: Create Your First MCP Server

Create a file called server.py:

int:
"""Add two numbers together."""
return a + b

@mcp.tool()
def get_weather(city: str) -> str:
"""Get current weather for a city (stub)."""
return f"Weather in {city}: 22°C, partly cloudy"

if name == "main":
mcp.run()" style="color:#E6E6E6;display:none" aria-label="Copy" class="code-block-pro-copy-button">

from mcp.server.fastmcp import FastMCP

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

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers together."""
    return a + b

@mcp.tool()
def get_weather(city: str) -> str:
    """Get current weather for a city (stub)."""
    return f"Weather in {city}: 22°C, partly cloudy"

if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode


shell

The @mcp.tool() decorator registers a function as a tool Claude can call. The docstring becomes the tool description Claude uses when deciding whether to call it — write clear, specific descriptions.


Step 3: Test Your Server Locally

Use the MCP dev inspector to test your tools interactively:

mcp dev server.py
Enter fullscreen mode Exit fullscreen mode


python

This opens an interactive inspector in your browser where you can call tools manually and see the JSON request/response. Verify your tools return the expected output before connecting to Claude.


Step 4: Add Real Tools

Let’s build something more useful — a file reader and a URL fetcher:

str:
"""Read the contents of a local file by path."""
p = pathlib.Path(path)
if not p.exists():
return f"Error: file not found: {path}"
if p.stat().st_size > 100_000:
return "Error: file too large (>100KB)"
return p.read_text(encoding="utf-8", errors="replace")

@mcp.tool()
def fetch_url(url: str) -> str:
"""Fetch the text content of a URL (first 5000 chars)."""
if not url.startswith(("http://", "https://")):
return "Error: only http/https URLs allowed"
with urllib.request.urlopen(url, timeout=10) as resp:
content = resp.read().decode("utf-8", errors="replace")
return content[:5000]

@mcp.tool()
def list_directory(path: str = ".") -> str:
"""List files in a directory."""
entries = sorted(pathlib.Path(path).iterdir())
lines = []
for e in entries:
kind = "DIR " if e.is_dir() else "FILE"
lines.append(f"{kind} {e.name}")
return "\n".join(lines) if lines else "(empty)"

if name == "main":
mcp.run()" style="color:#E6E6E6;display:none" aria-label="Copy" class="code-block-pro-copy-button">

from mcp.server.fastmcp import FastMCP
import urllib.request
import pathlib

mcp = FastMCP("dev-tools")

@mcp.tool()
def read_file(path: str) -> str:
    """Read the contents of a local file by path."""
    p = pathlib.Path(path)
    if not p.exists():
        return f"Error: file not found: {path}"
    if p.stat().st_size > 100_000:
        return "Error: file too large (>100KB)"
    return p.read_text(encoding="utf-8", errors="replace")

@mcp.tool()
def fetch_url(url: str) -> str:
    """Fetch the text content of a URL (first 5000 chars)."""
    if not url.startswith(("http://", "https://")):
        return "Error: only http/https URLs allowed"
    with urllib.request.urlopen(url, timeout=10) as resp:
        content = resp.read().decode("utf-8", errors="replace")
    return content[:5000]

@mcp.tool()
def list_directory(path: str = ".") -> str:
    """List files in a directory."""
    entries = sorted(pathlib.Path(path).iterdir())
    lines = []
    for e in entries:
        kind = "DIR " if e.is_dir() else "FILE"
        lines.append(f"{kind}  {e.name}")
    return "\n".join(lines) if lines else "(empty)"

if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

Notice the safety checks: we validate the URL scheme and limit file size. Claude will call your tools with whatever arguments it thinks are correct — always validate inputs on the server side.


Step 5: Expose Resources (Optional)

Resources are read-only data sources Claude can reference by URI. Unlike tools, Claude reads resources proactively rather than calling them on demand:

str:
"""Return the current application configuration."""
return """
APP_ENV=production
MAX_RETRIES=3
TIMEOUT=30
""".strip()

@mcp.resource("docs://readme")

def get_readme() -> str:
"""Return project README."""
return pathlib.Path("README.md").read_text()" style="color:#E6E6E6;display:none" aria-label="Copy" class="code-block-pro-copy-button">

@mcp.resource("config://app")
def get_app_config() -> str:
    """Return the current application configuration."""
    return """
APP_ENV=production
MAX_RETRIES=3
TIMEOUT=30
    """.strip()

@mcp.resource("docs://readme")  
def get_readme() -> str:
    """Return project README."""
    return pathlib.Path("README.md").read_text()
Enter fullscreen mode Exit fullscreen mode


json


Step 6: Connect to Claude Desktop

Once your server works in the inspector, add it to Claude Desktop. Open ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

{
  "mcpServers": {
    "dev-tools": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode


shell

Restart Claude Desktop. You’ll see a hammer icon in the chat interface — that confirms your MCP server is connected. Claude will now automatically use your tools when relevant.


Step 7: Connect to Claude Code

Claude Code (the CLI) also supports MCP servers. Add your server with:

claude mcp add dev-tools python /absolute/path/to/server.py
Enter fullscreen mode Exit fullscreen mode


python

Or add it to your project’s .claude/settings.json for per-project tools. Run claude mcp list to verify it’s registered.


Debugging Tips

If your server isn’t connecting or tools aren’t being called:

  • Check the server logs — MCP SDK prints errors to stderr
  • Run mcp dev server.py and test tools manually in the inspector first
  • Use absolute paths in claude_desktop_config.json — relative paths fail silently
  • Make sure python in the config points to the right virtualenv
  • Check Claude Desktop logs: ~/Library/Logs/Claude/ (macOS)

Complete Working Server

Here’s the full production-ready example with error handling:

str:
"""Read a local file. Returns file contents or an error message."""
try:
p = pathlib.Path(path).resolve()
if not p.exists():
return f"Error: {path} does not exist"
if not p.is_file():
return f"Error: {path} is not a file"
if p.stat().st_size > 200_000:
return "Error: file is larger than 200KB — too large to read"
return p.read_text(encoding="utf-8", errors="replace")
except PermissionError:
return f"Error: permission denied reading {path}"

@mcp.tool()
def fetch_url(url: str, max_chars: int = 5000) -> str:
"""Fetch text content from a URL. Returns up to max_chars characters."""
if not url.startswith(("http://", "https://")):
return "Error: only http/https URLs are allowed"
try:
req = urllib.request.Request(url, headers={"User-Agent": "MCP-Server/1.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
content = resp.read().decode("utf-8", errors="replace")
return content[:max_chars]
except urllib.error.URLError as e:
return f"Error fetching URL: {e}"

@mcp.tool()
def list_directory(path: str = ".") -> str:
"""List files and directories at the given path."""
try:
p = pathlib.Path(path)
if not p.exists():
return f"Error: {path} does not exist"
entries = sorted(p.iterdir())
lines = [f"{'DIR ' if e.is_dir() else 'FILE'} {e.name}" for e in entries]
return "\n".join(lines) if lines else "(empty directory)"
except PermissionError:
return f"Error: permission denied for {path}"

@mcp.tool()
def write_file(path: str, content: str) -> str:
"""Write text content to a file. Creates the file if it does not exist."""
try:
p = pathlib.Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return f"Written {len(content)} characters to {path}"
except Exception as e:
return f"Error writing file: {e}"

if name == "main":
mcp.run()" style="color:#E6E6E6;display:none" aria-label="Copy" class="code-block-pro-copy-button">

from mcp.server.fastmcp import FastMCP
import pathlib
import urllib.request
import urllib.error
import json

mcp = FastMCP("dev-tools", version="1.0.0")

@mcp.tool()
def read_file(path: str) -> str:
    """Read a local file. Returns file contents or an error message."""
    try:
        p = pathlib.Path(path).resolve()
        if not p.exists():
            return f"Error: {path} does not exist"
        if not p.is_file():
            return f"Error: {path} is not a file"
        if p.stat().st_size > 200_000:
            return "Error: file is larger than 200KB — too large to read"
        return p.read_text(encoding="utf-8", errors="replace")
    except PermissionError:
        return f"Error: permission denied reading {path}"

@mcp.tool()
def fetch_url(url: str, max_chars: int = 5000) -> str:
    """Fetch text content from a URL. Returns up to max_chars characters."""
    if not url.startswith(("http://", "https://")):
        return "Error: only http/https URLs are allowed"
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "MCP-Server/1.0"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            content = resp.read().decode("utf-8", errors="replace")
        return content[:max_chars]
    except urllib.error.URLError as e:
        return f"Error fetching URL: {e}"

@mcp.tool()
def list_directory(path: str = ".") -> str:
    """List files and directories at the given path."""
    try:
        p = pathlib.Path(path)
        if not p.exists():
            return f"Error: {path} does not exist"
        entries = sorted(p.iterdir())
        lines = [f"{'DIR ' if e.is_dir() else 'FILE'} {e.name}" for e in entries]
        return "\n".join(lines) if lines else "(empty directory)"
    except PermissionError:
        return f"Error: permission denied for {path}"

@mcp.tool()
def write_file(path: str, content: str) -> str:
    """Write text content to a file. Creates the file if it does not exist."""
    try:
        p = pathlib.Path(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")
        return f"Written {len(content)} characters to {path}"
    except Exception as e:
        return f"Error writing file: {e}"

if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

Next Steps

Once your basic server is working, you can extend it with:

  • Database tools — wrap SQLite, PostgreSQL, or any ORM queries as MCP tools
  • API integrations — GitHub, Jira, Slack, Notion — give Claude access to your workflow
  • HTTP transport — run your server as a persistent HTTP endpoint instead of stdio for remote access
  • Authentication — add API key checks or OAuth if exposing the server remotely
  • Browse the official MCP server registry for inspiration and ready-made servers

Conclusion

Building an MCP server gives Claude a structured, reusable way to interact with your environment. You define the tools once, and Claude uses them intelligently across all MCP clients. Start with the FastMCP decorator pattern, test with mcp dev, then connect to Claude Desktop or Claude Code. From there the sky’s the limit — database access, API integrations, custom workflows.

Have questions or built something cool with MCP? Drop a comment below.


Originally published at kalyna.pro

Top comments (0)