MCP: advanced topics
Production MCP beyond tools: sampling, notifications, elicitation, transports, roots enforcement (runnable), robustness, and treating MCP servers as shared org infrastructure.
Learning objectives
- Recall MCP primitives and add the advanced capabilities.
- Use sampling so a server can ask the client's model.
- Send notifications and support elicitation.
- Choose a transport and scope filesystem access; secure MCP.
30-second recap
- The MCP ecosystem: MCP is a standard protocol for connecting a model to tools and data: servers expose tools/resources, clients (like Claude) discover and call them.
code/ak6-mcp-advanced/. Python runs offline; configs are ready to use.1 · Beyond tools — the full protocol essential
Here's the mental jump most people never make. The first time you meet MCP, you learn it as "a way to give the model tools" — a function the model can call, like a fancy plugin. That framing is true but small, and it quietly caps how good your servers ever get. The people who ship MCP servers a whole org depends on made one realization: MCP isn't a plugin format, it's a protocol — a two-way conversation between a client and a server. Once you see it that way, a pile of "advanced" features stop looking like a grab-bag and start looking like the obvious consequences of a real connection.
An analogy: think of the difference between a vending machine and a phone call. A vending machine is one-directional — you press a button, it drops a snack, conversation over. That's the "MCP is just tools" picture: the client pokes the server, the server hands something back. A phone call is different — either side can speak, ask a question, or say "hold on, something changed." That's what MCP actually is. The server can call back to the client's model (sampling), it can interrupt to ask the user a question (elicitation), it can tap the client on the shoulder to say "your data is stale" (notifications), and the client can hand the server a fenced-off patch of the filesystem to work in (roots). None of that fits in a vending machine.
The common mistake is to build every server as a lonely bag of tools and then reach for hacks when you need the missing pieces — a server that ships its own API key so it can call an LLM (now you have a second key to secure and a second bill to watch), or one that makes the user re-run a command because it had no way to ask a mid-task question. Every one of those hacks is a feature MCP already gives you, done properly, with the client staying in charge of trust and cost. So this chapter isn't "six unrelated add-ons." It's the rest of the protocol you already started using — the capabilities that turn a toy server into shared infrastructure. It's worth learning because the ceiling on what an agent can safely do is set by how rich and how well-governed its server connections are.
In C4 you built an MCP server exposing tools, resources, and prompts. Production MCP adds four capabilities that make servers interactive and composable: sampling, notifications, elicitation, and roots — over a real transport.
This picture shows the three players in every MCP setup and the fact that requests can travel both ways along the connection. Understanding these boxes is the key to the rest of the lesson.
- The left box (Client) is the host app you actually use — here Claude. It owns the model, the API key, and the approval prompts.
- The middle box (MCP transport) is the pipe the two ends talk through. It is either
stdio(a local program) orHTTP(a network service) — you meet both in section 4. - The right box (Server) is the program you write: it exposes tools and data the client can use.
- The arrows point client → server, but the caption's phrase "both directions" is the important part: with sampling the server can call back to the client's model, and with notifications it can push updates. It is a two-way relationship, not a one-way remote control.
In short: Client = who you talk to; Server = the tools you plugged in; Transport = the wire between them. Everything in this lesson is one of those three boxes sending a message to another.
2 · Sampling — the server asks the model essential
Picture a server that wants to be smart. Say you're writing a server that summarizes files, or drafts a commit message, or classifies a support ticket — all jobs that need an LLM. The naive path is obvious and tempting: give the server its own API key, let it call the model directly, done. And it works on your laptop. Then you try to share the server with your team and the cracks appear: now every server has its own key to rotate and leak, its own bill nobody's watching, its own model choice that might not match what the user wants, and — worst of all — its own way to quietly burn tokens with no one approving the spend. You've scattered trust and cost across a dozen little programs.
Sampling is the protocol's answer, and the trick is that it turns the connection around. Instead of the client always calling the server, the server calls back to the client and says "please run the model on this prompt for me." The client — which already owns the model, the key, the budget, and the user's trust — runs it and hands the completion back. The server got all the LLM power it wanted and holds none of the keys.
The analogy that makes it click: sampling is a server asking to borrow the client's phone rather than buying its own. You don't hand a contractor your credit card and let them make calls on any line they like; you let them use your phone, in front of you, so you see every call and can hang up. The client is that single point of control — one place that approves requests, enforces rate limits, picks the model, and pays the bill. One key, one budget, one approval prompt, no matter how many servers are connected.
The mistake to avoid is thinking sampling is just a convenience for keyless servers. Its real value is governance: it collapses N scattered trust-and-billing decisions into one. That's why a security-minded team will insist their servers use sampling instead of embedded keys — not because it's easier, but because there's exactly one throat to choke when spend or safety goes wrong. In the SDK this is a single call, ctx.session.create_message(...), which the lab shows next. Normally the client calls the server's tools. Sampling reverses it: the server asks the client's model for a completion — so a server can use the LLM without its own key, and the client keeps control of model, cost, and approval.
ctx.session.create_message() (right) — one key, one budget, one approval path.
Read it left panel then right panel: the left is the naive way, the right is what sampling gives you.
- The left red box is the tempting shortcut — each server ships its own API key. The subtext spells out the cost: with N servers you now juggle N keys, N bills, and N places spend can leak.
- The right green box is a server with no key at all. The blue arrow labelled
create_message()is the sampling call going down to the client. - The bottom blue box is the client — it owns the model, the key, and the budget, so it is the single point that approves or denies every model call the server makes.
In short: Sampling collapses N scattered keys-and-bills into one — the server borrows the client's model rather than buying its own.
sampling_server.pyfrom mcp.server.fastmcp import FastMCP, Context
mcp = FastMCP("summarizer")
@mcp.tool()
async def summarize_file(path: str, ctx: Context) -> str:
"""Summarize a file by asking the CLIENT's model (sampling)."""
text = open(path).read()[:4000]
result = await ctx.session.create_message(
messages=[{"role": "user",
"content": {"type": "text", "text": f"Summarize:\n{text}"}}],
max_tokens=200)
return result.content.text
Normally the client calls the server. Sampling flips it: the server asks the client's model to do some thinking for it. This tool reads a file and asks the client's Claude to summarize it — so the server never needs its own API key or model choice.
from mcp.server.fastmcp import FastMCP, Contextimports the real MCP SDK.FastMCP("summarizer")creates a named server, andContextis the handle that lets a tool talk back to the client.@mcp.tool()registers the function below as a tool the client can call.async defmeans it can pause while waiting on the network without blocking everything else.text = open(path).read()[:4000]reads the file and keeps only the first 4000 characters — a simple guard so a huge file doesn't blow past the model's limits.await ctx.session.create_message(...)is the sampling call: the server asks the client's model for a completion, passing a normalmessageslist andmax_tokens=200. The client runs its model and hands the answer back.return result.content.textsends the model's summary back as the tool's result.
What the output means: Calling this tool returns a short summary of the file, produced by the client's model — even though the server itself has no API key.
Try this: Notice the server never picks a model or pays for tokens — the client does, and it can approve or deny each sampling request. That single approval path is exactly why sampling is considered safe.
3 · Notifications & elicitation intermediate
Imagine an agent that never gets told anything changed. Its MCP server just added three new documents, but the client still shows the old list — because nothing told it to look again. So the client does the only thing it can: it asks, over and over, "anything new? anything new? anything new?" That's polling, and it's the software equivalent of a kid in the back seat asking "are we there yet?" every thirty seconds. It wastes traffic, it's always either too slow (you asked a minute ago) or too chatty (you're asking constantly), and it scales terribly once many clients are all nagging the same server.
Notifications flip that around. Instead of the client asking, the server tells — a one-way message that says "the resource list changed, refresh yourself" with no reply expected. Think of it as the difference between refreshing a webpage every few seconds versus a live feed that pushes new posts to you the moment they arrive. The server fires the message and moves on; the client updates itself when it's ready. Because nobody's waiting on a round-trip, it's cheap and it's instant.
Elicitation is the mirror image, and the distinction between the two is the whole point of this section — get it wrong and your server either hangs or silently guesses. Elicitation is when the server needs an answer it genuinely can't figure out on its own: "which environment — staging or prod?" It routes that question through the client to the human, waits for the reply, and continues. The one-line test that separates them: does the server expect a reply? If no — it's just informing you — that's a notification (fire-and-forget). If yes — it's stuck until a human answers — that's elicitation (a paused conversation). The common trap is reaching for a tool argument when you should elicit: forcing the caller to know the deploy target up front, instead of letting the server ask at the exact moment it matters and keeping the human in the loop for the choice that needs judgment.
Notifications are one-way messages ("the resource list changed") so the client refreshes without polling. Elicitation lets a server ask the user for input mid-task through the client's UI.
Two panels, split by the dashed line — each traces the arrows between the boxes to show whether anything comes back.
- On the left, the single blue arrow from Server → Client is the notification:
"list changed"goes one way and nothing returns. The client just refreshes itself. - On the right, there are two arrows: a blue ask going out and an amber reply coming back. The server is stuck ("paused") until the human answers "staging or prod?".
- The presence of a return arrow is the whole distinction: no return arrow = notification (fire-and-forget); a return arrow = elicitation (a paused conversation).
In short: No reply expected → notification; a needed answer → elicitation. That single question tells you which one you're building.
notify.pyfrom mcp.server.fastmcp import FastMCP, Context
mcp = FastMCP("docs")
def save_document(name, body): ... # your storage
@mcp.tool()
async def add_document(name: str, body: str, ctx: Context) -> str:
save_document(name, body)
await ctx.session.send_resource_list_changed() # client cache is now stale
return f"added {name}"
A notification is a one-way heads-up from server to client: "something changed, refresh yourself." Without it the client would have to keep asking "anything new yet?" (polling). Here, adding a document tells the client its list of resources is out of date.
def save_document(name, body): ...is a placeholder for your real storage — the...just means "fill this in." The lesson focuses on the notification, not the saving.@mcp.tool()makesadd_documenta callable tool. It takes a name and body plus thectxhandle to reach the client.save_document(name, body)stores the new document (in real code).await ctx.session.send_resource_list_changed()is the notification: it tells the client "the set of resources changed, so what you cached is stale — reload it." The client refreshes on its own; the server never waits for a reply.return f"added {name}"gives the caller a plain confirmation.
What the output means: The document is saved and the client is nudged to refresh its resource list automatically — no polling loop needed.
Try this: Compare this to refreshing a webpage: instead of you hitting reload over and over, the server says "new content is ready" and the page updates itself.
4 · Transports — stdio vs HTTP intermediate
Everything so far has been about what the client and server say. This section is about the wire the words travel down. A transport is the physical connection — the pipe — and MCP gives you two. It's easy to skate past this as a boring plumbing detail, but the transport you pick quietly decides how your server is deployed, who can reach it, and how much security you're now on the hook for. Choosing it is a real architectural decision wearing plumbing's clothes.
The two options map onto a distinction you already understand from everyday computing. stdio ("standard in / standard out") is like a program you run on your own machine — the client launches the server as a subprocess and talks to it through the same input/output channels a terminal uses. There's no network, no port, no address. It's the simplest thing that works, and it's how most desktop tools connect: one client, one local server, sitting right next to each other. Streamable HTTP is the other extreme: the server runs as a networked service at a URL, and any number of clients can reach it over the network — exactly like a web API.
The analogy: stdio is talking to someone in the same room — no phone needed, no way for a stranger to eavesdrop, but they have to be physically present. HTTP is a phone line — now people anywhere can reach the server, which is powerful, but you've also opened a door the whole world can knock on. And that's the trade the section turns on: HTTP buys you reach and sharing, but the moment you go networked you inherit everything that comes with running a public service — authentication, so not just anyone can call it; TLS, so the traffic isn't sniffed; rate limiting and hardening, so it can't be hammered. stdio has none of that surface because there's no network to attack.
The common mistake is defaulting to HTTP because it sounds more "production." It isn't — it's more exposed. The right instinct is the reverse: reach for stdio unless you specifically need remote access or multiple clients, because the safest network surface is the one that doesn't exist. Only step up to HTTP when the requirement forces it, and then treat it like any other service you'd expose to a network. The lab below shows how the same server code runs either way, chosen by a single flag — proof that the transport is a deployment choice, not a rewrite.
A server speaks over a transport. stdio is a local subprocess the client launches — simplest, common for desktop tools. Streamable HTTP runs it as a networked service — for remote/shared, multi-client servers.
Compare the two panels: count how many clients can reach the server and what security you now owe.
- On the left, one Client launches one Server as a subprocess — a single arrow, and the green note underneath: no network, no port, no attack surface.
- On the right, three clients (A, B, C) all reach one networked Server at a URL — the fan-in of arrows is the "reach" you gain.
- The amber line is the price of that reach: the moment you go networked you own authentication, TLS, and rate-limiting — the door the whole world can knock on.
In short: stdio = one local caller, zero network surface; HTTP = many callers, but you take on running a public service. Default to stdio.
| Transport | Runs as | Use when |
|---|---|---|
| stdio | local subprocess | personal/desktop, one client |
| Streamable HTTP | networked service | remote, shared, multi-client |
run_server.pyfrom mcp.server.fastmcp import FastMCP
mcp = FastMCP("docs") # tools registered above
if __name__ == "__main__":
import sys
mcp.run(transport="streamable-http" if "--http" in sys.argv else "stdio")
A server has to speak over a transport — the wire between client and server. This tiny launcher lets the same server run either as a local program (stdio) or as a network service (streamable-http), chosen by a command-line flag.
mcp = FastMCP("docs")builds the server object; the comment notes the tools were registered earlier in the file.if __name__ == "__main__":means "only run this part when this file is executed directly" — the standard Python way to add a startup section.import sysgives access tosys.argv, the list of words typed after the program name on the command line."--http" in sys.argvchecks whether you launched with the--httpflag. If yes,transport="streamable-http"(networked, multi-client); if no,"stdio"(a local subprocess the client starts).mcp.run(...)then starts the server that way.
What the output means: Run it plainly and it starts on stdio for a local client; run it with --http and the same code serves over the network.
Try this: Run the file two ways — with and without --http — and notice you changed the transport without touching any tool code. That separation is the whole point.
5 · Advanced — roots & the request lifecycle advanced
Here's a scenario that should make you a little nervous. You connect a filesystem MCP server so your agent can read and edit your project. Handy. But that server is a program running on your machine, and the model is deciding which paths it opens. What stops it — or a cleverly crafted request, or an outright buggy tool — from reading ~/.ssh/id_rsa, or your browser cookies, or /etc/passwd? Nothing, unless something draws a line. Roots are that line: the client tells the server "you may operate inside these directories and nowhere else," and it's the server's job to honor it on every single file access.
Think of roots like a visitor badge that only opens certain doors. A contractor in an office building isn't trusted to roam everywhere; their badge works on floor 3 and the doors slam shut everywhere else. Roots are that badge for the filesystem. The client grants a scope (the project folder), and the server must check every path against it before touching disk — the same way the badge reader checks before the door clicks open.
Now the part that traps people, and it's a genuine security bug when they get it wrong. The obvious check is "does this path start with the allowed folder?" — a simple string comparison. It's also broken, because a path can lie. /home/user/project/../secrets.txt starts with /home/user/project, so a naive string check waves it through — but that ../ means "go up a level," and the file it actually reaches is /home/user/secrets.txt, outside the root. That's a path-traversal escape, one of the oldest tricks in the book, and it works against anyone who checks the text the user typed instead of the place it truly points.
The fix is one function: resolve the path to its real, canonical location first — os.path.realpath collapses the ../ and follows any symlinks — and only then compare against the allowed roots. You check the destination, not the label. That single discipline (normalize before you compare) is the beating heart of nearly every filesystem-security check you'll ever write, well beyond MCP. The lab below is a runnable model of exactly that boundary — legitimate paths pass, escapes get blocked. Roots let the client tell the server which directories it may operate in — a security boundary for filesystem servers. Model the access check the server should enforce.
../ escapes the root); os.path.realpath resolves the true destination first, so the boundary check sees the real location and blocks the traversal.
Follow the solid arrows left → right: the request travels through realpath before it is ever judged.
- The left box is the raw requested path — here a sneaky
project/../secrets.txtthat looks like it lives inside the project. - The middle amber box is
realpath(): it collapses the../and follows symlinks, producing the real location in the next box —/home/user/secrets.txt, plainly outside the root. - The path then splits: genuinely-inside paths take the arrow to the green ✓ allow; the resolved escape takes the arrow to the red ✗ block.
- The faint red curve along the top is the bug to avoid — comparing the typed string directly, which lets the escape sail straight through.
In short: Resolve the real destination first, then compare — that one discipline (normalize before you compare) stops path-traversal escapes.
roots.pyimport os
def within_roots(path, roots):
"""Only allow access inside client-granted roots (prevents path escape)."""
real = os.path.realpath(path)
return any(real.startswith(os.path.realpath(r) + os.sep) or real == os.path.realpath(r)
for r in roots)
roots = ["/home/user/project"]
for p in ["/home/user/project/src/app.py",
"/home/user/project/../secrets.txt", # path-escape attempt
"/etc/passwd"]:
print(f"{within_roots(p, roots)!s:5} {p}")
True /home/user/project/src/app.py
False /home/user/project/../secrets.txt
False /etc/passwd
Roots are the folders the client says a server is allowed to touch. This code is the security check a filesystem server must run before opening any file: it confirms the requested path really lives inside an allowed root and isn't a sneaky attempt to escape it. This block actually runs and prints its results.
os.path.realpath(path)resolves the true, absolute location — it follows shortcuts and collapses tricks like../(go up a folder). This is the crucial step: you must compare the real destination, not the text the caller typed.real.startswith(os.path.realpath(r) + os.sep)checks the resolved path begins with an allowed root folder (the+ os.sepmakes sure/home/user/project2can't sneak past a root of/home/user/project).any(...)returns true if the path is inside any allowed root.- The
forloop tests three paths: a legitimate file inside the root, a../secrets.txtpath-escape attempt, and an unrelated system file/etc/passwd. print(f"{within_roots(p, roots)!s:5} {p}")prints the True/False verdict (padded to width 5 with!s:5) next to each path.
What the output means: Only the file genuinely inside /home/user/project prints True; the ../secrets.txt escape and /etc/passwd both print False — blocked.
Try this: Add "/home/user/project/deep/nested/file.txt" to the list — it passes, because it's genuinely inside the root. Then try "/home/user" (the parent) — it fails. That's the boundary doing its job.
6 · Professional — errors, timeouts & robustness professional
The gap between a demo server and a production one is almost never the happy path — it's what happens when things go wrong. Your tool works perfectly when it's handed the input you imagined. Then a real client sends a number where you expected text, or the database it queries is down, or the operation that usually takes 200ms hits a slow disk and takes forever. On a demo, you shrug and re-run. In production, a tool that throws an unhandled exception or hangs doesn't just fail itself — it can wedge the whole agent that's waiting on it. One stuck server, one frozen conversation.
The habit that fixes this is to treat your tool like a well-trained receptionist rather than a nervous intern. Hand an intern a request they can't handle and they freeze, or blurt out a confusing error, or just disappear. A good receptionist always comes back with a composed answer — "done, here's the result," or "I couldn't do that, and here's exactly why" — but never leaves you standing there or panicking. Every call gets a clean, predictable response. That's the discipline: your tool should catch its own failures and return a tidy, uniform result whether it succeeded or not, so the caller can always keep working.
Two failure modes deserve special naming because beginners handle them worst. The first is bad input — the wrong type, a missing field, a nonsensical value — which should produce a clear error naming what was wrong, not a raw stack trace. The second, and the sneakier one, is the slow operation that never finishes: without a timeout, a single wedged call blocks forever, and "forever" is the one duration a user will never forgive. A hard timeout converts an infinite hang into a fast, honest failure the agent can recover from — retry it, route around it, or tell the user. The common mistake is optimism: assuming inputs are valid and operations are quick, so no guards are needed. Production is precisely the place that assumption dies. The lab below wraps any tool so every outcome — success, bad input, timeout — comes back as the same shaped dictionary, which is the small, boring pattern that keeps a client alive. This is the same resiliency discipline you'd give any real service (DF4). A production MCP server handles bad input, times out slow operations, and returns clear errors rather than hanging the client. Same resiliency discipline as any service (DF4).
{"ok": …} dict — the tool never crashes or hangs the client, so the caller can always keep working.
Read left → right: three very different fates enter on the left, one predictable answer leaves on the right.
- The left column is the three things that can happen to a tool call: a clean success (green), a bad input that raises, and a timeout that would otherwise hang forever (both red).
- The middle box is
safe_tool— itstry / exceptcatches all three so none of them escape as a raw crash. - The right box is the payoff: whatever happened, the caller gets the same
{"ok": …}dictionary — either aresultor a readableerror.
In short: Funnel every path — win or fail — into one uniform result shape; that predictability is what keeps the client alive.
robust_tool.pydef safe_tool(fn, *args, timeout_hit=False):
"""Wrap a tool call: validate, guard, return a clean result or error."""
try:
if timeout_hit:
raise TimeoutError("operation exceeded 10s")
return {"ok": True, "result": fn(*args)}
except TimeoutError as e:
return {"ok": False, "error": f"timeout: {e}"}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
print(safe_tool(lambda x: x.upper(), "hello"))
print(safe_tool(lambda x: x.upper(), 42)) # int has no .upper
print(safe_tool(lambda x: x, "x", timeout_hit=True))
{'ok': True, 'result': 'HELLO'}
{'ok': False, 'error': "AttributeError: 'int' object has no attribute 'upper'"}
{'ok': False, 'error': 'timeout: operation exceeded 10s'}
A production tool should never crash the client — it should catch its own problems and return a tidy result either way. This wrapper runs any function, and instead of letting an error explode, it hands back a small dictionary saying whether it succeeded. This is the same resiliency habit you'd give any real service.
def safe_tool(fn, *args, timeout_hit=False):takes the function to run (fn), whatever arguments it needs (*args), and a test flag to simulate a timeout.try:attempts the work. Iftimeout_hitis set it raisesTimeoutErrorto mimic a slow operation; otherwise it runsfn(*args)and returns{"ok": True, "result": ...}.except TimeoutErrorcatches the slow-operation case and returns{"ok": False, "error": "timeout: ..."}— a clean message, not a hang.except Exception as eis the safety net for any other failure.type(e).__name__names the error kind (likeAttributeError) so the caller learns what went wrong.- The three
printlines exercise all paths: a success, a real bug (calling.upper()on the number42), and a simulated timeout.
What the output means: Three dictionaries print: the first has ok: True with 'HELLO'; the other two have ok: False with a readable error string — never a crash.
Try this: Every result has the same shape — an ok flag plus either result or error. That predictable shape is what lets the client keep working no matter how the tool fails.
7 · Tech-lead — MCP servers as shared infrastructure tech-lead
Everything up to here has been about building one good server. This section is about what changes when other people start depending on it. That shift — from "my handy script" to "the thing five teams' agents now call" — is where the tech-lead mindset kicks in, and it reframes an MCP server as something bigger than code: it becomes infrastructure. The leverage is enormous. Build one solid server that wraps your ticket system or your deploy logs, and every agent and every teammate in the org gains that capability with a single config line. You wrote it once; the whole company got smarter.
The analogy that captures both the upside and the responsibility: a shared MCP server is like the office electrical wiring, not a desk lamp. A lamp is yours — if it flickers, you deal with it. Wiring is everyone's — if it fails, the whole floor goes dark, and if it's unsafe, everyone's at risk. The moment a server is shared, its bugs, its downtime, its security holes, and its breaking changes are no longer your private problem; they're a blast radius. That's why leads stop treating internal servers like scripts and start treating them like the shared APIs they are: versioned (so a change doesn't silently break every caller), documented (so people can adopt it without reading your source), access-controlled (because it runs with real permissions over real data), and monitored (so you learn it's down before your users do).
The mistake here is two-sided. One direction is building a fresh server for every need when a maintained, vetted one already exists — reinventing wiring the building already has. The other, more dangerous direction is adopting a third-party server without vetting it, forgetting that it will run with your agent's permissions and see your agent's data: a server you don't trust is a lamp wired straight into the mains with no fuse. The lead's real job is the deliberate build-vs-adopt call — prefer a maintained, vetted server by default, build only when nothing fits or the candidate fails the security check — and then to run whatever you adopt like the load-bearing infrastructure it now is. A lead treats internal MCP servers like shared APIs: versioned, documented, access-controlled, monitored. One well-built server (your ticket system, deploy logs) becomes reusable across every agent and teammate — the tool layer the whole org builds on.
Read it as a fan-out: one box on the left, many on the right, with arrows carrying the same capability outward.
- The left green box is the single server you build — say your ticket system or deploy logs. The blue labels under it are what makes it infrastructure rather than a script: versioned, documented, access-controlled, monitored.
- The arrows fanning right are the leverage: each one is a consumer wiring in with a single config line.
- The right column is who benefits — agents, teammates, and every future agent. You wrote it once; they all gained the capability. That same one-to-many shape is why a bug or outage is now everyone's problem (the blast radius).
In short: One governed server fans out to the whole org — huge leverage, which is exactly why leads run it like the load-bearing wiring it has become.
ApiClient (DF4) — infrastructure, not a one-off.Exercise AK6.1 — Upgrade your C4 server to production
Context: Taking a toy MCP server to production means adding the advanced protocol surface — sampling, notifications, an HTTP transport, roots enforcement, and robust error/timeout handling — then proving it works from a real client.
Your task: Upgrade your C4 MCP server with a sampling tool, a notification on data change, a --http transport flag, roots enforcement, and robust error/timeout handling, then connect it to Claude Code over stdio and confirm sampling works.
Requirements:
- Add a tool that uses sampling (the server asks the client to run the model)
- Emit a notification when the underlying data changes
- Add a
--httpflag to select the streamable-HTTP transport - Enforce roots (use
roots.py) so access stays inside allowed paths - Add robust error/timeout handling (use
robust_tool.py) - Connect over stdio to Claude Code and confirm sampling actually works end to end
💡 Hint: Add the primitives one at a time and verify each from the client — sampling in particular only proves out when the client mediates the model call.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: "MCP is just tools" undersells the protocol: a server exposes tools, resources, and prompts, so it can hand the model readable context and canned templates, not only callable functions.
Your task: Map each MCP server primitive — tools, resources, prompts — to what it provides.
Requirements:
- Tools are actions the model can invoke (do something)
- Resources are data the server exposes to read (files, records, docs)
- Prompts are reusable prompt templates the server offers
- The three roles are distinct: act vs provide-data vs provide-template
- Explain why knowing all three exist changes how you think about a server
💡 Hint: Sort each primitive by its verb — does it act, provide data, or provide a template?
Show solution
Three server primitives, three roles (pure concept):
def primitive_role(kind):
return {
"tools": "actions the model can invoke (do something)",
"resources": "data the server exposes to read (files, records, docs)",
"prompts": "reusable prompt templates the server offers",
}[kind]
for k in ("tools", "resources", "prompts"):
print(k, "->", primitive_role(k))
Tools act, resources provide data, and prompts provide templates. Knowing all three exist is why "MCP is just tools" undersells it — a server can hand the model readable context and canned prompts, not only callable functions.
Context: Sampling inverts control — the server requests a model completion through the client — which lets a server delegate a reasoning step without ever holding API keys, because the client stays the gatekeeper.
Your task: Explain the sampling flow and why the client remains in control throughout.
Requirements:
- Describe the flow: server requests a completion → client mediates → model → result back to the server
- The client owns the model credentials and cost budget
- The client can require user approval and rate-limit or deny the request
- The server never gets direct model access — it asks, the client decides
- Explain the payoff: a server reasons via the model without holding keys or quietly burning tokens
- Verify current sampling capability details in the MCP docs
💡 Hint: Follow who holds the keys — the server asks, but the client is the one that actually calls the model.
Show solution
With sampling, the server asks the client to run the model — the client mediates:
Flow (sampling request):
server --("please complete this prompt")--> client
client --(applies its own approval/limits, calls the model)--> model
model --(completion)--> client --(result)--> server
Why the client stays in control:
- the client owns the model credentials and cost budget,
- the client can require user approval before fulfilling the request,
- the server never gets direct model access — it asks, the client decides.
Sampling lets a server delegate a reasoning step back to the model without holding API keys itself. The client is the gatekeeper: it can approve, deny, or rate-limit the request, so a server can't quietly burn your tokens. Verify the current sampling capability details in the MCP docs.
Context: Servers can both push notifications (state changed, no reply expected) and elicit input (ask the user for a value mid-operation) — one informs, the other pauses to collect an answer the server genuinely needs.
Your task: Contrast notifications with elicitation and give a use case for each.
Requirements:
- Notification is a one-way server→client push, fire-and-forget, no reply expected
- Elicitation is a server request that needs a user answer, routed client→user→back
- The client renders the elicitation prompt and returns the answer, keeping the human in the loop
- Give a concrete use case for each (e.g. resource-list-changed vs choose an environment)
- Verify the exact message shapes in the MCP spec
💡 Hint: Ask whether a reply is expected — no reply is a notification, a needed answer is elicitation.
Show solution
Notifications are server→client pushes; elicitation is a server asking the user for input:
Notification (one-way, no reply expected):
server -> client: "the resource list changed, re-fetch it"
use case: a file-watching server tells the client new files appeared.
Elicitation (request that needs a user answer):
server -> client -> user: "which environment: staging or prod?"
user -> client -> server: "staging"
use case: a deploy server needs a target it can't infer.
A notification informs (fire-and-forget, e.g. "the tool list updated"); elicitation pauses to collect a value the server genuinely needs from the human. The client renders the elicitation prompt and returns the answer, keeping the human in the loop. Verify the exact message shapes in the MCP spec.
Context: MCP servers speak over stdio (a local subprocess with no network surface) or streamable HTTP (networked and shareable) — and the HTTP reach comes with owning authentication and hardening.
Your task: Write a selector that picks the transport from the deployment and note the tradeoff.
Requirements:
- The selector takes: local, remote-access needed, multiple clients
- Local-only with no remote access → stdio (subprocess, no network exposure)
- Remote access or multiple clients → streamable HTTP
- State the tradeoff: HTTP adds reach but you take on auth + network hardening
- stdio is the safe default for a single local tool
- Verify current transport names in the MCP spec
💡 Hint: Decide by where the server runs and who reaches it — local defaults to stdio, shared or remote forces HTTP and its security cost.
Show solution
Choose by where the server runs and who reaches it:
def pick_transport(local, remote_access, multiple_clients):
if local and not remote_access:
return "stdio — local subprocess, simplest, no network exposure"
if remote_access or multiple_clients:
return "streamable HTTP — networked/shareable, now you own auth + hardening"
return "stdio — default for a single local tool"
print(pick_transport(True, False, False)) # stdio
print(pick_transport(False, True, True)) # streamable HTTP
stdio is the safe default for a local tool the client launches as a subprocess — no ports, no network surface. Streamable HTTP is for remote or shared servers: more reach, but you take on authentication and network hardening. Verify current transport names in the MCP spec.
Context: A production MCP client must survive a slow or crashing server, which means distinguishing permanent errors (fix the request) from transient ones (bounded retry with backoff) and putting a hard timeout on every request.
Your task: Model the MCP request lifecycle with a timeout and a bounded retry.
Requirements:
- Each request has a hard timeout so one wedged server can't hang the agent
- Retries are bounded (a fixed max), not infinite
- A permanent error (e.g. invalid request) is raised immediately — not retried
- Transient errors (timeout, server hiccup) retry with backoff
- Return the response on success; raise after retries are exhausted
- Verify the concrete error/timeout fields in the MCP spec
💡 Hint: Split errors into fix-it (permanent, stop) vs try-again (transient, backoff) and cap how many times you try.
Show solution
Wrap each request with a timeout and bounded retry — never block forever:
import time
def call(server, request, timeout=10.0, retries=2):
for attempt in range(retries + 1):
start = time.monotonic()
resp = server.try_request(request, deadline=start + timeout) # your transport
if resp is not None and not resp.get("error"):
return resp
if resp is not None and resp["error"]["type"] == "invalid_request":
raise ValueError("permanent error — don't retry") # fix the request
# timeout or transient server error -> back off and retry
time.sleep(2 ** attempt)
raise TimeoutError("server unresponsive after retries")
Distinguish permanent errors (bad request — fixing, not retrying, is the answer) from transient ones (timeout, server hiccup — bounded retry with backoff). A hard timeout per request keeps one wedged server from hanging the whole agent. Verify the concrete error/timeout fields in the MCP spec.
Context: Because an MCP server runs with your agent's permissions and sees its data, the tech-lead call is build-vs-adopt plus vetting source, scope, secret handling, and transport before trusting a third-party server.
Your task: As tech lead, decide build-vs-adopt for MCP servers and write a scorer that vets a third-party candidate before adoption.
Requirements:
- A build-vs-adopt decision: build when nothing exists or the candidate fails vetting or logic is too custom; otherwise adopt a maintained + vetted server
- The vetting scorer weighs source availability, permission scope, secret handling, and transport/auth
- Plaintext-secret handling and unauthenticated HTTP raise the risk sharply
- The score maps to a verdict (trust / review / reject)
- Run adopted servers as real infra: versioned, monitored, access-controlled
💡 Hint: Adopt maintained + vetted by default and build only when forced — the scorer should punish broad scope, plaintext secrets, and unauthenticated HTTP.
Show solution
Adopt maintained+vetted servers; build only when you must — and vet before trusting:
def build_or_adopt(exists, maintained, passes_vetting, custom_logic):
if not exists: return "BUILD — nothing exists for this need"
if not passes_vetting: return "BUILD/FORK — candidate fails security vetting"
if custom_logic: return "BUILD on the pattern — logic too specific"
if maintained: return "ADOPT — maintained + vetted; faster than building"
return "ADOPT cautiously — pin a version, watch for abandonment"
def vet(s):
risk = 0
risk += 0 if s.get("open_source") else 2
risk += 2 if s.get("broad_scope") else 0
risk += 3 if s.get("wants_plaintext_secrets") else 0
risk += 2 if (s.get("transport")=="http" and not s.get("uses_auth")) else 0
return "TRUST" if risk==0 else ("REVIEW" if risk<4 else "REJECT"), risk
print(build_or_adopt(True, True, True, False)) # ADOPT
print(vet({"open_source":True,"transport":"stdio"})) # ('TRUST', 0)
A server runs with your agent's permissions and sees its data, so vet source, permission scope, secret handling, and transport before adopting. Prefer adopting a maintained, vetted server (faster, less to own) and build only when nothing fits or the candidate fails vetting. Run shared servers as real infra: versioned, monitored, access-controlled.
✓ Checkpoint — you can move on when you can…
- Name the primitives + four advanced capabilities.
- Implement sampling and a notification.
- Choose a transport; enforce roots.
- Make an MCP server robust, shared infrastructure.
Knowledge check check yourself
What does MCP "sampling" do, and why does having the server call the client's model (rather than its own) improve safety and cost?
Show answer
ctx.session.create_message(...) instead of holding its own API key. The client owns the model, the cost, and the single approval path, so there is one place to govern spend and consent rather than a separate key and billing per server.When enforcing MCP roots, why must a server call os.path.realpath before checking a path against allowed directories?
Show answer
../ escapes; realpath resolves the true destination (e.g. /home/user/project/../secrets.txt becomes /home/user/secrets.txt) so the boundary check compares real locations, not typed text. Checking the raw string would let a path traversal slip outside the root.