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 adddefault) - 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 userfor global MCP servers - Store servers in permanent location :
~/.claude-mcp-servers/ - Avoid project-local configs unless specifically needed
- Remove conflicting
.vscode/mcp.jsonfiles
If your MCP only works in one directory:
claude mcp list
claude mcp remove your-server
claude mcp add --scope user your-server python3 /path/to/server.py
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
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)
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)}"
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"]
}
}
]
Always wrap tool execution in try-except blocks and return proper JSON-RPC errors:
try:
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
Install with: pip install -r requirements.txt
Test individual methods:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python3 server.py
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | python3 server.py
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"hello_world","arguments":{"name":"Test"}}}' | python3 server.py
After adding to Claude Code:
claude mcp list
Check configuration scope first :
claude mcp list
ls .vscode/mcp.json
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.jsonexists 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
Configuration conflicts :
rm .vscode/mcp.json
claude mcp remove server-name
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:
{
"type": "text",
"text": "Your response"
}
{
"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...")
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"
{
"name": "server_info",
"description": "Get server version and status",
"inputSchema": {"type": "object", "properties": {}}
}
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:
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"
{
"name": "update_server",
"description": "Update the MCP server to latest version",
"inputSchema": {"type": "object", "properties": {}}
}
Support environment variables for configuration:
import os
API_KEY = os.environ.get("YOUR_API_KEY", "default-key-if-any")
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 :
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 3. Access Control : Limit what your MCP server can access 4. 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:
mkdir -p ~/.claude-mcp-servers/gemini-collab
pip install google-generativeai
curl -o ~/.claude-mcp-servers/gemini-collab/server.py https://your-repo/server.py
claude mcp add --scope user gemini-collab python3 ~/.claude-mcp-servers/gemini-collab/server.py
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
claude
mcp__gemini-collab__gemini_code_review
code: "function authenticate(user) { return user.password === 'admin'; }"
focus: "security"
- Always use
--scope userfor 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 userfor 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! π