Engineering a Multi-Capability MCP Server in Python
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:
| Reason | When It Applies |
|---|---|
| Complexity | You need specific, complex logic (custom calculations, data transformation) that standard APIs don’t offer. |
| Context | You need to feed local, proprietary documentation (Resources) into the model’s context window. |
| Standardization | You 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
| Tool | Version / Notes |
|---|---|
| Python | 3.12 or higher |
| Package manager | uv (fast, reliable dependency management) |
| SDK | mcp (the Python SDK) |
| Framework | FastMCP (high‑level wrapper to simplify server creation) |
Setting the Context for AI Assistance
-
Index the Documentation – Create an
llms.txt(or similar) containing:- Core MCP specifications
- Python SDK README
- FastMCP usage guide
-
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 (
stdiovs.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:
- List the
add,divide, anddocumentationendpoints. - Invoke
add(a=5, b=7)→ returns12. - Invoke
divide(a=10, b=0)→ returns"Error: Cannot divide by zero". - 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_promptsandget_promptcapabilities are now handled automatically by the modern FastMCP framework, eliminating redundancy and conflicts.- LLM‑generated code sometimes injects arguments like
modelortemperatureinto 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:
| Layer | What It Gives the Model | Example |
|---|---|---|
| Tools | Hands to perform calculations | @mcp.tool functions |
| Resources | Eyes to read local documentation | @mcp.resource functions |
| Prompts | Playbook 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:
- Start with
stdiofor simplicity. - Use the Inspector religiously to validate logic.
- Integrate into your daily workflow once everything is stable.
The code is the easy part. The real engineering lies in defining the context.