How to Build an MCP Server in 2026: Step-by-Step Tutorial
By the end of this tutorial, you'll have a working MCP server — tested, verified, and discoverable by every Claude and Cursor user.
By the end of this tutorial, you will have a working MCP server — tested, verified, and discoverable by every Claude and Cursor user. We will build a real, useful server from scratch: a weather lookup tool that Claude can use to answer questions about current conditions anywhere in the world.
This is not a toy example. The server we build will follow production best practices: proper error handling, typed schemas, input validation, and security considerations. You will learn not just how to build an MCP server, but how to build one well.
What You Will Build
Our MCP server will expose two tools:
- get_current_weather — returns current weather conditions for a given city
- get_forecast — returns a multi-day weather forecast
When connected to Claude Desktop, you will be able to ask questions like "What is the weather in Tokyo?" and Claude will call your MCP server to get a live answer.
Prerequisites
Before you start, make sure you have the following installed:
- Python 3.10 or later — check with
python --version - pip — Python's package installer
- Node.js 18+ — needed for the MCP inspector tool (optional but recommended)
- Claude Desktop — to test your server with a real AI assistant
- A code editor — VS Code, Cursor, or any editor you prefer
You will also need a free API key from a weather service. We will use OpenWeatherMap's free tier, which provides 1,000 calls per day at no cost.
Step 1: Project Setup
Create a new directory for your MCP server and initialize the project structure:
mkdir weather-mcp-server
cd weather-mcp-server
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install the MCP SDK
pip install mcp httpx
The mcp package is the official Python SDK for building MCP servers. The httpx package is a modern HTTP client we will use for weather API calls.
Create the project structure:
weather-mcp-server/
├── src/
│ └── weather_server/
│ ├── __init__.py
│ └── server.py
├── tests/
│ └── test_server.py
├── pyproject.toml
└── README.md
pyproject.toml
Create the project configuration file. This defines your package metadata, dependencies, and the entry point that Claude Desktop will use to launch your server:
[project]
name = "weather-mcp-server"
version = "1.0.0"
description = "An MCP server that provides weather data to AI assistants"
requires-python = ">=3.10"
dependencies = [
"mcp>=1.0.0",
"httpx>=0.27.0",
]
[project.scripts]
weather-server = "weather_server.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
The [project.scripts] section is important — it creates a command-line entry point (weather-server) that launches your MCP server. Claude Desktop will use this command to start the server.
Step 2: Implementing the Server
Now for the core implementation. Create src/weather_server/server.py:
"""Weather MCP Server — provides current weather and forecast data."""
import json
import os
from typing import Any
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
# Initialize the MCP server
app = Server("weather-server")
# Configuration
API_KEY = os.environ.get("OPENWEATHER_API_KEY", "")
BASE_URL = "https://api.openweathermap.org/data/2.5"
async def fetch_weather(city: str) -> dict[str, Any]:
"""Fetch current weather data from OpenWeatherMap."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/weather",
params={
"q": city,
"appid": API_KEY,
"units": "metric",
},
)
response.raise_for_status()
return response.json()
async def fetch_forecast(city: str, days: int = 5) -> dict[str, Any]:
"""Fetch weather forecast from OpenWeatherMap."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/forecast",
params={
"q": city,
"appid": API_KEY,
"units": "metric",
"cnt": days * 8, # API returns 3-hour intervals
},
)
response.raise_for_status()
return response.json()
@app.list_tools()
async def list_tools() -> list[Tool]:
"""Return the list of available tools."""
return [
Tool(
name="get_current_weather",
description="Get current weather conditions for a city",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name (e.g., 'London', 'Tokyo', 'New York')",
}
},
"required": ["city"],
},
),
Tool(
name="get_forecast",
description="Get a multi-day weather forecast for a city",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name",
},
"days": {
"type": "integer",
"description": "Number of forecast days (1-5)",
"minimum": 1,
"maximum": 5,
"default": 3,
},
},
"required": ["city"],
},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
"""Handle tool invocations."""
if name == "get_current_weather":
city = arguments["city"]
data = await fetch_weather(city)
result = {
"city": data["name"],
"country": data["sys"]["country"],
"temperature_c": data["main"]["temp"],
"feels_like_c": data["main"]["feels_like"],
"humidity_pct": data["main"]["humidity"],
"conditions": data["weather"][0]["description"],
"wind_speed_ms": data["wind"]["speed"],
}
return [TextContent(
type="text",
text=json.dumps(result, indent=2),
)]
elif name == "get_forecast":
city = arguments["city"]
days = arguments.get("days", 3)
data = await fetch_forecast(city, days)
forecasts = []
for item in data["list"]:
forecasts.append({
"datetime": item["dt_txt"],
"temp_c": item["main"]["temp"],
"conditions": item["weather"][0]["description"],
"humidity_pct": item["main"]["humidity"],
})
result = {
"city": data["city"]["name"],
"country": data["city"]["country"],
"forecasts": forecasts,
}
return [TextContent(
type="text",
text=json.dumps(result, indent=2),
)]
else:
raise ValueError(f"Unknown tool: {name}")
async def main():
"""Run the MCP server."""
async with stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options(),
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Let us break down the key components of this implementation.
The Server Instance
The Server("weather-server") call creates an MCP server instance with a human-readable name. This name is displayed in Claude Desktop when the server is connected.
Tool Definitions
The @app.list_tools() decorator registers a function that returns the list of available tools. Each tool has a name, description, and an input schema using JSON Schema format. Claude uses these definitions to understand what tools are available and how to call them.
Tool Handler
The @app.call_tool() decorator registers the function that handles tool invocations. When Claude decides to call one of your tools, this function receives the tool name and arguments. It executes the logic and returns the result as a TextContent object.
The stdio Transport
MCP servers communicate with clients (like Claude Desktop) over standard input/output streams. The stdio_server() context manager sets up this communication channel. This is the simplest and most common transport for MCP servers.
Step 3: Adding Error Handling
Production MCP servers need robust error handling. Let us enhance the tool handler to handle common failure cases gracefully:
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
"""Handle tool invocations with error handling."""
try:
if name == "get_current_weather":
city = arguments.get("city", "").strip()
if not city:
return [TextContent(
type="text",
text=json.dumps({"error": "City name is required"}),
)]
data = await fetch_weather(city)
# ... format and return result
elif name == "get_forecast":
city = arguments.get("city", "").strip()
days = min(max(arguments.get("days", 3), 1), 5)
if not city:
return [TextContent(
type="text",
text=json.dumps({"error": "City name is required"}),
)]
data = await fetch_forecast(city, days)
# ... format and return result
else:
return [TextContent(
type="text",
text=json.dumps({"error": f"Unknown tool: {name}"}),
)]
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return [TextContent(
type="text",
text=json.dumps({"error": f"City '{city}' not found"}),
)]
return [TextContent(
type="text",
text=json.dumps({"error": f"Weather API error: {e.response.status_code}"}),
)]
except httpx.RequestError as e:
return [TextContent(
type="text",
text=json.dumps({"error": f"Network error: {str(e)}"}),
)]
Key principles for MCP error handling:
- Never crash — always return a structured error response rather than raising an exception. Claude can understand error messages and relay them to the user.
- Validate inputs — check that required fields are present and within expected ranges before making external calls.
- Be specific — "City not found" is more useful than "API error." Give Claude enough context to provide a helpful response to the user.
Step 4: Testing Your Server Locally
Before connecting to Claude, test your server to make sure it works correctly. Create tests/test_server.py:
"""Tests for the weather MCP server."""
import pytest
import json
from unittest.mock import AsyncMock, patch
from weather_server.server import call_tool, list_tools
@pytest.mark.asyncio
async def test_list_tools_returns_both_tools():
"""Verify both tools are listed."""
tools = await list_tools()
assert len(tools) == 2
names = {t.name for t in tools}
assert "get_current_weather" in names
assert "get_forecast" in names
@pytest.mark.asyncio
async def test_get_current_weather_schema():
"""Verify the weather tool has correct input schema."""
tools = await list_tools()
weather_tool = next(t for t in tools if t.name == "get_current_weather")
assert "city" in weather_tool.inputSchema["properties"]
assert "city" in weather_tool.inputSchema["required"]
@pytest.mark.asyncio
async def test_unknown_tool_returns_error():
"""Verify unknown tools return an error, not an exception."""
result = await call_tool("nonexistent_tool", {})
data = json.loads(result[0].text)
assert "error" in data
Run the tests:
pip install pytest pytest-asyncio
pytest tests/ -v
You can also test your server interactively using the MCP Inspector:
npx @modelcontextprotocol/inspector python -m weather_server.server
The Inspector provides a web UI where you can see your tool definitions, call tools manually, and inspect the JSON-RPC messages flowing between client and server.
Step 5: Connecting to Claude Desktop
Now for the exciting part — connecting your server to Claude Desktop. You need to edit Claude's MCP configuration file.
On macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
On Windows: %APPDATA%\Claude\claude_desktop_config.json
Add your server to the configuration:
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["-m", "weather_server.server"],
"cwd": "/path/to/weather-mcp-server",
"env": {
"OPENWEATHER_API_KEY": "your-api-key-here"
}
}
}
}
Restart Claude Desktop. You should see a hammer icon in the input area indicating that tools are available. Now try asking Claude: "What is the current weather in Paris?"
Claude will recognize that it needs weather data, call your get_current_weather tool with {"city": "Paris"}, receive the JSON response, and present the information in a natural language answer.
For detailed configuration instructions across different editors, see our tutorial on using MCP servers with Claude and Cursor.
Step 6: Adding Resources and Prompts (Optional)
MCP servers can expose more than just tools. Two additional primitives are available:
Resources
Resources provide data that Claude can read without executing a tool call. They are useful for configuration, documentation, or static reference data:
@app.list_resources()
async def list_resources():
return [
Resource(
uri="weather://supported-cities",
name="Supported Cities",
description="List of cities with reliable weather data",
mimeType="application/json",
)
]
@app.read_resource()
async def read_resource(uri: str) -> str:
if uri == "weather://supported-cities":
return json.dumps(["London", "Tokyo", "New York", "Paris", "Sydney"])
raise ValueError(f"Unknown resource: {uri}")
Prompts
Prompts are reusable prompt templates that Claude can use. They are useful for complex workflows with specific instructions:
@app.list_prompts()
async def list_prompts():
return [
Prompt(
name="weather_report",
description="Generate a detailed weather report for a city",
arguments=[
PromptArgument(
name="city",
description="The city to report on",
required=True,
)
],
)
]
Step 7: Security Considerations
Before publishing your MCP server, review these security best practices:
Input Validation
Validate and sanitize all inputs. In our weather server, the city name is passed directly to an API. For servers that interact with databases or file systems, input validation is critical to prevent injection attacks and path traversal. Understanding AgentNode's security trust levels and permissions will help you design servers with appropriate security boundaries.
Minimal Permissions
Request only the permissions your server needs. Our weather server needs external network access (to call the weather API) but does not need filesystem access, code execution, or local network access. Declaring minimal permissions increases your trust score and makes your server safer for users.
API Key Handling
Never hardcode API keys in your server code. Use environment variables, as we did with OPENWEATHER_API_KEY. The MCP configuration file passes environment variables to the server process securely.
Rate Limiting
If your server calls external APIs, implement rate limiting to prevent abuse. A runaway agent could potentially make thousands of API calls in a short period.
Step 8: Publishing to AgentNode
Once your server is tested and working, you can publish it to AgentNode to make it discoverable by every Claude and Cursor user worldwide. Publishing your server means it will go through AgentNode's 4-step verification pipeline: sandboxed installation, import checking, smoke testing, and unit test execution.
To get started with publishing, follow the guide to publish your first ANP package. The publish page on AgentNode walks you through the process step by step.
You can also use the AgentNode Builder for generating agent skills to create and refine your package manifest before publishing.
For a comprehensive walkthrough of the publishing process, see our guide on how to publish your MCP server to a global registry.
Step 9: Advanced Patterns
As you build more sophisticated MCP servers, consider these advanced patterns:
Stateful Servers
Some servers need to maintain state between tool calls. For example, a database server might maintain a connection pool, or a browser automation server might track page state. MCP servers are long-running processes, so you can maintain state in memory.
Streaming Results
For operations that produce large amounts of data (log tailing, file watching, long-running queries), MCP supports progress notifications that let you stream updates to Claude as they become available.
Multi-Server Composition
Claude can connect to multiple MCP servers simultaneously. Design your servers to do one thing well rather than trying to be a Swiss Army knife. A focused server with clear tool boundaries is easier to verify, easier to maintain, and earns higher trust scores.
SSE Transport
For servers that need to run remotely (not on the same machine as Claude Desktop), MCP supports Server-Sent Events (SSE) transport over HTTP. This is useful for team environments where a shared MCP server runs on a central server:
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route
sse = SseServerTransport("/messages")
async def handle_sse(request):
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
await app.run(
streams[0], streams[1],
app.create_initialization_options(),
)
starlette_app = Starlette(
routes=[
Route("/sse", endpoint=handle_sse),
Route("/messages", endpoint=sse.handle_post_message, methods=["POST"]),
]
)
Next Steps
You now have a working MCP server that connects to Claude Desktop and provides real functionality. From here, you can:
- Add more tools to your server — any API or service can become an MCP tool
- Implement resources for static data that Claude can reference
- Add prompts for complex, reusable workflows
- Publish to AgentNode for global discovery and verification
- Build servers for your team's internal APIs and services
The MCP ecosystem is growing rapidly. Every server you build and publish makes AI assistants more capable for everyone.
Frequently Asked Questions
What tools do I need to build an MCP server?
You need Python 3.10 or later and the mcp Python SDK. For testing, the MCP Inspector (a Node.js tool) is helpful but not required. To connect your server to Claude, you need Claude Desktop installed. The total setup takes about 10 minutes, and all tools are free.
How long does it take to build an MCP server?
A basic MCP server with one or two tools can be built in under an hour if you are familiar with Python. The server in this tutorial — with two tools, error handling, and tests — takes approximately 30-45 minutes to complete from scratch. More complex servers with multiple tools, state management, and advanced features may take several hours to a full day.
Can I publish my MCP server to make it available to other users?
Yes. AgentNode is the primary registry for publishing and distributing MCP servers and agent tools. When you publish, your server goes through automated verification — sandboxed installation, import checking, smoke testing, and unit tests — and receives a trust score. Once published, any Claude or Cursor user can discover and install your server through AgentNode's search.
What is the ANP format?
ANP (AgentNode Package) is the standard format for packaging agent tools and MCP servers for distribution. An ANP package contains a manifest.json file that describes the tool's capabilities, input/output schemas, and permission requirements, along with the actual implementation code and optional tests. The ANP format ensures that packages are self-describing, verifiable, and compatible across different agent frameworks.