PRACTICE · 60 minutes
Build a small Model Context Protocol server that gives a support-triage assistant a knowledge base and a routing tool, using the real Python MCP SDK.
When I built my first internal support dashboard, the hardest part was never the model. It was wiring the model up to the things it actually needed: the notes my team kept on common problems, and the queues a ticket had to land in. A model on its own can write a friendly reply, but it cannot read your knowledge base or move a ticket — not unless you give it a clean, safe way to reach those things.
That clean, safe way is the Model Context Protocol (MCP). In this module I will build the server side of the de-identified support-triage assistant we have been developing across this path. The assistant takes an incoming customer message, looks up what we already know, and decides where the ticket should go. The MCP server is what lets it do the looking-up and the routing without me hand-coding a brittle bridge every time.
Think of an MCP server like the service window at a busy kitchen. The cooks (your data and actions) stay in the back where it is safe. The window exposes exactly two things to the outside: a menu you can read, and a bell you can ring to place an order. The assistant never wanders into the kitchen — it only uses the window. That boundary is the whole point.
Key insight: An MCP server does not make your assistant smarter. It makes your assistant capable — it is the controlled doorway between a language model and your real systems.
By the end of this module you will have a running server the assistant can connect to, exposing one knowledge-base lookup and one routing action.
MCP draws one line that beginners stumble over, so let me make it concrete before any code. A server offers two kinds of things, and they are not interchangeable.
A resource is something the assistant can read. It has no side effects. Asking for a resource twice changes nothing. In our triage assistant, the support knowledge base is a resource — the assistant reads the note for "export-failing" the same way you read a label on a shelf.
A tool is something the assistant can do. It has side effects, or at least makes a decision that matters. Routing a ticket to the billing queue is a tool — calling it is an action, not a lookup.
Here is the mental model I keep: a resource is a read-only shelf; a tool is a power outlet. You glance at the shelf as often as you like. You plug into the outlet deliberately, because something happens when you do.
Common pitfall: Beginners expose everything as a tool, including read-only lookups. That hands the model the ability to act when all it needed was the ability to look. Keep reads as resources — it is safer and easier to reason about.
Every MCP server also has a third piece — the server core that speaks the protocol — but the SDK gives you that for free. Your job is to declare the resources and tools.
Before writing code, I always write the contract in plain English. For the triage server it is short:
export-failing, return the note my team wrote about it. Read-only.Notice what is not here. The server does not call a language model, and it does not try to be clever. The intelligence — deciding when to look something up and when to route — lives in the assistant (the agent we build in the next module). The server just offers honest, predictable capabilities. Keeping that split clean is what makes the whole system debuggable later.
Why this matters: A deterministic server you can fully predict is a feature, not a limitation. When the assistant misbehaves, you want to know instantly whether the bug is in the model's judgment or in the tool's behavior. Mixing the two is how you end up unable to debug anything.
The Python MCP SDK ships a high-level interface called FastMCP that handles the protocol plumbing. Install it first:
Now the smallest possible server. This does nothing useful yet — it just starts up and speaks the protocol — but it is real and it runs:
FastMCP("support-triage") creates the server and names it. mcp.run() starts it and, by default, talks over standard input and output — which is exactly how a local assistant connects to it. Everything we add next hangs off that mcp object.
Now we give the server something to offer. First the resource — the read-only knowledge base. We attach it with the @mcp.resource(...) decorator, and the {topic} placeholder in the URI becomes the function's argument:
When the assistant reads kb://export-failing, the SDK calls support_note("export-failing") and hands back the string. No side effects, no decisions — a pure lookup, exactly what a resource should be.
Now the tool — the routing action. We use @mcp.tool(), and because it is a tool, the assistant calls it on purpose to make something happen:
This tool is deliberately simple keyword matching — and that is fine. It is a deterministic helper, not the brains of the operation. The assistant in the next module decides whether to call route_ticket at all; the tool just gives a fast, predictable answer when asked.
Common pitfall: Do not reach for a language model inside a tool like this. A routing tool that quietly calls an LLM is slow, costs money on every ticket, and is hard to test. Keep tools cheap and predictable; let the agent supply the judgment.
Before moving on, predict the answer: if the assistant needs to read a customer's past order history, should you expose it as a resource or a tool? Why? (Hint: does reading order history change anything?) Reasoning through that distinction is the skill this whole module is teaching.
The SDK ships an inspector that launches your server and lets you poke at its resources and tools by hand. Point it at your file:
That opens a local UI where you can read kb://export-failing and call route_ticket with a sample message — the fastest way to confirm the server behaves before connecting a real assistant.
For a quick sanity check in code, remember that the decorated functions are still ordinary Python. You can call them directly:
Good pattern: Test the plain functions in isolation first, then test them through the inspector, then connect the assistant. Each layer rules out a class of bug, so when something breaks you know exactly where to look.
When I built the real support-automation version of this, that layered habit is what kept me sane — I never had to wonder whether a failure was the model's fault or the server's, because I had already proven the server worked on its own.
Application-level questions on MCP servers, resources, and tools.
1. The assistant needs to read a customer's past order history. Should that be exposed as a resource or a tool?
Correct Answer: B
Reading order history is a pure lookup with no side effects, so it belongs as a resource (the read-only shelf), not a tool (the power outlet).
2. Why does the module keep route_ticket as simple keyword matching instead of calling a language model inside it?
Correct Answer: C
Tools should be cheap and predictable so failures are easy to localize. The agent supplies the judgment about when to call the tool; the tool just answers fast.
3. Given the routing tool in the module, what does route_ticket("I want a refund for last month's charge") return?
Correct Answer: C
The message contains both 'refund' and 'charge', so the first branch sets queue to 'billing', and needs_human is True because queue == 'billing'.
4. What does mcp.run() do by default in a FastMCP server?
Correct Answer: B
By default FastMCP communicates over stdio, which is exactly how a local assistant connects to the server.
5. An MCP server has resources, tools, and a third piece. What is it, and who writes it?
Correct Answer: C
The server core handles the protocol plumbing. FastMCP gives it to you, so your job is only to declare the resources and tools.
6. What does support_note("billing-dispute") return, given the knowledge base in the module?
Correct Answer: B
billing-dispute is not a key in NOTES, so the .get() fallback returns the not-found message instead of raising.