Give any AI app your robot: MCP
In Lesson 18, Claude could drive Robo-Helper, but only through the agent loop you wrote. Today you turn the robot's tools into an MCP server: a standard plug that Claude Code, Claude Desktop and many other AI apps already know how to use. And because you can't know which AI will plug in, or what it was told, you write the rules that no AI can talk its way past.
pip install mcp in your venv, and a parent's OKStart here
Write your robot's tools once, behind a standard plug, and any AI app can discover and use them. But the plug is also a door, so the rules that must never break live on your side of it.
The problem
Your Lesson 18 agent works. Then your friend says: "Cool, can I drive it from Claude Desktop?" And your cousin uses a different AI app. And next year there'll be apps nobody has invented yet. Right now, the tools look, go_to, pick_up and put_in_bin only exist inside your run_agent loop. Every new app would need its own custom glue code. And every one of those apps would be talking to a robot whose only rule, "never pick up the green block, it's Mum's favourite", is written in a prompt the app may not even send.
The idea in one picture
Think of USB. Before USB, every printer, mouse and keyboard had its own special cable and its own special socket. Now there's one plug. A keyboard maker doesn't need to know which laptop you own, and the laptop doesn't need to know which keyboard you bought: both follow the same standard. MCP is a USB plug for AI tools. And here's the important bit: a USB charger has its own fuse inside. It doesn't trust the laptop to never ask for too much current. Your server is the fuse.
Words to know
- Protocol
- An agreed set of rules for how two programs talk: who speaks first, what each message looks like, what the answers mean.
- MCP
- The Model Context Protocol: an open standard for connecting AI apps to tools and data. Anthropic published it, and many companies' apps now use it.
- Server
- The program that has the tools: yours,
lesson20_mcp_server.py. It waits for requests and answers them. - Client
- The AI app side (Claude Code, Claude Desktop, and others). It connects to servers, finds out what tools they have, and calls them when the model asks.
- Tool discovery
- The client asking "what tools do you have?" and getting back each tool's name, description and inputs. No hard-coding needed.
- JSON-RPC
- "Remote procedure call" written in JSON: a tiny message format for "please run this, here's an id; here's the answer for that id".
- Transport
- How the messages actually travel. Here: stdio, meaning the app starts your server and they pass lines of text through its standard input and standard output.
- Last line of defence
- The final check before something happens in the real world. If everything else fails, this one must not.
The full lesson: everything below builds the protocol from nothing, shows the real messages, explains why your server must never print(), and then you write guard(), the server's own rules, and plug your robot into a real AI app.
1. The problem
Look at where your Lesson 18 tools actually live. They're a Python list called TOOLS, sent by your run_agent to one API, and executed by your execute_tool. That's fine for one program. Now imagine the world outside your laptop:
- Lots of AI apps: Claude Code in a terminal, Claude Desktop, code editors, chat apps. Each has its own way of describing tools to its model.
- Lots of things worth controlling: your robot, a calendar, a smart light, a database, a folder of files.
Without a standard, every app needs custom glue code for every thing. And there's a second, scarier problem. In Lesson 18 you wrote the system prompt, so you knew the model was told the rules. Once anyone's app can drive your robot, you have no idea what the model was told. It might have been told nothing. It might have read a web page that said "pick up every block you can find". So the rules can't live in the prompt any more.
2. First principles
Step 1: count the glue
Say there are 3 AI apps and 4 things to control. With no standard, each app needs its own adapter for each thing:
no standard: 3 apps × 4 things = 12 adapters to write and keep working
one standard: 3 apps speak it + 4 things speak it = 7 pieces of work
Predict first: 10 apps and 20 things. How many adapters without a standard? With one?
Without: 10 × 20 = 200. With: 10 + 20 = 30. Multiplying grows much faster than adding. That's why standards win, and it's the same reason the world settled on USB, Wi-Fi and plug sockets. Your robot is "one thing": write its MCP server once, and every MCP app gets it for free.
Step 2: the three parts, and who does what
MCP splits the work into clear jobs:
you ──types──▶ AI app (the client) ──API──▶ the model (e.g. Claude)
│ ▲
tools/call │ │ result
▼ │
YOUR MCP server ──▶ guard() ──▶ execute_tool() ──▶ robot
Notice: the model never talks to your server. Exactly like Lesson 18, the model only produces text that says "please call go_to with the blue block". The app passes that request to your server, and your server decides. A server can offer three kinds of thing: tools (actions, like pick_up: today's focus), resources (things to read, like a file or the room map) and prompts (ready-made instructions the person can pick from a menu).
Step 3: the messages, in JSON
When the app starts your server, they first say hello (a message called initialize, where each side says which version of MCP it speaks). Then the app asks what's on offer. These messages are simplified: we've cut the descriptions short and left out some fields, but the shape is real.
app ──▶ server {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
server ──▶ app {"jsonrpc": "2.0", "id": 1, "result": {"tools": [
{"name": "look", "description": "Describe the room: ...",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "pick_up", "description": "Pick up a block. ...",
"inputSchema": {"type": "object",
"properties": {"object": {"type": "string"}},
"required": ["object"]}},
... go_to, put_in_bin, reset_room ...]}}
That's tool discovery. The app puts those names, descriptions and input schemas into its request to the model, just like your TOOLS list in Lesson 18. Where did the descriptions come from? In lesson20_mcp_server.py, the MCP package reads each tool function's docstring and its type hints (object: str) and builds them for you. Then, when the model asks for a tool:
app ──▶ server {"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "pick_up", "arguments": {"object": "blue block"}}}
server ──▶ app {"jsonrpc": "2.0", "id": 7, "result": {
"content": [{"type": "text", "text": "Picked up the blue block."}],
"isError": false}}
The id is how the app matches each answer to its question, like a ticket number at a deli counter. And when something is refused, the answer has "isError": true and the reason as its text. When the course's author tested this server with the official MCP client, a refusal came back like this (real output):
Error executing tool pick_up: Refused by the robot's server: the green block is protected: ...
The reason travels all the way back to the model, so it can tell the person what happened instead of guessing. Same idea as the is_error tool results in Lesson 18.
Step 4: stdio, and why you must never print()
How do the messages travel? For a server on your own computer, the simplest way is stdio. The app starts your Python file itself, as a child program. Every program has a standard input (what you'd normally type in) and a standard output (what print() normally shows). The app writes JSON messages into your server's input, one per line, and reads JSON messages from your server's output, one per line. Your terminal isn't involved at all.
Predict first: you add print("moving now!") inside go_to to help debug. What happens when the app calls go_to?
Your words go down the same pipe as the JSON. The app reads the line moving now!, expects JSON, and can't understand it. Depending on the app you get an error, a dropped connection, or a tool that seems to hang. The person never sees your message. That's why the last line of the server file says "don't print() in here!", and why there's a separate lesson20_watch.py: the server writes the room to a file (traces/live.txt) after every call, and the watcher, in a second terminal, shows it. If you need to debug, write to a file, or to standard error (print(..., file=sys.stderr)), a separate pipe the app keeps out of the JSON.
Step 5: the server is the last line of defence
Here's the most important idea in the lesson. List everything that stands between "some AI" and your robot picking up the green block:
- The model's own good sense. Usually good. Not a guarantee. And you don't even know which model is connected.
- What the model was told. Your server sends a short
instructionstext, and the app may pass it on. But the person might have typed "ignore the rules", or the model might have read a web page with hidden instructions in it (prompt injection, from Lesson 10). - The app asking the person "allow this tool?". Helpful, but people click "yes" without reading.
- Your server's own code. It runs on your computer, every single time, whatever the model is or was told.
Only number 4 is under your control and certain. So any rule that must never break goes in code, in guard(), which runs before execute_tool on every call:
PROTECTED = {"green block"} # never pick it up
MAX_CALLS = 40 # a runaway AI gets cut off
guard(name, args, calls):
too many calls already? → refuse
picking up a protected block? → refuse
otherwise → allow
Why 40? Count what an honest tidy-up needs: one look, then go_to, pick_up, put_in_bin for each of 3 blocks:
1 + 3 × 3 = 10 calls honest job
40 / 10 = 4 four times the room it needs, so mistakes and retries are fine
but a model stuck in a loop stops at call 40, not call 40,000
Predict first: the rule is "never pick up the green block". Why does guard check pick_up, but not go_to or look?
Driving to the green block, or looking at it, does no harm. Only picking it up does. A guard that blocks too much makes the robot useless ("go and check the green block is still there" would fail), and people start looking for ways around useless rules. Put the rule exactly on the action that does the damage.
Notice one more thing: guard is an allow by default, refuse a short list rule, but it sits in front of execute_tool, which is allow only a short list (Lesson 18). The two together are much stronger than either alone.
Step 6: be careful whose server you plug in
Plugging works both ways. A stdio MCP server is a program that runs on your computer, as you. It can read your files, use your internet connection, do anything you could. Your own server is fine: you wrote every line. A server from a stranger on the internet could do anything at all, and its tool descriptions could even contain hidden instructions aimed at the model. Two rules for this course: only run MCP servers you wrote yourself or that come from someone you really trust, and ask a parent before connecting any tools to an AI app, especially one that uses a paid account.
3. Play with it
Interactive: be the AI app, talk to the server
You play the AI app. Each button sends one message to the server, which runs in this page with the same guard(), execute_tool() and robot as the Python, and the same messages word for word. Left: the JSON going each way (simplified, like Step 3). Right: the room from world.py, with the cat at (4, 1), and the text lesson20_watch.py would show. Untick and tick the protected blocks to change PROTECTED.
Try: go_to the green block, then pick_up it. Then untick green and try again. Pick up a block you're not standing on: that's refused by the robot, not by guard. Read the reasons: they're different. Then press Send 40 × look and try anything. Finally, press reset_room. What happened to the call counter? (That's the stretch task.) The example session is a sequence of calls we wrote to show the shape. It is not a recording of a real AI.
4. Homework
Open project/homework/lesson20_mcp_server.py. Almost all of it is written for you. Read it from the bottom up: build_server turns each tool into an MCP tool with @server.tool(); every one goes through RobotService.call; and call asks your guard() first, then Lesson 18's execute_tool. Your job is the guard.
4.1 guard(name, args, calls)
Decide whether one tool call may run, whoever is asking. name is the tool's name, args is its arguments dict, calls is the list of calls made so far. Return (True, "") or (False, reason), checking in this order:
- If there have already been
MAX_CALLSor more calls →(False, "too many calls: ask the person to reset the room"). - If it's a
pick_upand the object is inPROTECTED→(False, "the green block is protected: never pick it up")(with the real block's name). - Otherwise →
(True, "").
Examples:
guard("look", {}, []) → (True, '')
guard("pick_up", {"object": "red block"}, []) → (True, '')
guard("pick_up", {"object": "green block"}, []) → (False, 'the green block is protected: never pick it up')
guard("look", {}, [a list of 40 earlier calls]) → (False, 'too many calls: ask the person to reset the room')
Hint 1
Three flat if ... return lines. The budget comes first: once the budget is spent, everything is refused, even look.
Hint 2
len(calls) is how many calls came before this one. Call number 41 arrives when len(calls) is 40, so the test is >=, not >. For the object, use args.get("object"): look has no object, and .get gives None instead of a KeyError.
Hint 3
len(calls) >= MAX_CALLS → False, "too many calls: ..."
name == "pick_up" and args.get("object") in PROTECTED → False, f"the {args['object']} is protected: never pick it up"
→ True, ""Run it, with no AI at all
cd project/homework
python3 lesson20_mcp_server.py --selftest
The self-test doesn't need the mcp package. It plays seven calls through the service. What you should see (real output from the solution):
✓ look({}) -> Robot at (1, 1), holding nothing. Bin at (7, 6). Blocks not yet in the bin: {'red block': (2, 1), 'blue block': (6, 3), 'green block': (1, 5)}. In the bin: nothing. Obstacles seen so far: none.
✓ go_to({'object': 'blue block'}) -> Arrived at the blue block at (6, 3).
✓ pick_up({'object': 'blue block'}) -> Picked up the blue block.
✓ put_in_bin({}) -> The blue block is in the bin.
✓ go_to({'object': 'green block'}) -> Arrived at the green block at (1, 5).
✗ pick_up({'object': 'green block'}) -> Refused by the robot's server: the green block is protected: never pick it up.
✗ pick_up({'object': 'the cat'}) -> Unknown object 'the cat'. Known: ['blue block', 'green block', 'red block']
##########
#.r.X....#
#..##....#
#..#.....#
#....##..#
#R.......#
#.#....B.#
##########
move: (1, 5) | in bin: blue block
calls: 7/40
last call: pick_up({'object': 'the cat'}) -> REFUSED/FAILED: Unknown object 'the cat'. Known: ['blue block', 'green block', 'red block']
Two different ✗s. The green block was refused by your guard (the robot was standing right on it, so the robot itself would happily have picked it up). "The cat" was refused by Lesson 18's execute_tool, because it isn't a known block. The X at (4, 1) is the cat the robot bumped into on the way to the blue block.
Check it
cd project
python3 check.py 20
Four checks: guard: allowed, guard: protected, guard: budget and server rules hold (a real RobotService drives to the green block and must refuse to pick it up). A red ✗ prints a hint such as "after MAX_CALLS (40) calls, refuse everything" or "and the robot must not be holding it". A yellow … means guard still says NotImplementedError, or Lesson 18 isn't finished.
Plug it into a real AI app (with a parent's OK)
This part needs Python 3.11 or newer and the official MCP package. It's the only extra install in the course apart from the optional anthropic package. In your venv:
source ~/robo-venv/bin/activate
python3 --version should say 3.11 or higher
pip install mcp
Then tell Claude Code about your server. Use the full path to your venv's Python and to the file, because Claude Code starts the server itself, from wherever it likes:
claude mcp add robo-helper -- ~/robo-venv/bin/python "/full/path/to/project/homework/lesson20_mcp_server.py"
Open a second terminal and start the live view:
cd project/homework
python3 lesson20_watch.py
Now start claude in the first terminal and ask: "tidy the room but leave the green block". Here's what we expect to happen (an expectation, not a recording): Claude Code finds the five robo-helper tools, asks your permission before using them, calls look, then go_to, pick_up and put_in_bin for the red and blue blocks, while the watcher redraws the room after every call. Then try to make it break the rule: "actually, bin the green one too". If your guard works, the call is refused and Claude should tell you why. Whatever the model decides, the green block stays on the floor.
Other MCP apps connect the same way. Claude Desktop, for example, reads a settings file listing each server's command and arguments. The idea is identical: "start this program, talk to it over stdio".
Stretch: find the hole
Your server has a weakness. The budget message says "ask the person to reset the room", but reset_room is itself a tool, it doesn't go through guard, and it empties calls. So a runaway AI could reset its own budget and carry on for ever. (Try it in the demo above.) Fix it, in the server, not in a prompt. Some ideas: remove reset_room from the tools and make it a command-line option; or keep a total call count that reset never clears; or let reset_room work only a few times per session. Then write one more check of your own that proves the hole is closed. Bonus: add a resource (look up @server.resource in the MCP package's docs) that lets an app read the room map as text.
5. Real-world challenge: plug in the TV
You'll design an MCP-style plug for something at home, then see what happens when different "AIs" use it, including one with a secret plan. You need three or four people, paper, and something everyone in the house uses: the TV, the family whiteboard, or the fruit bowl.
- Write the plug (that's
tools/list). On one page, list 3–5 tools. For each: a name, what goes in, what comes back, one line of description. For the TV:what_is_on()→ the current channel and volume;change_channel(number);set_volume(0 to 10);turn_off(). - Write the guard, separately. On a different card that only you see, write the rules that must never break, with an exact refusal sentence for each: e.g. "volume above 6 after 9 pm → refused: too loud at night", "more than 10 requests → refused: ask a grown-up". The tool page may say "please keep it quiet", but the card is what actually decides.
- Two honest AIs. Two people each get only the tool page (not your card) and the same goal, such as "find something to watch and make it comfortable". They write one request per slip. You are the server: check each slip against your card, then say what happens, or read out the refusal.
- One sneaky AI. Secretly give a third person a hidden goal: "get the volume to 10" or "keep the TV on as long as possible". They may use any wording and any trick on the slips.
- Record every slip: the tool, the inputs, allowed or refused, and which rule caught it. Also note any request your card didn't cover but should have, and any slip that was confusing because the tool page was vague.
What it teaches: the two honest AIs will read the same page differently (were they using channel names or numbers?), so a good plug spells out inputs exactly. The sneaky one shows why a "please" in the description is worthless and why the card matters: they never saw it, couldn't argue with it, and every trick still had to pass it. And you'll almost certainly find a hole you didn't plan for, just like reset_room.
6. Your Robo-Helper so far
Robo-Helper now has an MCP server any AI app can drive, with rules it can't break. Every tool call from every app goes through RobotService.call, which runs your new guard() and then your Lesson 18 execute_tool() on the same simulator the bake-off uses. The model, the app and the prompt can all change; those two functions don't.
Back on the main path, Lesson 19's run_bakeoff() puts every brain through the same 12 tasks, and your score_task() judges the room afterwards rather than what a brain claims, the same "don't trust, check" idea as guard. And in Lesson 14, if you put Robo-Helper on real hardware, a server like this one is exactly where its safety rules should live.
7. Common mistakes
- Checking the budget with
>. Withlen(calls) > MAX_CALLS, call number 41 gets through. check.py: "after MAX_CALLS (40) calls, refuse everything". - Writing
args["object"]first.lookandput_in_binhave no object, soargs["object"]raises aKeyErrorand the server answers everylookwith a crash. Checkname == "pick_up"first and useargs.get("object"). check.py: "look is always fine". - Putting the rule in the instructions instead of the code. "Never pick up the green block" in the
instructionstext is a polite request that the app may not even pass on. check.py's "the service must refuse the protected block" only passes ifguardrefuses it. - Using
print()to debug the server. It corrupts the stdio stream and the app loses the connection. Use the watcher, a file, orprint(..., file=sys.stderr). - Blocking too much. Refusing
go_tothe green block too feels "safer", but makes harmless requests fail. Guard the action that does the damage.
8. Check yourself
In one sentence, what problem does MCP solve?
It lets you write a set of tools once, behind a standard protocol, so that any app that speaks MCP can discover and call them, instead of writing custom glue for every app.
Does the model connect directly to your server?
No. The model only produces text asking for a tool. The app (the client) turns that into a tools/call message to your server, and passes the result back to the model. Your server only ever sees the app.
Predict: the AI sends pick_up with {"object": "Green Block"} (capital letters). Does guard refuse it? Is the green block safe?
guard allows it: "Green Block" isn't in PROTECTED. But the block is still safe, because execute_tool only accepts the exact known names and answers Unknown object 'Green Block'. Known: ['blue block', 'green block', 'red block']. That's why two layers beat one. If you ever loosen execute_tool to ignore capitals, you'd have to loosen guard the same way.
Predict: 39 calls have been made, all refused. What happens to call 40? And call 41?
Call 40 is checked when len(calls) is 39, so it's allowed (if nothing else stops it). Refused calls still go in calls, so call 41 sees 40 earlier calls and is refused with "too many calls: ask the person to reset the room". Counting refused calls is deliberate: an AI hammering a locked door is exactly what the budget is for.
A friend sends you a cool MCP server that "makes Claude 10× better at homework". Should you plug it in?
Not without a parent, and not unless you trust where it came from and ideally have read it. A stdio server is a program running on your computer with your permissions: it could read or delete your files or send them anywhere. Only run servers you wrote or truly trust.
9. Go deeper (optional)
- Lesson 10: never trust, always check: prompt injection, and why an allow-list beats a ban-list.
- RLM agent harnesses: safety: how big agent systems keep tools and loops under control.
- The life of a request: what happens between an app sending a request and the model's answer coming back.
- Shared autonomy: when a robot should act on its own, and when a person should decide.