Building a Custom MCP Server for Your AI Agent

The Model Context Protocol (MCP) is the open standard connecting AI agents to real-world tools. The official MCP Python SDK sees 23,000+ GitHub stars, and the protocol’s monthly download count has passed 97 million [1]. Most developers know what MCP is, but fewer have built their own custom server from scratch.

This tutorial walks through building a production-ready MCP server using FastMCP — the high-level Python SDK — from project setup through deployment. By the end, you’ll have a working server with tools, resources, prompts, and structured outputs that any MCP-compatible agent can use.

What you’ll build

A server that gives your AI agent three capabilities:

  • Tools — perform actions like running shell commands or querying APIs
  • Resources — expose data files and configs as URI-addressable content
  • Prompts — reusable interaction templates for common agent workflows

All wrapped in a single Python file under 100 lines.

Setup and project structure

Install the MCP Python SDK. The recommended tool is uv, but pip works too [2]:

# Using uv (recommended)
uv init mcp-custom-server
cd mcp-custom-server
uv add "mcp[cli]"

# Or pip
pip install "mcp[cli]"

The [cli] extra gives you the mcp development tools — a dev server runner and inspector for testing.

Project structure for what we’re building:

mcp-custom-server/
├── server.py       # MCP server definition
└── pyproject.toml  # Dependencies (uv init creates this)

That’s it. No framework boilerplate, no config files.

Building the server with FastMCP

FastMCP is the high-level API in the MCP Python SDK. It abstracts away JSON-RPC message handling, transport negotiation, and lifecycle management so you focus on your tools [3].

Create server.py:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("DevTools", json_response=True)

@mcp.tool()
def run_command(command: str) -> str:
    """Run a shell command and return stdout. Limited to safe operations."""
    import subprocess
    try:
        result = subprocess.run(
            command, shell=True, capture_output=True, text=True, timeout=30
        )
        return result.stdout if result.stdout else result.stderr
    except Exception as e:
        return f"Error: {e}"

@mcp.tool()
def read_env(key: str) -> str:
    """Read an environment variable value."""
    import os
    return os.environ.get(key, "Not set")

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

Three things happen here:

  1. FastMCP("DevTools") creates a server with discovery metadata
  2. @mcp.tool() registers each function as an MCP tool — the docstring becomes the tool description the LLM sees for deciding when to call it
  3. mcp.run() starts the server on stdio transport (the simplest for local development)

Run it:

uv run python server.py

The server starts and waits for JSON-RPC messages on stdin. It’s not useful alone — it needs an MCP client (like Claude Code or a custom agent) to connect.

Adding structured outputs

Functions that return Pydantic models or typed dicts automatically produce structured JSON output that agents can parse reliably [2]:

from pydantic import BaseModel
from datetime import datetime

class SystemInfo(BaseModel):
    hostname: str
    platform: str
    python_version: str
    uptime_seconds: float
    timestamp: str

@mcp.tool()
def get_system_info() -> SystemInfo:
    """Get detailed system information."""
    import platform, os
    return SystemInfo(
        hostname=platform.node(),
        platform=platform.platform(),
        python_version=platform.python_version(),
        uptime_seconds=float(open("/proc/uptime").read().split()[0]),
        timestamp=datetime.now().isoformat(),
    )

Agents get typed, predictable data instead of raw text they have to re-parse. This is the single biggest quality improvement you can make to an MCP tool.

Adding resources

Resources expose data via URI patterns — like GET endpoints in a REST API. They’re ideal for config files, documentation, or any data your agent needs to read [4]:

import json

CONFIG = {
    "allowed_commands": ["ls", "pwd", "echo", "cat", "whoami"],
    "timeout_seconds": 30,
    "log_level": "info",
}

@mcp.resource("config://server")
def get_config() -> str:
    """Server configuration as JSON string."""
    return json.dumps(CONFIG, indent=2)

@mcp.resource("config://tools/{tool_name}")
def get_tool_config(tool_name: str) -> str:
    """Get configuration for a specific tool by name."""
    return json.dumps({tool_name: CONFIG}, indent=2)

Resources follow the URI template pattern: config://tools/{tool_name} matches requests like config://tools/run_command. The agent can discover and read these resources just like browsing a filesystem.

Adding reusable prompts

Prompts are templates — you define the structure and the agent fills in the variables. They’re great for standardizing common agent workflows [5]:

@mcp.prompt()
def system_audit(target: str = "localhost") -> str:
    """Generate a structured system audit prompt for the agent."""
    return f"""Run a system audit on {target}:

1. Collect: hostname, OS version, Python version, uptime
2. Check: disk usage, memory usage, running processes
3. Report: format as a table with status column (pass/warn/fail)

Use the tools available to you. Report back in markdown."""

@mcp.prompt()
def debug_session(issue: str) -> str:
    """Start a structured debugging session for a given issue."""
    return f"""Debug the following issue:

{issue}

Follow this protocol:
1. Gather context — what changed recently?
2. Replicate — can you reproduce the issue?
3. Narrow — binary search or log analysis
4. Fix — propose a fix with reasoning
5. Verify — confirm the fix resolves the issue
"""

When connected to an MCP client, the agent can use debug_session to kick off a structured debugging workflow instead of guessing which tool to call first.

Testing with MCP Inspector

The MCP CLI includes an Inspector — a web UI for testing your server without an agent [2]:

# Start server with inspector
uv run mcp dev server.py

# Or use the standalone inspector
npx -y @modelcontextprotocol/inspector

Point your browser to http://localhost:5173 and you’ll see:

  • Tools tab — list all registered tools, inspect their schemas, call them with custom arguments
  • Resources tab — browse URI templates and fetch resource values
  • Prompts tab — inspect prompt templates and preview expanded output

The Inspector is the fastest way to find schema errors, wrong return types, or unexpected error messages before deploying.

Running with an agent

Connect your server to Claude Code for interactive use:

# Start server (keep this running)
uv run python server.py &

# Add to Claude Code
claude mcp add --transport stdio dev-tools \
  -- uv run --directory /path/to/mcp-custom-server python server.py

For streamable HTTP transport (useful for remote agent access) [2]:

# In server.py, change the last line:
if __name__ == "__main__":
    mcp.run(transport="streamable-http")
uv run python server.py
# Server listens on http://localhost:8000/mcp
claude mcp add --transport http dev-tools http://localhost:8000/mcp

Deployment patterns

Three production patterns based on the MCP architecture [1]:

Pattern Transport When to Use
Local stdio stdio Developer machine, single-user, low latency
Streamable HTTP HTTP/SSE Multi-service backends, shared teams
Docker stdio stdio via Docker Reproducible environments, CI/CD

Docker example for team deployment:

FROM python:3.12-slim
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY pyproject.toml server.py /app/
RUN uv sync --frozen
CMD ["uv", "run", "python", "server.py"]
# Start container
docker run -i --rm mcp-custom-server

# Connect agent
claude mcp add --transport stdio dev-tools \
  -- docker run -i --rm mcp-custom-server

The -i flag is critical — stdio transport requires stdin to be interactive for bidirectional JSON-RPC messaging.

Putting it all together

The complete server in under 80 lines — with three tools, two resources, two prompts, structured output, and debug logging — lives in the MCP Python SDK examples [2]. Copy it, customize the tools for your domain, and you have an agent-compatible extension in minutes.

The killer pattern: start with one tool that does something your existing workflow can’t. Add resources as you find yourself copy-pasting config data. Add prompts when you notice your agent keeps running the same multi-step sequence. The server grows with your needs, not before them.

References

[1] MCP Architecture Overview — https://modelcontextprotocol.io/docs/concepts/architecture [2] MCP Python SDK GitHub — https://github.com/modelcontextprotocol/python-sdk [3] MCP FastMCP Quickstart — https://github.com/modelcontextprotocol/python-sdk#quickstart [4] MCP Tools Documentation — https://modelcontextprotocol.io/docs/concepts/tools [5] MCP Prompts Documentation — https://modelcontextprotocol.io/docs/concepts/prompts

← Back to all posts