Every time we've given an LLM a tool, we have followed the same process — write a JSON schema, write the dispatch logic, handle the result format. It works, but every model has its own format. OpenAI expects one thing, Anthropic another, Ollama another. If you want to share a tool across multiple models or clients, you're writing the same glue code over and over.
That's the problem MCP was built to solve.
What MCP is
Model Context Protocol is an open standard published by Anthropic in late 2024. Think of it like USB-C for AI tools — build a tool once as an MCP server, and any MCP-compatible client can use it without any changes.
It defines three things: how a client discovers what tools are available, how a server exposes those tools in a standard format, and how tool calls and results are exchanged between them.
The architecture is straightforward:
┌─────────────────────────────────┐
│ MCP Client │
│ (Claude, Cursor, your Python) │
│ │
│ 1. connects to server │
│ 2. asks: "what tools exist?" │
│ 3. LLM sees tools in context │
│ 4. sends tool call │
│ 5. receives result │
└──────────────┬──────────────────┘
│ JSON-RPC (stdio or HTTP)
▼
┌─────────────────────────────────┐
│ MCP Server │
│ (filesystem, database, API...) │
│ │
│ exposes tools, resources, │
│ and prompts │
└─────────────────────────────────┘
The server doesn't need to know which client is connecting. The client doesn't need to know the server's internals. The protocol is the contract between them.
What a server exposes
An MCP server can expose three types of things. Tools are functions the LLM can call — the same concept as Post #2, but now in a standard format. Resources are data the application loads into context, like file contents or database records. Prompts are reusable message templates the user can invoke by name.
For most use cases, tools are what you'll work with.
The ecosystem
There are already hundreds of pre-built MCP servers you can connect to immediately:
-
@modelcontextprotocol/server-filesystem— read and write local files -
@modelcontextprotocol/server-github— GitHub repos, PRs, issues -
@modelcontextprotocol/server-postgres— query PostgreSQL -
@modelcontextprotocol/server-brave-search— web search -
mcp-server-fetch— fetch and read web URLs
Connect any MCP client to these and the LLM instantly gains those capabilities — no schema writing required.
Two packages to know
pip install fastmcp # for building MCP servers
pip install mcp # for connecting to MCP servers as a client
For the filesystem server used in Exercises 1–3, you also need Node:
npm install -g @modelcontextprotocol/server-filesystem
Exercise 1 — Explore an existing MCP server
Connect to the filesystem server and see what tools it exposes:
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def explore_server():
server_params = StdioServerParameters(
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/tmp"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("Available tools:\n")
for tool in tools.tools:
print(f"{tool.name}")
print(f"{tool.description}")
print()
asyncio.run(explore_server())
You'll see tools like read_file, write_file, list_directory. You didn't write any of these schemas — the server exposed them through the protocol.
Exercise 2 — Call a tool via MCP
Now let's actually invoke a tool:
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def use_filesystem_tools():
server_params = StdioServerParameters(
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/tmp"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Write a file via MCP
print("Writing file via MCP...")
await session.call_tool(
"write_file",
arguments={"path": "/tmp/mcp_test.txt", "content": "Hello from MCP!"}
)
# Read it back
print("Reading file back via MCP...")
read_result = await session.call_tool(
"read_file",
arguments={"path": "/tmp/mcp_test.txt"}
)
print(f"Content: {read_result.content[0].text}")
# List the directory
print("\nListing /tmp via MCP...")
list_result = await session.call_tool(
"list_directory",
arguments={"path": "/tmp"}
)
print(list_result.content[0].text[:300])
asyncio.run(use_filesystem_tools())
Same tool call mechanism as Post #2, but now going through a standard protocol instead of direct function calls.
Exercise 3 — Connect MCP tools to an LLM
Now let's bridge MCP tools into Ollama — the full integration:
import asyncio
import ollama
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
def mcp_to_ollama_tool_schema(mcp_tool) -> dict:
return {
"type": "function",
"function": {
"name": mcp_tool.name,
"description": mcp_tool.description or "",
"parameters": mcp_tool.input_schema or {"type": "object", "properties": {}}
}
}
async def run_llm_with_mcp_tools(user_question: str):
server_params = StdioServerParameters(
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/tmp"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Discover tools from the MCP server
mcp_tools_result = await session.list_tools()
ollama_tools = [mcp_to_ollama_tool_schema(t) for t in mcp_tools_result.tools]
print(f"Tools discovered: {[t.name for t in mcp_tools_result.tools]}\n")
messages = [{"role": "user", "content": user_question}]
response = ollama.chat(model="qwen2.5", messages=messages, tools=ollama_tools)
if response.message.tool_calls:
messages.append(response.message)
for tool_call in response.message.tool_calls:
name = tool_call.function.name
args = tool_call.function.arguments
print(f" → LLM called: {name}({args})")
# Execute via MCP — not a direct function call
result = await session.call_tool(name, arguments=args)
result_text = result.content[0].text if result.content else "Done."
print(f" ← Result: {result_text[:100]}")
messages.append({"role": "tool", "content": result_text})
final = ollama.chat(model="qwen2.5", messages=messages)
print(f"\nAnswer: {final.message.content}")
else:
print(f"Answer: {response.message.content}")
asyncio.run(run_llm_with_mcp_tools(
"List what files are in /tmp and tell me how many there are"
))
asyncio.run(run_llm_with_mcp_tools(
"Create a file called /tmp/hello.txt with the content 'Hello MCP'"
))
The LLM discovered the tools from the server automatically — no hardcoded schemas. If you swap in a different MCP server, the LLM immediately gains whatever tools that server exposes.
Exercise 4 — Build your own MCP server
Tools are just plain Python functions with docstrings — the framework handles everything else:
# my_mcp_server.py
from fastmcp import FastMCP
import datetime
mcp = FastMCP(name="my-tools")
@mcp.tool
def get_current_time(format: str = "full") -> str:
"""Get the current date and time.
Args:
format: Output format — 'full', 'date', or 'time'. Defaults to 'full'.
"""
now = datetime.datetime.now()
if format == "date":
return now.strftime("%Y-%m-%d")
elif format == "time":
return now.strftime("%H:%M:%S")
else:
return now.strftime("%Y-%m-%d %H:%M:%S")
@mcp.tool
def calculate(expression: str) -> str:
"""Perform arithmetic calculations.
Args:
expression: A Python math expression e.g. '(4 * 7) + 3'"""
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
if __name__ == "__main__":
mcp.run()
Run it:
python my_mcp_server.py
Connect to it using the same client from Exercise 1 — just swap the server_params:
server_params = StdioServerParameters(
command="python",
args=["my_mcp_server.py"]
)
Any MCP-compatible client can now connect to your server. You wrote two Python functions — the protocol, schema generation, and transport are all handled for you.
When to use MCP vs direct tool use
MCP adds a layer of indirection, so it's not always the right choice. Direct tool use (the approach from Post #2) is simpler and fine for quick prototypes with a single model. MCP makes sense when you want to share tools across multiple models or clients, when you're connecting to an existing MCP ecosystem, or when you're building something that needs to be maintainable at scale.
The underlying concepts are identical — MCP is just the standardised version of what you've been building from scratch since Post #2.
Wrapping up
MCP solves the fragmentation problem that comes with building AI tools — instead of writing custom glue code for every model and client combination, you write a server once and any compatible client can use it.
The ecosystem is already large and growing fast. Once you understand the protocol, plugging into existing servers like GitHub, Postgres, or filesystem is straightforward.
In Post #8, the final post in the series, we look at evals and safety — how to measure whether your agents are actually working well, and how to protect them from being manipulated. See you there. 🚀