How to Build a Custom MCP Server for Claude Code: Step-by-Step Guide
If you want to build a custom MCP server so Claude Code can call your own tools — weather lookup, file summary, todo manager — this tutorial gives you a complete step-by-step guide with working Python code, JSON Schema tool definitions, and real API integration in under 45 minutes.
Beginner · 45 min · Jun 14, 2026
TL;DR
If you're searching "how to build an MCP server for Claude Code," this tutorial walks you through building a custom MCP (Model Context Protocol) server in Python in under 45 minutes. You'll create a working server with three custom tools — weather lookup, file summary, and a todo manager — then connect it to Claude Code so the AI can call your tools directly from the terminal. No prior MCP experience needed.
What You'll Learn
- What MCP is and how the client-server protocol works
- How to set up a Python MCP server with the official SDK
- How to define custom tools with JSON Schema
- How to handle tool execution requests
- How to connect your server to Claude Code and test it live
- How to debug common MCP connection issues
Prerequisites
- Python 3.10+ installed on your machine
- Claude Code CLI installed (
npm install -g @anthropic-ai/claude-code) - Anthropic API key or Claude subscription (for Claude Code)
- Basic familiarity with Python and terminal
- A text editor (VS Code recommended)
Step 1: Understand the MCP Protocol (5 min)
MCP (Model Context Protocol) is an open standard that lets AI models talk to external tools through a client-server architecture. Think of it as a universal USB-C port for AI — once your server implements the MCP spec, any MCP-compatible client (Claude Code, Cursor, Continue.dev, Zed) can discover and use your tools.
How It Works
┌─────────────┐ stdio/HTTP ┌──────────────┐
│ Claude Code │ ◄──────────────────► │ Your MCP │
│ (Client) │ JSON-RPC 2.0 │ Server │
└─────────────┘ └──────┬───────┘
│
┌──────▼───────┐
│ Your Tools │
│ (Python fns) │
└──────────────┘The client (Claude Code) spawns your server as a subprocess and communicates via stdio (standard input/output) using JSON-RPC 2.0 messages. The server responds to three key request types:
| Request | Purpose |
|---|---|
tools/list | Return all available tools with their schemas |
tools/call | Execute a specific tool with arguments |
initialize | Handshake — server declares its capabilities |
Your job: write a Python script that reads JSON-RPC messages from stdin, processes them, and writes responses to stdout. The official mcp Python package handles all the protocol boilerplate.
Step 2: Set Up the Python MCP Server (10 min)
Create a new project directory and install the MCP SDK:
mkdir my-mcp-server && cd my-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install mcp httpxNow create server.py:
# !/usr/bin/env python3
"""Custom MCP Server with three tools: weather, file-summary, todo-manager."""
import json
import os
from datetime import datetime
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationCapabilities
from mcp.server.stdio import stdio_server
# In-memory todo store (for demo; use SQLite in production)
todos = []
next_id = 1
server = Server("my-tools-server")
# ── Tool 1: Weather Lookup ──────────────────────────────────
@server.list_tools()
async def list_tools() -> list:
return [
{
"name": "get_weather",
"description": "Get current weather for a city. Returns temperature, conditions, and humidity.",
"inputSchema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'San Francisco' or 'Tokyo'"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit (default: celsius)"
}
},
"required": ["city"]
}
},
{
"name": "summarize_file",
"description": "Summarize a text file's content — returns line count, word count, and first 500 characters.",
"inputSchema": {
"type": "object",
"properties": {
"filepath": {
"type": "string",
"description": "Absolute path to the file to summarize"
}
},
"required": ["filepath"]
}
},
{
"name": "todo_manager",
"description": "Manage a simple todo list. Supports 'add', 'list', 'done', and 'clear' actions.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "done", "clear"],
"description": "Action to perform on the todo list"
},
"text": {
"type": "string",
"description": "Todo item text (required for 'add' action)"
},
"id": {
"type": "integer",
"description": "Todo ID to mark as done (required for 'done' action)"
}
},
"required": ["action"]
}
}
]
# ── Tool Execution ──────────────────────────────────────────
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list:
global todos, next_id
if name == "get_weather":
city = arguments["city"]
units = arguments.get("units", "celsius")
# Simulated weather data (replace with real API call)
weather_data = {
"San Francisco": {"temp": 18, "condition": "Foggy", "humidity": 82},
"Tokyo": {"temp": 28, "condition": "Clear", "humidity": 65},
"London": {"temp": 15, "condition": "Rainy", "humidity": 78},
"New York": {"temp": 24, "condition": "Partly Cloudy", "humidity": 55},
}
data = weather_data.get(city, {"temp": 22, "condition": "Unknown", "humidity": 60})
return [{
"type": "text",
"text": f"📍 {city}: {data['temp']}°{'C' if units == 'celsius' else 'F'}, {data['condition']}, Humidity: {data['humidity']}%"
}]
elif name == "summarize_file":
filepath = arguments["filepath"]
if not os.path.exists(filepath):
return [{"type": "text", "text": f"❌ File not found: {filepath}"}]
if not os.path.isfile(filepath):
return [{"type": "text", "text": f"❌ Not a file: {filepath}"}]
try:
with open(filepath, "r") as f:
content = f.read()
lines = content.split("\n")
words = content.split()
preview = content[:500]
return [{
"type": "text",
"text": f"📄 {os.path.basename(filepath)}\nLines: {len(lines)}\nWords: {len(words)}\nChars: {len(content)}\n\n--- Preview (first 500 chars) ---\n{preview}"
}]
except Exception as e:
return [{"type": "text", "text": f"❌ Error reading file: {str(e)}"}]
elif name == "todo_manager":
action = arguments["action"]
if action == "add":
text = arguments.get("text", "")
if not text:
return [{"type": "text", "text": "❌ 'text' is required for add action"}]
todos.append({"id": next_id, "text": text, "done": False, "created": datetime.now().isoformat()})
next_id += 1
return [{"type": "text", "text": f"✅ Added todo #{next_id-1}: {text}"}]
elif action == "list":
if not todos:
return [{"type": "text", "text": "📋 No todos yet."}]
lines = ["📋 Todo List:"]
for t in todos:
status = "✅" if t["done"] else "⬜"
lines.append(f" {status} #{t['id']}: {t['text']}")
return [{"type": "text", "text": "\n".join(lines)}]
elif action == "done":
tid = arguments.get("id")
if tid is None:
return [{"type": "text", "text": "❌ 'id' is required for done action"}]
for t in todos:
if t["id"] == tid:
t["done"] = True
return [{"type": "text", "text": f"✅ Marked #{tid} as done: {t['text']}"}]
return [{"type": "text", "text": f"❌ Todo #{tid} not found"}]
elif action == "clear":
count = len(todos)
todos.clear()
return [{"type": "text", "text": f"🗑️ Cleared {count} todos"}]
return [{"type": "text", "text": f"Unknown tool: {name}"}]
# ── Main Entry ──────────────────────────────────────────────
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationCapabilities(
sampling={},
experimental={},
),
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())Test that your server starts without errors:
python3 server.py --help # Should not crash
# Press Ctrl+C to stop (it runs as a long-lived process)Step 3: Connect the Server to Claude Code (10 min)
Claude Code discovers MCP servers through a configuration file. Create or edit ~/.claude/claude_desktop_config.json (or .claude.json in your project root for project-level config):
{
"mcpServers": {
"my-tools": {
"command": "python3",
"args": ["/absolute/path/to/my-mcp-server/server.py"],
"env": {}
}
}
}⚠️ Always use absolute paths in the
argsarray. Relative paths are resolved from Claude Code's working directory, which may not be what you expect.
Now restart Claude Code (or start a new session):
claudeInside Claude Code, verify your server is connected:
> /mcp
You should see my-tools listed with 3 tools. If it says "No MCP servers connected," check the troubleshooting section below.
Try your tools:
> What's the weather in Tokyo?
> Summarize the file /Users/you/project/README.md
> Add "Buy groceries" to my todo listClaude Code will automatically discover your tools and call them when relevant to the user's request.
Step 4: Add a Real API Integration (15 min)
The weather tool above uses hardcoded data. Let's upgrade it to call a real API. We'll use OpenWeatherMap's free tier:
pip install httpxReplace the get_weather handler in server.py:
import httpx
# Add your API key (get one free at https://openweathermap.org/api)
OPENWEATHER_API_KEY = os.environ.get("OPENWEATHER_API_KEY", "your-key-here")
# ... inside call_tool, replace the get_weather block:
elif name == "get_weather":
city = arguments["city"]
units = arguments.get("units", "celsius")
unit_param = "metric" if units == "celsius" else "imperial"
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.openweathermap.org/data/2.5/weather",
params={"q": city, "appid": OPENWEATHER_API_KEY, "units": unit_param},
timeout=10.0
)
if resp.status_code == 404:
return [{"type": "text", "text": f"❌ City not found: {city}"}]
elif resp.status_code != 200:
return [{"type": "text", "text": f"❌ API error: {resp.status_code}"}]
data = resp.json()
temp = data["main"]["temp"]
condition = data["weather"][0]["description"]
humidity = data["main"]["humidity"]
symbol = "°C" if units == "celsius" else "°F"
return [{
"type": "text",
"text": f"📍 {data['name']}: {temp}{symbol}, {condition.title()}, Humidity: {humidity}%"
}]Add the API key to your Claude Code config:
{
"mcpServers": {
"my-tools": {
"command": "python3",
"args": ["/absolute/path/to/my-mcp-server/server.py"],
"env": {
"OPENWEATHER_API_KEY": "your-real-api-key"
}
}
}
}Restart Claude Code and test with any city worldwide.
Common Pitfalls
-
"No MCP servers connected": Check that the
commandin your config points to the correct Python binary. Runwhich python3and use that absolute path. Also verifyserver.pyruns without import errors:python3 server.py(it should hang waiting for stdin, not crash). -
"Tool not found" errors: Make sure your
@server.list_tools()decorator is spelled correctly and returns a list of tool definitions. Each tool needsname,description, andinputSchema. -
Arguments arriving as wrong type: MCP passes all arguments as their native JSON types, but Python sometimes receives
floatwhere you expectint. Cast explicitly:tid = int(arguments.get("id", 0)). -
Server crashes silently: Add logging to stderr (not stdout — stdout is the JSON-RPC channel). Use
import sys; print("DEBUG: got request", file=sys.stderr)to debug. -
Claude ignores your tools: Claude decides whether to call a tool based on the
descriptionfield. Write descriptions as if explaining the tool to a colleague: what it does, what it returns, and when to use it. Vague descriptions like "does stuff" will be ignored. -
Path issues with
summarize_file: The filepath argument must be an absolute path accessible from the server process. Claude Code runs the server as a subprocess, so relative paths resolve from wherever Claude Code was launched. -
Module not found: mcp: Make sure you installed the package in the same Python environment that Claude Code uses. Either activate the venv before running
claude, or use the venv's Python path in the config:"command": "/path/to/.venv/bin/python3".
Next Steps
-
Add more tools: Expose your project's internal APIs, database queries, or file operations as MCP tools. Anything you can write a Python function for, Claude Code can call.
-
Use HTTP transport: For remote servers or multi-client setups, switch from stdio to HTTP/SSE transport. The MCP Python SDK supports both.
-
Explore the MCP ecosystem: Check out mcp.so for community-built MCP servers — there are servers for GitHub, Slack, Notion, databases, and more.
-
Build for other clients: Your MCP server works with any compatible client: Cursor, Continue.dev, Zed, and the Claude desktop app all support MCP. Build once, use everywhere.
-
Try Claude Agent SDK: If you want Claude to orchestrate multiple tools in a workflow, the Claude Agent SDK extends MCP with multi-step reasoning and tool chaining.
This tutorial uses Claude Code, Python, and the MCP SDK. OpenWeatherMap provides the weather data. All code examples are MIT-licensed — use them freely in your projects.
Related tutorials
AI Coding Agents Complete Guide: Setup, Security, Workflow & Case Studies
If you are researching AI coding agents and want to know which one to use, how to set it up safely, and what real developers are building with them — this hub organizes every tutorial, case study, and comparison we have published. Start with the decision guide below to find the right path for your skill level and goals.
AI Micro SaaS FAQ: 25 Common Questions Answered (2026)
Everything you need to know about building and profiting from AI Micro SaaS products. This FAQ covers idea generation, tech stack choices, pricing strategy, marketing on a $0 budget, legal considerations, and scaling from $100 to $10K MRR. Based on real case studies and data from successful solo builders using Claude Code, Cursor, and other AI coding tools.
Topic hub
AI Coding Tools Hub (2026)
From Copilot pricing changes to Claude Code + DeepSeek cost-saving setups—one place to compare tools, read explainers, and follow tutorials.
Explore AI Coding Tools Hub (2026) →Monetization angle
How can you make money from this trend?
WayToClawEarn focuses on verified earn playbooks—not just news. Start from these cases.
DeepSeek + Claude Code Micro SaaS
Run multiple small products on cheap inference
Claude Code bug bounty
Productize agent skills into security services