MCP &
Protocols
Every agent you have built so far wires tools directly into Python code. This works for one agent, but breaks down when you need to share tools across agents, teams, or providers. The Model Context Protocol (MCP), released by Anthropic in late 2024, is an open standard that decouples tool servers from agent clients — any MCP-compatible host can connect to any MCP server. This section explains the protocol, its architecture, and the lab builds a working MCP server from scratch.
From Custom Integrations to a Standard Protocol
Before MCP, every agent-to-tool integration was bespoke. If you wanted your agent to query a database, read files, or call a GitHub API, you wrote a custom tool executor function in your agent's codebase. If another team wanted the same tools, they wrote their own version. If you wanted the same tools to work with Claude, GPT-4, and an open-weight model, you wrote three different integration layers.
This is the "M×N integration problem": M agent implementations × N tool sources = M×N custom integrations to build and maintain. The Model Context Protocol (MCP), released by Anthropic in November 2024, attacks this by defining a standard wire protocol — like HTTP for web servers — so any MCP host can connect to any MCP server without custom glue code.
Claude Agent → custom GitHub tool
Claude Agent → custom Slack tool
GPT Agent → custom DB tool
GPT Agent → custom GitHub tool
GPT Agent → custom Slack tool
= 6 integrations to maintain
GPT Agent → MCP client ─┤→ MCP DB Server
├→ MCP GitHub Server
└→ MCP Slack Server
= 2 clients + 3 servers = 5 integrations
Hosts, Clients, and Servers
MCP defines three roles that participate in every connection. Understanding these roles is essential for knowing where to put code when you build or integrate an MCP system.
Examples: Claude Desktop, VS Code with an AI extension, a custom web app with a chat interface. The host creates and manages MCP client connections. It controls which servers the user can connect to, enforces security policies, and presents tool results to the user. The host is responsible for user consent — it must not connect to servers the user hasn't approved.
Each MCP client maintains one connection to one MCP server. The client speaks the MCP wire protocol (JSON-RPC 2.0 over stdio or HTTP/SSE), handles capability negotiation during the handshake, and routes tool calls from the LLM to the correct server. A host may run multiple clients simultaneously — one per server.
MCP servers expose Resources, Tools, and Prompts over the MCP protocol. They can be local processes (stdio transport — the server runs as a subprocess) or remote services (HTTP/SSE transport). Each server focuses on a specific domain: a filesystem server, a database server, a GitHub server. The server does not talk to the LLM directly — it only talks to the client.
MCP uses JSON-RPC 2.0 as its message format. Two transports are defined:
- stdio — the client launches the server as a subprocess and communicates via stdin/stdout. Zero networking required. Default for local servers.
- HTTP + SSE — the server runs as an HTTP service; the client POSTs requests and receives Server-Sent Events for streaming responses. Used for remote/shared servers.
Resources, Tools, and Prompts
MCP servers expose three types of capabilities — called primitives. Each primitive serves a different role in the agent-tool interaction model.
file:///repo/README.md) and a MIME type. Resources are application-controlled — the host decides when to fetch and inject them, not the LLM.| Primitive | Controlled by | Direction | Typical use |
|---|---|---|---|
| Resources | Application / host | Server → LLM context | Inject file contents, DB records, live data into prompt |
| Tools | LLM (model decides) | LLM → server (call + result back) | Execute searches, write files, call APIs, run code |
| Prompts | User | User invocation → prompt template | Reusable slash commands, structured task starters |
Adoption, Ecosystem, and Where It Fits
Since its November 2024 release, MCP has been adopted by a growing ecosystem of hosts and server implementations. As of 2025–2026, it represents a significant shift in how production agent tooling is structured.
- Claude Desktop (Anthropic)
- VS Code + GitHub Copilot (Microsoft)
- Cursor, Windsurf (AI code editors)
- Custom agent apps via MCP Python/JS SDK
- filesystem — read/write local files
- github — repos, issues, PRs via GitHub API
- postgres — read-only SQL query interface
- brave-search — web search via Brave API
- slack — read/post messages to Slack
Trust, Consent, and Prompt Injection via MCP
MCP introduces new security surfaces. When an agent connects to an MCP server — especially a remote one — it is trusting that server to return safe data and honest tool results. The MCP specification explicitly addresses several security requirements that builders must implement.
Verified References
Every claim in this section is grounded in one of these sources. No content is generated from model training data alone.
| Source | Type | Covers | Recency |
|---|---|---|---|
| MCP Official Docs — Introduction | Official specification docs | MCP overview, M×N problem, host-client-server roles | Released Nov 2024, maintained 2025–2026 |
| MCP Docs — Architecture | Official specification docs | Host, client, server roles; transport layer (stdio, HTTP/SSE) | Maintained 2024–2026 |
| MCP Docs — Tools | Official specification docs | Tool primitive, JSON Schema, model-controlled invocation | Maintained 2024–2026 |
| MCP Docs — Resources | Official specification docs | Resource primitive, URIs, MIME types, application-controlled injection | Maintained 2024–2026 |
| MCP Docs — Security & Trust Hierarchy | Official specification docs | User consent requirements, minimal permissions, prompt injection risks | Maintained 2024–2026 |
| Anthropic — MCP Announcement | Official announcement (Anthropic) | MCP rationale, initial release, ecosystem goals | November 2024 |
| MCP Official Servers — GitHub | Official reference implementations | Filesystem, GitHub, Postgres, Brave Search, Slack servers | Maintained 2024–2026 |
Build an MCP Server from Scratch
You will build a working MCP server in Python using the official MCP Python SDK. The server exposes two Tools (a calculator and a note-taking store) and one Resource (the notes list). You will then connect to it and invoke its tools using an MCP client. The complete server script is mcp_server.py.
pip install "mcp[cli]"
mcp[cli] extra installs the Python SDK plus the mcp CLI tool — which lets you inspect and test MCP servers without writing a full host. The SDK is maintained by Anthropic and is the reference implementation of the MCP specification.
The MCP Python SDK uses a FastMCP class that auto-generates tool schemas from Python function signatures and docstrings — similar to how FastAPI generates OpenAPI schemas. No manual JSON Schema writing required.
from mcp.server.fastmcp import FastMCP # Create the server — give it a name clients will see mcp = FastMCP("course-tools") # ── In-memory note store ───────────────────────────────────────── _notes: list[str] = [] # ── Tool 1: Calculator ─────────────────────────────────────────── @mcp.tool() def calculator(expression: str) -> str: """Evaluate a Python arithmetic expression and return the result. Args: expression: A safe arithmetic expression, e.g. '(12 + 4) * 3' """ try: result = eval(expression, {"__builtins__": {}}) return str(result) except Exception as e: return f"Error: {e}" # ── Tool 2: Add a note ─────────────────────────────────────────── @mcp.tool() def add_note(note: str) -> str: """Save a note to the in-memory note store. Args: note: The text content of the note to save. """ _notes.append(note) return f"Note saved. Total notes: {len(_notes)}" # ── Tool 3: List notes ─────────────────────────────────────────── @mcp.tool() def list_notes() -> str: """Return all saved notes as a numbered list.""" if not _notes: return "No notes saved yet." return "\n".join(f"{i+1}. {n}" for i, n in enumerate(_notes))
@mcp.tool() decorator registers the function as an MCP Tool. FastMCP reads the type hints to build the JSON Schema input definition, and the docstring becomes the tool's description that the LLM reads. This is the same information you have been writing manually in every earlier lab.
Resources are read-only data sources the host application injects into the prompt context. Add a resource that returns the current notes list as plain text.
# ── Resource: notes list ───────────────────────────────────────── @mcp.resource("notes://all") def get_all_notes() -> str: """All saved notes, readable as a plain-text resource. URI: notes://all MIME type: text/plain """ if not _notes: return "(no notes saved)" return "\n".join(f"{i+1}. {n}" for i, n in enumerate(_notes)) # ── Start the server (stdio transport) ─────────────────────────── if __name__ == "__main__": mcp.run() # defaults to stdio transport
The mcp dev command launches your server and opens an interactive inspector in the browser — you can call tools and read resources without writing any client code.
mcp dev mcp_server.py
http://localhost:5173. In the inspector you will see the three tools (calculator, add_note, list_notes) and the resource (notes://all) with their auto-generated schemas. Try calling calculator with {"expression": "2 ** 10"} — you should get 1024.
Create a second file that acts as an MCP client — it connects to the server via stdio, lists available tools, and calls them directly. This is the code a host application would run internally.
import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): # Launch the server as a subprocess (stdio transport) 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: # Handshake — negotiate capabilities await session.initialize() # List available tools tools = await session.list_tools() print("\nAvailable tools:") for tool in tools.tools: print(f" - {tool.name}: {tool.description[:60]}") # Call calculator tool result = await session.call_tool("calculator", {"expression": "(365 * 24 * 60 * 60)"}) print(f"\ncalculator('365 * 24 * 60 * 60') = {result.content[0].text}") # Save a couple of notes await session.call_tool("add_note", {"note": "MCP uses JSON-RPC 2.0 over stdio or HTTP/SSE."}) await session.call_tool("add_note", {"note": "Tools are model-controlled; Resources are application-controlled."}) # Read the resource resource = await session.read_resource("notes://all") print(f"\nnotes://all resource:\n{resource.contents[0].text}") asyncio.run(main())
python mcp_client.py
Available tools:
- calculator: Evaluate a Python arithmetic expression and return t
- add_note: Save a note to the in-memory note store.
- list_notes: Return all saved notes as a numbered list.
calculator('365 * 24 * 60 * 60') = 31536000
notes://all resource:
1. MCP uses JSON-RPC 2.0 over stdio or HTTP/SSE.
2. Tools are model-controlled; Resources are application-controlled.
Use the MCP client session to pull the tool schemas dynamically, then pass them to client.messages.create() — bridging MCP tool discovery with the Anthropic tool use API.
import asyncio, os, json from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client import anthropic anthropic_client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) async def run_mcp_agent(task: str): 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() # 1. Discover tools from MCP server mcp_tools = await session.list_tools() # 2. Convert to Anthropic tool schema format anthropic_tools = [ { "name": t.name, "description": t.description, "input_schema": t.inputSchema, } for t in mcp_tools.tools ] # 3. Run a simple agent loop messages = [{"role": "user", "content": task}] for _ in range(5): response = anthropic_client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=512, tools=anthropic_tools, messages=messages, ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason == "end_turn": print(f"\nANSWER: {response.content[0].text}") break if response.stop_reason == "tool_use": tool_results = [] for block in response.content: if block.type == "tool_use": # 4. Execute via MCP session instead of local executor result = await session.call_tool(block.name, block.input) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": result.content[0].text, }) messages.append({"role": "user", "content": tool_results}) asyncio.run(run_mcp_agent( "Calculate 2 to the power of 16, then save a note with the result." ))
session.call_tool() instead of a local Python function. The tool schemas come from session.list_tools() rather than a hardcoded list. Everything else — the agent loop, the Anthropic API call, the message format — is identical to the agents you built in earlier sections. MCP is a drop-in replacement for the tool layer.