MCP Server = Tool Provider
Think of it as a store that sells tools.
It has tools on shelves, waiting for customers.
MCP Client = Tool User
This is the AI that comes shopping.
It asks: "What tools do you have?" then uses them.
💡 Quick Analogy: Restaurant vs Customer
Server = Restaurant with menu (available tools)
Client = Customer who orders from menu
Today, you're opening your first restaurant!
Step 1: Setting Up Your Python Environment
First, let's create a clean workspace.
Create Your Project Folder
# Open terminal/command prompt mkdir my-first-mcp-server cd my-first-mcp-server # Create virtual environment python -m venv venv # Activate it (choose your OS): # Windows: venv\Scripts\activate # Mac/Linux: source venv/bin/activate
✅ DO use virtual environments
They keep your projects isolated.
No conflicts between different Python packages.
Like having separate toolboxes for different jobs.
Install Required Packages
We need the MCP SDK:
pip install mcp
Create a requirements.txt file for future reference:
echo "mcp>=0.1.0" > requirements.txt
Step 2: Understanding MCP Server Structure
Every MCP server has three essential parts:
1. SERVER SETUP ├── Import MCP libraries ├── Create server instance └── Configure communication 2. TOOL REGISTRATION ├── Define what tools are available ├── Describe each tool's purpose └── Specify required inputs 3. TOOL IMPLEMENTATION ├── Write the actual code ├── Handle inputs properly └── Return results in MCP format
Step 3: Your First MCP Server Code
Create a file called calculator_server.py:
# calculator_server.py from mcp.server import Server from mcp.server.models import InitializationOptions import mcp.server.stdio import asyncio
📚 Import Breakdown:
Server: Main server classInitializationOptions: Server configurationstdio: Communication method (pipes)asyncio: Async programming (MCP uses this)
Creating the Server Instance
Add this to your file:
# Create server instance
server = Server("calculator-server")
# Define our first tool
@server.list_tools()
async def list_tools():
return [
{
"name": "add_numbers",
"description": "Add two numbers together",
"inputSchema": {
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "First number"
},
"b": {
"type": "number",
"description": "Second number"
}
},
"required": ["a", "b"]
}
}
]
✅ DO understand the decorator:
@server.list_tools() tells MCP:
"This function returns our tool menu."
Every MCP server needs this function.
What's Happening Here?
Let's break down the tool definition:
{
"name": "add_numbers", # Tool's ID (how AI calls it)
"description": "Add two...", # What it does (AI reads this)
"inputSchema": { # What inputs it needs
"type": "object",
"properties": { # List of inputs
"a": {
"type": "number", # Must be a number
"description": "First number" # Help text
},
"b": { ... }
},
"required": ["a", "b"] # Both inputs are required
}
}
Step 4: Implementing the Actual Tool
Now we write the code that does the actual work.
Add this to your file:
# Implement the add_numbers tool
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "add_numbers":
# Extract inputs
a = arguments.get("a")
b = arguments.get("b")
# Do the calculation
result = a + b
# Return in MCP format
return {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
# If tool not found
raise ValueError(f"Unknown tool: {name}")
Understanding the Tool Logic
The function flow:
1. AI asks for "add_numbers" with a=5, b=3
2. Function receives name="add_numbers", arguments={"a":5, "b":3}
3. Checks if name matches our tool
4. Extracts a and b from arguments
5. Calculates 5 + 3 = 8
6. Returns {"content": [{"type": "text", "text": "8"}]}
🔍 Important Detail: The return format
MCP expects content array with items.
Each item has type and actual content.
"type": "text" means we're returning plain text.
Step 5: Adding the Server Runner
We need code to start and run the server.
Add this to the bottom of your file:
async def main():
# Run server with stdio transport
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="calculator-server",
server_version="1.0.0"
)
)
if __name__ == "__main__":
asyncio.run(main())
Step 6: Complete Server Code
Here's everything together:
# calculator_server.py - COMPLETE CODE
from mcp.server import Server
from mcp.server.models import InitializationOptions
import mcp.server.stdio
import asyncio
# Create server instance
server = Server("calculator-server")
# List available tools
@server.list_tools()
async def list_tools():
return [
{
"name": "add_numbers",
"description": "Add two numbers together",
"inputSchema": {
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "First number"
},
"b": {
"type": "number",
"description": "Second number"
}
},
"required": ["a", "b"]
}
}
]
# Handle tool calls
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "add_numbers":
a = arguments.get("a")
b = arguments.get("b")
result = a + b
return {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
raise ValueError(f"Unknown tool: {name}")
# Server runner
async def main():
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="calculator-server",
server_version="1.0.0"
)
)
if __name__ == "__main__":
asyncio.run(main())
✅ DO save this file exactly as shown
Make sure indentation is correct.
Python is picky about spaces vs tabs.
Copy-paste to avoid typos.
Step 7: Testing Your Server
You don't need an AI to test! Let's do manual testing.
Method 1: Run and Check
First, run your server:
python calculator_server.py
It will start and wait for connections.
This is normal! It's listening for clients.
⚠️ If you get errors:
- Check Python version (needs 3.8+)
- Verify MCP installed correctly
- Ensure virtual environment is active
- Check for typos in code
Method 2: Create a Test Client
Create a new file test_client.py:
# test_client.py
import subprocess
import json
import time
def send_mcp_message(message):
"""Send a message to our server"""
# Start server process
proc = subprocess.Popen(
['python', 'calculator_server.py'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# Send message
proc.stdin.write(json.dumps(message) + '\n')
proc.stdin.flush()
# Read response
response = proc.stdout.readline()
# Clean up
proc.terminate()
return json.loads(response)
# Test initialization
init_message = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
},
"id": 1
}
print("Testing initialization...")
response = send_mcp_message(init_message)
print("Response:", json.dumps(response, indent=2))
Step 8: Understanding What Just Happened
Let's visualize the server's lifecycle:
Server Lifecycle
┌─────────────────────────────────────────────┐
│ SERVER STARTS │
│ 1. Import libraries, create Server instance │
│ 2. Define @server.list_tools() function │
│ 3. Define @server.call_tool() function │
│ 4. Wait for client connections (stdio) │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ CLIENT CONNECTS │
│ 1. Client sends "initialize" request │
│ 2. Server responds with capabilities │
│ 3. Client asks "what tools?" (tools/list) │
│ 4. Server returns tool definitions │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ TOOL EXECUTION │
│ 1. Client calls "add_numbers" with a,b │
│ 2. Server's call_tool() function runs │
│ 3. Calculation happens (a + b) │
│ 4. Result formatted and returned │
└─────────────────────────────────────────────┘
Step 9: Testing the Add Tool
Let's create a proper test.
Update your test_client.py:
# Extended test
def test_add_tool():
print("\n=== Testing add_numbers tool ===")
# First, get available tools
list_message = {
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": 2
}
print("1. Listing tools...")
tools_response = send_mcp_message(list_message)
print("Available tools:", json.dumps(tools_response, indent=2))
# Now call add_numbers
add_message = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "add_numbers",
"arguments": {
"a": 42,
"b": 17
}
},
"id": 3
}
print("\n2. Calling add_numbers(42, 17)...")
result = send_mcp_message(add_message)
print("Result:", json.dumps(result, indent=2))
# Extract the actual answer
content = result.get("result", {}).get("content", [])
if content:
answer = content[0].get("text", "No answer found")
print(f"\n✅ 42 + 17 = {answer}")
else:
print("\n❌ Failed to get answer")
if __name__ == "__main__":
test_add_tool()
❌ DON'T skip testing
Testing ensures your server works correctly.
AI will fail mysteriously if your server has bugs.
Always test manually first.
Step 10: Your Exercise - Add Subtraction
🏆 Challenge: Add subtract_numbers Tool
Your mission: Extend the server with subtraction.
Requirements:
- Add "subtract_numbers" to the tools list
- Implement the subtraction logic in call_tool()
- Test it works correctly (50 - 8 = 42)
Hints:
- Copy the add_numbers pattern
- Change the name and description
- Subtraction formula: a - b
- Add new if condition in call_tool()
Success criteria:
- Server starts without errors
- Both add and subtract appear in tools list
- subtract_numbers(50, 8) returns 42
Step-by-Step Guidance
Here's how to approach the exercise:
# Step 1: Update list_tools() function
# Add this to the tools list (alongside add_numbers):
{
"name": "subtract_numbers",
"description": "Subtract second number from first number",
"inputSchema": {
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "Number to subtract from"
},
"b": {
"type": "number",
"description": "Number to subtract"
}
},
"required": ["a", "b"]
}
}
# Step 2: Update call_tool() function
# Add this condition (after the add_numbers check):
if name == "subtract_numbers":
a = arguments.get("a")
b = arguments.get("b")
result = a - b # This is the key change!
return {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
Common Issues & Solutions
Issue 1: Server Doesn't Start
🔧 Troubleshooting:
- Error: "No module named 'mcp'"
- Solution: Activate virtual env, run
pip install mcp
- Error: SyntaxError
- Solution: Check Python version ≥ 3.8
- Server starts but immediately exits
- Solution: Ensure
asyncio.run(main())is at bottom
Issue 2: Tools Not Listed
If AI can't see your tools:
- Check
@server.list_tools()decorator exists - Verify function returns a list (not a dictionary)
- Ensure tool names match exactly (case-sensitive)
- Check JSON structure is valid
Issue 3: Tool Doesn't Work
If tool is listed but fails when called:
- Check
@server.call_tool()decorator - Verify
name == "your_tool_name"matches exactly - Ensure you're extracting arguments correctly
- Check return format matches MCP spec
Advanced Concepts: Leveling Up
Adding Multiple Tools Efficiently
Instead of giant if-else chains, use a dictionary:
# Define tool implementations
tool_functions = {
"add_numbers": lambda a, b: a + b,
"subtract_numbers": lambda a, b: a - b,
"multiply_numbers": lambda a, b: a * b,
"divide_numbers": lambda a, b: a / b if b != 0 else "Error: division by zero"
}
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name in tool_functions:
a = arguments.get("a")
b = arguments.get("b")
result = tool_functions[name](a, b)
return {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
raise ValueError(f"Unknown tool: {name}")
Adding Input Validation
Make your server robust:
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "divide_numbers":
a = arguments.get("a")
b = arguments.get("b")
# Validate inputs
if b == 0:
return {
"content": [
{
"type": "text",
"text": "Error: Cannot divide by zero"
}
],
"isError": True
}
result = a / b
return {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
Testing Without AI: Advanced Techniques
Create a Comprehensive Test Suite
# advanced_test.py
import json
import subprocess
import time
class MCPTester:
def __init__(self, server_script):
self.server_script = server_script
self.process = None
def start_server(self):
"""Start the MCP server"""
self.process = subprocess.Popen(
['python', self.server_script],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
time.sleep(0.5) # Give server time to start
def send_request(self, request):
"""Send a request and get response"""
request_str = json.dumps(request) + '\n'
self.process.stdin.write(request_str)
self.process.stdin.flush()
# Read response
response_line = self.process.stdout.readline()
return json.loads(response_line.strip())
def stop_server(self):
"""Stop the server"""
if self.process:
self.process.terminate()
self.process.wait()
def run_full_test(self):
"""Run complete test sequence"""
print("🚀 Starting MCP Server Tests...")
self.start_server()
try:
# Test 1: Initialize
print("\n1. Testing initialization...")
init_response = self.send_request({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"clientInfo": {"name": "tester", "version": "1.0.0"}
},
"id": 1
})
print(f" ✓ Server: {init_response.get('result', {}).get('serverInfo', {}).get('name')}")
# Test 2: List tools
print("\n2. Testing tool listing...")
tools_response = self.send_request({
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": 2
})
tools = tools_response.get('result', {}).get('tools', [])
print(f" ✓ Found {len(tools)} tools:")
for tool in tools:
print(f" - {tool['name']}: {tool['description']}")
# Test 3: Test each tool
print("\n3. Testing tool execution...")
test_cases = [
("add_numbers", {"a": 10, "b": 5}, "15"),
("subtract_numbers", {"a": 10, "b": 5}, "5"),
("multiply_numbers", {"a": 10, "b": 5}, "50"),
("divide_numbers", {"a": 10, "b": 5}, "2.0"),
]
for tool_name, args, expected in test_cases:
if any(t['name'] == tool_name for t in tools):
result = self.send_request({
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": args
},
"id": 3
})
actual = result.get('result', {}).get('content', [{}])[0].get('text', '')
status = "✓" if str(actual) == expected else "✗"
print(f" {status} {tool_name}({args['a']}, {args['b']}) = {actual} (expected: {expected})")
print("\n🎉 All tests completed!")
finally:
self.stop_server()
# Run tests
if __name__ == "__main__":
tester = MCPTester("calculator_server.py")
tester.run_full_test()
Production-Ready Improvements
Add Logging
import logging
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("calculator-server")
@server.call_tool()
async def call_tool(name: str, arguments: dict):
logger.info(f"Tool called: {name} with args: {arguments}")
if name == "add_numbers":
result = arguments.get("a") + arguments.get("b")
logger.info(f"Calculation: {arguments['a']} + {arguments['b']} = {result}")
return {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
Add Error Handling
@server.call_tool()
async def call_tool(name: str, arguments: dict):
try:
if name == "add_numbers":
# Validate inputs exist
if "a" not in arguments or "b" not in arguments:
raise ValueError("Missing required parameters: a and b")
# Validate types
if not isinstance(arguments["a"], (int, float)):
raise TypeError("Parameter 'a' must be a number")
if not isinstance(arguments["b"], (int, float)):
raise TypeError("Parameter 'b' must be a number")
result = arguments["a"] + arguments["b"]
return {
"content": [
{
"type": "text",
"text": str(result)
}
]
}
except Exception as e:
# Return error in MCP format
return {
"content": [
{
"type": "text",
"text": f"Error: {str(e)}"
}
],
"isError": True
}
Comments
Post a Comment