Want to give AI assistants like Claude superpowers? The Model Context Protocol (MCP) lets you extend AI capabilities with custom tools - from API integrations to database access to Git operations. In this series, we'll build a production-ready MCP server that integrates Plane project management with Claude Code.
By the end of Part 1, you'll understand MCP architecture and have a working server with custom tools running locally.
What is MCP?
The Model Context Protocol (MCP) is an open standard that allows AI assistants to securely connect to external tools and data sources. Think of it as a plugin system for AI - you build servers that provide tools, and AI clients (like Claude Code) can discover and use those tools dynamically.
The Problem MCP Solves
Without MCP:
- AI assistants have limited capabilities (only what's built-in)
- Connecting to APIs requires writing code in every conversation
- No standardized way to extend AI functionality
- Can't reuse integrations across different AI tools
With MCP:
- Build tools once, use them everywhere
- AI discovers tools automatically
- Standardized protocol (works with any MCP client)
- Secure, sandboxed tool execution
Real-World Example
Instead of asking Claude to "write Python code to fetch my Plane tickets," you build an MCP server once. Now Claude can natively call plane_list_issues() as if it were a built-in tool.
# Without MCP - Claude has to generate this code every time
import requests
headers = {"x-api-key": "..."}
response = requests.get("https://api.plane.so/issues")
# With MCP - Claude just calls the tool
plane_list_issues(project_id="PROJ-123")
MCP Architecture
┌─────────────────────┐
│ MCP Client │
│ (Claude Code) │
│ │
│ - Discovers tools │
│ - Calls tools │
│ - Shows results │
└──────────┬──────────┘
│
│ (stdio or HTTP/SSE)
│
┌──────────┴──────────┐
│ MCP Server │
│ (Your Code) │
│ │
│ - Provides tools │
│ - Executes logic │
│ - Returns results │
└──────────┬──────────┘
│
│
┌──────────┴──────────┐
│ External APIs │
│ - Plane API │
│ - GitHub API │
│ - Databases │
│ - File Systems │
└─────────────────────┘
Key Components
1. Transport Layer
MCP supports two transport modes:
-
stdio (Standard Input/Output): For local integrations
- Process spawned by MCP client
- Communication via stdin/stdout
- No network exposure
- Best for: Claude Code, local development
-
SSE (Server-Sent Events): For remote integrations
- HTTP server with streaming responses
- Accessible over network
- Supports authentication
- Best for: Remote access, Docker deployments
2. Tools
Functions that AI can call. Each tool has:
- Name (unique identifier)
- Description (helps AI decide when to use it)
- Parameters (typed inputs with validation)
- Return value (structured data)
3. Resources (Optional)
Long-lived data sources the AI can read from (files, databases, etc.). We won't use these in Part 1.
Building Your First MCP Server
Let's build a simple MCP server with three tools: addition, file reading, and API fetching. This demonstrates all the core patterns you'll need.
Prerequisites
# Python 3.11+
python3 --version
# Create project directory
mkdir my-mcp-server && cd my-mcp-server
# Create virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install FastMCP SDK
pip install mcp requests python-dotenv
Project Structure
my-mcp-server/
├── src/
│ ├── __init__.py
│ ├── server.py # Main MCP server
│ └── tools.py # Tool implementations
├── .env # Configuration
└── requirements.txt # Dependencies
Step 1: Server Foundation
Create src/server.py:
"""
MCP Server - Main entry point
"""
from mcp.server import FastMCP
from tools import MathTools, FileTools, APITools
# Initialize MCP server
mcp = FastMCP("My First MCP Server")
# Initialize tool providers
math_tools = MathTools()
file_tools = FileTools()
api_tools = APITools()
# Register tools using decorators
@mcp.tool()
def add(a: float, b: float) -> str:
"""Add two numbers together"""
result = math_tools.add(a, b)
return f"Result: {result}"
@mcp.tool()
def read_file(file_path: str) -> str:
"""Read contents of a text file"""
return file_tools.read_file(file_path)
@mcp.tool()
def fetch_url(url: str) -> str:
"""Fetch content from a URL"""
return api_tools.fetch(url)
# Start server
if __name__ == "__main__":
mcp.run() # FastMCP handles stdio protocol automatically
What's happening here?
FastMCPcreates an MCP server instance@mcp.tool()decorator registers functions as tools- Type hints (
float,str) define parameter types - Docstrings become tool descriptions (AI reads these!)
mcp.run()starts the stdio transport
Step 2: Tool Implementations
Create src/tools.py:
"""
Tool implementations - Keep server.py clean
"""
import requests
from typing import Dict, Any
class MathTools:
"""Mathematical operations"""
def add(self, a: float, b: float) -> float:
return a + b
class FileTools:
"""File system operations"""
def read_file(self, file_path: str) -> str:
"""Read file with error handling"""
try:
with open(file_path, 'r') as f:
content = f.read()
return f"File: {file_path}\n\n{content}"
except FileNotFoundError:
return f"Error: File not found: {file_path}"
except Exception as e:
return f"Error reading file: {str(e)}"
class APITools:
"""External API operations"""
def fetch(self, url: str) -> str:
"""Fetch URL with error handling"""
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
# Return first 500 chars (don't overwhelm the AI)
content = response.text[:500]
return f"Status: {response.status_code}\n\n{content}"
except requests.RequestException as e:
return f"Error fetching URL: {str(e)}"
Pattern to notice:
- Separate tool logic from MCP registration
- Always include error handling
- Return human-readable strings (AI interprets these)
- Limit response sizes (AI has token limits)
Step 3: Configuration
Create .env:
# MCP Server Configuration
MCP_TRANSPORT=stdio
LOG_LEVEL=INFO
Create requirements.txt:
mcp>=1.0.0
requests>=2.31.0
python-dotenv>=1.0.0
Step 4: Testing Locally
Run the server in stdio mode:
python3 -m src.server
The server starts and waits for stdio input. To actually use it, configure Claude Code.
Step 5: Claude Code Integration
Create ~/.claude.json and add:
{
"mcpServers": {
"my-server": {
"command": "python3",
"args": ["-m", "src.server"],
"cwd": "/full/path/to/my-mcp-server",
"env": {
"MCP_TRANSPORT": "stdio"
}
}
}
}
Important paths:
- macOS/Linux:
~/.claude.json - Windows:
%USERPROFILE%\.claude.json - Use absolute paths for
cwd
Restart Claude Code. Your tools should now appear in /mcp list!
Testing Your Tools
In Claude Code, try:
Add 42 and 17
Claude will discover your add tool and call it:
Tool call: add(a=42, b=17)
Result: 59
Understanding Tool Design
Good Tool Practices
1. Clear Descriptions
# Bad
@mcp.tool()
def get_data(id: str) -> str:
"""Gets data"""
# Good
@mcp.tool()
def get_user_profile(user_id: str) -> str:
"""
Retrieve user profile information by ID.
Returns user's name, email, and account status.
"""
2. Typed Parameters
# FastMCP uses type hints for validation
@mcp.tool()
def create_issue(
title: str, # Required string
priority: int, # Required integer
labels: list[str], # List of strings
estimate: float = 0 # Optional with default
) -> str:
...
3. Error Handling
@mcp.tool()
def safe_api_call(endpoint: str) -> str:
"""Call API with comprehensive error handling"""
try:
response = requests.get(endpoint, timeout=5)
response.raise_for_status()
return response.json()
except requests.Timeout:
return "Error: API request timed out after 5 seconds"
except requests.HTTPError as e:
return f"Error: API returned {e.response.status_code}"
except Exception as e:
return f"Unexpected error: {str(e)}"
4. Response Formatting
import json
@mcp.tool()
def list_files(directory: str) -> str:
"""List files in directory"""
files = os.listdir(directory)
# Return structured JSON for AI to parse
return json.dumps({
"directory": directory,
"count": len(files),
"files": files[:20] # Limit to 20 to save tokens
}, indent=2)
Common Pitfalls
❌ Don't:
- Return massive responses (AI has token limits)
- Raise unhandled exceptions (crashes the tool)
- Use vague descriptions ("Does stuff")
- Skip input validation
✅ Do:
- Truncate large responses
- Catch and return error messages
- Write descriptive docstrings
- Validate inputs and return helpful errors
Transport Modes: stdio vs SSE
Our simple server uses stdio (local only). Let's understand both modes.
stdio Mode (What We Built)
# server.py
if __name__ == "__main__":
mcp.run() # Defaults to stdio
Pros:
- Simple setup
- No network exposure
- Fast (local process)
- Perfect for Claude Code
Cons:
- Only works locally
- Can't access remotely
- No authentication needed (it's your local machine)
Use when:
- Building Claude Code integrations
- Developing locally
- No remote access needed
SSE Mode (HTTP Server)
import os
from mcp.server import FastMCP
mcp = FastMCP("My Server")
# ... register tools ...
if __name__ == "__main__":
transport = os.getenv("MCP_TRANSPORT", "stdio")
if transport == "sse":
# HTTP server mode
host = os.getenv("MCP_HOST", "0.0.0.0")
port = int(os.getenv("MCP_PORT", "8000"))
mcp.run(transport="sse", host=host, port=port)
else:
# stdio mode
mcp.run()
Pros:
- Accessible over network
- Supports authentication
- Can deploy to cloud
- Multiple clients can connect
Cons:
- More complex setup
- Need to handle security
- Requires exposed port
- Network latency
Use when:
- Remote access needed
- Docker deployment
- Multiple users
- Production hosting
We'll cover SSE mode and Docker deployment in Part 2!
What We've Built
You now have:
✅ Working MCP server with FastMCP SDK ✅ Three example tools (math, files, API) ✅ Claude Code integration via stdio ✅ Understanding of MCP architecture ✅ Tool design patterns
What's Next: Building the Plane MCP Server
In Part 2, we'll level up significantly:
- Build a production MCP server for Plane API
- Design 30+ tools for project management
- Add rate limiting and caching
- Implement dual-mode transport (stdio + SSE)
- Deploy with Docker and Traefik
- Production patterns: logging, error handling, security
Sneak peek - Tools we'll build:
@mcp.tool()
def plane_list_issues(project_id: str, state: str = None) -> str:
"""List issues in a Plane project with optional filters"""
@mcp.tool()
def plane_create_issue(project_id: str, title: str, description: str) -> str:
"""Create a new issue in Plane"""
@mcp.tool()
def plane_analyze_complexity(issue_id: str) -> str:
"""Analyze ticket complexity and recommend task splitting"""
Resources
Official Documentation:
Code Repository:
The complete code from this tutorial (plus Part 2's Plane MCP server):
Next Steps
Try these exercises before Part 2:
- Add a tool that reads environment variables
- Add a tool that calls a public API (e.g., weather, GitHub)
- Add error handling for invalid inputs
- Test with Claude Code - ask it to use your tools in creative ways
Questions? The MCP protocol is new and evolving. Join the discussion on GitHub or leave a comment!