Engineering a Multi-Capability MCP Server in Python

Published: (December 19, 2025 at 02:58 PM EST)
9 min read
Source: Dev.to

Source: Dev.to

The Era of Chatbot Isolation Is Ending

For a long time, Large Language Models (LLMs) lived in a walled garden—brilliant reasoning engines trapped behind a chat interface, unable to touch your local files, access your database, or execute code on your machine. The Model Context Protocol (MCP) changes that paradigm entirely. It provides a standardized way to connect AI assistants to the systems where actual work happens.

However, many developers stop at the “Hello World” of MCP: exposing a simple API tool. While useful, this barely scratches the surface of the protocol’s potential. A truly robust, accessible server doesn’t just offer tools; it provides context through Resources and structured workflows through Prompts.

In this guide we will engineer a comprehensive Python‑based MCP server from scratch. We’ll move beyond simple scripts to build a multi‑tool architecture that includes:

  • Mathematical capabilities
  • Documentation access
  • Dynamic prompt templates for meeting analysis

We’ll also adopt a modern vibe‑coding workflow—using LLMs to build LLM tools—and cover the critical, often‑overlooked art of debugging with the MCP Inspector.

Before We Open the Terminal: Do I Really Need to Build This?

Senior engineering isn’t just about writing code; it’s about knowing when not to. If you only need to integrate standard services (e.g., Google Drive, Slack), it often makes little sense to develop a server that likely already exists in the community ecosystem. Redundancy is the enemy of efficiency.

For simple automation, low‑code solutions (like n8n) might offer a faster path to value. However, building a custom Python MCP server becomes non‑negotiable when:

ReasonWhen It Applies
ComplexityYou need specific, complex logic (custom calculations, data transformation) that standard APIs don’t offer.
ContextYou need to feed local, proprietary documentation (Resources) into the model’s context window.
StandardizationYou want to enforce specific interaction patterns (Prompts) for your team (e.g., a unified “Meeting Summary” structure).

If your use case fits these criteria, it’s time to code.

“Vibe Coding” – Leveraging LLM‑Assisted Development

Gone are the days of manually writing every line of boilerplate. It is not 1999. To build this server efficiently, we will use a vibe‑coding methodology—leveraging an IDE like Cursor with Claude integration to generate scaffolding based on high‑quality documentation.

Prerequisites

ToolVersion / Notes
Python3.12 or higher
Package manageruv (fast, reliable dependency management)
SDKmcp (the Python SDK)
FrameworkFastMCP (high‑level wrapper to simplify server creation)

Setting the Context for AI Assistance

  1. Index the Documentation – Create an llms.txt (or similar) containing:

    • Core MCP specifications
    • Python SDK README
    • FastMCP usage guide
  2. Pass Context to Cursor – Index these files in your IDE so the model understands the exact SDK version you’re using.

This preparation lets you prompt the model with high‑level architectural requests (e.g., “Create a server with a calculator tool”) rather than fighting syntax errors.

Tools: The Foundation of Our Server

Tools are functions the LLM can call to perform actions. We’ll build a Calculator Server, but structure it to handle various logical operations.

Implementation Strategy

FastMCP makes defining tools deceptively simple. The nuance lies in the descriptions (docstrings) because they become the API documentation the model reads to decide when to call a tool.

from mcp.server.fastmcp import FastMCP
import math

# Initialize the server
mcp = FastMCP("calculator-server")

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

@mcp.tool()
def divide(a: float, b: float) -> float:
    """Divide the first number by the second number. Includes zero checks."""
    if b == 0:
        return "Error: Cannot divide by zero"
    return a / b

Key Insight – The docstring ("""Add two numbers together.""") is not for you; it is for the LLM. If you are vague, the model may hallucinate capabilities or fail to invoke the tool when needed.

You can expand this pattern to include subtraction, multiplication, power, square root, percentage calculations, etc. By wrapping each function with @mcp.tool(), we automatically handle the JSON‑RPC communication required by the protocol.

Debugging with the MCP Inspector

Programming an MCP server without a debugging tool is like flying blind. The MCP Inspector lets you test your server in isolation, decoupling backend logic from the frontend client (Claude).

Running the Inspector

uv run mcp-inspector server.py

This spins up a local web interface (usually http://localhost:) where you can:

  • List Tools – Verify that your server is actually exposing the functions you wrote.
  • Test Execution – Manually input arguments (e.g., a=10, b=2) and see raw output or error traces.
  • Check Connections – Verify transport protocols (stdio vs. HTTP).

Security Gotcha – When the inspector launches, it may generate a URL with a security token. If you try to connect via a generic localhost URL without this token, the connection will be rejected. Always follow the specific link provided in your terminal logs to ensure authenticated access.

Use the inspector to rigorously test edge cases—like dividing by zero—before you ever attempt to connect the server to a real client.

Resources: Giving the Model Read‑Only Access

Tools let the model act. Resources let the model read.

A common mistake is treating the LLM as a static knowledge base. By integrating Resources, you give the model direct, read‑only access to specific data on your machine—logs, API documentation, or codebases.

Implementing a File‑Based Resource

from mcp.server.fastmcp import FastMCP
from pathlib import Path

# Initialize the server (if not already done)
mcp = FastMCP("calculator-server")

# Register a file resource
@mcp.resource()
def documentation() -> str:
    """Read the local documentation file."""
    doc_path = Path("docs/mcp_specification.md")
    return doc_path.read_text(encoding="utf-8")

Now the LLM can request documentation() to retrieve the full text of your MCP spec, allowing it to reason with up‑to‑date, domain‑specific information.

Putting It All Together

Below is a minimal, runnable example that combines tools, resources, and the inspector:

# server.py
from mcp.server.fastmcp import FastMCP
from pathlib import Path

mcp = FastMCP("calculator-server")

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

@mcp.tool()
def divide(a: float, b: float) -> float:
    """Divide the first number by the second number. Returns an error string on division by zero."""
    if b == 0:
        return "Error: Cannot divide by zero"
    return a / b

# ---- Resources ----
@mcp.resource()
def documentation() -> str:
    """Read the local MCP documentation file."""
    return Path("docs/mcp_specification.md").read_text(encoding="utf-8")

# Run the server (this line is optional; the inspector can start it)
if __name__ == "__main__":
    mcp.run()

To start debugging:

uv run mcp-inspector server.py

From the inspector UI you can:

  1. List the add, divide, and documentation endpoints.
  2. Invoke add(a=5, b=7) → returns 12.
  3. Invoke divide(a=10, b=0) → returns "Error: Cannot divide by zero".
  4. Invoke documentation() → returns the raw markdown of your spec file.

Next Steps

  • Add more tools (e.g., multiply, sqrt, percentage).
  • Create richer resources (e.g., JSON config files, CSV datasets).
  • Design Prompts that orchestrate multiple tool calls (e.g., “Summarize meeting notes and calculate total hours”).
  • Secure the server (TLS, authentication tokens) before exposing it to external clients.

With a solid foundation of Tools, Resources, and Prompts, your MCP server becomes a powerful bridge between LLM reasoning and real‑world systems. Happy coding!

MCP Resource Example

import Context, Resource

# Define the path to your knowledge base
resource_path = "./docs/typescript_sdk.md"

@mcp.resource("mcp://docs/typescript-sdk")
def get_typescript_sdk() -> str:
    """Provides access to the TypeScript SDK documentation."""
    with open(resource_path, "r") as f:
        return f.read()

When you add this, the MCP Inspector will show a new Resources tab. In a real‑world scenario (e.g., Claude Desktop), the user can now attach this resource to a chat. The model instantly gains the context of that file without you needing to copy‑paste thousands of words into the prompt window.

Strategic Value:
This turns your server into a dynamic library. Update the local file, and the model’s knowledge updates instantly.

Prompt Layer

Tools are reactive (the model decides to use them).
Prompts are proactive—they are standardized, user‑initiated templates designed to enforce a specific workflow.

A perfect use case is a “Meeting Summary.” Instead of typing a long instruction each time, bake that structure into the server.

Creating a Dynamic Prompt Template

{{date}}, {{transcript}}) as our template.

The Template (prompt.md)

You are an executive assistant. Analyze the following meeting transcript.

**Date:** {{date}}
**Title:** {{title}}

**Transcript:**
{{transcript}}

Please provide a summary with:
1. Overview
2. Key Decisions
3. Action Items

The Implementation

@mcp.prompt()
def meeting_summary(date: str, title: str, transcript: str) -> str:
    """Generates a meeting summary based on a transcript."""
    # Logic to read the template and replace placeholders
    # Returns the formatted prompt to the client
    return render_template(
        "prompt.md",
        date=date,
        title=title,
        transcript=transcript,
    )

Debugging Journey with Prompts

  • list_prompts and get_prompt capabilities are now handled automatically by the modern FastMCP framework, eliminating redundancy and conflicts.
  • LLM‑generated code sometimes injects arguments like model or temperature into the prompt logic.
    • Insight: Configuration (which model to use, temperature, etc.) belongs to the client settings, not the prompt template. Clean code removes these hallucinations.

When tested in the Inspector (and eventually Claude Desktop), this feature creates a form‑like UI: the user selects “Meeting Summary,” fills in the fields, and the LLM receives a perfectly engineered context package.

Bridging Server to Client

Once the server possesses Tools, Resources, and Prompts, it must be connected to the client via the claude_desktop_config.json file.

Transport Protocols: stdio vs. HTTP

stdio (Standard Input/Output)

The client spawns the server process and talks to it via terminal streams. This is secure, fast, and perfect for local development.

{
  "mcpServers": {
    "my-python-server": {
      "command": "uv",
      "args": ["run", "server.py"],
      "env": {
        "PYTHONPATH": "."
      }
    }
  }
}

HTTP (SSE – Server‑Sent Events)

As you scale, you may want to expose your server over a network. SSE over HTTP decouples the server’s lifecycle from the client’s, allowing multiple clients to connect to the same persistent server instance.

Note on Evolution: The protocol now distinguishes between transport types. While SSE was once a standalone concept, modern implementations usually involve a streamable HTTP endpoint (e.g., using Starlette or FastAPI). Clients connect to http://localhost:8000/sse.

Recap: Building an MCP Server

Transitioning from a consumer of AI to an architect of AI workflows involves three core layers:

LayerWhat It Gives the ModelExample
ToolsHands to perform calculations@mcp.tool functions
ResourcesEyes to read local documentation@mcp.resource functions
PromptsPlaybook for standard operating procedures@mcp.prompt templates

Don’t let “vibe coding” fool you. The rigor lies in:

  • Describing your tools clearly
  • Structuring your prompts thoughtfully
  • Validating your resources

Suggested workflow:

  1. Start with stdio for simplicity.
  2. Use the Inspector religiously to validate logic.
  3. Integrate into your daily workflow once everything is stable.

The code is the easy part. The real engineering lies in defining the context.

Back to Blog

Related posts

Read more »