add(a, b) proved the wire works. Now make it useful. Any function with type hints becomes a tool with the same decorator, so we wire three at once. Then we expose a Resource, which is context the agent can read rather than an action it can take. The catch the rest of the room will miss: a tool description is the agent's only signal for when to call it. Verbose is fine. Ambiguous is fatal. We will watch a small model fail on a bad schema, then fix it in one line.
Same decorator, three times. These are deliberately the shape of functions you already have: a lookup, a search, a write. Save as tools_server.py.
from fastmcp import FastMCP
mcp = FastMCP("toolsmith-byo")
# Stand-in for your real data layer. Swap for a DB, an API client, anything.
NOTES = {
"ray": ["ships on Vercel", "codes on Windows", "prefers pnpm"],
"mich": ["runs the security beat", "loves a hard demo"],
}
@mcp.tool
def get_notes(user_id: str) -> list[str]:
"""Return all stored notes for one user, by their user_id."""
return NOTES.get(user_id, [])
@mcp.tool
def search_notes(query: str) -> list[str]:
"""Find notes across all users whose text contains the query string."""
hits = []
for user, notes in NOTES.items():
for note in notes:
if query.lower() in note.lower():
hits.append(user + ": " + note)
return hits
@mcp.tool
def add_note(user_id: str, note: str) -> str:
"""Store a new note for a user. Returns a short confirmation string."""
NOTES.setdefault(user_id, []).append(note)
return "stored note for " + user_id + " (now " + str(len(NOTES[user_id])) + " total)"
if __name__ == "__main__":
import os
if os.environ.get("MCP_HTTP"):
# production transport ("streamable-http" is an accepted alias)
mcp.run(transport="http", host="127.0.0.1", port=8000)
else:
mcp.run() # stdio, which is all we need to inspect the schemas
Three functions, three decorators. The agent now sees three tools, each with a schema inferred from its signature and a description taken straight from its docstring. We default to stdio here because inspecting tool schemas needs nothing more; set MCP_HTTP=1 to serve the very same tools over Streamable HTTP at /mcp/.
A tool is something the agent does. A Resource is something the agent reads. Use a Resource to hand the agent context up front: a schema description, a policy doc, the current state. It is addressed by a URI and the agent pulls it when it wants grounding. Add this to the same file, above if __name__.
@mcp.resource("notes://schema")
def notes_schema() -> str:
"""Describe the notes store so the agent knows the shape before it calls a tool."""
return (
"Notes store. Keyed by user_id (a short string like 'ray' or 'mich').\n"
"Each user maps to a list of short free-text note strings.\n"
"Use get_notes(user_id) to read one user, search_notes(query) to scan all,\n"
"add_note(user_id, note) to write. Known user_ids: ray, mich."
)
The URI notes://schema is yours to design. The agent lists resources, reads this one, and now understands that user_id is a short handle, not an email or a number. That single piece of context prevents a whole class of wrong calls.
Ask the server for its tool list the same way Lab 01 did: pipe the handshake plus a tools/list into it over stdio. (Over HTTP you would first read the mcp-session-id the server hands back on initialize and echo it on every later call. stdio skips that ceremony, so it is the fastest way to read your schemas.) This is the agent's-eye view.
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| python tools_server.py
@(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
'{"jsonrpc":"2.0","method":"notifications/initialized"}'
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
) | python tools_server.py
"tools":[
{"name":"get_notes","description":"Return all stored notes for one user, by their user_id.",
"inputSchema":{"type":"object","properties":{"user_id":{"type":"string"}},"required":["user_id"]}},
{"name":"search_notes", "description":"Find notes across all users whose text contains the query string.", ...},
{"name":"add_note", "description":"Store a new note for a user. Returns a short confirmation string.", ...}
]
Notice the inputSchema was written for you from the type hints, and the description is your docstring verbatim. The agent picks a tool by reading those descriptions. They are the product.
Here is the lesson the rest of the room learns in prod, the hard way. The 177,000-tool study (Stein, UK AI Security Institute with the Bank of England) and the TSCG paper agree: at real catalog sizes, the binding constraint is not "does a tool exist." It is "can a small model read its schema." JSON schemas are parser-shaped, not model-shaped, and a smaller model (the 4B to 14B class people actually self-host) will silently pick the wrong tool or skip the call. TSCG reports a deterministic schema rewrite taking one 14B model from 0 to 84 percent tool-use accuracy: same model, same task, only the schema changed.
Suppose you had written this instead:
A capable model might guess. A small model sees three one-letter params, no types worth the name, and a description that says nothing. It does this:
The one-line fix is the version you already wrote in step 1: real parameter names, real type hints, a docstring that says what the tool does and what it returns. Same behavior, same code path, but now the agent can actually use it:
Verbose is fine. Ambiguous is fatal. Write your descriptions for the smallest model you expect to serve, not for yourself.
Your repo is now agent-callable. Three of your functions are tools, the agent has a Resource for context, and your schemas are written so even a small model can route to them. That is a real MCP server doing real work. The only thing standing between this and production is the one step everybody skips: auth.