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:
- User - Interacts via command-line interface
- ChatSession - Orchestrates conversation flow and OpenAI API communication
- MCPToolExecutor - Translates between OpenAI tool calls and MCP server protocol
- 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_callsarray - 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 viatool_executor.initialize_tools()for the tools arraysend_message(user_message)- Orchestrates the full chat completion cycle including tool callsrun()- 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()fromget_mcp_tools.pyto 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_toolrequests - Connection Management: Establishes stdio connections to MCP servers for each tool execution
Key Methods:
initialize_tools()- Callsget_tools(config_path)and transforms schemas to OpenAI formatexecute_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.jsonconfiguration - 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:
- A formatting component (
get_tools()) to fetch tool schemas - A translation component (MCPToolExecutor) to convert between formats
- 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.