labs | 03 | byo tools
lab 03 | ~8 min | segment 3

Three of your functions. One resource. Agent-callable.

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.

step 1

Wire three real functions as tools.

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.

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/.

step 2

Expose a Resource: context, not action.

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__.

add to tools_server.py
@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.

step 3

List what the agent sees.

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.

terminal | tools/list over stdio
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
powershell | tools/list over stdio
@(
  '{"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
what you'll see in the id-2 reply (each tool carries the description an agent reads)
"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.

what the agent sees when your schema is badTSCG, arXiv 2605.04107

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:

your docstring (bad) def proc(u, q=None, w=None): """Process the request."""

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:

small model, on the bad schema I have a tool named "proc" but I cannot tell what u, q, or w mean or which one holds the user. I will not call it. [task fails silently]

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:

small model, on the good schema get_notes takes a user_id string and returns that user's notes. The user asked about "ray". I will call get_notes(user_id="ray"). [PASS]

Verbose is fine. Ambiguous is fatal. Write your descriptions for the smallest model you expect to serve, not for yourself.

checkpoint

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.