The Model Context Protocol (MCP) allows large language models (LLMs) to call external tools while answering user prompts. In practice, most people use chat-based tools like Claude or Copilot rather than interacting directly with model APIs, and these tools make it easy to configure MCP servers without writing code. That said, if you want to connect your own data sources or custom functions as MCP tools, or connect your code to MCP tools, you’ll have to roll up your sleeves and do some hands-on work.

With that in mind, I’ve put together an example that shows how the pieces fit together, including a simple mcp server, to illustrate what it takes to use these MCP tools using OpenAI-compatible API calls. I wanted to create an example with a minimum of dependencies (FastMCP, OpenAI) so that the major ideas are not obfuscated behind too many abstractions.

The code can be found here mcp-chatwithtools project repo.

Overview

chatwithtools.py demonstrates how to integrate MCP (Model Context Protocol) into a “tools with chat” application.

Architecture

The system consists of four main participants:

  1. User - Interacts via command-line interface
  2. ChatSession - Orchestrates conversation flow and OpenAI API communication
  3. MCPToolExecutor - Translates between OpenAI tool calls and MCP server protocol
  4. MCP Servers - External processes providing tools via stdio (e.g., weather, calculator)

Component Description

ChatSession Class

The ChatSession class implements the “tools with chat” pattern, orchestrating the complete conversation flow with OpenAI.

Responsibilities:

  • Chat Orchestration: Manages conversation history and sends requests to OpenAI with tools array
  • Tool Array Preparation: Calls MCPToolExecutor.initialize_tools() to get MCP tools formatted for OpenAI
  • Tool Call Detection: Monitors OpenAI responses for tool_calls array
  • Tool Execution Coordination: Delegates tool execution to MCPToolExecutor and adds results to conversation
  • Response Synthesis: Sends tool results back to OpenAI for final response generation

Key Methods:

  • initialize() - Loads MCP tools via tool_executor.initialize_tools() for the tools array
  • send_message(user_message) - Orchestrates the full chat completion cycle including tool calls
  • run() - Interactive command-line loop

Key Pattern:

# Phase 1: Chat with tools array
response = openai.chat.completions.create(
    model=self.model,
    messages=self.messages,
    tools=self.tools,  # Formatted by MCPToolExecutor
    tool_choice="auto"
)

# Phase 2: Execute tools if requested
if response.tool_calls:
    for tool_call in response.tool_calls:
        result = await tool_executor.execute_tool(...)
        # Add result to messages

    # Phase 3: Get final response with tool results
    response = openai.chat.completions.create(...)

MCPToolExecutor Class

The MCPToolExecutor class acts as a translation layer between OpenAI’s function calling format and MCP’s protocol:

Responsibilities:

  • Tool Discovery: Uses get_tools() from get_mcp_tools.py to fetch tools from all MCP servers
  • Format Translation: Converts MCP tool schemas to OpenAI function calling format (tools array)
  • Tool Routing: Maps tool names to their source MCP servers
  • Call Translation: Translates OpenAI tool call format into MCP call_tool requests
  • Connection Management: Establishes stdio connections to MCP servers for each tool execution

Key Methods:

  • initialize_tools() - Calls get_tools(config_path) and transforms schemas to OpenAI format
  • execute_tool(tool_name, arguments) - Translates and executes tool call on appropriate MCP server

Translation Process:

# MCP Format (from server)
{
    "name": "get_weather",
    "description": "Get weather for location",
    "inputSchema": { "type": "object", "properties": {...} }
}

# OpenAI Format (for chat completions)
{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get weather for location",
        "parameters": { "type": "object", "properties": {...} }
    }
}

get_tools() Function (from get_mcp_tools.py)

Utility function used by MCPToolExecutor during initialization:

Responsibilities:

  • Loads mcp.json configuration
  • Connects to each MCP server via stdio
  • Calls session.list_tools() to retrieve tool definitions
  • Returns array of tools with their schemas in MCP format

Returns:

[
    {
        "server": "utilities",
        "tools": [
            {
                "name": "get_weather",
                "description": "...",
                "inputSchema": {...}
            }
        ],
        "tool_count": 2
    }
]

Configuration

The system uses a json configuration file that defines the MCP servers you want to include mcp configuration reference. You can use any file name you want, but mcp.json is what I’m using. The code accepts this as a parameter.

{
  "mcpServers": {
    "utilities": {
      "command": "python3",
      "args": ["server.py"],
      "env": {}
    }
  }
}

Interaction Flow

Initialization Sequence

This diagram shows how MCP tools are discovered and formatted for OpenAI during startup. The main purpose of this flow is to create the “tools array” content that you pass as a parameter to the LLM.

sequenceDiagram
    participant Main as main()
    participant Chat as ChatSession
    participant Executor as MCPToolExecutor
    participant GetTools as get_tools()
    participant MCP1 as MCP Server 1
    participant MCP2 as MCP Server 2

    Main->>Chat: ChatSession(config_path, model)
    Chat->>Executor: MCPToolExecutor(config_path)
    Executor->>Executor: Load mcp.json

    Main->>Chat: await chat.initialize()
    Chat->>Executor: await initialize_tools()

    Note over Executor,GetTools: Tool Discovery Phase
    Executor->>GetTools: await get_tools(config_path)
    GetTools->>GetTools: Load mcp.json

    par Connect to all servers
        GetTools->>MCP1: stdio_client() connection
        GetTools->>MCP1: session.initialize()
        GetTools->>MCP1: session.list_tools()
        MCP1->>GetTools: [tool1, tool2] (MCP format)
    and
        GetTools->>MCP2: stdio_client() connection
        GetTools->>MCP2: session.initialize()
        GetTools->>MCP2: session.list_tools()
        MCP2->>GetTools: [tool3] (MCP format)
    end

    GetTools->>Executor: [{server: "srv1", tools: [...]}, ...]

    Note over Executor: Translation Phase
    loop For each server's tools
        Executor->>Executor: Convert MCP schema → OpenAI format
        Executor->>Executor: Map tool_name → server_name
    end

    Executor->>Chat: [OpenAI formatted tools array]
    Chat->>Main: Ready with tools

    Note over Chat: Now ready to send chat.completions<br/>with tools parameter

Standard Message Flows

If the prompt being evaluated by the LLM doesn’t contain any “trigger” words or phrases that might indicate the need to look for a tool - things like “query for that latest…’, “calculate xyx…”, “what is the weather like in…”, etc., then it simply responds as normal. Otherwise, one or more tools calls will be requested by the LLM.

sequenceDiagram
    participant User
    participant Chat as ChatSession
    participant LLM as OpenAI LLM
    
    User->>Chat: Enter message
    Chat->>LLM: Send message + available tools
    LLM->>Chat: Response (no tool calls)
    Chat->>User: Display response

Tool-Assisted Message Flow

This diagram shows the complete sequence when OpenAI requests tool execution (tool_calls will be presented by the LLM).

sequenceDiagram
    participant User
    participant Chat as ChatSession
    participant OpenAI as OpenAI API
    participant Executor as MCPToolExecutor<br/>(Translation Layer)
    participant MCP as MCP Server

    User->>Chat: "What's the weather in Paris?"

    Note over Chat,OpenAI: Phase 1: Initial Chat Completion
    Chat->>Chat: Add user message to history
    Chat->>OpenAI: chat.completions.create(<br/>messages=[...],<br/>tools=[...],<br/>tool_choice="auto")
    OpenAI->>Chat: Response with tool_calls array:<br/>[{id: "call_123", function: {<br/>name: "get_weather",<br/>arguments: '{"location": "Paris"}'}}]

    Note over Chat,MCP: Phase 2: Tool Execution via Translation Layer
    Chat->>Chat: Detect tool_calls in response
    Chat->>Chat: Add assistant message with tool_calls to history

    loop For each tool_call
        Chat->>Executor: execute_tool(<br/>"get_weather",<br/>{"location": "Paris"})

        Note over Executor: Translate OpenAI → MCP
        Executor->>Executor: Lookup server for "get_weather"<br/>→ "utilities" server
        Executor->>MCP: stdio_client(command, args)
        Executor->>MCP: session.initialize()
        Executor->>MCP: session.call_tool(<br/>"get_weather",<br/>{"location": "Paris"})
        MCP->>Executor: CallToolResult:<br/>content: [TextContent(<br/>text="Weather in Paris: Sunny, 72°F")]

        Note over Executor: Extract result
        Executor->>Executor: Extract text from content array
        Executor->>Chat: "Weather in Paris: Sunny, 72°F"

        Chat->>Chat: Add tool result to history:<br/>{role: "tool",<br/>tool_call_id: "call_123",<br/>content: "..."}
    end

    Note over Chat,OpenAI: Phase 3: Final Response Synthesis
    Chat->>OpenAI: chat.completions.create(<br/>messages=[..., tool_results])
    OpenAI->>Chat: Final response synthesized<br/>from tool results
    Chat->>User: "The weather in Paris is<br/>sunny with 72°F"

Key Design Features

Translation Layer Pattern

MCPToolExecutor acts as a protocol translator between OpenAI and MCP:

During Tools Initialization:

# MCP → OpenAI Format Translation
MCP: {"name": "x", "inputSchema": {...}}
  
OpenAI: {"type": "function", "function": {"name": "x", "parameters": {...}}}

During Tool Execution:

# OpenAI → MCP Call Translation
OpenAI: tool_call.function.name, tool_call.function.arguments
  
MCP: session.call_tool(name, arguments)

This allows MCP servers to be used with any LLM API that supports function calling.

Tools with Chat Pattern

ChatSession demonstrates the standard “tools with chat” workflow:

# Pattern: Discovery → Format → Call → Translate → Execute → Respond
async def initialize():
    # Get MCP tools formatted for OpenAI
    self.tools = await tool_executor.initialize_tools()

async def send_message(user_message):
    # Send with tools parameter
    response = openai.chat.completions.create(tools=self.tools, ...)

    # If tool calls requested, execute via translation layer
    if response.tool_calls:
        for tool_call in response.tool_calls:
            result = await tool_executor.execute_tool(...)
            # Add to conversation

        # Get final response with tool results
        response = openai.chat.completions.create(...)

Automatic Tool Discovery

The system dynamically discovers all tools from configured MCP servers using get_tools():

  • No hardcoded tool definitions
  • Supports any number of MCP servers
  • Tools are discovered at runtime during initialization
  • New tools automatically available when servers are updated
# All tools fetched dynamically via get_tools()
server_tools = await get_tools(config_path)
# Then translated to OpenAI format

Multi-Server Tool Routing

MCPToolExecutor maintains a routing map to execute tools on the correct server:

# Built during initialization
self.tool_to_server = {
    "get_weather": "utilities",
    "calculate": "utilities",
    "other_tool": "different_server"
}

# Used during execution
server_name = self.tool_to_server.get(tool_name)

Key Takeaways

This example shows that integrating MCP into a “tools with chat” application requires:

  1. A formatting component (get_tools()) to fetch tool schemas
  2. A translation component (MCPToolExecutor) to convert between formats
  3. A orchestration component (ChatSession) to manage the chat flow

This three-layer architecture cleanly separates MCP protocol details from application logic.

Example Interaction

Initializing MCP tools...
[01/04/26 15:53:25] INFO     Processing request of type ListToolsRequest                                                                           server.py:558
Loaded 2 tools from MCP servers

Chat session started. Type 'exit' or 'quit' to end the session.
============================================================

You: what is the product of five and three?
Calling tool: calculate with args: {'operator': 'multiply', 'argument1': '5', 'argument2': '3'}
[01/04/26 15:53:36] INFO     Processing request of type CallToolRequest                                                                            server.py:558

Assistant: The product of five and three is fifteen (15).

You: what is the temperature in Paris right now?
Calling tool: get_weather with args: {'location': 'Paris'}
[01/04/26 15:54:01] INFO     Processing request of type CallToolRequest                                                                            server.py:558

Assistant: The current temperature in Paris is 72°F and it's sunny.

You: write a haiku about the weather in Paris

Assistant: Sunny skies above,  
Paris basking in warm light,  
Joyful hearts take flight.

You: quit
Goodbye!

Final Thoughts

Once you’ve understood the underlying mechanisms demonstrated here, it helps to understand more complex parts of the MCP specification - such as Authorization - and where that might fit into the overall workflow. Many tools and packages are already available to help wire these services up: MCP aggregators, directories, agentic frameworks, etc., and they offer a lot of flexibilty. At the end of the day, under the covers, a list of tools and their parameter specifications are being passed to the LLM’s API.

Context engineering is still a factor to consider. This example shows how you could pass every tool offered by an MCP server and how that all becomes part of the context window (a limited resource). A fully functional implementation of this would need to consider how to filter for just the tools you really need - as there is a limit to context (and tools array size is limited to 128 entries for OpenAI). These are features you would find in something like arcade.dev, but now you have some idea why and where these features are needed.