Introduction
Look, I'll be honest with you - when I first heard about MCP servers, I thought "great, another acronym to learn." But after spending way too many late nights building one from scratch (and making every mistake you can imagine), I realized this stuff is actually pretty game-changing.
The Model Context Protocol basically lets AI assistants talk to real-world tools instead of just regurgitating training data. Think of it like giving your AI a toolbox instead of just a really good memory.
I remember my first attempt at this - I tried to build everything in one massive file and ended up with 800 lines of spaghetti code that crashed every time someone asked for the weather. Don't be like me. We're gonna do this the right way, step by step.
Why I Think You Should Build an MCP Server
Here's the thing - regular AI assistants are basically really smart parrots. They can tell you what Paris is like based on their training, but they can't check if it's actually raining there right now. MCP changes that game completely.
After building dozens of these servers (some good, some... well, let's not talk about those), I've seen how MCP unlocks stuff like:
- Grabbing live data from APIs while you wait
- Crunching numbers that would take you hours to calculate
- Connecting to databases and actually useful business systems
- Running custom code that does exactly what your specific use case needs
What We're Actually Gonna Build Together
By the end of this tutorial, you'll have a rock-solid MCP server that I wish I had when I started. We're talking:
- A calculator tool that actually works (mine didn't for the first week)
- Weather integration that won't crash when someone types "Timbuktu"
- Text analysis that's surprisingly useful for real work
- OpenAI integration that makes the whole thing feel like magic
- A FastAPI server that won't fall over under pressure
What You Need Before We Start
- Python 3.10 or newer (trust me, the older versions will give you headaches)
- Some idea of what async/await does (don't worry, I'll explain the parts that matter)
- An OpenAI API key (yeah, it costs money, but we're talking like $5 for all your testing)
Step 1: Getting Our Project Set Up (Without Losing Our Minds)
Alright, let's talk about project setup. I used to just throw everything in a single folder and call it a day, but that approach bit me hard when I tried to add new features six months later.
Here's what I learned the hard way about organizing an MCP server project.
How I Organize My MCP Projects Now
After building my third or fourth MCP server and realizing I couldn't remember which file did what, I came up with this structure that actually makes sense:
mcp-server-tutorial/
├── main.py # The FastAPI web server - your HTTP entry point
├── mcp_server.py # Core MCP logic - where the magic happens
├── mcp_stdio_server.py # Direct MCP protocol server - for advanced use
├── openai_agent.py # OpenAI integration - makes everything smart
├── pyproject.toml # Modern Python config - way better than setup.py
├── requirements.txt # Old school dependencies - for compatibility
├── run.sh # One-command startup - saves so much time
├── .env # Secret stuff - never commit this to git!
└── README.md # Future you will thank present you
Why I Split Things Up This Way
I tried doing everything in one file. It was a disaster. Here's what I learned:
The main.py
handles all the web requests, while mcp_server.py
does the actual tool work. This means when I want to add a new tool, I only touch one file. When I want to change how the web API works, I only touch the other file.
I keep both HTTP and STDIO versions because sometimes you want the AI to talk directly to your tools (STDIO), and sometimes you want a web API that any app can use (HTTP). Trust me, you'll want both eventually.
The separate OpenAI file means I can swap out AI providers later without rewriting everything. I learned this lesson when OpenAI changed their API and I had AI code scattered across five different files.
The Dependencies That Won't Let You Down
Let me save you that headache by walking through each dependency and why I actually use it in real projects.
The pyproject.toml File That Works
I used to use requirements.txt for everything, but pyproject.toml is just better and it works with UV
. Here's the exact setup I use now:
[project]
name = "mcp-openai-server"
version = "1.0.0"
description = "MCP Server with OpenAI Integration"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.104.1", # This is the web framework that doesn't suck
"uvicorn[standard]>=0.24.0", # Runs FastAPI and handles all the server stuff
"openai>=1.40.0", # Official OpenAI client - don't use unofficial ones
"pydantic>=2.5.0", # Handles data validation so you don't have to
"httpx>=0.25.2", # For calling other APIs without blocking everything
"python-dotenv>=1.0.0", # Keeps your API keys out of your code
"mcp>=1.0.0", # The actual MCP protocol stuff
"anyio>=4.0.0", # Makes async work better across different systems
"typing-extensions>=4.8.0", # Better type hints that catch bugs early
"requests>=2.31.0", # Sometimes you just need simple HTTP requests
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"black>=23.0.0",
"ruff>=0.1.0",
"mypy>=1.5.0",
]
[tool.black]
line-length = 100
target-version = ['py310']
[tool.ruff]
line-length = 100
target-version = "py310"
Create requirements.txt
:
fastapi==0.104.1
uvicorn[standard]==0.24.0
openai==1.40.0
pydantic==2.5.0
httpx==0.25.2
python-dotenv==1.0.0
mcp==1.0.0
anyio==4.0.0
typing-extensions==4.8.0
requests==2.31.0
Install dependencies:
# Using pip
pip install -r requirements.txt
# Or using uv (recommended)
pip install uv
uv sync
Step 2: Why MCP Protocol Actually Matters (And Why I Wish I'd Learned This First)
So here's the thing about MCP protocol - I totally skipped learning it properly the first time and paid for it later. I just wanted to build cool AI tools, but I ended up creating this frankenstein system that only worked with one specific AI model.
What MCP Protocol Actually Is
Think of MCP as the universal translator between AI brains and useful tools. Before MCP, every AI company had their own way of talking to external functions. It was like having five different remote controls for the same TV.
I remember building my first tool integration and having to write three different versions - one for OpenAI's format, one for Anthropic's, and another for a local model. What a mess.
The Four Main Parts You Need to Know
Tools are basically functions your AI can call. I like to think of them as giving your AI hands to actually do stuff in the real world. My calculator tool, for example, lets the AI actually crunch numbers instead of just guessing at math.
Resources are where your tools get their data from. This could be a database, a file system, or that weather API you've been meaning to integrate. The key thing I learned is that resources make your tools dynamic instead of just static functions.
Servers (like what we're building) are the middleman that holds all your tools and resources. I used to think servers were just for web apps, but MCP servers are more like tool libraries that any AI can check out books from.
Clients are the AI assistants that actually use your tools. The cool part is that once you build an MCP server, any compatible AI can use it without you having to rewrite anything.
Why This Protocol Design Is Actually Brilliant
After building servers both with and without proper MCP protocol, I can tell you the difference is night and day. With MCP, I wrote my weather tool once and it worked with GPT-4, Claude, and even my custom local model. Without it, I was maintaining three separate codebases that basically did the same thing.
Step 3: Building Your First Tool
I start with a calculator tool because it's predictable and doesn't depend on external services. After spending a week debugging a weather API integration that kept failing, I learned to build reliable tools first.
Why Start with Math Tools
Math operations are deterministic - the same input always produces the same output. There are no API keys to manage, no rate limits to hit, and no external services that go down at inconvenient times.
Calculator tools also see heavy usage in real applications. I've seen them used for mortgage calculations, tip calculations, unit conversions, and financial modeling.
Why Async Matters Here
Async programming allows your server to handle multiple requests simultaneously. Without it, if five users try to use your calculator at the same time, they'll queue up and wait for each other to finish.
I built a server once that could only handle one calculation at a time. Users would timeout waiting for other calculations to complete, which created a poor experience.
Here's the calculator implementation in mcp_server.py
:
import asyncio
import json
from typing import Dict, List, Any, Optional
import logging
logger = logging.getLogger(__name__)
class MCPServer:
def __init__(self):
self.is_initialized = False
self.available_tools: List[Dict] = []
async def initialize(self):
"""Initialize the MCP server"""
try:
# Define our tools using JSON Schema for validation
# Why JSON Schema? It provides automatic validation and clear documentation
self.available_tools = [
{
"name": "calculate",
"description": "Perform mathematical calculations",
"inputSchema": {
"type": "object", # Always an object for MCP tools
"properties": {
"expression": {
"type": "string",
"description": "Mathematical expression to evaluate"
}
},
"required": ["expression"] # Ensures AI provides necessary data
}
}
]
self.is_initialized = True
logger.info("MCP Server initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize MCP server: {e}")
self.is_initialized = False
Step 4: Tool Execution Implementation
Tool execution is where the MCP protocol comes together. This is typically where most debugging time gets spent, so proper implementation is important.
Separating Discovery from Execution
The MCP protocol requires a two-step process: tool discovery followed by tool execution. The AI first requests a list of available tools, then calls specific tools with arguments.
This separation allows the AI to understand what capabilities are available before making decisions about which tools to use. I tried combining these steps once, which created unpredictable behavior and confused both the AI and users.
The pattern is: AI asks "what tools do you have?" → receives tool definitions → selects appropriate tool → calls tool with specific parameters.
Structured Error Handling
Proper error handling prevents server crashes and provides useful information to the AI. When my calculator tool first encountered a division by zero error, it crashed the entire server instead of returning a helpful error message.
MCP error responses need to include enough context for the AI to understand what went wrong and potentially try alternative approaches. If a calculation fails, the AI should receive information about why it failed.
Here's the tool execution implementation:
async def list_tools(self) -> List[Dict]:
"""List available MCP tools"""
# Why auto-initialize? Ensures tools are always available when requested
if not self.is_initialized:
await self.initialize()
return self.available_tools
async def call_tool(self, tool_request: Dict) -> Dict[str, Any]:
"""Call an MCP tool"""
tool_name = tool_request.get("name")
arguments = tool_request.get("arguments", {})
if not self.is_initialized:
raise Exception("MCP server not initialized")
# Find the tool - why this pattern? Ensures tool exists before execution
tool = next((t for t in self.available_tools if t["name"] == tool_name), None)
if not tool:
raise Exception(f"Tool '{tool_name}' not found")
# Execute with proper MCP response format
try:
result = await self._execute_tool(tool_name, arguments)
# Why this response format? It matches MCP protocol specification
return {
"toolResult": {
"content": [{"type": "text", "text": str(result)}],
"isError": False
}
}
except Exception as e:
# Why return error in this format? Allows AI to understand failure and potentially recover
return {
"toolResult": {
"content": [{"type": "text", "text": f"Error: {str(e)}"}],
"isError": True
}
}
async def _execute_tool(self, tool_name: str, arguments: Dict) -> str:
"""Execute a specific tool"""
if tool_name == "calculate":
expression = arguments.get("expression", "")
try:
# Safe evaluation of mathematical expressions
# Why restrict eval()? eval() can execute arbitrary code - we limit it to safe math functions
import math
allowed_names = {
k: v for k, v in math.__dict__.items() if not k.startswith("__")
}
allowed_names.update({"abs": abs, "round": round, "min": min, "max": max})
# Why empty __builtins__? Prevents access to dangerous functions like exec, open, etc.
result = eval(expression, {"__builtins__": {}}, allowed_names)
return f"Result: {result}"
except Exception as e:
return f"Calculation error: {str(e)}"
else:
raise Exception(f"Unknown tool: {tool_name}")
Step 5: Expanding Your Tool Collection
A single calculator tool creates a limited MCP server. Adding multiple tools demonstrates the platform's capabilities and provides more practical value.
Building Multiple Tools
I launched an MCP server with only one tool once, planning to add more later. Six months passed before I expanded it, and user adoption remained low because the server appeared incomplete.
Multiple tools showcase the server's capabilities and give the AI more options for helping users. The difference in user engagement between single-tool and multi-tool servers is significant.
Essential Tool Categories
Weather tools see consistent usage because weather requests appear frequently in conversations, often unexpectedly in the middle of unrelated topics.
Text analysis tools are popular with content creators, students, and professionals who need word counts, readability scores, or text statistics for their work.
Time tools handle scheduling, time zone conversions, and timestamp operations. They're reliable because they don't depend on external services (assuming your server's clock is accurate).
Using Simulated Data Initially
Simulated weather data avoids the complexity of API keys, rate limits, and external service dependencies during development. This approach lets you focus on MCP implementation mechanics.
Once the MCP structure is working correctly, replacing simulated data with real API calls typically requires minimal code changes.
Here's the expanded tool implementation:
async def initialize(self):
"""Initialize the MCP server with multiple tools"""
try:
self.available_tools = [
{
"name": "calculate",
"description": "Perform mathematical calculations",
"inputSchema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Mathematical expression to evaluate"
}
},
"required": ["expression"]
}
},
{
"name": "get_weather",
"description": "Get current weather for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The location to get weather for"
}
},
"required": ["location"]
}
},
{
"name": "text_analysis",
"description": "Analyze text for word count and statistics",
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Text to analyze"
}
},
"required": ["text"]
}
},
{
"name": "get_time",
"description": "Get current time and date",
"inputSchema": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "Timezone (optional)",
"default": "local"
}
},
"required": []
}
}
]
self.is_initialized = True
logger.info("MCP Server initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize MCP server: {e}")
self.is_initialized = False
Update the _execute_tool
method to handle all tools:
async def _execute_tool(self, tool_name: str, arguments: Dict) -> str:
"""Execute a specific tool"""
if tool_name == "calculate":
expression = arguments.get("expression", "")
try:
import math
allowed_names = {
k: v for k, v in math.__dict__.items() if not k.startswith("__")
}
allowed_names.update({"abs": abs, "round": round, "min": min, "max": max})
result = eval(expression, {"__builtins__": {}}, allowed_names)
return f"Result: {result}"
except Exception as e:
return f"Calculation error: {str(e)}"
elif tool_name == "get_weather":
location = arguments.get("location", "")
# Simulated weather data
return f"Weather in {location}: Sunny, 72°F (22°C), Humidity: 65%, Wind: 5 mph"
elif tool_name == "text_analysis":
text = arguments.get("text", "")
words = text.split()
chars = len(text)
chars_no_spaces = len(text.replace(" ", ""))
lines = len(text.split("\n"))
result = f"Text Analysis:\n"
result += f"Characters: {chars}\n"
result += f"Characters (no spaces): {chars_no_spaces}\n"
result += f"Words: {len(words)}\n"
result += f"Lines: {lines}\n"
result += f"Average word length: {chars_no_spaces / len(words) if words else 0:.1f}"
return result
elif tool_name == "get_time":
from datetime import datetime
timezone = arguments.get("timezone", "local")
now = datetime.now()
result = f"Current time ({timezone}): {now.strftime('%Y-%m-%d %H:%M:%S')}\n"
result += f"Day of week: {now.strftime('%A')}\n"
result += f"Unix timestamp: {int(now.timestamp())}"
return result
else:
raise Exception(f"Unknown tool: {tool_name}")
Step 6: OpenAI Integration
OpenAI integration transforms static tools into an intelligent system. The AI can automatically select and use appropriate tools based on natural language requests.
The Difference Integration Makes
Before OpenAI integration, using tools required knowing specific tool names and parameters. Users had to manually construct tool calls, which limited practical usage.
With OpenAI function calling, users can ask "What's the weather like in Tokyo?" and the AI automatically determines it needs to call the weather tool with "Tokyo" as the location parameter.
Intelligent Tool Selection
GPT-4 can analyze complex requests and make multiple tool calls. For example, with the request "What's 15% tip on a $47 dinner in Seattle where it's currently raining?" the AI will:
- Call the calculator tool to compute the tip (47 * 0.15 = 7.05)
- Call the weather tool to check Seattle's current conditions
- Combine both results in a natural language response
This demonstrates tool chaining and contextual reasoning beyond simple single-tool usage.
Architectural Separation
I keep the AI integration layer separate from the core tool implementation. When OpenAI changed their API format, this separation meant updating only the integration file rather than rewriting the entire tool layer.
This design also allows other developers to use the same tools with different AI providers (Anthropic, local models, etc.) by implementing their own integration layer.
Here's the OpenAI integration implementation:
import os
import json
from typing import List, Dict, Any, Optional
from openai import OpenAI
import logging
logger = logging.getLogger(__name__)
class OpenAIAgent:
def __init__(self, mcp_server=None):
self.client: Optional[OpenAI] = None
self.is_initialized = False
self.model = "gpt-4-turbo-preview"
self.mcp_server = mcp_server
async def initialize(self):
"""Initialize the OpenAI client"""
try:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable is required")
self.client = OpenAI(api_key=api_key)
self.is_initialized = True
logger.info("OpenAI Agent initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize OpenAI agent: {e}")
self.is_initialized = False
async def run_with_tools(self, message: str, tools: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Run the agent with MCP tools available"""
if not self.is_initialized:
await self.initialize()
if not self.client:
raise Exception("OpenAI client not initialized")
try:
# Convert MCP tools to OpenAI function format
openai_tools = self._convert_mcp_tools_to_openai(tools)
messages: List[Dict[str, Any]] = [
{
"role": "system",
"content": "You are a helpful assistant with access to various tools. Use the available tools to help answer questions and complete tasks."
},
{"role": "user", "content": message}
]
# First request with tools
response = self.client.chat.completions.create(
model=self.model,
messages=messages, # type: ignore
tools=openai_tools, # type: ignore
tool_choice="auto",
max_tokens=1000,
temperature=0.7
)
response_message = response.choices[0].message
# Handle tool calls if any
if response_message.tool_calls:
tool_results = []
for tool_call in response_message.tool_calls:
tool_name = tool_call.function.name
tool_args = json.loads(tool_call.function.arguments)
# Call the actual MCP tool
if self.mcp_server:
try:
mcp_result = await self.mcp_server.call_tool({
"name": tool_name,
"arguments": tool_args
})
tool_result = mcp_result.get("toolResult", {}).get("content", [{}])[0].get("text", "No result")
except Exception as e:
tool_result = f"Error calling MCP tool: {str(e)}"
else:
tool_result = f"Tool '{tool_name}' executed with args: {tool_args} (simulated)"
tool_results.append({
"tool_call_id": tool_call.id,
"name": tool_name,
"result": tool_result
})
messages.append({
"tool_call_id": tool_call.id,
"role": "tool",
"name": tool_name,
"content": tool_result
})
# Second request with tool results
final_response = self.client.chat.completions.create(
model=self.model,
messages=messages, # type: ignore
max_tokens=1000,
temperature=0.7
)
final_content = final_response.choices[0].message.content
return {
"response": final_content if final_content is not None else "No response generated",
"tool_calls": tool_results,
"message_history": messages
}
else:
return {
"response": response_message.content if response_message.content is not None else "No response generated",
"tool_calls": [],
"message_history": messages
}
except Exception as e:
logger.error(f"Agent run error: {e}")
raise Exception(f"Failed to run agent with tools: {str(e)}")
def _convert_mcp_tools_to_openai(self, mcp_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Convert MCP tool format to OpenAI function calling format"""
openai_tools = []
for tool in mcp_tools:
openai_tool = {
"type": "function",
"function": {
"name": tool["name"],
"description": tool["description"],
"parameters": tool.get("inputSchema", {})
}
}
openai_tools.append(openai_tool)
return openai_tools
Step 7: FastAPI Web Server Implementation
The web server provides HTTP access to the MCP tools and AI integration. This transforms the local tool collection into a service accessible from any application.
FastAPI Selection
FastAPI offers several advantages for MCP servers: automatic API documentation generation, built-in request validation using Python type hints, native async/await support, and strong performance characteristics.
The automatic documentation feature eliminates the need to manually write API documentation. FastAPI's async support allows concurrent handling of multiple tool requests, preventing user timeouts when multiple requests arrive simultaneously.
Essential Endpoints
Four endpoints cover the core functionality needed for an MCP web server:
/health
provides server status monitoring, useful for deployment health checks and debugging server availability.
/mcp/tools
enables tool discovery, allowing clients to programmatically determine what capabilities are available without requiring static documentation.
/mcp/call-tool
supports direct tool execution for testing and simple integrations that don't need AI interpretation.
/agent/run-with-mcp
handles natural language requests, where the AI interprets user input and automatically selects appropriate tools.
CORS Configuration
CORS middleware allows web applications from different domains to access the API. Without proper CORS headers, browsers will block cross-origin requests, limiting the API's usability from web frontends.
Here's the FastAPI implementation:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import os
from dotenv import load_dotenv
from mcp_server import MCPServer
from openai_agent import OpenAIAgent
# Load environment variables
load_dotenv()
app = FastAPI(
title="MCP Server with OpenAI Integration",
description="A Model Context Protocol server integrated with OpenAI",
version="1.0.0"
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize services
mcp_server = MCPServer()
openai_agent = OpenAIAgent(mcp_server=mcp_server)
@app.on_event("startup")
async def startup_event():
"""Initialize services on startup"""
await mcp_server.initialize()
await openai_agent.initialize()
@app.get("/")
async def root():
"""Health check endpoint"""
return {"message": "MCP Server with OpenAI is running", "status": "healthy"}
@app.get("/health")
async def health_check():
"""Detailed health check endpoint"""
return {
"status": "healthy",
"mcp_server": await mcp_server.get_status(),
"openai_agent": await openai_agent.get_status()
}
@app.post("/mcp/tools")
async def list_mcp_tools():
"""List available MCP tools"""
try:
tools = await mcp_server.list_tools()
return {"tools": tools}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/mcp/call-tool")
async def call_mcp_tool(tool_request: dict):
"""Call an MCP tool"""
try:
result = await mcp_server.call_tool(tool_request)
return {"result": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/agent/run-with-mcp")
async def run_agent_with_mcp(request: dict):
"""Run OpenAI agent with MCP tools"""
try:
# Get available MCP tools
mcp_tools = await mcp_server.list_tools()
# Run agent with MCP tools
result = await openai_agent.run_with_tools(
message=request.get("message", ""),
tools=mcp_tools
)
return {"result": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
host = os.getenv("FASTAPI_HOST", "0.0.0.0")
port = int(os.getenv("FASTAPI_PORT", 8000))
uvicorn.run(
"main:app",
host=host,
port=port,
reload=True,
log_level="info"
)
Step 8: Configuration That Won't Get You Fired
Let's talk about environment variables and why they'll save you from accidentally posting your API keys on GitHub for the whole world to see. Trust me, I've seen it happen, and it's not pretty.
Why Environment Variables Are Your Friend
I used to hardcode everything directly into my Python files. API keys, database passwords, server URLs - everything just sitting there in plain text. Then one day I pushed my code to a public GitHub repo and got an email from GitHub saying they'd found my OpenAI API key in my code.
Turns out, there are bots that scan public repos looking for API keys, and they found mine within minutes. I had to revoke the key, generate a new one, and learned a very expensive lesson about security.
Environment variables keep your secrets in a separate file that never gets committed to version control. It's like keeping your house keys separate from your business cards.
The Variables That Actually Matter
OPENAI_API_KEY
is obviously the big one - this costs you real money if someone gets hold of it. Keep it secret, keep it safe.
FASTAPI_HOST
of 0.0.0.0
means your server will accept connections from anywhere on the network. If you're just testing locally, you can use 127.0.0.1
to keep it locked down to your own machine.
FASTAPI_PORT
of 8000 is pretty standard for development. In production, you'll probably want 80 or 443, but 8000 won't conflict with other services during development.
Here's your .env
file:
OPENAI_API_KEY=your_openai_api_key_here
MCP_SERVER_PORT=3000
FASTAPI_HOST=0.0.0.0
FASTAPI_PORT=8000
Create run.sh
:
#!/bin/bash
echo "Starting MCP Server with OpenAI Integration..."
# Check if .env file exists
if [ ! -f ".env" ]; then
echo "Creating .env file..."
echo "OPENAI_API_KEY=your_openai_api_key_here" > .env
echo "MCP_SERVER_PORT=3000" >> .env
echo "FASTAPI_HOST=0.0.0.0" >> .env
echo "FASTAPI_PORT=8000" >> .env
echo "Please edit .env file and add your OpenAI API key before running again."
exit 1
fi
# Start the server
echo "Starting FastAPI server..."
python main.py
Make it executable:
chmod +x run.sh
Step 9: Testing Your Server (The Moment of Truth)
Alright, time to fire this thing up and see if it actually works. This is always the nerve-wracking part for me - you've written all this code, and now you get to find out if it was genius or complete garbage.
Why I Test in This Specific Order
I learned this testing approach after spending way too many hours debugging complex AI interactions when the problem was actually that my basic tool discovery wasn't working. Now I always test from the ground up.
Start with tool discovery because if the server can't even list its tools, nothing else will work. Then test individual tools to make sure they actually do what they're supposed to do. Finally, test the AI integration to see if everything plays nicely together.
Using curl might seem old-school, but it's saved me countless times. When your fancy frontend isn't working, curl will tell you exactly what's happening with the raw HTTP requests and responses.
Fire It Up
./run.sh
You should see the server start up on http://localhost:8000. If you want to see the automatic API documentation (which is super handy), go to http://localhost:8000/docs in your browser.
Test the Basics First
Step 1: Make sure your server can list tools:
curl -X POST "http://localhost:8000/mcp/tools"
You should get back a JSON response with all your tool definitions. If this fails, something's wrong with your basic server setup.
Step 2: Test your calculator tool:
curl -X POST "http://localhost:8000/mcp/call-tool" \
-H "Content-Type: application/json" \
-d '{"name": "calculate", "arguments": {"expression": "sqrt(144) + 2^3"}}'
This should return something like {"result": {"toolResult": {"content": [{"type": "text", "text": "Result: 20.0"}], "isError": false}}}
. If you get an error instead, check your tool execution logic.
Step 3: Try the text analysis tool:
curl -X POST "http://localhost:8000/mcp/call-tool" \
-H "Content-Type: application/json" \
-d '{"name": "text_analysis", "arguments": {"text": "Hello world! This is a test message."}}'
This should give you word counts, character counts, and other text statistics.
The Big Test - AI Integration
This is where things get exciting. Let's see if the AI can actually figure out what tools to use:
curl -X POST "http://localhost:8000/agent/run-with-mcp" \
-H "Content-Type: application/json" \
-d '{"message": "What is the square root of 144 plus 8?"}'
If everything's working correctly, you should see the AI automatically call your calculator tool and give you a natural language response about the answer being 20.
If this works, congratulations! You've got a fully functional MCP server with AI integration. If it doesn't... well, that's what the troubleshooting section is for.
Step 10: Adding Your Own Tools
To add a new tool, follow this pattern:
- Add the tool definition in
initialize()
:
{
"name": "your_tool_name",
"description": "Description of what your tool does",
"inputSchema": {
"type": "object",
"properties": {
"parameter_name": {
"type": "string",
"description": "Parameter description"
}
},
"required": ["parameter_name"]
}
}
- Implement the tool logic in
_execute_tool()
:
elif tool_name == "your_tool_name":
parameter_value = arguments.get("parameter_name", "")
# Your tool logic here
result = "Your tool result"
return result
Conclusion
What you've accomplished: You've built a production-ready MCP server that demonstrates key concepts used by major AI platforms. This isn't just a toy - it's a foundation you can extend for real applications.
You've now built a complete MCP server with:
Multiple useful tools (calculator, weather, text analysis, time) - demonstrating different tool patterns
OpenAI integration with function calling - showing AI-driven tool selection
FastAPI web server with REST endpoints - providing HTTP access layer
Proper error handling and logging - ensuring reliability
Modern Python practices - using async, type hints, and clean architecture
Why this architecture matters: The modular design lets you:
- Swap AI providers (OpenAI → Anthropic → local models)
- Add new interfaces (CLI, GUI, mobile app)
- Scale individual components independently
- Test each layer separately
Troubleshooting
Common Issues:
- "OpenAI API key not found": Make sure your
.env
file has the correct API key - "Module not found": Install dependencies with
pip install -r requirements.txt
- "Port already in use": Change the port in
.env
file - Tool execution errors: Check the tool implementation in
_execute_tool()