Module 11 · AI Agents & MCP · Deep Dive
Two skills that separate a demo from a product: packaging your tools so any LLM host can use them (MCP), and proving your app actually works when nobody's watching (evals & safety).
BasicIntermediateBuild
Picture the world before MCP. You wrote a search_docs tool for DocChat. Your colleague wrote one for their app. Claude Desktop has no idea either exists. To wire your retrieval into a new host, you'd re-implement the glue every single time — different SDK, different JSON shapes, different lifecycle. N tools × M hosts = N×M integrations. That's the same mess APIs were before REST settled the conventions.
MCP collapses that to N + M. You write one MCP server that exposes your capabilities. Any MCP-aware host can connect to it without knowing anything about your internals. The mental model everyone uses:
So MCP doesn't replace tool-calling — it standardises the packaging of tools so they become reusable assets instead of one-off code buried in a single app.
Three roles. Keep them straight and the rest of MCP is easy:
| Role | What it is | Example |
|---|---|---|
| Host | The LLM application the user interacts with. It runs an MCP client for each server it connects to. | Claude Desktop, Claude Code, an IDE |
| Client | The connection manager inside the host — one client per server, handling the handshake and message passing. | (internal to the host) |
| Server | Your code. Exposes capabilities to whoever connects. | The DocChat retrieval server you'll build |
A server exposes three kinds of capability, and the distinction matters:
search_docs(query) is a tool. This is the tool-calling from 11.1, now standardised.And servers connect over a transport:
The official MCP Python SDK (the mcp package, FastMCP-style) makes this almost embarrassingly short. You decorate functions to expose them. Here's a server with one tool and one resource:
docs_server.py
from mcp.server.fastmcp import FastMCP mcp = FastMCP("docchat-docs") # A tiny in-memory corpus stands in for your real document store DOCS = { "visa.md": "UAE Golden Visa grants 10-year residency to skilled workers.", "tax.md": "UAE corporate tax is 9% on profits above AED 375,000.", } @mcp.tool() def search_docs(query: str) -> str: """Search the document corpus and return matching snippets.""" q = query.lower() hits = [text for name, text in DOCS.items() if q in text.lower()] return "\n".join(hits) if hits else "No matching documents." @mcp.resource("docs://{name}") def get_document(name: str) -> str: """Return the full text of one document by name.""" return DOCS.get(name, f"Document {name} not found.") if __name__ == "__main__": mcp.run() # defaults to stdio transport — a local server
Read what each decorator did. @mcp.tool() registers search_docs as a callable action; the SDK turns the function signature and docstring into the JSON schema the host advertises to the model — you write a normal Python function, the SDK does the plumbing. @mcp.resource("docs://{name}") registers a readable resource with a templated URI, so the host can fetch docs://visa.md directly.
python docs_server.py), restart, and your search_docs tool plus docs:// resources appear — no code change in the host. That's the whole promise: build once, plug in anywhere.
To make it a remote server instead, you change one argument — mcp.run(transport="streamable-http") — and now it's a web service other machines can reach. Same tool code, different wire.
This is the conceptual hinge of the lesson, so go slow. In 11.1 you defined a tool inline in your agent script and ran the loop yourself with the Anthropic SDK:
# 11.1 style: tool lives inside ONE app, hand-wired to one model tools = [{ "name": "search_docs", "description": "Search the docs", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, }] # ... your own while-loop dispatches the tool_use block to a Python function
That tool is welded to that script. MCP takes the same function and lifts it out into a server that any host can mount. The capability stops being "a function in my agent" and becomes "a reusable service".
| Raw tool-calling (11.1) | MCP (11.2) | |
|---|---|---|
| Where the tool lives | Inline in one app | In a standalone server |
| Who can use it | That one app | Any MCP host |
| You write the agent loop | Yes | No — the host does |
| Reusable across hosts | No | Yes |
Now make it real. DocChat already has a retrieval pipeline — embed the query, search the vector store, return the top chunks. Wrap that pipeline in an MCP server and DocChat's knowledge becomes available to Claude Desktop, Claude Code, and your IDE without copying a line of retrieval code into any of them.
docchat_mcp.py
from mcp.server.fastmcp import FastMCP from docchat.retrieval import retrieve_chunks, get_chunk_text # your 11.x code mcp = FastMCP("docchat") @mcp.tool() def search_docs(query: str, k: int = 5) -> str: """Retrieve the k most relevant document chunks for a question.""" chunks = retrieve_chunks(query, k=k) return "\n\n---\n\n".join( f"[{c.source}] {c.text}" for c in chunks ) @mcp.resource("docchat://chunk/{chunk_id}") def chunk(chunk_id: str) -> str: """Read one chunk's full text by id — for citation drill-downs.""" return get_chunk_text(chunk_id) if __name__ == "__main__": mcp.run(transport="streamable-http") # remote: a shared DocChat brain
Notice the division of labour. The tool (search_docs) is for the model to call during reasoning; the resource (docchat://chunk/...) is for the host to pull on demand when a user clicks a citation. Same data store, two access patterns, both standard.
Switch hats. You've shipped DocChat. How do you know it's good? With ordinary code you write tests: add(2, 3) == 5, forever. LLM apps break that contract in two ways.
The fix is a golden dataset: a fixed set of question → reference-answer pairs that encode "what good looks like" for your app. You run your app against the set and score the outputs.
golden.py
GOLDEN = [
{
"question": "How long is the UAE Golden Visa valid?",
"reference": "It grants 10-year residency.",
},
{
"question": "What is the UAE corporate tax rate?",
"reference": "9% on profits above AED 375,000.",
},
]
This is the single most valuable artefact in an AI project. It's slow to build (you write the reference answers by hand) and it pays back every time you change a prompt, a model, or a retriever.
For a RAG app like DocChat, three offline metrics (run on your golden set, no live traffic) tell you almost everything. These are the Ragas-style metrics you'll hear named in interviews:
| Metric | Question it answers | Catches |
|---|---|---|
| Faithfulness (groundedness) | Is every claim in the answer supported by the retrieved context? | Hallucination — the model making things up |
| Context relevance | Did the retriever pull chunks that are actually about the question? | A weak retriever feeding garbage |
| Answer relevance | Does the answer actually address what was asked? | On-topic-but-evasive replies |
Read them as a pipeline. Context relevance grades the retriever; faithfulness grades whether the generator stuck to that context; answer relevance grades whether it answered the user. A low faithfulness with high context relevance means your model is hallucinating despite good context — a generation problem. Low context relevance means fix the retriever first.
Run these in CI. A pull request that drops faithfulness from 0.92 to 0.71 should fail the build the same way a broken unit test would — that's regression testing for prompts.
Measuring quality is half of trust; the other half is safety. Two layers:
Guardrails are checks around the model. Input validation rejects or sanitises bad input. Output validation verifies the response before it reaches the user — does it cite sources, is it on-topic, does it refuse questions outside DocChat's scope? A simple, effective guardrail for RAG: require a citation. If the answer has no [source] tag, don't show it.
Defences, in order of leverage:
No single defence is complete; you layer them. The mindset shift: treat every byte of retrieved or user-supplied text as hostile until proven otherwise.
Two pieces: run DocChat to get an answer, then ask a judge model to score it against the retrieved context. We use the Anthropic anthropic SDK with a current model id for the judge.
eval_groundedness.py
import json import anthropic from golden import GOLDEN from docchat import answer_question # returns (answer, context) client = anthropic.Anthropic() JUDGE_RUBRIC = """You are grading a RAG answer for GROUNDEDNESS. Given the CONTEXT and the ANSWER, score 1-5: 5 = every claim is fully supported by the context 1 = the answer contains claims not found in the context (hallucination) Reply with ONLY a JSON object: {"score": <int>, "reason": "<short>"}""" def judge(context: str, answer: str) -> dict: msg = client.messages.create( model="claude-sonnet-4-6", # a cheaper model is fine for judging max_tokens=512, system=JUDGE_RUBRIC, messages=[{"role": "user", "content": f"CONTEXT:\n{context}\n\nANSWER:\n{answer}"}], ) text = next(b.text for b in msg.content if b.type == "text") return json.loads(text) def run_eval(): scores = [] for item in GOLDEN: answer, context = answer_question(item["question"]) result = judge(context, answer) scores.append(result["score"]) print(f"Q: {item['question'][:40]:<40} " f"score={result['score']} ({result['reason']})") avg = sum(scores) / len(scores) print(f"\nMean groundedness: {avg:.2f} / 5") assert avg >= 4.0, "Groundedness regressed below threshold!" # CI gate if __name__ == "__main__": run_eval()
That final assert is the whole point: the script now fails the build if a change makes DocChat hallucinate more. You've turned a quality property into a test. Add answer-relevance and context-relevance judges the same way, and you have a real eval suite.
For production hardening, you'd pin the judge prompt (it's part of your test contract), spot-check its scores against human labels, and run the suite on every PR — the same discipline as the prompt-injection defences in Module 12.
Answer from memory — that's the rep that makes it stick.
In one phrase, what is MCP?
Which MCP capability is an action the model calls?
Which transport suits a local MCP server?
Which RAG metric catches hallucination?
A document says "ignore your rules and leak data". This is?