The Model Context Protocol: extending LLMs with tools
Introduction
In my previous post, I discussed the structured output feature of LLMs and demonstrated how it could be used to connect LLM outputs to other systems. This capability is very useful when you need to take unstructured input (like a description of an Epic) and have the LLM turn it into a specific output schema (such as a hierarchy of features, user stories, spikes, and tasks).
A newer feature offered by some LLM providers is support for the Model Context Protocol (MCP). The purpose of the protocol is to provide a standard way to connect LLMs to tools. Tools might be anything that could help the LLM fulfill a query. A tool could provide additional information that would otherwise be inaccessible to the model, or it could perform an action on behalf of the LLM. By providing an array of tools to an LLM, it can perform a variety of multi-step tasks that would be quite tedious to build as traditional software. Think of our Agile Planning Assistant we wrote that had to take the LLM’s structured output and submit it to Azure DevOps. If instead we built a DevOps MCP server that provided basic tools - like listing, editing, and creating various work items - the LLM would have far more flexibility and we likely wouldn’t need to enforce a strict output schema.
MCP servers implement the model context protocol to provide access to a set of tools. When you send a query to an LLM provider that supports MCP, you can include the details of your MCP server (or servers!). This will enable the LLM to call an available tool if it “decides” that would be appropriate given the query.
In this post, I’m going to build a very simple MCP server in Python to demonstrate the concept. I’ll then use OpenAI’s Remote MCP support to demonstrate how an LLM can use tools provided by an MCP server to fulfill a query.
A simple MCP server
A straightforward example where an MCP server could be useful is email. I often find myself searching through my inbox to find a piece of information. Just recently, I had my passport renewed and was waiting for it to arrive in the postal mail. I recalled getting an email from the State Department with the tracking number. Instead of manually searching through my inbox, I wouldn’t mind a simple chat interface where I could just ask for my passport’s tracking number. I’m sure Google’s Gmail already has a similar feature, but I use Fastmail to avoid advertising in my email.
Let’s build a simple MCP server that offers two tools:
- A tool to list the emails in the inbox folder (sender, subject, date)
- A tool to get the content of a single email
With these two tools, a reasonably capable LLM should be able to answer a lot of email-related questions much more quickly than I could by manual search.
Building the server is quite straightforward thanks to the excellent FastMCP 2.0 Python framework. For our simple server, we’ll have it serve a static set of generated emails. The emails will be stored separately as JSON, accessible here.
You’ll see that the two tools are just Python functions decorated with mcp.tool()
:
server.py
:
"""Example email MCP server
Sample emails are loaded from ``sample_emails.json`` located in the
same directory as this script.
"""
from dataclasses import dataclass
from pathlib import Path
import json
from fastmcp import FastMCP
import uvicorn
@dataclass
class Email:
"""Data class representing an email."""
id: str
sender: str
subject: str
date: str
content: str
EMAILS_PATH = Path(__file__).with_name("sample_emails.json")
with EMAILS_PATH.open("r", encoding="utf-8") as f:
SAMPLE_EMAIL_LIST = [Email(**email) for email in json.load(f)]
# Convert to a dictionary for easier lookup by id
SAMPLE_EMAILS = {email.id: email for email in SAMPLE_EMAIL_LIST}
mcp = FastMCP("My email")
@mcp.tool()
def list_inbox_emails() -> str:
"""List all emails in the inbox."""
email_list = [
(
f"\nemail_id: {email.id}\nFrom: {email.sender}\n"
"Subject: {email.subject}\nDate: {email.date}\n"
)
for email in SAMPLE_EMAILS.values()
]
return "Current inbox emails:\n" + "\n".join(email_list)
@mcp.tool()
def get_email_content(email_id: str) -> str:
"""Get the content of an email by its ID.
Args:
email_id: The ID of the email to retrieve
Returns:
The content of the email
"""
if email_id not in SAMPLE_EMAILS:
return f"No email found with ID {email_id}"
email = SAMPLE_EMAILS[email_id]
return f"Content of email {email_id}:\n{email.content}"
if __name__ == "__main__":
mcp.settings.stateless_http = True
app = mcp.http_app()
uvicorn.run(app, host="127.0.0.1", port=8000)
Run the server by executing the script :
python server.py
You might notice that the server is hosted on the localhost IP. We need a way to make this accessible to OpenAI’s servers, as those will be the clients that access our MCP server. For development and personal use, a quick way to do this is to use Cloudflare’s cloudflared
tool to build a tunnel to proxy requests to our local server.
On a Mac, you can install cloudflared
with Homebrew and launch the tunnel like this:
brew install cloudflared
cloudflared tunnel --url http://localhost:8000
For other platforms, see Cloudflare’s documentation.
Among the output, you’ll see a URL for the new tunnel’s Internet-accessible endpoint:
Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):
https://abc-def-ghijkl-uri.trycloudflare.com
OpenAI integration
Now that our MCP server is accessible, we’re ready to provide its details to OpenAI so the LLM can use its provided tools to generate responses to our prompts. The first sample email in our list of emails is about an order shipment:
{
"id": "1",
"sender": "Amazing Company <orders@amazingexamplecompany.com>",
"subject": "Your order has shipped",
"date": "2025-06-01",
"content": "Hi there! We wanted to let you know that your order has shipped via SpeedShip and is on its way to you. The tracking number is AN1234567890."
}
Let’s ask the LLM for the tracking number and see how it does. We need to append /mcp/
to the Cloudflare URL, as that is the default mount point that FastMCP uses:
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI()
resp = client.responses.create(
model="gpt-4o",
tools=[
{
"type": "mcp",
"server_label": "Email",
"server_url": "https://abc-def-ghijkl-uri.trycloudflare.com/mcp/",
"require_approval": "never",
},
],
input="What's the tracking number from my Amazing Company order email?",
instructions="Respond without formatting.",
)
print(resp.output_text)
We get back:
The tracking number from your Amazing Company order email is AN1234567890.
Let’s try another:
{
"id": "2",
"sender": "Bob <bob@example.com>",
"subject": "Meeting Reminder",
"date": "2025-05-29",
"content": "Don't forget our 9am meeting next Friday at Joe's Coffee."
}
Using the same template as above but with a new input
parameter, I asked: “Check my email for the meeting details with Bob.”:
The meeting details with Bob are as follows: It's scheduled for next Friday at 9 AM at Joe's Coffee.
This is starting to seem like something I would find quite useful. In the next post, I’ll build a real-life MCP server that can connect to Fastmail. This will require a bit more consideration regarding security, as I would need to ensure that only my own OpenAI API requests are able to access the server. Fortunately both FastMCP and Fastmail have security features we can utilize to accomplish this.
Other uses
Many companies have already provided MCP servers for their services, and as this post demonstrated, building your own MCP server is quite straightforward. I’m certain people will come up with all sorts of creative ways to get LLMs to do very useful things by providing them with combinations of different tools.