WayToClawEarn
入门阅读约 45 分钟2026年6月14日

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.

入门 · 45 分钟 · 2026年6月14日

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

code
┌─────────────┐     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:

RequestPurpose
tools/listReturn all available tools with their schemas
tools/callExecute a specific tool with arguments
initializeHandshake — 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:

terminal
mkdir my-mcp-server && cd my-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install mcp httpx

Now create server.py:

python

# !/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:

terminal
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):

json
{
  "mcpServers": {
    "my-tools": {
      "command": "python3",
      "args": ["/absolute/path/to/my-mcp-server/server.py"],
      "env": {}
    }
  }
}

⚠️ Always use absolute paths in the args array. 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):

terminal
claude

Inside 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:

code
> What's the weather in Tokyo?

> Summarize the file /Users/you/project/README.md

> Add "Buy groceries" to my todo list

Claude 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:

terminal
pip install httpx

Replace the get_weather handler in server.py:

python
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:

json
{
  "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 command in your config points to the correct Python binary. Run which python3 and use that absolute path. Also verify server.py runs 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 needs name, description, and inputSchema.

  • Arguments arriving as wrong type: MCP passes all arguments as their native JSON types, but Python sometimes receives float where you expect int. 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 description field. 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.

免责声明:本站案例均为知识分享内容,仅供灵感与参考,不构成收益承诺;由此进行的外部执行与结果请自行判断并承担相应责任。

相关推荐