Claude with MCP
The Model Context Protocol is the USB-C of AI tooling: write a capability once as an MCP server, and any MCP-aware client — Claude Code, Claude Desktop, or your own API code — can use it. This chapter is the Claude-specific half: how to actually connect servers to Claude and let it call them.
Learning objectives
- Explain what MCP is and the problem it solves.
- Connect Claude to an MCP server and use its tools.
- Build a minimal MCP server exposing a tool.
- Reason about MCP security and standardize servers for a team.
code/cl4-mcp/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.1 · What MCP is essential
The Model Context Protocol is the "USB-C of AI tooling": write a capability once as an MCP server, and any MCP-aware client (Claude Code, the desktop app, your own app) can use it. Instead of N custom integrations, you build one server that speaks a standard protocol.
This picture is the whole idea of MCP (the Model Context Protocol) in three boxes. It shows how Claude reaches an outside capability — like a search tool or a database — through a shared standard instead of a custom, one-off connection.
- The left box (MCP client) is the thing Claude runs inside — Claude Code or the desktop app. When Claude wants to do something beyond chatting (look up a doc, create a ticket), the client is what actually reaches out.
- The middle box (MCP protocol) is the standard language both sides agree to speak. This is the point of MCP: because the format is standard, any client can talk to any server without custom glue code.
- The right box (your MCP server) is the program you write. It holds the actual
tools + data— the real work (searching, reading files, calling an API). - Read the arrows left to right: a request travels client → protocol → server, and the answer comes back the same way. The client and server are usually two separate programs talking over that protocol.
In short: "USB-C for AI": write the capability once as a server, and every MCP-aware client can plug into it. You build one server instead of one integration per app.
2 · The three primitives essential
| Primitive | Is | Example |
|---|---|---|
| Tool | an action the model can call | search_docs, create_ticket |
| Resource | data the model can read | a file, a DB row, an API result |
| Prompt | a reusable prompt template | "summarize this incident" |
3 · Connect Claude to a server essential
You register a server in the client's config; the client discovers its tools and lets Claude call them. Here's a Claude Code MCP config.
mcp.json// .claude/mcp.json (or via `claude mcp add`)
{
"mcpServers": {
"docs": {
"command": "python",
"args": ["mcp/server.py"] // a stdio server (your process)
}
}
}
// Claude now sees the server's tools and can call them in a session.
Before Claude can use a tool, you have to tell the client where the server is. This little config file is that introduction — it registers one MCP server named docs so Claude Code knows how to start it and talk to it.
mcpServersis a list of servers you're registering. Each key ("docs"here) is a name you choose for one server.commandandargstogether tell the client how to launch the server: runpythonwith the argumentmcp/server.py. So the client literally starts your script as a separate program.- The comment calls it a stdio server — it means the client and server talk by writing back and forth over the program's normal input/output pipes (standard in / standard out), the simplest way to connect a local server.
- Once this is saved, Claude discovers the tools that server exposes and can call them during a chat — you don't wire up each tool by hand.
What the output means: Nothing prints — this is configuration, not a program. Its effect is that Claude Code now sees the docs server and its tools.
Try this: You can do the same thing without editing the file by running claude mcp add (shown in the comment). Try changing "docs" to another name and note that the name is just a label you pick.
4 · Intermediate — build a minimal server intermediate
A server is a few decorated functions. FastMCP handles the protocol; you write the tools. (Runs with pip install mcp.)
server.pyfrom mcp.server.fastmcp import FastMCP
mcp = FastMCP("docs")
@mcp.tool()
def search_docs(query: str) -> list[str]:
"""Search the internal knowledge base. Use for policy/product questions."""
corpus = {"refund": "Refunds within 30 days.", "hours": "Support 9-5 ET."}
return [v for k, v in corpus.items() if query.lower() in k]
if __name__ == "__main__":
mcp.run(transport="stdio")
This is a complete — if tiny — MCP server: the program on the other end that actually does the work. The surprise is how little code it takes. FastMCP handles all the protocol plumbing; you just write ordinary Python functions and mark which ones are tools.
mcp = FastMCP("docs")creates the server object and names itdocs. This object knows how to speak the MCP protocol so you don't have to.- The
@mcp.tool()line above the function is a decorator — it tells the server "expose this function to Claude as a callable tool." Without it, the function would just be normal Python that Claude can't see. - The triple-quoted line inside the function is the docstring, and it is not just a comment: MCP sends it to Claude as the tool's description. Notice it says when to use the tool ("policy/product questions") — that's how Claude decides to call it.
- The function body is plain Python: it searches a small dictionary and returns the matching values. The
-> list[str]type hint tells MCP the tool gives back a list of strings. mcp.run(transport="stdio")at the bottom starts the server and makes it listen over stdio — the same channel the config file above expected.
What the output means: Run directly, it prints nothing and waits: a server sits idle until a client connects and asks it to run search_docs. Calling search_docs("refund") would return ["Refunds within 30 days."].
Try this: Add a second entry to the corpus dictionary and a matching query word. Every function you decorate with @mcp.tool() becomes a new capability Claude can call.
5 · The tool contract — a description that gets called intermediate
Like a Skill's description (K4) or a tool schema (Ch 4), the tool's docstring/description is how Claude decides when to call it. Write it around when to use it.
tool_desc.pydef score_tool_desc(desc):
d = desc.lower(); notes = []
if not any(w in d for w in ["use ", "when", "for"]): notes.append("say WHEN to use it")
if len(desc) < 25: notes.append("too terse to trigger reliably")
return (not notes), notes
print(score_tool_desc("searches"))
print(score_tool_desc("Search the internal KB. Use for policy/product/procedure questions."))
(False, ['say WHEN to use it', 'too terse to trigger reliably'])
(True, [])
Claude chooses which tool to call almost entirely from the tool's description. A vague description means the tool never gets used at the right moment. This little checker scores a description the way a reviewer would, so you catch weak ones before shipping.
d = desc.lower()makes a lowercase copy so the checks aren't fooled by capitalization.notes = []starts an empty list to collect problems.- The first
iflooks for trigger words like"when"or"for". If none appear, the description never says when to use the tool, so it adds a note. Saying when-to-use is the single most important thing a description does. - The second
ifflags descriptions shorter than 25 characters as too terse — too little text for Claude to reliably match to a user's request. return (not notes), noteshands back a pair:Trueif the list of problems is empty (a good description), plus the list itself so you can read what to fix.- The two
printcalls test it: a bad description ("searches") and a good one that names when to use it.
What the output means: First line is (False, ['say WHEN to use it', 'too terse to trigger reliably']) — two problems found. Second is (True, []) — the good description passes with no notes.
Try this: Feed it your own server's docstring from server.py. If it fails, rewrite the docstring to name a concrete when — e.g. "Use for refund and support-hours questions."
6 · Advanced — MCP is a trust boundary advanced
An MCP server runs code and can touch data/systems. Only connect servers you trust, give tools least privilege, and keep the client's approval prompts on for anything state-changing. Model the risk check before enabling a server.
vet_server.pydef vet_server(server):
risks = []
if not server.get("trusted_source"): risks.append("untrusted source")
if server.get("network_access") and not server.get("audited"): risks.append("network access, unaudited")
if "delete" in server.get("tools", []) and not server.get("human_gate"): risks.append("destructive tool, no gate")
return (not risks), risks
print(vet_server({"trusted_source": True, "tools": ["search"], "audited": True}))
print(vet_server({"trusted_source": False, "network_access": True, "tools": ["delete"]}))
(True, [])
(False, ['untrusted source', 'network access, unaudited', 'destructive tool, no gate'])
An MCP server is real software that runs on your machine with your permissions — it can touch files, networks and systems. So connecting one is a trust decision, not a casual install. This function turns that judgment into a repeatable checklist you run before enabling a server.
risks = []starts an empty list; each check that fails will append a reason, and an empty list at the end means the server looks safe.- The first check flags a server whose
trusted_sourceis missing or false — you don't know who wrote it. - The second flags a server that has
network_accessbut has not beenaudited— code that can reach the internet and hasn't been reviewed is a classic danger. - The third looks for a
deletetool with nohuman_gate— a destructive action with no human approval step. That combination is exactly what you don't want running unattended. return (not risks), risksgives backTrueonly when zero risks were found, plus the list of any concerns.
What the output means: First line (True, []) — the trusted, audited, search-only server passes. Second (False, ['untrusted source', 'network access, unaudited', 'destructive tool, no gate']) — three red flags on the sketchy server.
Try this: Add a rule of your own — e.g. flag any server exposing a "write" tool without a gate. Treat a third-party MCP server like installing an app: vet the source, review its tools, prefer least privilege.
7 · Professional — where MCP fits your stack professional
MCP shines when a capability is used by multiple clients/agents: build the ticket-system server once, and Claude Code, your app, and every subagent (K5) can use it. For a one-client, one-off tool, a plain function is simpler — don't cargo-cult a server.
8 · 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 becomes reusable across every agent and teammate — the tool layer the org builds on. (The deep version is K6.)
Exercise C4.1 — Build & connect a server
Context: The whole chapter comes together when you build a server, write a description Claude will actually trigger on, connect it, and vet it as if it were third-party — the full lifecycle in one pass.
Your task: Build a minimal MCP server with one tool (a search or lookup), write a triggering description (score it with tool_desc.py), connect it to Claude Code via config, confirm Claude calls it, then run vet_server as if reviewing a third-party server before connecting.
Requirements:
- Write a server with one
@mcp.tool()function - Give it a description that says when to use it, and score it until it passes
- Register it in
.claude/mcp.jsonpointing at the server script - Launch a session, ask something matching the when-to-use, and confirm Claude calls it
- Run the vetting checks (trusted source, audited network access, gated destructive tools) as a dry run
💡 Hint: If Claude won't call your tool, the description is almost always the culprit — score it first, then debug the wiring.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: MCP is the "USB-C of AI tooling": write a capability once as a server and any MCP-aware client can use it. It starts with registering a server in the client config.
Your task: Following section 3, write a .claude/mcp.json that registers one stdio MCP server named docs, launched by running python mcp/server.py, so Claude Code discovers its tools.
Requirements:
- An
mcpServersmap whose keydocsis the name you choose - A
commandandargsthat tell the client how to launch the server - A stdio server — client and server talk over standard in/out pipes
- Once saved, Claude discovers the server's tools automatically
- This is configuration, not executed code
💡 Hint: The config just tells the client how to start the server; tool discovery is automatic once the process is launched over stdio.
Show solution
// .claude/mcp.json (or via `claude mcp add`)
{
"mcpServers": {
"docs": {
"command": "python",
"args": ["mcp/server.py"] // a stdio server (your process)
}
}
}
// Claude now sees the server's tools and can call them in a session.
mcpServers lists the servers you register; the key "docs" is a name you choose. command plus args tell the client how to launch the server — it literally starts python mcp/server.py as a separate program. It is a stdio server, meaning client and server talk over standard in/out pipes, the simplest local transport. Once saved, Claude discovers the server's tools automatically; you don't wire up each tool by hand. You can do the same without editing the file via claude mcp add. This is configuration, not executed code.
Context: Building a server is a few lines with FastMCP, which handles the protocol plumbing. The one thing you must get right is the tool's description — it's how Claude decides when to call it.
Your task: Reproduce section 4: build a minimal MCP server named docs that exposes one search_docs tool over stdio using FastMCP.
Requirements:
- Create the server with
FastMCP("docs") - Expose the function with the
@mcp.tool()decorator - Write a docstring that says when to use the tool — MCP sends it to Claude as the description
- Use type hints so MCP knows the return type
- Start it with
mcp.run(transport="stdio"); needs an API key to drive from a client
💡 Hint: The docstring is not a comment here — it's the tool description Claude reads to decide whether this is the right tool for the request.
Show solution
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("docs")
@mcp.tool()
def search_docs(query: str) -> list[str]:
"""Search the internal knowledge base. Use for policy/product questions."""
corpus = {"refund": "Refunds within 30 days.", "hours": "Support 9-5 ET."}
return [v for k, v in corpus.items() if query.lower() in k]
if __name__ == "__main__":
mcp.run(transport="stdio")
FastMCP("docs") creates the server object and handles all the MCP protocol plumbing. The @mcp.tool() decorator exposes an ordinary Python function to Claude as a callable tool — without it the function stays invisible. The docstring is not just a comment: MCP sends it to Claude as the tool's description, and it names when to use the tool. mcp.run(transport="stdio") starts the server listening over the same stdio channel the config expects. Runs with pip install mcp; a server sits idle until a client connects.
Context: Claude chooses which tool to call almost entirely from its description, so a vague or terse one silently never gets called. A linter for descriptions catches that before it ships.
Your task: Reproduce section 5's tool_desc.py: write score_tool_desc(desc) that flags a description if it never says when to use the tool or is too terse, and test it on a bad and a good description.
Requirements:
- Flag the description if it lacks trigger words like "use", "when", or "for"
- Flag it if it's below a minimum length (too terse to match reliably)
- Return a pass with no problems, or a fail with the list of problems
- Show a bad description (e.g. "searches") failing with both reasons
- Show a good when-to-use description passing; pure Python, no server or API key
💡 Hint: A good description names the situations the tool is for — "searches" says what it does but never when Claude should reach for it.
Show solution
def score_tool_desc(desc):
d = desc.lower(); notes = []
if not any(w in d for w in ["use ", "when", "for"]): notes.append("say WHEN to use it")
if len(desc) < 25: notes.append("too terse to trigger reliably")
return (not notes), notes
print(score_tool_desc("searches"))
print(score_tool_desc("Search the internal KB. Use for policy/product/procedure questions."))
(False, ['say WHEN to use it', 'too terse to trigger reliably'])
(True, [])
Claude chooses which tool to call almost entirely from the tool's description, so a vague one never gets used at the right moment. The first check looks for trigger words like "when" or "for" — saying when-to-use is the single most important thing a description does. The second flags descriptions under 25 characters as too terse for Claude to match reliably. It returns True only when the notes list is empty, plus the notes to fix. This is runnable pure Python — no server or API key needed.
Context: An MCP server runs code on your machine with your permissions, so connecting one is a trust decision, not a casual install. A vetting checklist encodes the questions to ask first.
Your task: Reproduce section 6's vet_server.py: write a checklist that flags an untrusted source, network access without an audit, and a destructive delete tool with no human gate.
Requirements:
- Flag a server whose source isn't trusted
- Flag network access that hasn't been audited
- Flag a destructive tool (e.g.
delete) that has no human gate - Pass only when zero risks are found; otherwise return the list of concerns
- Show a clean search-only server passing and a sketchy one failing on all three; pure Python, no server needed
💡 Hint: Treat a third-party server like installing an app with your credentials — vet the source, review the tools, and prefer least privilege before you connect.
Show solution
def vet_server(server):
risks = []
if not server.get("trusted_source"): risks.append("untrusted source")
if server.get("network_access") and not server.get("audited"): risks.append("network access, unaudited")
if "delete" in server.get("tools", []) and not server.get("human_gate"): risks.append("destructive tool, no gate")
return (not risks), risks
print(vet_server({"trusted_source": True, "tools": ["search"], "audited": True}))
print(vet_server({"trusted_source": False, "network_access": True, "tools": ["delete"]}))
(True, [])
(False, ['untrusted source', 'network access, unaudited', 'destructive tool, no gate'])
An MCP server is real software that runs on your machine with your permissions — it can touch files, networks, and systems — so connecting one is a trust decision, not a casual install. This function turns that judgment into a repeatable checklist: unknown author, internet-reaching-but-unreviewed code, and a destructive action with no human approval step are all red flags. Treat a third-party server like installing an app: vet the source, review its tools, prefer least privilege, and keep the client's approval prompts on for anything state-changing. Runnable pure Python — no server needed.
Context: Not everything should be a server. The professional call is knowing when a plain function is simpler and when a server genuinely earns its keep — when a capability is shared across clients.
Your task: Per section 7, a colleague wants to wrap a one-off, single-client helper as an MCP server. Write a short decision rule (and a code sketch) for when a plain function is the right call and when a server earns its keep.
Requirements:
- Rule: reach for a server only when the capability is used by multiple clients or agents
- For a single client / one-off, a plain function is simpler — don't cargo-cult a server
- Sketch the condition (e.g. shared across ≥2 clients → server)
- Note the payoff: build once, every client plugs in
- Runnable illustrative Python; the substance is the design decision
💡 Hint: The server's value is reuse across clients — if there's exactly one caller, the protocol overhead buys you nothing.
Show solution
# Rule: reach for an MCP server only when a capability is used by
# MULTIPLE clients/agents. For one client, one-off, a plain
# function is simpler — don't cargo-cult a server.
def decide(capability):
if capability["num_clients"] >= 2 or capability["shared_across_agents"]:
return "MCP server: build once, every client plugs in"
return "plain function: one client, one-off — keep it simple"
print(decide({"num_clients": 1, "shared_across_agents": False}))
print(decide({"num_clients": 3, "shared_across_agents": True}))
plain function: one client, one-off — keep it simple
MCP server: build once, every client plugs in
MCP shines when a capability is used by multiple clients or agents: build the ticket-system server once and Claude Code, your app, and every subagent can use it — that is the "USB-C of AI tooling" payoff of one server instead of N integrations. But for a single-client, one-off tool a plain function is simpler; don't cargo-cult a server where a function does. This is runnable illustrative Python; the real leverage is the design decision.
Context: A lead ships an internal MCP server like shared API infrastructure — versioned, documented, access-controlled, monitored, human-gated — so it becomes reusable org-wide leverage.
Your task: Per section 8, describe how a lead should ship an internal MCP server so it becomes reusable org infrastructure, and capture the standards as a checklist a team can enforce.
Requirements:
- Versioned (semver; clients pin a version)
- Documented (every tool's docstring says when to use it)
- Access-controlled (least privilege; auth on state-changing tools)
- Monitored (log tool calls; alert on failures/abuse)
- Human-gated (approval prompts stay ON for destructive tools)
- Runnable illustrative Python; the substance is the standards checklist
💡 Hint: Least privilege plus approval prompts on state-changing tools are the non-negotiables — the MCP-as-trust-boundary lesson applied to infrastructure you own.
Show solution
# A lead treats an internal MCP server like a shared API.
SHARED_SERVER_STANDARDS = {
"versioned": "semver the server; clients pin a version",
"documented": "each tool's docstring says WHEN to use it",
"access_controlled": "least-privilege; auth on state-changing tools",
"monitored": "log tool calls; alert on failures/abuse",
"human_gated": "approval prompts stay ON for destructive tools",
}
def ready_to_share(server):
missing = [k for k in SHARED_SERVER_STANDARDS if not server.get(k)]
return (not missing), missing
print(ready_to_share({k: True for k in SHARED_SERVER_STANDARDS}))
print(ready_to_share({"versioned": True, "documented": True}))
(True, [])
(False, ['access_controlled', 'monitored', 'human_gated'])
A lead treats internal MCP servers like shared APIs: versioned, documented, access-controlled, and monitored. One well-built, trusted server becomes the tool layer the whole org builds on — every agent and engineer gains that capability with a single config line, the same leverage as a shared client library. This ties back to MCP as a trust boundary: least privilege and keeping approval prompts on for anything state-changing are non-negotiable. Runnable illustrative Python; the substance is the standards checklist.
✓ Checkpoint — you can move on when you can…
- Explain MCP and its three primitives.
- Connect Claude to a server and build a minimal one.
- Write a triggering tool description.
- Vet a server's security; standardize servers for a team.
Knowledge check check yourself
MCP is described as the "USB-C of AI tooling" — what problem does that standardization solve?
Show answer
What are MCP's three primitives, and what is each one?