Introduction to Model Context Protocol
MCP architecture and building servers and clients with tools, resources and prompts.
Series Claude Academy: Learn the products Part 2 of 7
A walkthrough of the Claude Academy course Introduction to Model Context Protocol (Learn the products track). Each lesson is summarised with my own tables and examples; take the course for the full lessons. All the courses are listed in the Claude Academy course index.
Note: My own summary of the course, not the course itself; verify details against the lessons and current docs.
Prerequisites: Python basics, async/await, API concepts, Anthropic API key.
Module 1 — Introduction (Why MCP + Architecture)
The problem MCP solves
- Without MCP, to let Claude use an external service (e.g., GitHub), you must:
- Write a JSON schema for every tool
- Write the function that executes each tool
- Maintain and test all of that integration code
- This is a lot of boilerplate, and every app repeats it.
What MCP is
- An open protocol that standardizes how LLM apps get context (tools, data, prompts) from external systems.
- It shifts the burden of tool definition + execution from your app to a dedicated MCP server (often written by the service provider or community).
Architecture
User → Your App (contains MCP Client) ⇄ MCP Server ⇄ External Service (GitHub, DB, files…)
⇅
Claude (Anthropic API)
| Component | Role |
|---|---|
| Host / App | Your application (chatbot, IDE, Claude Desktop) |
| MCP Client | Lives in your app; holds a 1:1 connection to a server; speaks the protocol |
| MCP Server | Exposes tools, resources, prompts; wraps an external service |
Key ideas to remember
- MCP ≠ tool use replacement. It’s a way of getting tool definitions/implementations from someone else. Claude still uses normal tool calling.
- MCP servers vs. direct API calls: server = pre-packaged, reusable tool schemas + implementations.
Transport-agnostic communication
- Client and server exchange JSON-RPC messages; the transport can vary:
- stdio (local subprocess — most common, used in the course)
- HTTP / Streamable HTTP (SSE) (remote servers)
- WebSockets, etc.
- Common message types (request → result pairs):
initializeListToolsRequest→ListToolsResultCallToolRequest→CallToolResultListResourcesRequest/ReadResourceRequest→ resultsListPromptsRequest/GetPromptRequest→ results- Plus notifications (one-way, no response)
Full request–response flow (memorize this!)
- User asks: “What repos do I have?”
- App’s MCP client sends ListToolsRequest → server returns tool list.
- App sends user query + tool definitions to Claude.
- Claude replies with a tool_use block (which tool + args).
- App asks MCP client to run it → CallToolRequest to server.
- Server calls the external API (e.g., GitHub) and returns CallToolResult.
- App sends the tool_result back to Claude.
- Claude produces the final answer for the user.
Module 2 — Hands-on with MCP Servers
Project: CLI document-management chatbot
- In-memory documents dict, e.g.
docs = {"deposition.md": "...", "report.pdf": "...", ...} - Files:
mcp_server.py(server),mcp_client.py(client),main.py/core/(chat app) - Package manager:
uv(e.g.,uv run main.py)
Building a server with the Python SDK (FastMCP)
from mcp.server.fastmcp import FastMCP
from pydantic import Field
mcp = FastMCP("DocumentMCP", log_level="ERROR")
docs = {"deposition.md": "This deposition covers...", "report.pdf": "..."}
@mcp.tool(
name="read_doc_contents",
description="Read the contents of a document and return it as a string."
)
def read_document(doc_id: str = Field(description="Id of the document to read")):
if doc_id not in docs:
raise ValueError(f"Doc with id {doc_id} not found")
return docs[doc_id]
@mcp.tool(
name="edit_document",
description="Edit a document by replacing a string in the document's content with a new string."
)
def edit_document(
doc_id: str = Field(description="Id of the document that will be edited"),
old_str: str = Field(description="The text to replace. Must match exactly, including whitespace."),
new_str: str = Field(description="The new text to insert in place of the old text."),
):
if doc_id not in docs:
raise ValueError(f"Doc with id {doc_id} not found")
docs[doc_id] = docs[doc_id].replace(old_str, new_str)
if __name__ == "__main__":
mcp.run(transport="stdio")
Key takeaways
@mcp.tooldecorator → SDK auto-generates the JSON schema from type hints +Field(description=...).- No hand-written schemas. Raise errors for invalid input — they’re sent back to Claude.
MCP Inspector (testing/debugging)
- Run:
mcp dev mcp_server.py(with uv:uv run mcp dev mcp_server.py) - Opens a browser UI → click Connect → tabs for Tools / Resources / Prompts.
- List tools, fill in args, run them, view results — without writing a client.
Module 3 — Connecting with MCP Clients
The client
- Wraps a
ClientSessionfrom the SDK; manages connection + cleanup (async context managers /AsyncExitStack).
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
async def list_tools(self) -> list[types.Tool]:
result = await self.session().list_tools()
return result.tools
async def call_tool(self, tool_name: str, tool_input: dict) -> types.CallToolResult | None:
return await self.session().call_tool(tool_name, tool_input)
- Connection:
StdioServerParameters(command="uv", args=["run", "mcp_server.py"])→stdio_client(...)→ClientSession(read, write)→await session.initialize(). - Test the client directly:
uv run mcp_client.py.
Resources (read-only data)
- Expose data the app fetches directly (not via Claude tool calls). Like GET endpoints.
- Two kinds:
- Direct resource – static URI:
docs://documents - Templated resource – URI with params:
docs://documents/{doc_id}(params passed to the function as args)
- Direct resource – static URI:
@mcp.resource("docs://documents", mime_type="application/json")
def list_docs() -> list[str]:
return list(docs.keys())
@mcp.resource("docs://documents/{doc_id}", mime_type="text/plain")
def fetch_doc(doc_id: str) -> str:
if doc_id not in docs:
raise ValueError(f"Doc with id {doc_id} not found")
return docs[doc_id]
- Client side — reading with MIME handling:
import json
from pydantic import AnyUrl
async def read_resource(self, uri: str):
result = await self.session().read_resource(AnyUrl(uri))
resource = result.contents[0]
if isinstance(resource, types.TextResourceContents):
if resource.mimeType == "application/json":
return json.loads(resource.text)
return resource.text
- Use case in the app: typing
@shows autocomplete of doc names (fromdocs://documents); selecting@report.pdfinjects the doc content into the prompt as context → Claude doesn’t need a tool call to read it.
Prompts (pre-built, high-quality instructions)
- Server authors ship tested prompt templates for common workflows.
from mcp.server.fastmcp.prompts import base
@mcp.prompt(name="format", description="Rewrites the contents of the document in Markdown format.")
def format_document(doc_id: str = Field(description="Id of the document to format")) -> list[base.Message]:
prompt = f"""Your goal is to reformat a document to be written with markdown syntax.
The id of the document you need to reformat is:
<document_id>{doc_id}</document_id>
...Use the 'edit_document' tool to edit the document..."""
return [base.UserMessage(prompt)]
- Client side:
async def list_prompts(self) -> list[types.Prompt]:
return (await self.session().list_prompts()).prompts
async def get_prompt(self, prompt_name, args: dict[str, str]):
return (await self.session().get_prompt(prompt_name, args)).messages
- Use case in the app: typing
/lists commands (prompts);/format report.pdf→ prompt messages are sent to Claude, which then uses tools to edit.
The 3 MCP Primitives — WHO controls them (exam favorite!)
| Primitive | Controlled by | Purpose | Example in app |
|---|---|---|---|
| Tools | Model (Claude decides when to call) | Give Claude new capabilities/actions | read_doc_contents, edit_document |
| Resources | App (your code decides when to fetch) | Get data into the app for UI/context | @ mention autocomplete, inject doc content |
| Prompts | User (user triggers) | Predefined workflows | /format slash command |
Rule of thumb
- Need to add capabilities to Claude? → Tool
- Need data in your app’s UI or context? → Resource
- Need a reusable, user-triggered workflow? → Prompt
Quick-Review Flashcards
- What does MCP shift? → Tool definition & execution burden from your app to MCP servers.
- Message format? → JSON-RPC; transport-agnostic (stdio, HTTP/SSE…).
- Who writes MCP servers? → Anyone; often service providers.
- How does FastMCP make schemas? → From decorators, type hints,
Field(description=...). - How to test a server without a client? →
mcp dev server.py→ Inspector in browser. - Direct vs templated resource? → Static URI vs URI with
{params}. - Why MIME types? → Client knows how to parse (
application/json→json.loads, else text). - Tools/Resources/Prompts controlled by? → Model / App / User.
- Order of client calls for a tool? →
list_tools→ send to Claude →call_tool→ returntool_result. - Does MCP replace tool use? → No; it supplies tools to standard tool use.
Practice Ideas
- Add a
create_document/delete_documenttool and test in Inspector. - Add a
summarizeprompt. - Add a resource
docs://documents/{doc_id}/metadatareturning JSON.