Building a Custom MCP Server with Python: Connecting AI to Your Systems

Artificial Intelligence tutorial - IT technology blog
Artificial Intelligence tutorial - IT technology blog

A few months ago, a colleague of mine complained that even though he was using Claude to help with his work, he still had to spend half the day copy-pasting logs from the server into chat, then copying the analysis results back out for reports. “Can Claude just read directly from my server?” — that question got me started looking into MCP.

He’s not alone in this. Most AI models operate like a “black box”: they know a lot from their training data, but they’re completely blind to your own systems. Internal databases, file server directories, company-specific APIs — all of it is out of reach. What’s needed is a bridge. That’s exactly what an MCP Server is.

What is MCP and Why Do You Need It?

MCP — short for Model Context Protocol — is an open protocol developed by Anthropic that allows AI models to connect with external tools and data sources in a standardized way.

Think of it this way: USB is the standard for hardware connectivity — mice, keyboards, and hard drives all plug in through a single port. MCP plays the same role for AI. Any client that supports MCP can connect to any MCP server, with no need for custom integrations between each pair.

Three components make up the MCP architecture:

  • MCP Host: The application running the AI — Claude Desktop, Cursor, or a custom-built app
  • MCP Client: The component inside the Host that handles MCP communication
  • MCP Server: Your server — it exposes tools, resources, and prompts to the AI

A server can provide three types of things to the AI:

  • Tools: Functions the AI can call (read files, query databases, call APIs…)
  • Resources: Data the AI reads via URI — similar to fetching a URL
  • Prompts: Reusable prompt templates for repetitive tasks

Building Your First MCP Server with Python

We’ll build a “File Manager” — a server that lets the AI list directories and read file contents. Simple, but enough to see how MCP works in practice. This is actually a server I use daily for log analysis: instead of copy-pasting hundreds of lines into chat, Claude reads directly from /var/log/ and analyzes on the spot.

Step 1: Install the MCP Python SDK

Create a virtual environment and install the library:

python -m venv venv
source venv/bin/activate  # Linux/macOS
# venv\Scripts\activate   # Windows

pip install mcp

Step 2: Create a Basic MCP Server

Create the file file_manager_server.py:

from mcp.server.fastmcp import FastMCP
import os
import json
import logging
from pathlib import Path
import datetime

# Configure logging to track all tool calls
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
log = logging.getLogger(__name__)

# Initialize server with display name
mcp = FastMCP("File Manager")

# Define the allowed directory for access (important for security)
ALLOWED_DIR = Path("/home/user/documents").resolve()


def _is_safe_path(filepath: str) -> bool:
    """Check if the path is within the allowed directory"""
    try:
        target = Path(filepath).resolve()
        return str(target).startswith(str(ALLOWED_DIR))
    except Exception:
        return False


@mcp.tool()
def list_files(directory: str) -> str:
    """List files and directories at the specified path."""
    if not _is_safe_path(directory):
        return "Error: Access to this directory is not allowed"

    log.info(f"list_files: {directory}")
    try:
        path = Path(directory)
        items = []
        for item in sorted(path.iterdir()):
            items.append({
                "name": item.name,
                "type": "directory" if item.is_dir() else "file",
                "size_kb": round(item.stat().st_size / 1024, 2) if item.is_file() else None
            })
        return json.dumps({"path": directory, "items": items, "count": len(items)}, ensure_ascii=False)
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
def read_file(filepath: str, max_lines: int = 100) -> str:
    """Read text file content. max_lines defaults to 100 to avoid context overflow."""
    if not _is_safe_path(filepath):
        return "Error: Access to this file is not allowed"

    log.info(f"read_file: {filepath} (max_lines={max_lines})")
    try:
        with open(filepath, 'r', encoding='utf-8') as f:
            lines = f.readlines()

        total_lines = len(lines)
        content = ''.join(lines[:max_lines])

        if total_lines > max_lines:
            content += f"\n... ({total_lines - max_lines} more lines remaining, increase max_lines if needed)"

        return content
    except UnicodeDecodeError:
        return "Error: File is not text or the encoding is not supported"
    except Exception as e:
        return f"Error: {str(e)}"


@mcp.tool()
def get_file_info(filepath: str) -> str:
    """Get file metadata: size and last modified time."""
    if not _is_safe_path(filepath):
        return "Error: Access denied"

    try:
        stat = Path(filepath).stat()
        return json.dumps({
            "path": filepath,
            "size_bytes": stat.st_size,
            "size_kb": round(stat.st_size / 1024, 2),
            "modified": datetime.datetime.fromtimestamp(stat.st_mtime).isoformat(),
            "is_file": Path(filepath).is_file()
        }, ensure_ascii=False)
    except Exception as e:
        return f"Error: {str(e)}"


if __name__ == "__main__":
    mcp.run()

Step 3: Run the Server

Run it directly to check for syntax errors:

python file_manager_server.py

The terminal goes completely silent — that’s correct, not an error. MCP communication uses stdin/stdout following the JSON-RPC standard; the server waits for connections rather than printing anything to the screen like a typical web server would.

Step 4: Connect to Claude Desktop

Open the Claude Desktop config file. On macOS:

nano ~/Library/Application\ Support/Claude/claude_desktop_config.json

On Linux:

nano ~/.config/claude/claude_desktop_config.json

Add your server to the config:

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

Restart Claude Desktop. The MCP icon (a hammer) will appear in the chat — from this point on, Claude can call your tools directly within conversations, with no more copy-pasting required.

Going Further: Exposing Resources for Static Data

Tools aren’t the only thing you can expose. Resources let the AI read data via URIs like config://app-settings — useful when you want the AI to always have context about your system without needing to ask every time. Here’s an example that exposes a config file so Claude always knows the current app configuration:

@mcp.resource("config://app-settings")
def get_app_config() -> str:
    """Return the current application configuration"""
    config_path = ALLOWED_DIR / "config.json"
    if config_path.exists():
        return config_path.read_text(encoding='utf-8')
    return "{}"

The AI reads config://app-settings like fetching a URL — much cleaner than remembering a file path and passing it manually to a tool every time.

Key Considerations for Real-World Deployment

After running a self-built MCP server for a while, I ran into a few issues that would have saved me a lot of debugging time had I known about them earlier:

  • Always validate input: The AI can pass any string to a tool — including ../../etc/passwd. The _is_safe_path() function in the example prevents the AI from escaping the allowed directory. Skipping this is a serious security vulnerability, not an excess of caution.
  • Limit output size: max_lines=100 matters more than you’d think. Reading a 100MB log file without limits instantly overflows the context window, Claude returns an error, and the entire session is effectively lost.
  • Return errors as strings, don’t raise exceptions: The AI can read the error message and relay it to the user. Raising an exception gives Claude a generic error message, leaving the user with no idea what went wrong.
  • Log every tool call: When something goes wrong, you need to know which tool Claude called, with what parameters, and at what time. Without logs, debugging is nearly impossible.

Conclusion

At its core, an MCP Server is a Python script that exposes regular functions over JSON-RPC — the AI calls them just like an API. There’s nothing mysterious about it. If you can write Python functions and handle exceptions, you have everything you need to build a complete MCP Server.

The best part about the MCP standard: write your server once, and any client can use it. Claude Desktop today, Cursor next week, an internal app next month — they all connect to the same server without rewriting any integration code.

Once you’re comfortable with the file system example, try connecting to SQLite or PostgreSQL so Claude can query your database directly. Or wrap your company’s internal APIs as MCP tools. My colleague, after getting his server deployed, no longer has to copy-paste logs at all — Claude reads and analyzes everything automatically, and he just reads the results.

Share: