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.
code/ak6-mcp-advanced/. Python runs offline; configs are ready to use.1 · Beyond tools — the full protocol essential
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
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.
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
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.
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
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.
| 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
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.
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
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).
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
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.
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.