Building a Server: Tools, Resources & Prompts
The official Python SDK's FastMCP style makes a server almost embarrassingly small: decorate functions and run. The real content of this lesson is the three primitives — tools, resources, prompts — and the question that distinguishes them: who invokes each?
MCP servers expose three kinds of capability, and the cleanest way to keep them straight is by who decides to use them. Tools are model-controlled: the model reads the schema and decides mid-task to call one — actions and lookups. Resources are application-controlled: the host decides which to read and attach to context — files, records, reference data the app surfaces. Prompts are user-controlled: templates a human explicitly picks (think slash commands) that expand into structured messages. Same server, three different invokers.
| Primitive | Invoked by | Nature | Example |
|---|---|---|---|
| Tool | The model, mid-task | Action or query with side effects allowed | search_orders(query, status), create_ticket(...) |
| Resource | The application/host | Read-only data identified by URI | orders://recent, file:///docs/policy.md |
| Prompt | The user, explicitly | Reusable message template with arguments | /weekly-report customer=acme |
# Colab cell 1 — run once. Installs the official 'mcp' SDK. No external
# API needed: the tool queries an in-memory store so you can see it run.
!pip install -q mcp
import os
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders-server")
# Server-side secret: read from the environment, used only inside tool
# bodies, and NEVER placed in a schema, description, or response — the
# model never sees it. A real server passes this to httpx; here we query
# an in-memory store instead so the cell runs offline.
API_KEY = os.environ.get("ORDERS_API_KEY", "dev-key-not-real")
ORDERS = [
{"id": "A1001", "customer": "Acme", "status": "open", "total": 250, "note": "late delivery reported"},
{"id": "A1002", "customer": "Globex", "status": "shipped", "total": 80, "note": "on time"},
{"id": "A1003", "customer": "Initech", "status": "open", "total": 420, "note": "late delivery, wants refund"},
{"id": "A1004", "customer": "Umbrella", "status": "cancelled", "total": 15, "note": "duplicate order"},
]
@mcp.tool()
def search_orders(query: str, status: str = "open", limit: int = 10) -> str:
"""Search customer orders by free-text query and status.
Returns up to 'limit' order summaries (id, customer, status, total).
Use for questions about order history or finding a specific order.
Do NOT use for refunds or edits -- this tool is read-only.
"""
if status not in {"open", "shipped", "cancelled"}:
# instructive error the model can act on -- not a stack trace
return (f"Error: unknown status {status!r}. Valid values: "
"open, shipped, cancelled.")
q = query.lower()
hits = [o for o in ORDERS
if o["status"] == status and q in o["note"].lower()][:limit]
lines = [f"{o['id']} | {o['customer']} | {o['status']} | {o['total']}"
for o in hits]
return "\n".join(lines) if lines else "No orders matched."
# In production, mcp.run() serves the server over stdio and a host spawns it;
# in Colab that would block forever. Because @mcp.tool() leaves the function
# a normal callable, we invoke it directly to see the EXACT text the model
# would receive as the tool result:
print(search_orders("late delivery", status="open"))
print("--- unknown status ---")
print(search_orders("late delivery", status="pending"))ORDERS for an httpx call using API_KEY and nothing else changes.# Colab cell 2 — run cell 1 first (it defines mcp and ORDERS).
def fetch_recent(customer_id: str) -> str: # stub for your real API call
hits = [o for o in ORDERS if o["customer"].lower() == customer_id.lower()]
return "\n".join(f"{o['id']} | {o['status']} | {o['total']}"
for o in hits) or f"No orders for {customer_id!r}."
@mcp.resource("orders://status-codes")
def status_codes() -> str:
"""Reference: every order status code and its meaning."""
return ("open: placed, not yet shipped\n"
"shipped: with carrier\n"
"cancelled: cancelled before shipment")
@mcp.resource("orders://recent/{customer_id}")
def recent_orders(customer_id: str) -> str:
"""The 5 most recent orders for one customer, as a summary."""
return fetch_recent(customer_id) # your API call
@mcp.prompt()
def order_investigation(order_id: str) -> str:
"""Template for investigating a problematic order end to end."""
return (f"Investigate order {order_id}. Steps: (1) fetch the order "
f"and its status history; (2) check shipping events; "
f"(3) summarize what went wrong and draft a customer reply. "
f"Cite specific timestamps.")
# each primitive is a normal callable too — see what each returns:
print("[resource] status_codes:\n" + status_codes())
print("\n[resource] recent_orders('Acme'):\n" + recent_orders("Acme"))
print("\n[prompt] order_investigation('A1003'):\n" + order_investigation("A1003"))orders://recent/{customer_id} parameterize them — the host picks which to attach to context. The prompt becomes a user-facing command in clients that support it: a support engineer picks 'order_investigation', supplies the ID, and gets a consistent, well-engineered starting prompt instead of freestyling one. The demo calls all three directly so you can read exactly what each produces; in a real host the invoker differs per primitive (model, application, user), but the returned content is what you see here. Ask yourself for each capability: who should decide this gets used? That answer picks the primitive.Connecting it to a real client
- Claude Desktop: add your server to
claude_desktop_config.jsonundermcpServers— a name, acommand(e.g.python), andargs(the path to server.py, or useuv runwith your project). Restart the app; the tool icon appears. - Claude Code: register with
claude mcp add orders -- python /abs/path/server.py(stdio), then verify with/mcpin a session. - Debugging: the MCP Inspector (an official dev tool) connects to your server and lets you list and call tools interactively — use it before blaming the client. And remember: with stdio, stdout belongs to the protocol — a stray
print()corrupts framing. Log to stderr.
logging configured to stderr, or print to sys.stderr. If your server works in the Inspector but not in Claude Desktop, check for stdout pollution first.Choosing wrong: a design smell audit
When a 'tool' is really reference data the model must remember to call — a get_status_codes() tool added so the model can 'look up' values — that's the resource primitive's job, and using a tool for it costs a wasted round trip plus a real chance the model just guesses the values from training data rather than calling the tool at all. The symmetric mistake runs the other way: exposing an action (e.g. 'restart the service') as a resource nobody would ever proactively attach to context is invisible to the model and simply never fires, because nothing in the resource model lets the model decide to trigger it. The diagnostic question from the summary — who decides this gets used? — has a companion worth asking for every capability: what happens if the model never calls it? If the answer is 'the task silently degrades to guessing,' it was probably reference data mis-modeled as a tool.
get_company_holidays() as a tool ('call this to see the holiday calendar') and create_ticket(title, description) as a resource template (tickets://create/{title}), reasoning 'resources feel more RESTful for both.' Two weeks in, users report the agent frequently answers holiday questions wrong, and never actually files tickets even when asked to. What's broken, and how do you fix it?Whiteboard drills
data://everything, which returns the company's entire product catalog as raw JSON — 40,000 items. What's wrong, and what would you ask the author to change?"order_investigation(order_id), works great in Claude Desktop's slash-command UI. A teammate asks why you didn't just make this a tool instead — 'then the model could invoke it automatically when relevant.' Defend the choice."- ▸Tools are model-invoked; resources are application-invoked; prompts are user-invoked. That's quiz material and a design compass.
- ▸Wrong-primitive smell: reference data modeled as a tool gets skipped and guessed instead of called; an action modeled as a resource never fires because nothing invokes it.
- ▸Whatever a resource returns still occupies context and costs tokens on every subsequent turn — resources need response budgeting too, not just tools.
- ▸FastMCP:
@mcp.tool()on a typed, docstringed function → name, schema, description generated for you. - ▸Docstrings are prompts — purpose, output shape, limits, and when NOT to use the tool.
- ▸Credentials come from env vars into the server process; the model never sees them.
- ▸Return errors as instructive text the model can act on, not exceptions.
- ▸On stdio, stdout is the wire: log to stderr, never print().