Model Context Protocol in Practice

Teaching kit

From Edition 2026.11.8, the book's current edition. It changes when the book does. Use and adapt it freely, with credit to the book.

Syllabus

Reading times are for the chapter's text. Students also run its examples, in the Lab or on their own machine.

A chapter a week: 12 weeks

In 5 weeks

Objectives and outlines

Each chapter's objectives are its first sentence. Each outline follows the chapter's sections, with the terms it defines, the predictions it asks for before an example runs, and the rule each section ends on.

1. MCP end to end

About 21 minutes to read, 5 examples.

By the end, students can put an existing HTTP API behind a Model Context Protocol server over stdio, call its tool from a client, read every message the two exchange, and let a model choose the tool through a small host.

Lecture outline: 7 sections
  1. Meet the shop and its API
    • Working rule Wrap the API you already run, and keep it the system of record. The MCP server should be a thin adapter that calls the API with its own credentials and translates the answers, so the API's validation, authorization and logs keep working for every client, not only for models. Build directly on the database only when there is no API to wrap, and even then put the business rules in one place that both kinds of client share.
  2. Wrap one endpoint as a tool
    • Working rule Start with one read-only tool that maps to one endpoint, and write its docstring for a model: what it does, what its inputs look like, what it returns. Raise ToolError with a message that tells the model what to do differently, and let unexpected exceptions stay generic so internal details never leave the server. Once you wrap more than a handful of endpoints, shape the tools around the tasks the model has to do rather than one per endpoint (Shape tools around tasks, not endpoints).
  3. Call the tool from a client
    • Working rule Connect with mcp.Client, launch stdio servers with sys.executable and an explicit env=, and give the client a name. Pass each variable the server needs, and nothing else from your own environment, so a server never sees secrets meant for another program. When a tool fails with only the SDK's generic message, read the server's standard error before you change the client.
  4. Read the messages on the wire
    • Key terms server/discover
    • Working rule When a call behaves in a way you don't expect, trace the wire before you read the SDK's code: a relay that copies lines and logs them works with any client and any server. Never write to standard output from a stdio server, not even a debugging print(), because the client reads every line there as a message. Use logging, which the SDK sends to standard error, or write to standard error yourself.
  5. Let a model choose the tool
    • Working rule Keep the host's prompt and the parsing of the model's reply in your own code, and test the loop against recorded replies rather than a live model. Record the model's raw text, not SDK objects, so an SDK release doesn't invalidate a recording, and make a replay fail when its input changes. Switch to a live model only for the measurement runs that decide which descriptions and which model you ship.
  6. What goes wrong in production
    • Working rule Before a stdio server goes to production, check three things on the machine that will launch it: nothing but JSON-RPC reaches standard output, every variable the server needs is passed explicitly, and the server answers server/discover within 10 seconds of starting. Keep the server's standard error in your logs, because that is where its real errors go.
  7. Decisions this chapter made

2. What MCP adds to an API

About 26 minutes to read, 6 examples.

By the end, students can say what the host, the client and the server each do, put each of the shop's capabilities in the right Model Context Protocol primitive, read the version and capabilities that every request carries, and decide when the shop should keep a plain HTTP API instead.

Lecture outline: 11 sections
  1. Separate the host, the client and the server
    • Key terms host, client, capability
    • Working rule Give each server its own client in the host, and keep the conversation and anything that crosses servers in the host's code, never in a server. Start every client by reading what the server offers rather than assuming it, so that a server that changes its tools doesn't break the host. When you write a server, assume it sees only the requests sent to it, because that is all the specification lets it see.
  2. Put each capability in the right primitive
  3. Tools are actions the model chooses
    • Key terms tool
    • Working rule Make a capability a tool when the model should decide, from the conversation, whether to use it. That covers lookups that depend on what the customer wrote, and every action that changes something, such as a refund, for which the host then asks for consent before the call.
  4. Resources are data the application chooses
    • Key terms resource
    • Working rule Make data a resource when the host's own code can tell when it's needed, such as a policy that applies to a known kind of ticket, or a record the user has opened. Keep it a tool when only the model, reading the conversation, can tell whether it's needed.
  5. Prompts are templates the user chooses
    • Key terms prompt
    • Working rule Make a workflow a prompt when a person should start it and every run should follow the same steps, and keep the prompt's text on the server so that a change reaches every host at once. If no person chooses when it runs, it isn't a prompt: make the steps a tool, or put them in the host's own instructions.
  6. Carry the version and capabilities on every request
    • Working rule Read the version and the client's capabilities from each request, never from an earlier one, and answer -32021 when a call needs a capability the request didn't declare. Put the shop's own request metadata under its own reverse-DNS prefix, never under io.modelcontextprotocol/ or mcp. Log clientInfo and serverInfo for debugging, and decide nothing on them.
  7. Handle a version the server doesn't support
    • Working rule When a server answers -32022, pick the newest version in both your list and its supported list, retry once, and show the user an error naming both lists if they share none. Don't retry in a loop, and don't guess a version the server didn't list. On your own server, keep the supported list exact, because clients choose from it.
  8. Cache list results
    • Working rule Set a TTL and "public" scope on lists that are the same for every caller, such as the shop's tool list, and choose a TTL no longer than you'd accept a stale list after a deploy: minutes, not hours. Keep "private", the SDK's default, on anything that depends on who is asking, and never let cacheScope stand in for an authorization check. On the client, keep the default cache, which drops an entry when a change notification arrives on a client.listen(tools_list_changed=True) stream (mcp/client/client.py), the kind of stream that Tell clients when a resource changes opens. Without that stream no notification reaches the client, so open one, and use cache_mode="refresh" when you have another reason to doubt an entry, rather than polling on the TTL.
  9. Keep an HTTP API where MCP adds nothing
    • Working rule Keep a plain HTTP API for callers whose code knows which endpoint to call, such as other services, fixed workflows and batch jobs, and put an MCP server in front of the same API for hosts where a model or a user chooses at run time. Measure the overhead on your own transport before you route high-volume traffic through MCP, and pin the protocol version in clients you control, so they skip the discovery probe. Keep auto mode for clients that may meet servers you don't control, which may speak only the 2025 protocol.
  10. What goes wrong in production
    • Working rule Before a server goes to production, check four things: each capability's primitive matches who decides when it runs, nothing between client and server edits MCP bodies, nothing is decided on clientInfo, and every "public" cache hint covers data that is the same for every caller. Log the protocol version and error code of every failed request, because version and envelope errors are the first sign that a client or a gateway has changed.
  11. Decisions this chapter made

3. Design tools a model can use

About 32 minutes to read, 7 examples.

By the end, students can turn the shop's API into a small set of Model Context Protocol tools whose names, descriptions, input schemas, results and errors a model can act on, and measure how a change to a description changes which tool a model picks.

Lecture outline: 10 sections
  1. Shape tools around tasks, not endpoints
    • Working rule Start from the tasks in your tickets or logs, not from your routes, and give each task one tool. Move every decision the model shouldn't make into the server, such as an amount or a sequence of calls. Use a handle when one task needs the result of another, state its lifetime in the description of the tool that creates it, make it unguessable, and check on every call that it belongs to the caller. Expose an endpoint as it stands only when the task and the endpoint are the same, as check_stock and GET /stock/{sku} are.
  2. Write descriptions the model reads
    • Ask first Before you run it, predict how many first calls the vague set and the draft each get right.
    • Working rule Write each description in two to four sentences: what the tool does, when to use it instead of the tool it's most often confused with, what its inputs look like, and what it returns. Pass it as description= rather than relying on a multi-line docstring, so the model reads exactly the text you wrote. Then record the model you ship choosing tools for real tickets, read the wrong choices, and change the descriptions those choices point to. Change one thing per recording if you want to know which change worked, and check the result on tickets you didn't use to write the change.
  3. Type inputs with JSON Schema
    • Working rule Type every argument as narrowly as the API allows: a pattern for IDs, an enum for fixed values, bounds for counts and a format for dates. Let the SDK reject bad calls before your code runs, and return every failure at once. Don't trust the schema with meaning, because a valid date can still be the wrong date. Check what the schema can't express inside the tool, and say in the description how to fill a value that tickets often leave incomplete.
  4. Return structured content with an output schema
    • Working rule Give every tool a pydantic model as its return type, so the SDK publishes an output schema, returns structured content with no wrapper, and adds the text block for you. Put a field in the model for each decision the host's code makes, such as within_limit, rather than leaving it in a sentence. Return a bare string only from a tool whose result no code reads.
  5. Report failures as tool results
    • Key terms protocol error, tool execution error
    • Working rule Raise ToolError for every failure a model could correct, with a message that names what was wrong and what to send instead, including the valid values when there are few. Raise MCPError only for failures no model can fix, such as configuration, and let unexpected exceptions stay generic so internal details stay on the server. Never return an error as a plain string.
  6. Mark what a tool changes with annotations
  7. Treat annotations as hints, not guarantees
    • Working rule Annotate every tool, because leaving the hints out tells a careful host to assume the worst. Mark a tool idempotent only when the server enforces it, and keep refunds, payments and anything else that must happen once behind a check on the server. As a host, use annotations from a server you trust to decide what to show the user, never as permission to skip consent (Ask for consent before side effects), and treat every other server's annotations as unverified claims.
  8. Keep the tool list small
    • Working rule Give each server one audience and the few tools that audience's tasks need, and measure what the list costs in your host's prompt. Spend the tokens on schemas and on the sentence that separates two tools a model confuses, and cut description text that repeats the schema. When a host needs more tools than its model chooses among well, split them across servers and connect only the servers a conversation needs, rather than making one server's list depend on the connection.
  9. What goes wrong in production
    • Working rule Before the shop's tools go to production, record the model you ship choosing tools for a labelled sample of real tickets, check that every failure a model could fix comes back as a ToolError with a useful message, and put each output schema under a contract test. After release, watch tool-choice counts, example values in arguments, and RuntimeErrors in the host.
  10. Decisions this chapter made

4. Serve data as resources and prompts

About 25 minutes to read, 5 examples.

By the end, students can publish the shop's policies and records as Model Context Protocol resources, address many records with one resource template, complete an argument while a user types it, tell a client when a resource changes, and package a refund review as a prompt the user can pick.

Lecture outline: 8 sections
  1. Expose the policies as resources
    • Working rule Publish the documents a host should be able to put in front of a model as static resources, under a scheme that names your system, with a MIME type and a description the host can show. Use https:// only for resources the client can fetch without your server. Mark results public only when they are the same for every caller, and give a high priority only to the one or two documents every answer depends on, not to all of them.
  2. Address records with resource templates
    • Working rule Use a resource template for records that share one shape and an ID, return the API's own JSON, and raise ResourceNotFoundError for a miss so the client gets -32602 with the URI. Keep the scope private for anything that belongs to a user. Leave the SDK's ResourceSecurity on, and validate the variable's format yourself too. Exempt a variable from the check only when it must carry a path, and then check the path yourself.
  3. Complete arguments for the user
    • Working rule Complete arguments only from what the caller may read, decided by who the server acts for, not by anything in the request. Filter by the typed prefix yourself, and keep the list within 100 values, because the SDK won't. Set has_more whenever the source you filtered was itself cut short, and report a total only when you counted every match. Give an inaccessible record the same not-found answer as a missing one, on reads and in completions alike.
  4. Tell clients when a resource changes
    • Working rule Watch only the resources the host has put in front of a model or a user, and treat each event as "read this again", never as the new value. Re-read everything you depend on whenever you open a stream, the first time and after every drop, because nothing is replayed. Publish an update for the exact URI clients read, from the code path that makes the change.
  5. Package a workflow as a prompt
    • Working rule Offer a prompt for a procedure a person starts by name, and keep its arguments to values with a format you can check. Embed the resources the procedure depends on, so the host needn't fetch them. Validate every argument before you use it, and raise MCPError with INVALID_PARAMS for a bad one, because the SDK's own checks answer with an internal error.
  6. Choose between a resource and a tool
    • Working rule Offer a record as both a resource and a tool when hosts attach it and models look it up, and serve both from one code path so they can't disagree. When a tool finds records the host may keep, return resource links to them rather than their contents.
  7. What goes wrong in production
    • Working rule Before resources and prompts go to production, test them with two callers. Neither may see, complete or watch the other's records, and neither should see a list change without a list-changed notification. Check that every resources/read of a user's data carries the private scope, and that a host recovers from a dropped stream by re-listening and re-reading.
  8. Decisions this chapter made

5. Ask the user in the middle of a call

About 27 minutes to read, 6 examples.

By the end, students can make the shop's refund tool stop in the middle of a call and ask the person at the host to confirm a refund, with Model Context Protocol elicitation inside a Multi Round-Trip Request, on a server that keeps no call open while it waits for the answer.

Lecture outline: 9 sections
  1. See why a stateless server can't wait for an answer
    • Working rule Never wait inside a tool call for something only the client can give you. On a connection at the pinned revision, treat every call to ctx.elicit() or ctx.elicit_url() as a bug, and return an input request instead, as the next section does. Keep the old call only on a server that must also answer 2025-era clients, and only behind a check of ctx.protocol_version (Serve old and new clients through the window).
  2. Return an input request instead of a result
    • Working rule When a tool can't finish without the user, return an InputRequiredResult with one input request per question and a requestState that says what the retry must finish. Check the client's declared capabilities before you ask. Put in the state only what the tool needs to carry on, and let the SDK seal it. Make every side effect before the question either idempotent or harmless when no one answers, because the client may never call again.
  3. Resume the call with the answer
    • Working rule Use Client.call_tool with an elicitation callback in a host, and let the SDK run the rounds. Drive the loop yourself with client.session.call_tool(..., allow_input_required=True) only when the host must keep the state across a restart or show each round, and then repeat the arguments exactly. On the server, read the answer and the state together. When your server always sends a state, treat an answer that arrives without it, or with a state for another record, as a question never asked.
  4. Ask for structured input with elicitation
    • Working rule Use form mode for answers only the person at the host can give, and keep each form to the few fields the tool acts on, with enums wherever the answer is one of a known set. Declare forms with a resolver and a pydantic model, and take ElicitationResult when decline and cancel should lead different ways. Build the input request by hand only when you need to control the state yourself.
  5. Send sensitive steps to a URL
    • Working rule Send anything secret, and anything that signs a person in to another service, through URL mode, never through a form. Put the flow's own ID in requestState, treat accept as "the user went to the page", and decide on each retry from the server's own records whether the flow has finished. Check that the person who finishes a flow is the one who started it.
  6. Keep round-trip state tamper-proof
    • Working rule Let the SDK seal every requestState, and never turn the seal off for state that touches money, access or data. Keep the state small and opaque: an ID, not the record. For any step that must happen once, keep your own record of answered states on the server, and check it before the side effect, not after.
  7. Choose elicitation or a tool argument
    • Working rule Make a value a tool argument when the model can know it, and validate it in the tool. Elicit it when only the person can give it, or when the policy says a person must decide, and never accept that kind of value as an argument. Use URL mode for every secret. When you aren't sure, ask who would be at fault if the value were wrong.
  8. What goes wrong in production
    • Working rule Before a server that asks questions goes to production, give its replicas one shared key that can rotate, set the state's lifetime to how long people take, and move the quote store and every once-only record into shared storage. Bind each question to the caller wherever the transport has one. Then watch -32602 by its logged reason, -32021 by host, and the number of rounds per call.
  9. Decisions this chapter made

6. Serve over Streamable HTTP

About 28 minutes to read, 6 examples.

By the end, students can serve the shop's Model Context Protocol server over Streamable HTTP, read what each request and response carries, stream progress from a long call and cancel it, run the server as replicas behind one address with no sticky sessions, and refuse requests that a browser was tricked into sending.

Lecture outline: 9 sections
  1. Move the server from stdio to HTTP
    • Working rule Serve over Streamable HTTP with MCPServer.run(transport="streamable-http") and its loopback default while you develop, and give clients the endpoint's URL. Leave stateless_http alone unless you also serve clients on a 2025 revision, where it decides whether they get sessions. Keep stdio for servers that a host launches for one user, as Choose stdio or Streamable HTTP explains.
  2. Read one HTTP exchange
    • Working rule When an HTTP call fails, read the exchange first: the three routing headers, _meta in the body, the status and the content type. Route in a gateway on Mcp-Method and Mcp-Name if you need to, and never rewrite a body without rewriting its headers to match, because the server will refuse the pair with -32020. On a server that supports only the pinned revision, a 405 means an older client is opening a GET stream; the SDK's app serves those clients instead.
  3. Stream progress on a long call
    • Key terms Progress
    • Working rule Report progress from any tool that can run longer than a few seconds, with a total when you know it and a message a person can read. Keep the SDK's default response mode. Use json_response=True only for a server whose every tool is quick, and know that it drops every progress report and every cancellation without telling anyone.
  4. Cancel a call the client no longer needs
    • Working rule Cancel a call over Streamable HTTP by cancelling the task that awaits it, or with a timeout, and let the SDK close the stream. Write long tools as async def, re-raise cancellation, and put any awaited cleanup under a shielded scope. Make every tool that runs for long safe to retry, because a broken stream loses the call and the client sends it again.
  5. Run replicas without sticky sessions
    • Working rule Run replicas with no sticky sessions, and give every replica the same server name and the same requestState keys, injected from your secret store. Before you add a second replica, list every piece of state your tools keep in memory, such as handles, once-only records and subscriptions, and move each into storage that every replica reaches. Until one is moved, route the tools that depend on it to a single replica by Mcp-Name.
  6. Reject requests from other origins
    • Working rule Keep the SDK's loopback default on a developer's machine. When you deploy on any other address, pass transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...]) with the names your server answers to, such as mcp.fernhill.example, and never rely on the default. Treat Origin validation as protection against browsers only, and put authorization in front of every server that anything but its own host can reach.
  7. Choose stdio or Streamable HTTP
    • Working rule Ship a server over stdio when it acts for one user on that user's machine. Ship it over Streamable HTTP when it serves several hosts, guards systems the hosts can't reach, or must scale, and give it origin checks and authorization before its first external user. The same server code can serve both, so choose per deployment, not per server.
  8. What goes wrong in production
    • Working rule Before the shop's server takes traffic on an address, test four things through the real proxy path: a foreign Origin gets 403, progress arrives while the call runs, a call longer than the proxy's idle limit survives, and closing a call's stream stops the tool. Then watch 421, 403 and -32020 by client, and cancellations by duration.
  9. Decisions this chapter made

7. Run long work as tasks

About 26 minutes to read, 4 examples.

By the end, students can negotiate a Model Context Protocol extension, run the shop's slow restock report as a task that the client polls, let tasks expire and cancel them, and say what MCP Apps add to a tool.

Lecture outline: 8 sections
  1. Negotiate an extension
    • Working rule Turn an extension on only on a side that implements it, through the SDK's extension classes, never by writing the identifier into capabilities by hand. On the server, decide for each tool what a client without the extension gets: an ordinary result when the work can finish within a normal call, and error -32021 when it can't. Check the client's declaration on every request, because at the pinned revision each request declares its own.
  2. Start a task instead of waiting
    • Working rule Answer with a task when the work can outlive the connection, and decide per call, not per tool, when some calls are short. Store the task before you return the handle, and generate its ID from a cryptographic random source, with at least 128 bits. Run the same code a plain call runs, so a task's result never differs from a plain result. Never return a task to a request that didn't declare the extension.
  3. Poll a task and fetch its result
    • Working rule Poll at the server's pollIntervalMs, re-read it on every reply, and stop at the first terminal status. Treat completed as the original call's result, isError and all, and failed as a protocol error. Hide the polling in a client extension so the rest of the host sees plain calls. When a host must survive a restart, store each task ID before the first poll, and resume from it.
  4. Expire and cancel tasks
    • Working rule Give every task a TTL that covers the longest wait you expect between the work finishing and the client fetching it, and purge after it; for the shop's restock report, that is an hour. Make long work check for cancellation at each natural step, such as each product, and clean up there. In the client, treat a cancel's acknowledgement as "asked", not "stopped", and treat -32602 from tasks/get as a task that is gone for good.
  5. Know what MCP Apps add
    • Working rule Reach for MCP Apps when a result needs more than text to act on, such as a table to sort or a choice per row, and the hosts your users run support the extension. Keep every UI-enabled tool's text result complete, so a host without Apps loses only the view. Give app-only tools a visibility of ["app"] when the model shouldn't call them, and remember that the host enforces it, not your server.
  6. Choose a task, progress or a plain call
    • Working rule Use a plain call when the result comes back well within every timeout between the host and the server. Use progress when the work is long but the caller waits for it on a connection you trust to stay open, and wants a number to show. Use a task when the work can outlive the connection, or when the caller wants to leave and come back. Accept that a task reports only a status message, and keep a plain-call fallback or a clear -32021 for clients without the extension.
  7. What goes wrong in production
    • Working rule Before tasks go to production, move their storage to where every replica can read it, record each task's owner and check it on every task request, and set a TTL with a purge that runs on a schedule. Then watch -32602 from tasks/get by replica, the ratio of tasks/get to tools/call, the number of stored tasks, and how long work runs after a cancel.
  8. Decisions this chapter made

8. Authorize clients with OAuth

About 31 minutes to read, 7 examples.

By the end, students can protect the shop's Model Context Protocol server with OAuth.

Lecture outline: 9 sections
  1. Find the authorization server from the resource
    • Working rule Give every protected server an AuthSettings with its endpoint's URL as resource_server_url, exactly as clients will type it, and the smallest required_scopes that every request needs. Check the chain with one request that carries no token: a 401, a resource_metadata URL, metadata whose resource is the endpoint, and authorization server metadata whose issuer is the URL you fetched it from. When a value differs by a trailing slash or a port, fix the configuration rather than relaxing the client.
  2. Identify the client with a metadata document
    • Working rule Give every host one Client ID Metadata Document at a stable https:// URL, with only the redirect URIs it uses and no secret, and pass that URL to the SDK as client_metadata_url. Use pre-registered credentials where an authorization server has issued them, since they rank first, and keep Dynamic Client Registration only for authorization servers that support nothing else. In tests on loopback, keep the client ID an https:// URL and associate it with a local copy of the document in the test authorization server, rather than giving the client an http:// ID that no production server accepts.
  3. Get a token with PKCE
    • Working rule Let the SDK's OAuthClientProvider run the authorization code flow, and check once, from the authorization server's side, what it sends: S256, state, and a resource equal to the server's URL in both requests. Add the check the SDK skips, and refuse an authorization server whose metadata doesn't list S256. On an authorization server you run, accept only S256, make codes single-use and short-lived, and bind each code to its client, its redirect URI and its resource.
  4. Bind each token to one server
    • Working rule Set validate_token_resource=True on every protected server, and make the token verifier fill AccessToken.resource from the token's aud. Treat an MCPDeprecationWarning from AuthSettings as a failed deployment, not as noise. Test the check with a token for another resource from the same authorization server, which must get 401. A server that calls another service on a client's behalf gets its own token for that service and never passes the client's token on (Never pass a client's token through).
  5. Validate the issuer of each response
    • Working rule Send iss from every authorization server you run, and advertise it with authorization_response_iss_parameter_supported: true, so that clients reject an answer without it. In a host, keep the SDK's check, add no normalisation around it, and treat a mismatch as an attack: show the user none of that response's error text. When a host must use an authorization server that doesn't send iss, keep the host to an allow-list of authorization servers, and refuse or warn the user about any server not on it. Don't count on a separate redirect URI per server when the host is identified by a metadata document.
  6. Scope tools by what the token allows
    • Working rule Map every tool to its scopes, and authorize on the parsed request with exactly the credential the SDK authenticates, never on a header alone: refuse two Authorization headers, and any method your map doesn't cover. Treat Mcp-Name as a hint that must match the body once decoded, and refuse any body you can't parse exactly as the server does. Answer a missing scope with a 403 insufficient_scope challenge that names all of the tool's scopes, and keep required_scopes to what every request needs.
  7. Ask for more scope when a call needs it
    • Working rule Let the SDK step up, and keep its union of scopes. List every tool a caller could gain by step-up, since a hidden tool never gets the 403 that starts it. In the host, compare the scope the token response granted with the scope the challenge asked for, and when they differ, treat it as a decision, not a fault: tell the user which scope the call needs and pass the ticket to someone who holds it. Never let an authorization server grant by step-up what a user's role doesn't allow; the client only asks, and the authorization server decides.
  8. What goes wrong in production
    • Working rule Before the shop's server takes traffic, run three probes against the deployment: a request without a token gets 401 with a resource_metadata URL, a token for another resource gets 401, and a refund with a read-only token gets 403 with scope="refunds:write", and the same refund with a second Authorization header gets 400. Then check the metadata of every authorization server your hosts use for S256, iss support and metadata document support, and watch 401s, 403s and authorization requests per host.
  9. Decisions this chapter made

9. Secure tools against misuse

About 27 minutes to read, 6 examples.

By the end, students can draw the trust boundaries around a Model Context Protocol server and its host, refuse token passthrough, keep a server from acting as a confused deputy, pin tool definitions so a changed one is caught, treat ticket text as data, and ask the user before any tool call with side effects.

Lecture outline: 9 sections
  1. Draw the trust boundaries
    • Working rule Before you ship an MCP server or a host, write its boundary table: one row for every pair of parties that exchange data or authority, with what crosses and who checks it. A row whose checker is "the model" or "the other side" is a gap. Close it with a check you control, and when no check can exist, as for text the model reads, limit what the tools behind it can do.
  2. Never pass a client's token through
    • Working rule Accept only tokens whose audience is your server, and turn the check on explicitly, since the SDK doesn't by default. Call every upstream service with a credential of the server's own: an API key, a client-credentials token, or a token for the user from the upstream's own authorization server. Never forward the bearer token you received, even to a service that would accept it, and log the caller's identity yourself, because the upstream's logs will show only your server.
  3. Keep the server from acting as a confused deputy
    • Working rule Never take the acting user from a tool argument. Take it from the verified token's subject, together with the issuer whenever you accept more than one. Where customers sign in, the subject is the customer. Where staff sign in, it is the staff member, and the customer comes from the record the server looks up by ID and checks against what that person may handle. Check every record a call touches against it. Bind each handle your server issues to the user it was issued for, and answer another user's records and handles as not found. If your server proxies a third-party OAuth API, also follow the security guide's confused deputy section, and show your own consent page for each client before you forward anything.
  4. Catch poisoned tool descriptions
    • Ask first Before you run the example, predict how many of the three poisoned runs call track_parcel.
    • Working rule Treat every definition from a server you don't operate as untrusted input to the model. When a user first enables a server, show the full definitions, not only titles, and look for free-text parameters the tool doesn't need: they are a poisoned description's channel. Don't count on the model or a scanner. Limit what each server can receive and each tool can do, and gate the calls.
  5. Pin tool definitions and flag changes
    • Working rule Pin every tool definition the user approves, by a hash over its name, title, description, schemas and annotations, per server. On a list-changed notification or a cache refresh, compare, keep any changed or new tool from the model, and show the user the difference before enabling it again. Accept that harmless rewordings ask again too: a definition the model reads is part of what the user agreed to.
  6. Treat ticket text as untrusted data
    • Working rule Assume that any text a customer wrote will at some point reach the model as instructions, and design tools so that obeying it does bounded harm. Take amounts, owners and limits from the server's own records, never from arguments a model fills in from text, and keep side effects behind checks on the server and consent in the host. Log arguments a tool ignores, because an amount on a call that takes none is a sign of injected text.
  7. Ask for consent before side effects
    • Working rule Ask the user before every tool call that isn't on the host's own list of calls without side effects, and build that list per server from what you know, never from annotations. Show the exact arguments, in full when the user expands them, and log every decision with the call. Keep consent in the host even when a server asks for confirmation itself, because a server you don't trust won't ask.
  8. What goes wrong in production
    • Working rule Before security controls go to production, test them over HTTP with real tokens for at least two users, store handle owners where every replica can read them, start tokens with read scopes, and validate the commands and URLs that servers hand the host. Then watch tokens issued against scopes used, "no quote" errors by replica, consent questions asked per server, and how fast users answer.
  9. Decisions this chapter made

10. Build a host that works through tickets

About 28 minutes to read, 6 examples.

By the end, students can connect a host to several Model Context Protocol servers, give their tools names that can't collide, run a short loop in which a model calls tools and reads their results, answer a server's input requests on the user's behalf, put resources into the model's context, and work a queue of tickets through all of it.

Lecture outline: 8 sections
  1. Connect one client per server
    • Working rule Give each server its own Client, in auto mode, under a key from the host's configuration. Cache each server's era for as long as its process or origin lasts, reuse it across restarts of the same configuration if you like, and probe again when a request fails with a version error. Don't use ClientSessionGroup at mcp 2.3.0: its connect_to_server speaks only the 2025 handshake, and given Client sessions it adds merged lists but not the retry rounds or the host's own names. Check again at each SDK release, because a group that probes would remove the need for your own map.
  2. Name tools across servers
    • Working rule Prefix every tool name with a key from the host's configuration, joined by . or _, and route each call back through your own map, never by serverInfo.name. Prefix all names, not only the ones that collide, so names stay stable when a server changes. Send an unknown or unprefixed name back to the model as an error instead of guessing.
  3. Run the host loop
    • Working rule Keep the loop in the host, short and bounded: route each call through your own map, pass it through consent, give it a timeout, and send every outcome back to the model, errors included, in a format your own code writes. Cap the model calls per ticket. Anything longer than a few turns, such as planning across tickets or memory, belongs in an agent framework, not in the host's protocol code.
  4. Answer input requests for the user
    • Working rule Give each client its own elicitation callback that knows which server it belongs to, and show the user that server's name with its message, the fields and a way to decline. Return a decline to the model as the server's error, not as silence, so the model can say what happened. Keep consent in the host as well, because the server's question covers only what the server chooses to ask.
  5. Put resources in the model's context
    • Working rule Decide in the host which resources reach the model, by rules you can state: a fixed list, or annotations from servers you trust, with a cap on their size. Read them through the client's cache. Let the user add a resource for one ticket when the rule misses it, and never let the model or a ticket's text choose on its own.
  6. Work through the ticket queue
    • Working rule Treat the model's last reply as a draft and the host's call log as what happened. Before a reply reaches a customer, compare its promises with the calls that ran, and send a ticket to a person when they differ, such as a refund ticket with no refund made. Strip any handle, such as a quote_id, from a reply before it goes out. Score a run on outcomes, not only on the first tool, and record a run of the whole queue each time you change the prompt, a description or the model.
  7. What goes wrong in production
    • Working rule Before the host goes to production, log per ticket the tools offered, every call with its outcome, every consent question and input request, and the model's last reply, so a bad answer can be traced to the turn that caused it. Watch the size of the tool list per model call, reconnect and retry on a lost call, and keep every server's tool list and pins under the host's control.
  8. Decisions this chapter made

11. Test a server and watch it run

About 28 minutes to read, 6 examples.

By the end, students can test a Model Context Protocol server's tools in memory and over HTTP, catch a schema change that would break clients before it ships, replay a recorded host run as a regression test, trace tool calls with OpenTelemetry, measure how often a model chooses the right tool, and retire a tool without breaking the clients that use it.

Lecture outline: 8 sections
  1. Test tools in memory
    • Working rule Test each tool in memory with Client(server, raise_exceptions=True), and assert on structured_content fields and is_error, not on whole results. Keep at least one test per transport you ship that goes over it on loopback, for the header, origin and streaming checks an in-memory test can't reach, and put the tests of trace propagation and list changes there too.
  2. Check schemas as a contract
    • Working rule Keep the last release's tools/list in the repository, and in CI validate every structured result your tests produce against it as well as against the new schema, each by the dialect it declares and by 2020-12 when it declares none. Treat a removed, renamed or retyped output field and a new required input as a breaking change: add the new field beside the old one, or add a new tool, and retire the old shape through a window (Change tools without breaking clients). Don't rely on a short TTL to protect clients that are already connected.
  3. Replay recorded host runs
    • Working rule Replay a recorded run of the whole queue in CI, assert on each ticket's tool sequence, and run a check that compares each reply's promises with the calls that returned a result. Keep generated IDs and clocks fixed in the servers a replay runs against, or it stops for reasons that aren't changes. Then treat RecordingMismatch as a signal, not as a flaky test: when you change a description, a prompt or a tool's output, record again with the recording script and compare the new run's scores with the old run's before you ship.
  4. Trace calls with OpenTelemetry
    • Working rule Install an OpenTelemetry SDK and exporter in every server and host you run, and let the SDK's middleware make the server spans. Build dashboards on the attributes the SDK emits, mcp.method.name, gen_ai.tool.name and error.type, and label them as following a Development-status convention at a named version, because the names may change. Don't validate method names against the pinned conventions package. Test propagation over a real transport, and wrap in-memory calls in a span of your own when a test needs a parent.
  5. Measure how often the right tool is chosen
    • Working rule Score every recorded run on the first tool per tag, not only overall, and score arguments separately, against what the ticket gave the model and against the truth. Work on the descriptions behind the largest confusion first, record again, and compare per-tag scores between the two recordings. State the model, the prompt and the ticket set beside every score, and never compare scores across different setups.
  6. Change tools without breaking clients
    • Working rule Retire a tool in three releases: add the replacement and mark the old tool deprecated in its description, keep both working through a window you announce, then answer the old name with an execution error that names the replacement. Shorten ttlMs on tools/list while the change is under way, publish a list change at each release, and open a subscriptions/listen stream in every host you control. Say in your changelog that the window is your policy, since the protocol defines none.
  7. What goes wrong in production
    • Working rule Before you rely on a trace or a score, check what it covers: which transport, which model, and which release of each schema. Keep span names bounded and arguments out of spans by default, pin the attribute names your dashboards use to a named convention version, and run the contract test and the replay against every release before it reaches a replica.
  8. Decisions this chapter made

12. Migrate a 2025-era server

About 26 minutes to read, 6 examples.

By the end, students can move a Model Context Protocol server written for the 2025 revisions and the Python SDK's 1.x line to the specification at revision 2026-07-28 and mcp 2.3.0:

  • find what it depends on
  • remove its sessions
  • move it off the HTTP+SSE transport
  • replace sampling
  • roots, logging and Dynamic Client Registration
  • port the code
  • serve clients of both eras while the old ones are still around
Lecture outline: 9 sections
  1. Inventory what the old server depends on
    • Working rule Inventory before you change anything, and sort the list by status. Fix every removed dependency before you let a client at the pinned revision connect, because there is no window for it, and count every request the server pushes mid-call as removed, even when the feature it asks for is only deprecated. Give each deprecated one a date of your own, earlier than the registry's earliest removal, and don't wait for warnings: run a scan like this one in CI, because the SDK is silent about some deprecated features.
  2. Remove initialize and session IDs
    • Working rule Search the old server for every use of the session, its ID and anything stored per connection, and turn each piece of state into a handle that one tool returns and the next takes as an argument. Make the handle opaque, give it an expiry that the creating tool's description states, check on every call that the caller may use it, and store it where every replica can read it. Keep nothing a later call needs in a dictionary keyed by the session, even for 2025-era clients that still have one.
  3. Move from HTTP+SSE to Streamable HTTP
    • Working rule Delete the HTTP+SSE transport from the server rather than port it, unless your logs show hosts that still use it. If they do, serve the old endpoints beside the Streamable HTTP endpoint, log every connection to them by client, and set a date for removing them that you tell each host's owner. Treat the transport as already eligible for removal, because its window was three months, not twelve.
  4. Replace sampling, roots and logging
    • Working rule Move every pushed elicitation into a resolver, which serves both eras. Delete sampling rather than move it: let the host's model do model work, or call a provider from the server with the server's own credentials. Turn roots into a parameter checked against an allow-list in the server's configuration, and send log lines to stderr and your traces, never to the client.
  5. Replace dynamic client registration
    • Working rule Publish a Client ID Metadata Document for every host and pass its URL as client_metadata_url. Make every authorization server you run advertise support for it, keep its registration endpoint only until the hosts that need it have moved, and log which kind of client ID each token was issued to, so that you can see when registration traffic has stopped.
  6. Port the code from SDK 1.x to 2.x
    • Working rule Keep a 2025-era server pinned to mcp<2 until its port passes its tests, then port in one change: the imports and renames the errors point at, then each silent break from the migration guide, checked with a test against what a client sees. Pin the 2.x release exactly afterwards, and treat each 2.x release as a change to test, since the SDK's warnings and defaults move between releases.
  7. Serve old and new clients through the window
    • Working rule Serve both eras from one streamable_http_app() through the window, write every question a tool asks as a resolver, and route requests that carry Mcp-Session-Id to the replica that minted it. Count requests by era. When the 2025 count has stayed at zero for as long as your slowest host takes to upgrade, serve the pinned revision only, and answer initialize with an error that names the versions you support, as the specification asks.
  8. What goes wrong in production
    • Working rule Before the ported server takes traffic, test four things through the real proxy path: a 2025 client completes a confirmation, a 2026 client completes the same one, a 2025 session survives a deployment, and the version header reaches the server. Then watch requests by era, 404s by client and every deprecation warning, until the 2025 count reaches zero.
  9. Decisions this chapter made

Quiz bank

Questions on every chapter, with answer keys, for verified teachers with the Educator plan. Each answer is tied to its section. Ask for the answer keys