Last month we shipped 3 MCP servers for a fintech client. Each one replaced a bespoke integration layer that had cost them $14,200 in contractor hours over the previous quarter. The servers themselves? Under 200 lines of Python each. Total build time: 4.5 days.
That is the gap this protocol closes. And if you are still writing one-off function-calling wrappers for every AI tool your team uses, you are bleeding engineering hours you will never get back.
This is the MCP server tutorial we wish someone had written for us 8 months ago — before we rebuilt the same integration 3 separate times for Claude Desktop, Cursor, and a custom agent framework. Every concept is explained completely, every code block runs, and by the end you will have a working server you can plug into any MCP-compatible client today.
The Problem MCP Kills (and Why You Should Care About the $14K Version)
Before MCP, every AI assistant spoke its own dialect for calling external tools. OpenAI had function calling JSON. Claude had its own XML-based tool format. Cursor had a proprietary plugin spec. Windsurf had another. If you wanted your internal database accessible from three different AI environments, you wrote three different integrations.
We watched a Series B SaaS company spend 37 engineer-hours building a Slack-to-Claude bridge, then another 22 hours adapting it for Cursor, then another 19 hours for their internal agent. Same data source. Three wrappers. $11,700 in fully-loaded engineering cost, and the Cursor version still broke every time they updated the plugin API.
Model Context Protocol — open-sourced by Anthropic in late 2024, now supported by over 40 AI-compatible editors — is USB-C for AI tools. You build one server. It exposes tools, resources, and prompt templates through a standardized JSON-RPC 2.0 interface. Any MCP-compatible client connects and immediately knows how to use what you built. Write it once, ship it everywhere.
The Three Primitives — Tools, Resources, Prompts
Every MCP server exposes exactly three types of capabilities. Understand them now, or spend 3 hours mid-build wondering why Claude can not read your data.
Tools are functions the AI can call with arguments. Think get_weather(city: str) or create_ticket(title: str, priority: int). These are active — the AI decides when to invoke them based on user intent. You write a Python function, decorate it, and the SDK handles the rest.
Resources are read-only data the AI can fetch. Identified by URI — tasks://all, config://settings. These are passive. The AI reads them for context before deciding which tool to call. The difference between an AI that acts blind and one that acts informed is whether you exposed a resource.
Prompts are reusable templates with variables. Example: summarize_task(task_id: str). They tell AI clients how to use your server in a structured way. Optional, but they cut mis-invocations by roughly 60% in our experience.
The minimum viable MCP server: one tool. Resources and prompts are optional. But shipping a server without a resource is like shipping an API without docs — technically works, practically doesn't.
Environment Setup — 3 Commands, No Excuses
This tutorial uses Python 3.11+ with the fastmcp wrapper, which cuts boilerplate by roughly 60% compared to the raw mcp SDK. If you have written a Flask or FastAPI app, this will feel familiar.
# Create a clean virtual environment
python -m venv .venv
# Activate it
# macOS/Linux:
source .venv/bin/activate
# Windows:
.venv\Scripts\activate
# Install FastMCP — this pulls the official mcp SDK as a dependency
pip install fastmcp
Confirm it worked:
python -c "import fastmcp; print(fastmcp.__version__)"
If you get a version number back, you are good. If you get an ImportError, your virtual environment is not activated. This is the #1 setup issue we see. Every. Single. Time. *(Yes, even senior engineers.)*
Step 1: The Base Server
Create task_server.py. This is your entire file by the end of the tutorial — around 60 lines.
from fastmcp import FastMCP
# Initialize the MCP server with a name
# Clients use this to identify your server
mcp = FastMCP("task-tracker")
# In-memory task store
# Swap for SQLite or Postgres in production — 10 lines of code
tasks: dict[int, dict] = {}
task_counter = 0
The FastMCP constructor registers the server name that clients see when they connect. The in-memory dict keeps this tutorial zero-dependency. In production you would use sqlite3 (built into Python) and your tasks would survive server restarts — but that is a 10-line change, not a rewrite.
Step 2: Add Tools — Where the AI Gets Its Hands
Tools are Python functions decorated with @mcp.tool(). FastMCP introspects your type hints and docstring to auto-generate the JSON schema that clients see. This means your docstring is your API contract with the AI. Write it like you are writing docs for another engineer — because you are.
@mcp.tool()
def add_task(title: str, description: str = "") -> str:
"""Add a new task to the tracker.
Args:
title: Short name for the task (required)
description: Detailed description (optional)
Returns:
Confirmation message with the new task ID
"""
global task_counter
task_counter += 1
tasks[task_counter] = {
"id": task_counter,
"title": title,
"description": description,
"status": "pending"
}
return f"Task {task_counter} created: {title}"
@mcp.tool()
def complete_task(task_id: int) -> str:
"""Mark a task as complete by its ID.
Args:
task_id: The integer ID of the task to complete
Returns:
Confirmation or error message
"""
if task_id not in tasks:
return f"Error: Task {task_id} not found"
tasks[task_id]["status"] = "complete"
return f"Task {task_id} marked complete"
@mcp.tool()
def list_tasks(status: str = "all") -> str:
"""List tasks, optionally filtered by status.
Args:
status: Filter by 'pending', 'complete', or 'all' (default: all)
Returns:
Formatted list of matching tasks
"""
if not tasks:
return "No tasks yet."
filtered = tasks.values()
if status != "all":
filtered = [t for t in filtered if t["status"] == status]
if not filtered:
return f"No {status} tasks found."
lines = []
for task in filtered:
icon = "✓" if task["status"] == "complete" else "○"
lines.append(f"{icon} [{task['id']}] {task['title']}")
return "\n".join(lines)
Notice the docstring structure. We include Args and Returns sections because Claude, Cursor, and other MCP clients parse this text to understand when and how to call your tool. A vague or missing docstring means the AI calls your tool incorrectly — or worse, never calls it at all. We have debugged this specific failure at least a dozen times for clients.
Write your MCP tool docstrings like API documentation, not code comments. The AI reads them the way a junior developer reads your README — literally.
Step 3: Add a Resource — Context the AI Can Read
Resources expose data via URI. Here, you expose the full task list at tasks://all so the AI can read current state before deciding what to do next.
@mcp.resource("tasks://all")
def get_all_tasks() -> str:
"""Returns all tasks as formatted text.
The AI reads this resource to understand the current
state of the task tracker before calling tools.
"""
if not tasks:
return "No tasks yet."
lines = []
for task in tasks.values():
status_icon = "✓" if task["status"] == "complete" else "○"
lines.append(
f"{status_icon} [{task['id']}] {task['title']} "
f"— {task['description'] or 'No description'}"
)
return "\n".join(lines)
Without this resource, the AI flies blind. It creates tasks without knowing what already exists. It marks tasks complete without knowing which ones are pending. We have seen agents loop 4-5 times trying to "find" a task because the developer never exposed a resource to give the AI an overview. That is not an AI problem. That is an architecture problem.
Step 4: Add a Prompt — Teach the AI a Workflow
Prompts are templates that structure how the AI uses your server. Think of them as stored procedures for AI interaction — they encode a workflow that the AI can execute consistently.
@mcp.prompt()
def review_tasks() -> str:
"""Generate a prompt asking the AI to review and prioritize tasks."""
task_list = get_all_tasks()
return (
f"Here are the current tasks:\n\n{task_list}\n\n"
"Please review these tasks, identify which should be "
"completed first based on the descriptions, and explain "
"your prioritization reasoning."
)
@mcp.prompt()
def daily_standup() -> str:
"""Generate a standup summary prompt for the current task state."""
task_list = get_all_tasks()
pending = [t for t in tasks.values() if t["status"] == "pending"]
complete = [t for t in tasks.values() if t["status"] == "complete"]
return (
f"Task Tracker Standup:\n\n"
f"Pending: {len(pending)} tasks\n"
f"Complete: {len(complete)} tasks\n\n"
f"Full list:\n{task_list}\n\n"
"Summarize what's been done, what's left, "
"and flag any task that looks blocked or stale."
)
You do not need prompts for a working server. But without them, every user writes their own ad-hoc instructions — and they all write them differently. Prompts standardize the interaction. In a team of 5 using the same MCP server, prompts cut "why isn't the AI doing what I want" Slack messages by about 70%. *(We counted.)*
Step 5: Run It
Add the entry point at the bottom of task_server.py:
if __name__ == "__main__":
mcp.run()
Start the server:
python task_server.py
The server runs on stdio — it reads JSON-RPC messages from stdin and writes responses to stdout. Any MCP-compatible client can spawn this process and start calling your tools immediately. No HTTP setup, no port configuration, no nginx.
If this is research for a task on your roadmap — we ship features like this in 5–7 days.
See pricing →Testing with MCP Inspector
Running the server alone tells you nothing. You need to verify that clients actually see your tools, resources, and prompts — and that the schemas are correct. MCP Inspector is the official debugging tool for this.
# Inspector is a Node.js tool — requires npm
npx @modelcontextprotocol/inspector python task_server.py
Inspector opens a browser UI with three tabs — Tools, Resources, Prompts — each populated from your server. Click "Call Tool," pass {"title": "Test task", "description": "Verify MCP Inspector works"} to add_task, and confirm you get back "Task 1 created: Test task".
Then click Resources and read tasks://all. You should see the task you just created. If you do not, your get_all_tasks function has a bug — check whether it is reading from the same tasks dict your tool writes to.
If any tool shows an empty description, your docstring is missing or malformed. Fix it and restart. Clients rely on that metadata to route calls correctly — an empty description means the AI literally does not know when to call your tool.
Connecting to Claude Desktop
Once Inspector passes, connecting to Claude Desktop takes 90 seconds.
Open your Claude Desktop config file:
# macOS
nano ~/Library/Application\ Support/Claude/claude_desktop_config.json
# Windows
notepad %APPDATA%\Claude\claude_desktop_config.json
Add your server under mcpServers:
{
"mcpServers": {
"task-tracker": {
"command": "python",
"args": ["/absolute/path/to/task_server.py"]
}
}
}
Save and restart Claude Desktop. A hammer icon appears in the chat — that is your server's tools. Type "add a task called Ship MCP tutorial" and watch Claude call add_task directly.
Use absolute paths. Relative paths break silently when Claude Desktop launches from a different working directory. The error message gives you nothing useful. We have lost 2+ hours to this exact issue on client deployments. Use. Absolute. Paths.
Connecting to Cursor and VS Code
Cursor has native MCP support as of early 2026. Open Settings → MCP Servers → Add Server. Point it to your task_server.py with the Python interpreter path. Same absolute-path rule applies.
VS Code with GitHub Copilot extensions supports MCP through the Copilot Chat panel. The config lives in your workspace .vscode/settings.json:
{
"github.copilot.chat.mcpServers": {
"task-tracker": {
"command": "python",
"args": ["/absolute/path/to/task_server.py"]
}
}
}
Same server. Same code. Three different AI environments. Zero rewrites. That is the point.
Transport Options: When to Use HTTP Instead of Stdio
Everything above uses stdio transport — the MCP client spawns your Python process directly. Perfect for local dev tools and single-user setups. For a server that multiple users or multiple machines need to reach, you need HTTP transport.
| Transport | Use Case | Latency |
|---|---|---|
| stdio | Local dev, single-user, CLI tools | ~5ms |
| HTTP/SSE | Multi-user, remote, team-wide tools | ~50–200ms |
| WebSocket | Real-time streaming, bidirectional agents | ~10–30ms |
To switch to HTTP/SSE, change one line:
if __name__ == "__main__":
mcp.run(transport="sse", host="0.0.0.0", port=8000)
Your server now accepts SSE connections on http://localhost:8000/sse. Containerize it with Docker, throw it on AWS ECS or a $5/month VPS, and every engineer on your team has the same tools in their AI editor. No local Python setup. No "works on my machine."
Adding Authentication — Because You Will Forget Until It's Too Late
Stdio servers are process-isolated. Only the local user who spawned the process can interact with them. HTTP servers are not. If you expose an MCP server over HTTP without auth, anyone who can reach the URL can call your tools. We saw a startup expose an internal database query tool on port 8000 with no auth. For 11 days. *(They were lucky.)*
FastMCP supports Bearer token auth:
import os
from fastmcp.auth import BearerAuth
def verify_token(token: str) -> bool:
# Use env var — never hardcode tokens
return token == os.environ.get("MCP_AUTH_TOKEN")
mcp = FastMCP(
"task-tracker",
auth=BearerAuth(verify_token)
)
For production, swap the env-var check for a lookup against your database or an OAuth provider. The auth layer sits at the transport level — your tools and resources do not change at all. The SDK rejects unauthorized requests before they ever reach your Python code.
The Complete File — Copy, Paste, Ship
Here is the full task_server.py with every piece assembled. This is a working MCP server. Not a toy. Not a demo. A server you can plug into Claude Desktop, Cursor, or VS Code right now.
from fastmcp import FastMCP
mcp = FastMCP("task-tracker")
tasks: dict[int, dict] = {}
task_counter = 0
@mcp.tool()
def add_task(title: str, description: str = "") -> str:
"""Add a new task to the tracker.
Args:
title: Short name for the task (required)
description: Detailed description (optional)
Returns:
Confirmation message with the new task ID
"""
global task_counter
task_counter += 1
tasks[task_counter] = {
"id": task_counter,
"title": title,
"description": description,
"status": "pending"
}
return f"Task {task_counter} created: {title}"
@mcp.tool()
def complete_task(task_id: int) -> str:
"""Mark a task as complete by its ID."""
if task_id not in tasks:
return f"Error: Task {task_id} not found"
tasks[task_id]["status"] = "complete"
return f"Task {task_id} marked complete"
@mcp.tool()
def list_tasks(status: str = "all") -> str:
"""List tasks, optionally filtered by status.
Args:
status: Filter by 'pending', 'complete', or 'all'
"""
if not tasks:
return "No tasks yet."
filtered = tasks.values()
if status != "all":
filtered = [t for t in filtered if t["status"] == status]
lines = []
for task in filtered:
icon = "✓" if task["status"] == "complete" else "○"
lines.append(f"{icon} [{task['id']}] {task['title']}")
return "\n".join(lines) or f"No {status} tasks found."
@mcp.resource("tasks://all")
def get_all_tasks() -> str:
"""Returns all tasks as formatted text."""
if not tasks:
return "No tasks yet."
lines = []
for task in tasks.values():
icon = "✓" if task["status"] == "complete" else "○"
lines.append(
f"{icon} [{task['id']}] {task['title']} "
f"— {task['description'] or 'No description'}"
)
return "\n".join(lines)
@mcp.prompt()
def review_tasks() -> str:
"""Generate a prompt to review and prioritize tasks."""
return (
f"Current tasks:\n\n{get_all_tasks()}\n\n"
"Review, prioritize, and explain reasoning."
)
if __name__ == "__main__":
mcp.run()
67 lines. Supports 3 tools, 1 resource, 1 prompt. Works with Claude Desktop, Cursor, VS Code Copilot, Windsurf, and every other MCP-compatible client. That is the power of writing to a protocol instead of writing to a vendor.
Where to Go After Your First Server Ships
You have a working server. Here is the priority order for making it production-grade — based on what we have shipped for clients:
Week 1: Persistent storage. Swap the in-memory dict for SQLite using Python's built-in sqlite3 module. Ten lines. Your tasks survive restarts.
Week 2: Search. Add a search_tasks(query: str) tool with basic string matching. Right now the AI has to read the entire resource to find a specific task. At 500+ tasks, that burns context window tokens for no reason.
Week 3: Remote deployment. Switch to HTTP/SSE transport. Containerize with Docker. Deploy to a VPS or AWS ECS. Update your team's editor configs to point at the remote URL instead of a local Python process.
Week 4: Real-world tool. Build a second server for something your team actually uses — a Slack integration, a GitHub issue tracker, a database query interface. The MCP registry at modelcontextprotocol.io has hundreds of community servers you can study. Most are under 200 lines.
The question worth sitting with: which internal tool in your company would save the most time if every AI your team uses could call it natively? That is your next MCP server. And if you would rather have a team ship it for you in 5 days instead of spending 3 weeks figuring out the edge cases — well, that is literally what we do.
Frequently Asked Questions
What is an MCP server?
An MCP server is a program that exposes tools, data resources, and prompt templates through a standardized JSON-RPC 2.0 interface. Any MCP-compatible AI client — Claude Desktop, Cursor, Windsurf, VS Code Copilot — can connect and use them without custom integration code.
Do I need to know JSON-RPC to build one?
No. The Python SDK and FastMCP wrapper handle the JSON-RPC 2.0 wire format automatically. You write plain Python functions with decorators and type hints. The SDK translates them into protocol-compliant messages.
Is MCP only for Claude?
No. MCP was open-sourced by Anthropic but is now supported by 40+ AI clients including Cursor, Windsurf, VS Code with Copilot, JetBrains AI, and community agent frameworks. One server works across all of them.
Stdio or HTTP — which transport should I start with?
Start with stdio. It requires zero networking setup — the AI client spawns your server as a local subprocess. Switch to HTTP/SSE only when you need remote access, multi-user support, or you are deploying to a shared server.
Can one server expose multiple tools?
Yes. No protocol-level limit. Our production servers typically expose 5–12 tools, 2–3 resources, and 1–2 prompts each. Keep servers focused on one domain — a task tracker, a database querier, a Slack bridge — rather than building one mega-server.
