The Testing Academy · AI for QA Study guide
AI for QA · Study guide

MCP, the three primitives, and the SDK rename that breaks lesson four

Anthropic's Introduction to Model Context Protocol is free, fourteen lessons, about an hour of video, and ends in a graded assessment. This is the whole syllabus on one page, in the course's own running order, followed by eight questions with answers. Read it before the course to know what to watch for, or after it as the revision pass.

By Pramod Dutta, The Testing Academy. Written for the AI Tester Blueprint batches. The syllabus and lesson order come from the course itself. Every class name, method, transport and command here was checked against the current MCP specification and architecture guide, and the Python SDK was installed and run rather than recalled, on both 1.29.1 and 2.1.1. Section 13 lists the places where the course and the current protocol no longer agree. The eight questions are mine, written to cover the assessed material; they are not the course's own quiz.

01

The course, and what it is really about

Introduction to Model Context Protocol is Anthropic's free course on building MCP servers and clients in Python. Fourteen lessons, roughly an hour of video, ending in a graded assessment.

Stated prerequisites: working knowledge of Python, and a basic understanding of JSON and HTTP request-response patterns. The lessons also assume you are comfortable with async and await, which is worth knowing before you start. Environment setup uses uv.

BlockLessons
IntroductionWelcome · Introducing MCP · MCP clients
Hands-on with MCP serversProject setup · Defining tools · The server inspector
Connecting with MCP clientsImplementing a client · Defining resources · Accessing resources · Defining prompts · Prompts in the client
Assessment and wrap upFinal assessment · MCP review

The whole course is built around one running example: a document management server holding a handful of documents in a Python dictionary. Small enough to keep in your head, big enough to demonstrate all three primitives.

Read section 13 before you write any code. MCP has moved fast. The Python SDK renamed the class the course opens with, and the specification has grown a client-side half the course does not mention. The concepts in the course are sound; several of the specifics no longer are.

02

The problem, in one picture

Start with the example the course uses. You are building a chat interface where people ask Claude about their GitHub data: which pull requests are open across all their repositories, say.

Claude needs tools to reach GitHub's API. But GitHub is enormous: repositories, pull requests, issues, projects, actions, releases. Without MCP you hand-write a tool schema and a function for every one of them, then test them, then maintain them as GitHub's API changes.

What MCP actually takes off your plateWithout MCPyour serverget_repos schema + functionlist_prs schema + functionget_issues schema + function...and every other endpointyou write it, you test it, you own it foreverWith MCPyour serverone connectionGitHub MCP servertools and schemas already writtensomebody else wrote it,often the service provider themselves
Without MCP you author a tool for every endpoint. With MCP you connect to a server where that work is done.

MCP shifts that burden off your server and onto a specialised MCP server. You connect to a GitHub MCP server that already has the tools and schemas defined, and it talks to GitHub's API on your behalf.

Anyone can write an MCP server, and service providers increasingly publish official ones for their own products.

The misconception worth clearing up early: MCP is not a replacement for tool use. Tool use is the mechanism by which Claude calls a function. MCP is about who wrote that function and its schema in the first place. They are complementary, and the distinction is one of ownership, not capability.

03

Host, client, server

Three roles, and the course is slightly loose about the first one.

Host, client, server: one client per connectionMCP Hostthe AI applicationMCP Client 1MCP Client 2MCP Client 3Filesystemstdio, localDatabasestdio, localSentryStreamable HTTP, remotea dedicated connection eachThe host creates one client per server. Clients do not fan out to several servers.
The host is the AI application. It creates one client per server, and each client holds one connection.
RoleWhat it is
MCP HostThe AI application itself. Claude Code, Claude Desktop, VS Code
MCP ClientMaintains a connection to exactly one server, and obtains context from it
MCP ServerA program that provides context. Local or remote

The detail worth holding on to: the host creates one client per server. Connect to three servers and the host instantiates three clients, each with its own dedicated connection. A client does not fan out across several servers.

"Local" and "remote" describe where the server runs, not what it is. A local server is typically launched as a subprocess over stdio. A remote one is reached over HTTP and usually serves many clients at once.

04

One question, six hops

This is the sequence worth being able to draw from memory, because it is the most likely thing an interviewer asks you to walk through.

One question, six hopsYouYour appMCP clientMCP serverClaudea questionwhat tools exist?tools/listthe tool listquestion + toolscall this tooltools/calltool result, then the answerThe MCP server makes the real GitHub API call at step 7. Everything else is protocol.
Three conversations, not eight arrows. Claude is consulted twice: once to pick the tool, once to use the result.

Read it as three conversations rather than eight arrows. Your app talks to the client. The client talks to the server. Your app talks to Claude. Nothing else talks to anything.

Claude enters twice, and this catches people out. Once before the tool call, to be given the question plus the tool list and decide what to call. Once after, to be given the tool result and compose the answer.

On the wire those exchanges are JSON-RPC 2.0 methods: tools/list to discover, tools/call to execute. The Python SDK wraps them in types named ListToolsRequest, ListToolsResult, CallToolRequest and CallToolResult, which is what the course calls them. Same thing, two vocabularies, and it is worth knowing both.

Every primitive follows the same shape: a */list method to discover what exists, a */get or */read to retrieve, and for tools a */call to execute. Learn the pattern once and resources and prompts need no new mental model.

05

Transports, and the one the course gets wrong

Client and server exchange the same JSON-RPC messages regardless of how those messages travel. That is the point of separating a transport layer from a data layer.

Two transports, and one the course adds by mistakeDefined by the specstdiolocal, a subprocess on your machineStreamable HTTPremote, POST plus optional SSENot standard transportsWebSocketspossible as a custom transport, not definedHTTP + SSEthe old two-endpoint transport, deprecatedStreamable HTTP replaced HTTP+SSE. One endpoint that takes both POST and GET,rather than a separate SSE endpoint alongside a POST endpoint.
Two standard transports, and two things that are often listed as though they were.

The specification defines exactly two transports.

  • stdio. The client launches the server as a subprocess and they talk over standard input and output. Local, fast, no network. Clients should support it wherever possible. One rule that bites people: the server must write nothing to stdout that is not a valid MCP message, so debug prints go to stderr.
  • Streamable HTTP. A single endpoint that accepts both POST and GET, with Server-Sent Events used optionally to stream multiple messages back. This is what remote servers use.

The course lists WebSockets as an option. It is not a defined transport. The spec allows custom transports in a pluggable fashion, so WebSockets is possible, but naming it alongside stdio and HTTP as though it were standard is wrong and would cost you a mark. The two standard transports are stdio and Streamable HTTP, and nothing else.

Also worth knowing, because it dates any MCP material you read: Streamable HTTP replaced an older HTTP+SSE transport that used two separate endpoints. If a tutorial has you standing up an /sse endpoint next to a POST endpoint, it predates the change. There is more on what that shift means for testers on MCP went stateless.

Security, which the course does not cover at all. A Streamable HTTP server must validate the Origin header, and when running locally should bind to 127.0.0.1 rather than 0.0.0.0. Without that, a web page you visit can reach your local MCP server through DNS rebinding.

06

Setting up, and the rename that breaks lesson four

The course installs the Python SDK and opens with this line:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("DocumentMCP", log_level="ERROR")

On a fresh install today that first line does not run.

ModuleNotFoundError: No module named 'mcp.server.fastmcp'.
This is mcp 2.x, where FastMCP was renamed to MCPServer
(from mcp.server.mcpserver import MCPServer) and other APIs changed

The SDK went to 2.x and renamed the class the entire course is built on. A plain pip install "mcp[cli]" now gives you 2.1.1, and every code sample in the course fails at import.

Two ways forward, both checked:

RouteInstallWhat happens
Follow the course as recordedpip install "mcp[cli]<2"Gives 1.29.1. Every course sample runs unmodified. Recommended while working through the lessons
Use the current SDKpip install "mcp[cli]"Gives 2.1.1. Swap the import, and expect field names to differ

On 2.x the same server becomes:

from mcp.server.mcpserver import MCPServer

mcp = MCPServer("DocumentMCP", log_level="ERROR")

The decorators are unchanged. @mcp.tool, @mcp.resource and @mcp.prompt register exactly as the course shows, and tools and resources come back correctly. What did change is the shape of the objects you read back: the SDK moved from camelCase to snake_case, so tool.inputSchema is now tool.input_schema. Anything in your own code that inspects the returned types needs the same treatment.

Tools on 2.x also carry fields the course never mentions: title for a human-readable display name, output_schema, icons, annotations and execution. None are required, and none change the lessons, but they are there when you inspect a tool and wonder what the extra keys are.

07

Tools

The pitch for the SDK is that you never write a JSON schema. Type hints and Pydantic Field descriptions become the schema Claude sees.

@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]

Three things combine into the schema: the decorator supplies name and description, the type hints supply types and drive validation, and the Field descriptions explain each argument.

The edit tool is where the field descriptions start doing real work:

@mcp.tool(
    name="edit_document",
    description="Edit a document by replacing a string in the documents 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)

Read the old_str description again. "Must match exactly, including whitespace" is not documentation for you, it is an instruction to Claude, and it is the difference between a tool that works and one that silently replaces nothing. Descriptions are load-bearing.

Error handling comes free: raising an ordinary Python ValueError is enough, and the SDK turns it into a proper error response.

08

The inspector

A browser-based inspector ships with the SDK, so you can exercise a server without building a client first. This part of the course still holds exactly: the command is unchanged in both 1.x and 2.x.

mcp dev mcp_server.py

It prints a local URL, typically around http://127.0.0.1:6274. Open it, press Connect, and watch the status move from Disconnected to Connected. Then the tabs for Resources, Tools and Prompts each have a List button, and selecting an entry gives you input fields and a Run button.

The property that makes it genuinely useful: the inspector holds server state across calls. Run the edit tool, then immediately read the same document, and you can confirm the change persisted. That makes multi-step workflows and error paths testable without writing a test script.

Its interface changes often, so screenshots in any tutorial age quickly. What persists is the shape: connect, list, select, run. Learn the shape rather than the layout.

09

Resources

If a tool is a POST handler, a resource is a GET handler. Reach for a resource when you want to fetch information rather than perform an action.

The motivating feature is a document mention: typing @plan.md in a chat box. That needs two operations, and they are different shapes.

Two shapes of resourceDirecta fixed URI, no parametersdocs://documentsapplication/jsonthe list of every documentdrives the autocompleteTemplatedthe SDK parses the parameter outdocs://documents/{doc_id}text/plainthat one document, as a keyword argumentinjected straight into the prompt
A direct resource takes no parameters. A templated one has them parsed out of the URI for you.
@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]

The direct resource has a static URI and takes no parameters. The templated one carries {doc_id} in the URI, and the SDK parses it out and hands it to your function as a keyword argument. The inspector reflects the split too, listing them under separate Resources and Resource Templates sections.

mime_type tells the client how to interpret what comes back, and the SDK serialises the return value for you. Return the list, not json.dumps(the_list).

On the client side, one method covers both, branching on that MIME type:

async def read_resource(self, uri: str) -> Any:
    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

It reads contents[0] because a read usually concerns one resource, even though the result arrives as a list.

The payoff is worth naming, because it is the reason to use resources at all. When a mentioned document's contents are injected straight into the prompt, Claude never spends a tool call fetching it. One turn instead of two, and an immediate answer.

10

Prompts

The honest question about prompts: a user can already ask Claude to reformat a document, and it will do a decent job. So why ship a prompt?

Because you can invest in it and they cannot. As the server author you write it once, test it against edge cases, evaluate it, and improve it. Every user gets that work without needing to be a prompt engineer.

@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>

    Add in headers, bullet points, tables, etc as necessary.
    Use the 'edit_document' tool to edit the document.
    """

    return [
        base.UserMessage(prompt)
    ]

Two details. It returns a list of messages, so you can stage a multi-turn exchange with both user and assistant messages, not just one block of text. And the prompt tells Claude to use the edit_document tool: prompts and tools compose. A prompt is a scripted way to drive the tools you already exposed.

On the client, arguments arrive as a dictionary and become keyword arguments on the server function:

async def get_prompt(self, prompt_name, args: dict[str, str]):
    result = await self.session().get_prompt(prompt_name, args)
    return result.messages

So {"doc_id": "plan.md"} interpolates into the template. The inspector shows the fully interpolated messages before you ship, which is the point at which you catch a template that reads badly.

11

The three primitives, and who controls each

This is the conceptual payoff of the course, and the single most likely assessment question.

Three primitives, three different bossesToolsmodel-controlledClaude decides to call itClaude runs a calculation on its ownResourcesapp-controlledyour code decides to fetch it"Add from Google Drive" in the UIPromptsuser-controlleda person triggers ita slash command or a workflow buttonwho decideswhat it looks like in a productTools serve the model. Resources serve your app. Prompts serve your users.
The rule that settles most design questions: tools serve the model, resources serve your app, prompts serve your users.

Tools are model-controlled. Claude decides when to call one and consumes the result. This is how you give Claude an autonomous capability.

Resources are app-controlled. Your application code decides when to fetch and what to do with the data, usually to drive UI or add context. The real-world shape is an "Add from Google Drive" button: your code decides what to show and what to inject.

Prompts are user-controlled. A person triggers one deliberately, through a slash command, a menu, or a button under the chat input.

You want to...Reach for
Give Claude a new capability it can use on its ownA tool
Get data into your app for UI or contextA resource
Offer users a predefined, optimised workflowA prompt

The compressed version, worth memorising: tools serve the model, resources serve your app, prompts serve your users. These are guidelines rather than hard rules, and the course says so, but they resolve most design questions on their own.

12

The client half the course never reaches

The course teaches three primitives and calls it the picture. It is half the picture. Those three are server features, things a server offers a client. The specification also defines client features, things a client offers a server.

The half of the protocol the course never reachesServer featuresthe course covers all threeToolsResourcesPromptsClient featuresnot in the course at allElicitation, the server asks the user somethingSamplingdeprecatedLoggingdeprecatedLearn the three server primitives from the course. Read the client half from the spec.
The course teaches the left-hand box. The right-hand box is the half you read from the spec.

Elicitation is the current one. It lets a server ask the user for more information mid-operation, or ask them to confirm an action, via elicitation/create. For anything with a destructive step, that is the difference between a server that guesses and one that asks.

Two more exist and are now deprecated, which matters mostly so you recognise them in older material:

  • Sampling let a server request a completion from the client's model, so a server could use an LLM without bundling an SDK. New implementations are told to integrate with a provider API directly.
  • Logging let a server send log messages to the client. New implementations should log to stderr on stdio, or use OpenTelemetry.

Beyond the primitives there is a good deal the course does not touch: a mandatory server/discover request for capability and version negotiation, opt-in change notifications through subscriptions/listen, caching hints on results, pagination on list methods, and a Tasks extension for long-running work. None of it is needed to finish the course. All of it is on the spec.

13

Where the course and the current spec disagree

The concepts hold up well. The specifics have drifted. Take the assessment on the course's answers; build on these.

PointAs taughtAs it is now
The SDK classfrom mcp.server.fastmcp import FastMCPRenamed in 2.x. from mcp.server.mcpserver import MCPServer, or pin mcp<2 to run the course code unchanged
Object fieldstool.inputSchematool.input_schema on 2.x. camelCase became snake_case
Transportsstdio, HTTP, WebSockets, othersExactly two are defined: stdio and Streamable HTTP. WebSockets is only possible as a custom transport
The primitive countThreeThree server features, plus client features. Elicitation is current; sampling and logging are deprecated
Where the client lives"a client in your server, connecting to one or more servers"A host creates one client per server. Each client holds one connection
Protocol shapeNot coveredMCP is now stateless: every request carries its own version and capabilities in _meta
SecurityNot coveredHTTP servers must validate Origin and should bind to localhost, or DNS rebinding is possible

None of this makes the course a bad use of an hour. It is free, well sequenced, and the mental model it gives you (three primitives, three controllers) is exactly right and has not changed. Treat the code as needing a version pin, and read the spec for the parts that grew.

14

Eight questions, with answers

These cover the assessed material rather than reproducing the course's own assessment. Answers are hidden.

1. A colleague says MCP is pointless because Claude already does tool use. What is the answer, and what work does MCP actually remove?

They are complementary, not competing. Tool use is the mechanism by which Claude calls a function. MCP is about who authored that function and its schema. With MCP the schemas and implementations already exist and you connect to them.

The work removed is writing, testing and maintaining integration code. For GitHub, covering repositories, pull requests, issues and projects would mean hand-authoring a very large number of tool definitions and then owning them as the API changes. Anyone can write a server, and providers often publish official ones.

2. Trace the messages for "which repositories do I own?", and say where Claude enters.

Two exchanges with the server. First tools/list out, the tool list back, because your app must know what exists before it can tell Claude. Later tools/call out, the result back, wrapping the real GitHub API call the server makes.

Claude enters twice, between those two exchanges. First it receives the question plus the tool list and decides which tool to call. Then it receives the tool result and composes the final answer. The client is what hides the protocol from your application code.

3. You never write a JSON schema. So what tells Claude how to call a tool correctly?

Three things combine. The @mcp.tool decorator carries name and description. Python type hints give the types and drive validation. Pydantic Field descriptions explain each argument.

The edit_document example shows why the third matters: telling Claude that old_str must match exactly including whitespace is what stops a silent no-op replace. Registration is automatic through the decorator, and raising a plain ValueError gives you error handling for free.

4. You need an autocomplete list of documents, and the body of the one that gets picked. Which resource type for each, and what is the mechanical difference?

The list is a direct resource: a static URI such as docs://documents, mime_type of application/json, returning the keys. Static URIs suit operations with no parameters.

The body is a templated resource: docs://documents/{doc_id}, mime_type of text/plain. The mechanical difference is that the SDK parses the parameter out of the URI and passes it as a keyword argument. The inspector separates them into Resources and Resource Templates.

5. Which transports does the specification define, and what is wrong with saying "stdio, HTTP or WebSockets"?

Exactly two: stdio and Streamable HTTP. Clients should support stdio wherever possible. Streamable HTTP uses a single endpoint accepting POST and GET, with SSE optionally used to stream responses.

WebSockets is not a defined transport. The spec permits custom transports, so you could build one, but it does not sit alongside the two standard ones. Also worth knowing: Streamable HTTP replaced an older two-endpoint HTTP+SSE transport, so material describing a separate /sse endpoint is out of date.

6. If a user can just ask Claude to reformat a document, what justifies a format prompt, and what does the function return?

Asking directly works reasonably. A server-authored prompt can be crafted, tested and evaluated against edge cases, giving consistent output that users get without prompt engineering skill. The wider wins are encoded domain expertise, reuse across every client, and one place to improve it.

The function returns a list of messages, built with helpers like base.UserMessage, so you can stage multi-turn flows. Client arguments arrive as keyword arguments and interpolate into the template, and prompts can instruct Claude to use the server's tools, so the two compose.

7. Assign a primitive and its controller: Claude running a calculation on its own; an "Add from Google Drive" picker; a workflow button under the chat input.

The calculation is a tool, and tools are model-controlled: Claude decides to invoke it and uses the result.

The Drive picker is a resource, and resources are app-controlled: your code decides what to surface and injects the content into context.

The workflow button is a prompt, and prompts are user-controlled: a person triggers a predefined workflow.

The rule: tools serve the model, resources serve your app, prompts serve your users.

8. You are writing an MCP server for an internal ticketing system. Which primitives, and what would you check before release?

Tools for the actions Claude should take autonomously: create a ticket, change status, add a comment. Resources for data your app needs: a direct resource listing open tickets to drive autocomplete, and a templated one fetching a ticket by id so its content can be injected without spending a tool call. Prompts for user-invoked workflows such as a triage or weekly-summary command.

In the inspector: run each tool with realistic input and an error path such as a nonexistent ticket id, checking both status and returned data. Check resources in both sections, since templated ones need parameter values, and confirm the MIME type and response shape are what your client expects. Because the inspector keeps state, sequence a write then a read to prove the change persisted. Check prompts for correct interpolation.

Beyond the course: if any tool is destructive, consider elicitation so the server asks the user to confirm rather than guessing. And if it is served over HTTP, validate the Origin header and bind to localhost.

The course's own final assessment is 7 questions and records against your account, as does the satisfaction survey. The eight above are independent practice, written from the lesson content.