Learn/Claude Academy: Learn the productsguide

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
  1. Claude Code in action
  2. Introduction to Model Context Protocol
  3. Model Context Protocol: Advanced topics
  4. Introduction to subagents
  5. Introduction to agent skills
  6. The AI-native SDLC playbook
  7. Deploying Claude Enterprise with confidence

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:
    1. Write a JSON schema for every tool
    2. Write the function that executes each tool
    3. 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):
    • initialize
    • ListToolsRequest → ListToolsResult
    • CallToolRequest → CallToolResult
    • ListResourcesRequest/ReadResourceRequest → results
    • ListPromptsRequest/GetPromptRequest → results
    • Plus notifications (one-way, no response)

Full request–response flow (memorize this!)

  1. User asks: “What repos do I have?”
  2. App’s MCP client sends ListToolsRequest → server returns tool list.
  3. App sends user query + tool definitions to Claude.
  4. Claude replies with a tool_use block (which tool + args).
  5. App asks MCP client to run it → CallToolRequest to server.
  6. Server calls the external API (e.g., GitHub) and returns CallToolResult.
  7. App sends the tool_result back to Claude.
  8. 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.tool decorator → 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 ClientSession from 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)
@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 (from docs://documents); selecting @report.pdf injects 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

  1. What does MCP shift? → Tool definition & execution burden from your app to MCP servers.
  2. Message format? → JSON-RPC; transport-agnostic (stdio, HTTP/SSE…).
  3. Who writes MCP servers? → Anyone; often service providers.
  4. How does FastMCP make schemas? → From decorators, type hints, Field(description=...).
  5. How to test a server without a client? → mcp dev server.py → Inspector in browser.
  6. Direct vs templated resource? → Static URI vs URI with {params}.
  7. Why MIME types? → Client knows how to parse (application/json → json.loads, else text).
  8. Tools/Resources/Prompts controlled by? → Model / App / User.
  9. Order of client calls for a tool? → list_tools → send to Claude → call_tool → return tool_result.
  10. Does MCP replace tool use? → No; it supplies tools to standard tool use.

Practice Ideas

  • Add a create_document / delete_document tool and test in Inspector.
  • Add a summarize prompt.
  • Add a resource docs://documents/{doc_id}/metadata returning JSON.