This guide will walk you through creating a custom MCP (Model Context Protocol) server that integrates with Claude Code, allowing you to extend Claude's capabilities with external tools, APIs, or even other AI models.
MCP (Model Context Protocol) is a protocol that allows Claude to communicate with external servers to access tools and capabilities beyond its built-in features. Think of it as a plugin system for Claude.
- Python 3.8 or higher
- Claude Code CLI installed (
npm install -g @anthropic-ai/claude-code
) - Basic understanding of JSON-RPC protocol
Before building your MCP server, understand Claude Code's configuration hierarchy to avoid common issues:
Claude Code supports three configuration scopes (in order of priority):
- Project Scope (
.vscode/mcp.json
) - Highest priority, overrides everything - Local Scope (
claude mcp add
default) - Works only in current directory - User Scope (
claude mcp add --scope user
) - Global configuration
❌ WRONG (Local scope - only works in current directory):
claude mcp add my-server python3 /path/to/server.py
✅ CORRECT (User scope - works globally):
claude mcp add --scope user my-server python3 /path/to/server.py
- Always use
--scope user
for global MCP servers - Store servers in permanent location:
~/.claude-mcp-servers/
- Avoid project-local configs unless specifically needed
- Remove conflicting
.vscode/mcp.json
files
If your MCP only works in one directory:
# Check current configuration
claude mcp list
# Remove local config
claude mcp remove your-server
# Re-add with user scope
claude mcp add --scope user your-server python3 /path/to/server.py
# Remove any project-local configs
rm .vscode/mcp.json # if exists
mkdir my-mcp-server
cd my-mcp-server
Create a file named server.py
:
#!/usr/bin/env python3
"""
Basic MCP Server Template
"""
import json
import sys
import os
from typing import Dict, Any, Optional
# Ensure unbuffered output for proper communication
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
sys.stderr = os.fdopen(sys.stderr.fileno(), 'w', 1)
def send_response(response: Dict[str, Any]):
"""Send a JSON-RPC response"""
print(json.dumps(response), flush=True)
def handle_initialize(request_id: Any) -> Dict[str, Any]:
"""Handle initialization request"""
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "my-mcp-server",
"version": "1.0.0"
}
}
}
def handle_tools_list(request_id: Any) -> Dict[str, Any]:
"""List available tools"""
tools = [
{
"name": "hello_world",
"description": "A simple hello world tool",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name to greet"
}
},
"required": ["name"]
}
}
]
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"tools": tools
}
}
def handle_tool_call(request_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
"""Handle tool execution"""
tool_name = params.get("name")
arguments = params.get("arguments", {})
try:
if tool_name == "hello_world":
name = arguments.get("name", "World")
result = f"Hello, {name}! This is a response from your MCP server."
else:
raise ValueError(f"Unknown tool: {tool_name}")
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"content": [
{
"type": "text",
"text": result
}
]
}
}
except Exception as e:
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32603,
"message": str(e)
}
}
def main():
"""Main server loop"""
while True:
try:
line = sys.stdin.readline()
if not line:
break
request = json.loads(line.strip())
method = request.get("method")
request_id = request.get("id")
params = request.get("params", {})
if method == "initialize":
response = handle_initialize(request_id)
elif method == "tools/list":
response = handle_tools_list(request_id)
elif method == "tools/call":
response = handle_tool_call(request_id, params)
else:
response = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": f"Method not found: {method}"
}
}
send_response(response)
except json.JSONDecodeError:
continue
except EOFError:
break
except Exception as e:
if 'request_id' in locals():
send_response({
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32603,
"message": f"Internal error: {str(e)}"
}
})
if __name__ == "__main__":
main()
chmod +x server.py
claude mcp add my-server python3 /path/to/your/server.py
Your MCP server must handle these JSON-RPC methods:
-
initialize
- Called when Claude connects to your server- Must return protocol version and capabilities
-
tools/list
- Lists all available tools- Returns array of tool definitions with schemas
-
tools/call
- Executes a specific tool- Receives tool name and arguments
- Returns results that Claude can use
All communication uses JSON-RPC 2.0 over standard input/output:
Request from Claude:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "hello_world",
"arguments": {"name": "Claude"}
}
}
Response from your server:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "Hello, Claude!"
}
]
}
}
Here's a more practical example that fetches weather data:
#!/usr/bin/env python3
import json
import sys
import os
import requests
from typing import Dict, Any
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
sys.stderr = os.fdopen(sys.stderr.fileno(), 'w', 1)
# Your API key (store securely in production)
WEATHER_API_KEY = "your-api-key-here"
def get_weather(city: str) -> str:
"""Fetch weather data from API"""
try:
url = f"http://api.openweathermap.org/data/2.5/weather"
params = {
"q": city,
"appid": WEATHER_API_KEY,
"units": "metric"
}
response = requests.get(url, params=params)
data = response.json()
if response.status_code == 200:
temp = data["main"]["temp"]
desc = data["weather"][0]["description"]
return f"Weather in {city}: {temp}°C, {desc}"
else:
return f"Error: {data.get('message', 'Unknown error')}"
except Exception as e:
return f"Error fetching weather: {str(e)}"
# ... (include the same boilerplate as before)
def handle_tools_list(request_id: Any) -> Dict[str, Any]:
tools = [
{
"name": "get_weather",
"description": "Get current weather for a city",
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name"
}
},
"required": ["city"]
}
}
]
# ... rest of implementation
Always wrap tool execution in try-except blocks and return proper JSON-RPC errors:
try:
# Your tool logic
result = do_something()
except Exception as e:
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32603,
"message": str(e)
}
}
Validate all inputs from the arguments:
def validate_arguments(arguments: Dict[str, Any], required: List[str]):
for field in required:
if field not in arguments:
raise ValueError(f"Missing required field: {field}")
Use stderr for logging to avoid interfering with JSON-RPC:
import logging
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
Create a requirements.txt
file:
requests>=2.28.0
# Add other dependencies
Install with: pip install -r requirements.txt
Test individual methods:
# Test initialize
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python3 server.py
# Test tools/list
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | python3 server.py
# Test tool call
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"hello_world","arguments":{"name":"Test"}}}' | python3 server.py
After adding to Claude Code:
# List all MCP servers
claude mcp list
# In a Claude Code session, your tools will appear as:
# mcp__<server-name>__<tool-name>
-
Check configuration scope first:
# Check what's configured claude mcp list # Check if you're in a directory with local config ls .vscode/mcp.json # Test in different directories cd ~ && claude mcp list
-
Check logs:
ls ~/Library/Caches/claude-cli-nodejs/*/mcp-logs-<server-name>/
-
Run with debug mode:
claude --debug
-
Common issues:
- MCP only works in one directory: Wrong scope, use
--scope user
- MCP not found: Check if
.vscode/mcp.json
exists and conflicts - Import errors: Ensure all dependencies are installed
- Connection closed: Check for syntax errors or crashes
- Tools not appearing: Verify tools/list returns valid schema
- MCP only works in one directory: Wrong scope, use
-
Configuration conflicts:
# Remove project-local config rm .vscode/mcp.json # Remove local scope config claude mcp remove server-name # Re-add with user scope claude mcp add --scope user server-name python3 /path/to/server.py
Store conversation context:
class MCPServer:
def __init__(self):
self.conversation_history = []
def add_to_history(self, role: str, content: str):
self.conversation_history.append({
"role": role,
"content": content
})
Return different content types:
# Text content
{
"type": "text",
"text": "Your response"
}
# Image content (base64)
{
"type": "image",
"data": base64_encoded_image,
"mimeType": "image/png"
}
For long-running tasks, consider implementing progress updates or background processing.
Store your MCP servers in a permanent location:
~/.claude-mcp-servers/
├── your-server-name/
│ ├── server.py
│ ├── requirements.txt
│ ├── setup.py
│ └── README.md
└── backup.sh
Create setup.py
for easy installation:
#!/usr/bin/env python3
"""Setup script for MCP server"""
import subprocess
import sys
import os
def check_python_version():
if sys.version_info < (3, 8):
print("❌ Python 3.8+ required")
sys.exit(1)
def install_dependencies():
print("📦 Installing dependencies...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
def add_to_claude():
server_path = os.path.join(os.path.dirname(__file__), "server.py")
print(f"🔧 Adding to Claude MCP with global scope...")
# IMPORTANT: Use --scope user for global access!
subprocess.run(["claude", "mcp", "add", "--scope", "user", "your-server", "python3", server_path])
if __name__ == "__main__":
check_python_version()
install_dependencies()
add_to_claude()
print("✅ Setup complete!")
Add version tracking to your server:
__version__ = "1.0.0"
__updated__ = "2025-06-11"
# In your tools list, add:
{
"name": "server_info",
"description": "Get server version and status",
"inputSchema": {"type": "object", "properties": {}}
}
# In tool handler:
if tool_name == "server_info":
return f"Server v{__version__} (updated {__updated__})"
Add automatic dependency checking:
def check_dependencies():
"""Check if all required packages are installed"""
required = ["requests", "other-package"]
missing = []
for package in required:
try:
__import__(package)
except ImportError:
missing.append(package)
if missing:
return f"Missing packages: {', '.join(missing)}"
return "All dependencies installed!"
Create an update mechanism for your server:
# In your server.py
def check_for_updates():
"""Check if updates are available"""
try:
import requests
response = requests.get("https://api.github.com/repos/YOUR_REPO/releases/latest")
latest_version = response.json()["tag_name"]
if latest_version > __version__:
return f"Update available: {latest_version}"
return "Server is up to date"
except:
return "Could not check for updates"
# Add update tool to your tools list
{
"name": "update_server",
"description": "Update the MCP server to latest version",
"inputSchema": {"type": "object", "properties": {}}
}
Support environment variables for configuration:
import os
# API keys and sensitive data
API_KEY = os.environ.get("YOUR_API_KEY", "default-key-if-any")
# Configuration
DEBUG = os.environ.get("MCP_DEBUG", "false").lower() == "true"
LOG_LEVEL = os.environ.get("MCP_LOG_LEVEL", "ERROR")
-
Package your server:
my-mcp-server/ ├── server.py ├── requirements.txt ├── setup.py ├── README.md └── LICENSE
-
Create one-line installer:
# In your README: curl -sSL https://your-repo/install.sh | bash
-
Share on GitHub with clear documentation
- Database Query Tool: Allow Claude to query your database
- API Integration: Connect to any REST API
- System Monitoring: Check system stats, logs, etc.
- Custom AI Models: Integrate other AI models (like we did with Gemini)
- Development Tools: Linters, formatters, test runners
- Communication Tools: Send emails, Slack messages, etc.
-
API Keys: Never hardcode sensitive keys
API_KEY = os.environ.get("MY_API_KEY")
-
Input Sanitization: Always validate and sanitize inputs
-
Access Control: Limit what your MCP server can access
-
Rate Limiting: Implement rate limits for API calls
Here's a complete working example that enables Claude Code to collaborate with Google's Gemini AI:
# 1. Create permanent directory
mkdir -p ~/.claude-mcp-servers/gemini-collab
# 2. Install Gemini SDK
pip install google-generativeai
# 3. Download server (simplified version)
curl -o ~/.claude-mcp-servers/gemini-collab/server.py https://your-repo/server.py
# 4. Add to Claude with USER SCOPE (crucial!)
claude mcp add --scope user gemini-collab python3 ~/.claude-mcp-servers/gemini-collab/server.py
# 5. Test from any directory
claude
/mcp # Should show gemini-collab connected
Once installed, you'll have these tools globally:
mcp__gemini-collab__ask_gemini
- Ask Gemini questionsmcp__gemini-collab__gemini_code_review
- Code reviewsmcp__gemini-collab__gemini_brainstorm
- Collaborative brainstorming
# In any directory, start Claude Code:
claude
# Use Gemini for code review:
mcp__gemini-collab__gemini_code_review
code: "function authenticate(user) { return user.password === 'admin'; }"
focus: "security"
# Gemini's response appears directly in Claude's context!
- Always use
--scope user
for global access - Store in
~/.claude-mcp-servers/
for permanence - Remove conflicting local configs like
.vscode/mcp.json
- Test in multiple directories to verify global access
- Environment variables for API keys when possible
MCP servers extend Claude Code's capabilities infinitely. You can integrate any API, tool, or service by following this protocol. The key points for success:
- Use proper configuration scope (
--scope user
for global) - Handle errors gracefully with try-except blocks
- Provide clear tool descriptions so Claude knows how to use them
- Test thoroughly in multiple directories
- Store servers permanently in
~/.claude-mcp-servers/
Remember: Configuration scope is the #1 source of MCP issues. When in doubt, use --scope user
!
Happy building! 🚀