$catMANUAL||~47 min

Building an MCP Server from Scratch: A Hands-On Guide for Developers

advertisement

Building an MCP Server from Scratch: A Hands-On Guide for Developers

I wrote a piece a while back explaining what MCP is and why it matters. Someone in the comments asked: "OK, I get the concept — but how do I actually build one?" Fair question. Let's do it.

I'll be honest: my first attempt at building an MCP server was a disaster. Not because the protocol is hard, but because the docs are written in that "technically correct but somehow unhelpful" way. Every concept is explained clearly, yet you still don't know where to start. It's like a cookbook that lists twenty ingredients but never tells you whether to heat the pan first.

Spent about two days figuring it out, hit a bunch of walls. Writing this so you don't have to repeat my mistakes.

What We're Building

MCP (Model Context Protocol) is a standardized way for AI models to call external tools. Think of it as USB for the AI world — instead of writing custom integration code for every tool, you implement MCP once and it works with every MCP-compatible client.

What we're doing is straightforward: write an MCP server that exposes some tools, so clients like Claude Code or Hermes Agent can use them.

Sounds fancy? The code is surprisingly minimal. FastMCP, the Python framework, handles most of the heavy lifting. You just need to care about one thing: "What do I want the AI to be able to do?"

Setting Up

I'm using Python 3.11+ and recommend uv for dependency management. If you don't have uv yet:

bash
1
curl -LsSf https://astral.sh/uv/install.sh | sh

Then create the project:

bash
1
uv init my-mcp-server
2
cd my-mcp-server
3
uv add "mcp[cli]"

That's it. Three commands. mcp[cli] installs both the MCP Python SDK and the CLI tools.

If you insist on using pip:

bash
1
pip install "mcp[cli]"

But seriously, use uv. It's fast, handles dependency resolution properly, and you won't waste half an hour debugging version conflicts.

FastMCP: A Working Server in Three Minutes

FastMCP is the high-level wrapper provided by the MCP Python SDK. Writing an MCP server with it is about as complex as writing a Flask route.

Start with the simplest possible example:

python
1
from mcp.server.fastmcp import FastMCP
2
 
3
mcp = FastMCP("My First MCP Server")
4
 
5
@mcp.tool()
6
def add(a: int, b: int) -> int:
7
    """Add two numbers"""
8
    return a + b
9
 
10
if __name__ == "__main__":
11
    mcp.run()

That's really all there is. The @mcp.tool() decorator turns a regular function into an MCP tool. The function's docstring automatically becomes the tool's description — the AI model reads this to decide when to call your tool.

Run it:

bash
1
uv run server.py

By default it uses stdio transport, meaning it communicates through standard input/output. This works great for local use — no ports to open.

The Three Core Concepts

Before we go further, you need to understand MCP's three building blocks. Get this wrong and you'll be confused about which one to use when.

Tools

Tools are the most common. They let the AI "do things" — call APIs, write files, query databases, send messages. Anything with side effects should be a tool.

python
1
@mcp.tool()
2
def search_users(query: str, limit: int = 10) -> str:
3
    """Search users
4
    
5
    Args:
6
        query: Search keywords
7
        limit: Maximum number of results to return
8
    """
9
    results = do_user_search(query, limit)
10
    return json.dumps(results)

That Args section in the docstring? It's not decoration. The AI model reads those descriptions to understand what each parameter means. Write them well and the model calls your tool accurately. Write them poorly and the model guesses.

Resources

Resources expose data to the AI. They're like GET endpoints in a REST API — they provide information without side effects.

python
1
@mcp.resource("config://app/settings")
2
def get_app_settings() -> str:
3
    """Get application configuration"""
4
    return json.dumps({
5
        "theme": "dark",
6
        "language": "en",
7
        "version": "1.0.0"
8
    })
9
 
10
@mcp.resource("users://{user_id}/profile")
11
def get_user_profile(user_id: str) -> str:
12
    """Get user profile"""
13
    user = db.get_user(user_id)
14
    return json.dumps(user)

Resources use URI templates for identification, with support for path parameters. AI clients can list available resources and read them on demand.

Prompts

Prompts are predefined prompt templates. Honestly, these come up less often — tools and resources cover most use cases. But they're handy when you want the AI to process something in a specific format:

python
1
@mcp.prompt()
2
def code_review(code: str, language: str = "python") -> str:
3
    """Generate a code review prompt"""
4
    return f"""Review this {language} code, focusing on:
5
1. Potential bugs
6
2. Performance issues
7
3. Code style
8
 
9
Code:
10
```{language}
11
{code}
12
```"""

Real Example: A Task Manager Server

A calculator is boring. Let's build something actually useful — an MCP server for a simple task management system. Imagine you want the AI to help you manage your TODO list.

python
1
import json
2
import os
3
from datetime import datetime
4
from mcp.server.fastmcp import FastMCP
5
 
6
mcp = FastMCP("Task Manager")
7
 
8
DATA_FILE = os.path.expanduser("~/.task_manager/tasks.json")
9
 
10
def load_tasks() -> list[dict]:
11
    """Load tasks from file"""
12
    if not os.path.exists(DATA_FILE):
13
        return []
14
    with open(DATA_FILE, "r", encoding="utf-8") as f:
15
        return json.load(f)
16
 
17
def save_tasks(tasks: list[dict]) -> None:
18
    """Save tasks to file"""
19
    os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
20
    with open(DATA_FILE, "w", encoding="utf-8") as f:
21
        json.dump(tasks, f, indent=2)
22
 
23
@mcp.tool()
24
def add_task(title: str, priority: str = "medium", due_date: str = "") -> str:
25
    """Add a new task
26
    
27
    Args:
28
        title: Task title
29
        priority: Priority level: high/medium/low
30
        due_date: Due date in YYYY-MM-DD format, optional
31
    """
32
    tasks = load_tasks()
33
    task = {
34
        "id": len(tasks) + 1,
35
        "title": title,
36
        "priority": priority,
37
        "due_date": due_date,
38
        "status": "pending",
39
        "created_at": datetime.now().isoformat()
40
    }
41
    tasks.append(task)
42
    save_tasks(tasks)
43
    return f"Task added: #{task['id']} {title}"
44
 
45
@mcp.tool()
46
def list_tasks(status: str = "all") -> str:
47
    """List tasks
48
    
49
    Args:
50
        status: Filter by status: all/pending/done
51
    """
52
    tasks = load_tasks()
53
    if status != "all":
54
        tasks = [t for t in tasks if t["status"] == status]
55
    
56
    if not tasks:
57
        return "No tasks found"
58
    
59
    lines = []
60
    for t in tasks:
61
        icon = "✅" if t["status"] == "done" else "⬜"
62
        lines.append(f"{icon} #{t['id']} [{t['priority']}] {t['title']}")
63
    return "\n".join(lines)
64
 
65
@mcp.tool()
66
def complete_task(task_id: int) -> str:
67
    """Mark a task as complete
68
    
69
    Args:
70
        task_id: Task ID number
71
    """
72
    tasks = load_tasks()
73
    for t in tasks:
74
        if t["id"] == task_id:
75
            t["status"] = "done"
76
            t["completed_at"] = datetime.now().isoformat()
77
            save_tasks(tasks)
78
            return f"Task #{task_id} completed: {t['title']}"
79
    return f"Task #{task_id} not found"
80
 
81
@mcp.tool()
82
def delete_task(task_id: int) -> str:
83
    """Delete a task
84
    
85
    Args:
86
        task_id: Task ID number
87
    """
88
    tasks = load_tasks()
89
    original_count = len(tasks)
90
    tasks = [t for t in tasks if t["id"] != task_id]
91
    if len(tasks) == original_count:
92
        return f"Task #{task_id} not found"
93
    save_tasks(tasks)
94
    return f"Task #{task_id} deleted"
95
 
96
@mcp.resource("tasks://stats")
97
def get_task_stats() -> str:
98
    """Get task statistics"""
99
    tasks = load_tasks()
100
    total = len(tasks)
101
    done = sum(1 for t in tasks if t["status"] == "done")
102
    pending = total - done
103
    high_priority = sum(1 for t in tasks if t["priority"] == "high" and t["status"] == "pending")
104
    
105
    return json.dumps({
106
        "total": total,
107
        "done": done,
108
        "pending": pending,
109
        "high_priority_pending": high_priority,
110
        "completion_rate": f"{done/total*100:.1f}%" if total > 0 else "N/A"
111
    })
112
 
113
if __name__ == "__main__":
114
    mcp.run()

This server has four tools and one resource. You can tell the AI something like "add a task: finish the report by tomorrow, high priority" and it'll call add_task for you.

Testing with MCP Inspector

Before hooking up your server to a real client, test it with the MCP Inspector. It's a visual debugging tool that lets you see and call your tools in a browser.

First, switch to streamable-http transport (the Inspector needs HTTP):

python
1
 
2
# Change the last line of server.py
3
if __name__ == "__main__":
4
    mcp.run(transport="streamable-http")
5
 

Then launch the Inspector:

bash
1
npx -y @modelcontextprotocol/inspector
2
 

A browser UI opens at http://localhost:8000/mcp. You can see all registered tools, resources, and prompts, and call them directly.

I didn't know about this tool the first time I built an MCP server. Went straight to Claude Code, got errors, had no idea what was wrong. Found the Inspector later — debugging speed doubled instantly.

Connecting to Claude Code

Once testing is done, connecting to Claude Code is one command:

bash
1
claude mcp add --transport http task-manager http://localhost:8000/mcp
2
 

Restart Claude Code and the task management features are available in your conversations. Say "show me my pending tasks" and Claude Code automatically calls list_tasks.

For stdio transport (no HTTP), edit ~/.claude/claude_code_config.json:

json
1
{
2
  "mcpServers": {
3
    "task-manager": {
4
      "command": "uv",
5
      "args": ["run", "python", "/path/to/server.py"]
6
    }
7
  }
8
}
9
 

With this approach, Claude Code launches your server process directly and communicates through stdin/stdout. Good for local tools that don't need networking.

Connecting to Hermes Agent

Hermes Agent supports MCP too. Add to your config:

yaml
1
mcp:
2
  servers:
3
    task-manager:
4
      url: http://localhost:8000/mcp
5
      transport: streamable-http
6
 

Or with stdio:

yaml
1
mcp:
2
  servers:
3
    task-manager:
4
      command: uv
5
      args: ["run", "python", "/path/to/server.py"]
6
      transport: stdio
7
 

Once configured, Hermes Agent can call your tools. Feels pretty great in practice — tell the AI "mark the blog writing task as done" and it just does it. No manually editing JSON files.

Going Further: Database Integration

The examples above use a JSON file for storage. In a real project, you'd probably use a database. MCP servers support lifespan management — connect to the database on startup, disconnect on shutdown.

python
1
import sqlite3
2
from collections.abc import AsyncIterator
3
from contextlib import asynccontextmanager
4
from dataclasses import dataclass
5
from mcp.server.fastmcp import Context, FastMCP
6
 
7
@dataclass
8
class AppContext:
9
    db: sqlite3.Connection
10
 
11
@asynccontextmanager
12
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
13
    """Manage database connection lifecycle"""
14
    db = sqlite3.connect("tasks.db")
15
    db.execute("""
16
        CREATE TABLE IF NOT EXISTS tasks (
17
            id INTEGER PRIMARY KEY AUTOINCREMENT,
18
            title TEXT NOT NULL,
19
            priority TEXT DEFAULT 'medium',
20
            status TEXT DEFAULT 'pending',
21
            created_at TEXT,
22
            completed_at TEXT
23
        )
24
    """)
25
    db.commit()
26
    try:
27
        yield AppContext(db=db)
28
    finally:
29
        db.close()
30
 
31
mcp = FastMCP("Task Manager", lifespan=app_lifespan)
32
 
33
@mcp.tool()
34
def add_task(title: str, priority: str = "medium", ctx: Context = None) -> str:
35
    """Add a new task"""
36
    db = ctx.request_context.lifespan_context.db
37
    cursor = db.execute(
38
        "INSERT INTO tasks (title, priority, created_at) VALUES (?, ?, ?)",
39
        (title, priority, datetime.now().isoformat())
40
    )
41
    db.commit()
42
    return f"Task added: #{cursor.lastrowid} {title}"
43
 
44
@mcp.tool()
45
def list_tasks(status: str = "all", ctx: Context = None) -> str:
46
    """List tasks"""
47
    db = ctx.request_context.lifespan_context.db
48
    if status == "all":
49
        rows = db.execute("SELECT * FROM tasks").fetchall()
50
    else:
51
        rows = db.execute("SELECT * FROM tasks WHERE status = ?", (status,)).fetchall()
52
    
53
    if not rows:
54
        return "No tasks found"
55
    
56
    lines = []
57
    for row in rows:
58
        icon = "✅" if row[3] == "done" else "⬜"
59
        lines.append(f"{icon} #{row[0]} [{row[2]}] {row[1}]")
60
    return "\n".join(lines)
61
 

Notice the ctx: Context parameter — FastMCP injects it automatically. Access the database through ctx.request_context.lifespan_context.

Streamable HTTP vs SSE vs Stdio

MCP supports three transport options. Which one depends on your use case:

Stdio: Simplest option. The client launches your server process directly. No network config needed. Claude Code defaults to this.

SSE (Server-Sent Events): HTTP-based one-way push. Early MCP versions used this, now superseded by Streamable HTTP. If you see old tutorials using SSE, switch to Streamable HTTP.

Streamable HTTP: Latest transport, supports bidirectional communication. Best for remote deployment and shared servers. If you're deploying an MCP server for your team, use this.

In practice: stdio for local development, Streamable HTTP for server deployment. SSE is basically obsolete.

Debugging Tips

Debugging MCP servers is unavoidable. Here's what I've learned from hitting walls:

Logging matters. Your server runs in the background — you can't see its output. Add logging:

python
1
import logging
2
 
3
logging.basicConfig(level=logging.DEBUG)
4
logger = logging.getLogger("mcp-server")
5
 
6
@mcp.tool()
7
def my_tool(query: str) -> str:
8
    logger.info(f"Received request: {query}")
9
    try:
10
        result = do_something(query)
11
        logger.info(f"Returning result: {result}")
12
        return result
13
    except Exception as e:
14
        logger.error(f"Error: {e}")
15
        raise
16
 

Strict parameter types. MCP validates parameters against your function's type annotations. If you write def foo(x: int) but pass a string, it errors. Don't assume "Python doesn't enforce types" — MCP does.

Friendly error messages. When a tool fails, return a meaningful error message. Don't let the AI see a raw traceback:

python
1
@mcp.tool()
2
def risky_operation(path: str) -> str:
3
    """Execute an operation that might fail"""
4
    try:
5
        result = do_something_risky(path)
6
        return f"Success: {result}"
7
    except FileNotFoundError:
8
        return f"Error: File not found: {path}"
9
    except PermissionError:
10
        return f"Error: No permission to access {path}"
11
    except Exception as e:
12
        return f"Error: {str(e)}"
13
 

Test edge cases with Inspector. Pass empty strings, huge numbers, special characters. See if your tools handle them correctly. AI users are more "creative" with input than you'd expect.

A Real Debugging Story

Last week I was building an MCP server to query GitHub repository info. Code was done, Inspector tests all passed, but it flat-out refused to work in Claude Code.

Spent ages debugging. Turns out the problem was my function's return value — I was returning a Python object, not a string. The MCP protocol requires tool return values to be strings (or JSON-serializable things). The Inspector handled it automatically, but Claude Code's MCP client wasn't as forgiving.

Fix was simple — make sure the return value is a string:

python
1
@mcp.tool()
2
def get_repo_info(owner: str, repo: str) -> str:
3
    """Get GitHub repository info"""
4
    info = github_api.get_repo(owner, repo)
5
    return json.dumps(info)  # Must serialize to string
6
 

This is documented, but not prominently. I skimmed right past it and wasted two hours.

Security Considerations

MCP servers execute external requests. Security can't be an afterthought.

Don't expose dangerous operations. If your tool can delete files or execute commands, add permission checks. AI models sometimes "creatively" interpret your tool descriptions and do things you didn't expect.

Validate input parameters. AI-provided parameters aren't always reliable:

python
1
@mcp.tool()
2
def delete_file(path: str) -> str:
3
    """Delete a specified file"""
4
    # Always validate the path!
5
    if not path.startswith("/safe/directory/"):
6
        return "Error: Can only delete files in the safe directory"
7
    if ".." in path:
8
        return "Error: Path cannot contain .."
9
    # ... proceed with deletion
10
 

Consider rate limiting. If your tool calls external APIs, add rate limiting. Don't let the AI loop through and burn through your API quota.

Deploying to a Server

Local development with stdio is fine, but for team use or multi-client sharing, deploy to a server.

Using Streamable HTTP with uvicorn:

python
1
 
2
# server.py
3
from mcp.server.fastmcp import FastMCP
4
 
5
mcp = FastMCP("Team Tools Server")
6
 
7
@mcp.tool()
8
def team_tool(query: str) -> str:
9
    """Shared team tool"""
10
    return f"Result: {query}"
11
 
12
if __name__ == "__main__":
13
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
14
 

Then run it with systemd or Docker:

bash
1
 
2
# With systemd
3
sudo systemctl enable my-mcp-server
4
sudo systemctl start my-mcp-server
5
 
6
# Or with Docker
7
docker run -d -p 8000:8000 my-mcp-server
8
 

Clients connect using the server address:

bash
1
claude mcp add --transport http team-tools http://your-server:8000/mcp
2
 

What Else Can You Build?

MCP goes way beyond task managers. I've seen people build:

  • Database query tools: Let AI query SQL databases directly
  • API gateways: Wrap multiple third-party APIs as MCP tools for unified AI access
  • File management: Let AI read/write local files, manage project structure
  • Monitoring and alerts: AI checks server health periodically, notifies on issues
  • Code generators: Generate code snippets from templates

The pattern is simple: anything you want AI to help with, wrap it as an MCP tool.

What's Next

I'm planning to build a few more practical MCP servers:

  1. An Airtable integration — let AI manage my content tables directly
  2. A system monitoring tool — check CPU, memory, disk usage
  3. A web scraper tool — fetch and parse web pages on demand

I'll write those up when they're done. Got questions? Drop them in the comments.

Resources:

advertisement

Building an MCP Server from Scratch: A Hands-On Guide for Developers — AI Hub