30–40 min read · Hands-on coding
In Lesson 12 you saw tool calling: the LLM describes what function it wants to call, your code executes it and returns the result. This works well when you control both the LLM integration and the tools. But what if you want the same tool to work in Claude.ai, in a VS Code plugin, in your custom chatbot, and in a colleague’s agent — without rebuilding the integration each time?
That is the problem the Model Context Protocol (MCP) solves. Introduced by Anthropic in late 2024 and rapidly adopted across the industry, MCP is a standardised protocol for exposing tools, resources, and prompts to AI applications. You build an MCP server once; any MCP-compatible client (Claude.ai, Claude Code, LangChain, LangGraph, custom agents) can connect and use your tools. MCP is becoming the standard interface layer for AI tooling — it appears in job descriptions and is expected knowledge at companies building serious AI products.
MCP is a client-server protocol. The MCP server is a process that exposes capabilities — tools (functions the LLM can call), resources (data the LLM can read), and prompts (reusable prompt templates). The MCP client is the AI application that connects to the server and makes those capabilities available to the LLM.
Think of it like a USB standard: the USB port is MCP. Any USB device (your MCP server) works in any USB port (any MCP client). Before USB, every device needed a custom cable for every computer. Before MCP, every AI tool needed a custom integration for every AI application.
The protocol runs over stdio (for local servers) or HTTP with SSE (for remote servers). In both cases, the communication is JSON-RPC messages that follow the MCP specification.
| Approach | When to use it |
|---|---|
| Tool calling (raw API) | You control the LLM integration and the tool definition. Single application, no sharing needed. |
| REST API | Your service needs to be called by other services, browsers, or humans — not just LLMs. |
| MCP server | You want multiple LLM clients (Claude.ai, your agent, a VS Code plugin) to use the same tools with zero per-client integration work. |
Anthropic publishes the official Python MCP SDK:
uv add mcp
Here is a minimal MCP server that exposes two tools — a database query tool and a summarization tool. When Claude connects to this server, it sees both tools and can choose to call them:
import json
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
from openai import OpenAI
# ── Create the MCP server ──────────────────────────────────────────────
server = Server("ai-backend-tools")
openai_client = OpenAI()
# ── Define tools ───────────────────────────────────────────────────────
@server.list_tools()
async def list_tools() -> list[types.Tool]:
"""Tell clients what tools this server provides."""
return [
types.Tool(
name="summarize_text",
description="Summarize a piece of text in a given number of sentences.",
inputSchema={
"type": "object",
"properties": {
"text": {"type": "string", "description": "The text to summarize"},
"sentences": {"type": "integer", "description": "Number of sentences in the summary", "default": 3},
},
"required": ["text"],
},
),
types.Tool(
name="count_words",
description="Count the number of words in a text string.",
inputSchema={
"type": "object",
"properties": {
"text": {"type": "string", "description": "The text to count words in"},
},
"required": ["text"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
"""Execute a tool and return the result."""
if name == "summarize_text":
text = arguments["text"]
sentences = arguments.get("sentences", 3)
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"Summarize the following in exactly {sentences} sentences:\n\n{text}",
}],
)
summary = response.choices[0].message.content
return [types.TextContent(type="text", text=summary)]
elif name == "count_words":
count = len(arguments["text"].split())
return [types.TextContent(type="text", text=str(count))]
else:
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
# ── Run the server ──────────────────────────────────────────────────────
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
if __name__ == "__main__":
asyncio.run(main())
Run it: python mcp_server.py. The server starts and waits for a client to connect over stdio.
MCP resources are read-only data sources that a client can request. They are like files or database records that the LLM can read for context. Adding a resource to your server looks like this:
@server.list_resources()
async def list_resources() -> list[types.Resource]:
return [
types.Resource(
uri="conversations://recent",
name="Recent Conversations",
description="The 10 most recent conversations from the database",
mimeType="application/json",
),
]
@server.read_resource()
async def read_resource(uri: str) -> str:
if uri == "conversations://recent":
# In practice: query your Postgres database here
conversations = [
{"id": 1, "title": "Python debugging session"},
{"id": 2, "title": "API design review"},
]
return json.dumps(conversations, indent=2)
raise ValueError(f"Unknown resource: {uri}")
A client can now ask your server for conversations://recent and inject the response into the LLM’s context automatically.
The most direct way to test your MCP server is with Claude Desktop or Claude Code. Add your server to the configuration file:
# ~/.claude/claude_desktop_config.json (macOS/Linux)
{
"mcpServers": {
"ai-backend-tools": {
"command": "python",
"args": ["/absolute/path/to/mcp_server.py"],
"env": {
"OPENAI_API_KEY": "sk-..."
}
}
}
}
Restart Claude Desktop and the tools appear automatically. When you ask Claude to “summarize this text”, it can choose to call your summarize_text tool.
You can also connect from your own Python code using the MCP client:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
import asyncio
async def use_mcp_server():
server_params = StdioServerParameters(
command="python",
args=["mcp_server.py"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# List available tools
tools = await session.list_tools()
print("Available tools:", [t.name for t in tools.tools])
# Call a tool
result = await session.call_tool(
"count_words",
{"text": "The quick brown fox jumps over the lazy dog"},
)
print("Word count:", result.content[0].text)
asyncio.run(use_mcp_server())
Stdio servers run locally on the same machine as the client. For servers you want to deploy and share over the network, MCP supports HTTP with Server-Sent Events (SSE). The Python SDK provides a built-in SSE transport:
from mcp.server.sse import SseServerTransport
from fastapi import FastAPI
import uvicorn
app = FastAPI()
transport = SseServerTransport("/messages/")
# Mount the MCP transport on your FastAPI app
app.mount("/mcp", transport.router)
# Register tools the same way as stdio
@server.list_tools()
async def list_tools() -> list[types.Tool]:
... # same as before
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8001)
HTTP MCP servers integrate naturally into the Docker Compose stack you built in Lessons 21–23. A remote MCP server deployed to a cloud VM can serve dozens of AI applications simultaneously.
The LLM decides when to call your tool based on the description field. A vague description leads to missed or incorrect calls. Follow the same principles as tool calling in Lesson 12: be specific about what the tool does, what input it expects, and what output it returns. Include examples in the description if the usage is non-obvious.
A tool called do_everything with 15 parameters is harder for the LLM to use correctly than five tools each with 3 parameters. The LLM has to reason about which tool to call and what arguments to pass — smaller, clearer tools reduce reasoning errors.
mcp_server.py using the pattern in this lessonsummarize_text (calls your summarize logic), classify_text (calls your classify logic with hardcoded labels: technical/billing/feedback/other), and count_tokens_estimate (estimates token count as len(text) // 4, no LLM call needed)api://status that returns a JSON object with the server’s current status (uptime, tool names, version)Answer each question from memory, then click to reveal.
Regular tool calling requires you to integrate each tool separately into each AI application. MCP standardises the interface so a tool is defined once (in an MCP server) and any MCP-compatible client (Claude.ai, Claude Code, LangChain, custom agents) can use it without any per-client integration work. It is the USB standard for AI tools.
→ What MCP isA tool is imperative — the LLM actively calls it to perform an action: run a query, send a message, transform data. A resource is declarative — the LLM reads it passively for context: a database record, a file, configuration. Tools do things; resources provide data.
→ Resources — letting the LLM read dataStdio — the server runs as a subprocess on the same machine as the client, communicating via standard input/output. Use for local development tools, IDE plugins, and desktop apps. HTTP with SSE — the server runs as a web service reachable over the network. Use for shared servers accessed by multiple clients or deployed to the cloud.
→ Remote MCP servers over HTTP