Building Your First MCP Server

Module 16: MCP & Tool Integration | Expansion Guide

Back to Module 16

The Problem

Claude can write code, explain concepts, and answer questions. But it can't read your filesystem, query your database, or call your APIs. It's a brain in a jar - powerful but disconnected.

You've tried copy-pasting file contents. You've manually run commands and fed results back. It works, but it's slow, error-prone, and kills the flow state. You need Claude to do things, not just suggest things.

The Model Context Protocol (MCP) turns Claude from a chatbot into an agent with capabilities. But most tutorials are either too abstract ("here's the protocol spec") or too complex ("build a full production server"). You need the middle path: build something that works in 30 minutes, understand the fundamentals, then scale up.

The Core Insight

MCP is just three things: resources, tools, and prompts. Everything else is plumbing.

Think of MCP servers like browser extensions:

The protocol handles discovery, parameter validation, and result formatting. You just define what's available and implement the handlers. It's an RPC framework with AI affordances baked in.

Primitive Purpose Example
Resource Expose readable data File contents, API docs, logs
Tool Execute actions Search files, run query, send email
Prompt Templated workflows "Review this PR", "Debug error"

The Walkthrough

Step 1: Install the SDK

We'll use Python (TypeScript works too). Install the official SDK:

pip install mcp

Step 2: Create a Minimal Server

Create file_search_server.py. This server will expose one tool: searching files by pattern.

#!/usr/bin/env python3
from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.server.stdio
import os
import fnmatch

# Initialize server
server = Server("file-search-server")

# Define the tool
@server.list_tools()
async def list_tools():
    return [
        Tool(
            name="search_files",
            description="Search for files matching a pattern in a directory",
            inputSchema={
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "File pattern to match (e.g., '*.py', 'test_*.js')"
                    },
                    "directory": {
                        "type": "string",
                        "description": "Directory to search in (default: current directory)"
                    }
                },
                "required": ["pattern"]
            }
        )
    ]

# Implement the tool
@server.call_tool()
async def call_tool(name: str, arguments: dict):
    if name != "search_files":
        raise ValueError(f"Unknown tool: {name}")

    pattern = arguments["pattern"]
    directory = arguments.get("directory", ".")

    # Safety: prevent directory traversal
    directory = os.path.abspath(directory)

    matches = []
    for root, dirs, files in os.walk(directory):
        for filename in files:
            if fnmatch.fnmatch(filename, pattern):
                full_path = os.path.join(root, filename)
                relative_path = os.path.relpath(full_path, directory)
                matches.append(relative_path)

    result = f"Found {len(matches)} files:\n" + "\n".join(matches[:50])
    if len(matches) > 50:
        result += f"\n... and {len(matches) - 50} more"

    return [TextContent(type="text", text=result)]

# Run the server
async def main():
    async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            server.create_initialization_options()
        )

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

Step 3: Configure Claude Desktop

Tell Claude Desktop about your server. Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or equivalent on other platforms:

{
  "mcpServers": {
    "file-search": {
      "command": "python3",
      "args": ["/absolute/path/to/file_search_server.py"]
    }
  }
}

Path Must Be Absolute

Use the full path to your script. Relative paths and ~ won't work. Run pwd in your script directory to get the absolute path.

Step 4: Test It

Restart Claude Desktop. The server runs automatically when Claude starts. Try this prompt:

"Search for all Python files in my current directory"

Claude will:

  1. Discover the search_files tool
  2. Generate arguments: {"pattern": "*.py"}
  3. Call your server
  4. Present the results

You've just given Claude filesystem awareness. With 60 lines of Python.

Understanding the Request-Response Cycle

Here's what happens under the hood:

  1. Initialization: Claude Desktop launches your server via stdio
  2. Discovery: Claude calls list_tools() to see what's available
  3. Invocation: User prompt triggers Claude to call call_tool()
  4. Execution: Your handler runs, returns result
  5. Presentation: Claude incorporates results into its response

The server stays alive for the session. No startup cost per-tool-use.

Failure Patterns

1. Server Crashes Silently

Symptom: Claude says "tool unavailable" but gives no error message.

Fix: Add logging. MCP servers run headless - print statements disappear. Use a log file:

import logging
logging.basicConfig(
    filename='/tmp/mcp_server.log',
    level=logging.DEBUG,
    format='%(asctime)s - %(message)s'
)
logger = logging.getLogger(__name__)

# Then use logger.debug() throughout your code

2. Tool Never Gets Called

Symptom: Claude doesn't attempt to use your tool, even when relevant.

Fix: Improve the tool description. Claude decides what to call based on descriptions. Be specific:

# Bad
description="Search for files"

# Good
description="Search for files matching a glob pattern (e.g., '*.py', 'test_*') in a directory tree. Returns up to 50 matching file paths."

3. Invalid Parameters

Symptom: Your handler crashes on unexpected input types.

Fix: Validate early, fail with helpful messages:

if not isinstance(pattern, str) or not pattern.strip():
    raise ValueError("pattern must be a non-empty string")

if ".." in directory or directory.startswith("/"):
    raise ValueError("directory must be relative and cannot contain ..")

4. Results Too Large

Symptom: Server times out or Claude says "response truncated".

Fix: Limit output size. Return summaries, not full contents:

# Don't return 10MB of file contents
# Do return: "Found 150 files totaling 10MB. First 10: ..."

The Discovery Problem

Claude won't use a tool unless it knows it exists. If you add a new tool, restart Claude Desktop to force re-discovery. In production, implement the tools/list notification to push updates.

Extending Your Server

Once the basic pattern works, add more tools:

# Add a "read file" tool
@server.list_tools()
async def list_tools():
    return [
        # ... search_files tool ...
        Tool(
            name="read_file",
            description="Read contents of a file",
            inputSchema={
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Path to the file"
                    }
                },
                "required": ["path"]
            }
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    if name == "search_files":
        # ... existing implementation ...

    elif name == "read_file":
        path = arguments["path"]
        # Safety checks
        path = os.path.abspath(path)
        if not os.path.isfile(path):
            raise ValueError(f"Not a file: {path}")

        with open(path, 'r') as f:
            content = f.read(100000)  # Limit to 100KB

        return [TextContent(type="text", text=content)]

    else:
        raise ValueError(f"Unknown tool: {name}")

Now Claude can search for files AND read them. Combine tools in one prompt:

"Find all configuration files and show me the database connection settings"

Quick Reference

MCP Server Checklist:

Common Issues:

Next Steps: