Build a Model Context Protocol (MCP) Server with OpenAI Integration

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:

  1. Call the calculator tool to compute the tip (47 * 0.15 = 7.05)
  2. Call the weather tool to check Seattle's current conditions
  3. 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:

  1. 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"]
    }
}
  1. 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:

  1. "OpenAI API key not found": Make sure your .env file has the correct API key
  2. "Module not found": Install dependencies with pip install -r requirements.txt
  3. "Port already in use": Change the port in .env file
  4. Tool execution errors: Check the tool implementation in _execute_tool()