labs | 03 | schema
lab 03 | ~8 min | masterclass

A tool's schema is the agent's only signal. Verbose is fine. Ambiguous is fatal.

You wired your tools in Lab 02. Whether an agent can actually use them comes down to the schema you expose: the parameter names, the type hints (the declared types of each input, like str), and the docstring (the description right under the function). A capable model forgives a sloppy schema. The smaller 4B to 14B models people run on their own hardware do not. (The "B" is billions of parameters, a rough size; smaller models are cheaper to run but read a vague schema worse.) This lab makes routing, picking the right tool, something your code guarantees, not something you hope for.

step 1

Write the bad tool, then read what the agent sees.

Here is a tool that works perfectly when you call it from Python and is useless to an agent. One-letter parameters, no type hints, and a docstring that says nothing. Save as schema_server.py.

schema_server.py (the bad version)
from fastmcp import FastMCP

mcp = FastMCP("schema-lab")

# Stand-in for your real data layer. Behavior is fine. The SCHEMA is the problem.
NOTES = {
    "ray": ["ships on Vercel", "codes on Windows"],
    "mich": ["runs the security beat"],
}

@mcp.tool
def proc(u, q=None, w=None):
    """Process the request."""
    if w is not None:
        NOTES.setdefault(u, []).append(w)
        return "ok"
    if q is not None:
        return [n for ns in NOTES.values() for n in ns if q.lower() in n.lower()]
    return NOTES.get(u, [])

if __name__ == "__main__":
    mcp.run()  # stdio, which is all we need to inspect the schema

Now ask the server for its tool list, the same way Lab 02 did: pipe the handshake plus a tools/list request into it over stdio. This is the exact view the agent gets, nothing more.

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 schema_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 schema_server.py
the id-2 reply: this is everything the agent gets to decide with
"tools":[
  {"name":"proc","description":"Process the request.",
   "inputSchema":{"type":"object",
     "properties":{"u":{},"q":{},"w":{}}}}
]

No types. No required list. Three one-letter keys and a description that says nothing. The function runs fine. The agent has no way to know that u is the user, that w writes, or when to reach for this at all.

step 2

Rewrite the schema. The code path does not change.

Same behavior, split into three clear tools. Real parameter names, real type hints, and a docstring that says what the tool does and when to call it. Replace proc with this.

schema_server.py (the good version)
@mcp.tool
def get_notes(user_id: str) -> list[str]:
    """Return all stored notes for one user, by their user_id.
    Call this when the user names a specific person and wants their notes."""
    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.
    Call this when you do not know whose notes to read and want to scan everyone."""
    return [u + ": " + n for u, ns in NOTES.items() for n in ns if query.lower() in n.lower()]

@mcp.tool
def add_note(user_id: str, note: str) -> str:
    """Store a new note for one user. Returns a short confirmation string.
    Call this only when the user explicitly asks to save or record something."""
    NOTES.setdefault(user_id, []).append(note)
    return "stored note for " + user_id

Run the same tools/list command from step 1. Same server, same data, same logic. Only the schema changed, and the reply is now a thing an agent can route on.

the id-2 reply now: rich schema + a description the agent can read
"tools":[
  {"name":"get_notes",
   "description":"Return all stored notes for one user, by their user_id. Call this when...",
   "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. Call this when...",
   "inputSchema":{"type":"object",
     "properties":{"query":{"type":"string"}},"required":["query"]}},
  {"name":"add_note",
   "description":"Store a new note for one user. Returns a short confirmation string. Call this only when...",
   "inputSchema":{"type":"object",
     "properties":{"user_id":{"type":"string"},"note":{"type":"string"}},"required":["user_id","note"]}}
]

The inputSchema was written for you from the type hints. The description is your docstring verbatim. You did not write any schema by hand. You wrote good function signatures and FastMCP did the rest.

step 3

Make it a habit you can enforce.

Run this four-point check, in your head, over every tool you ship. It is the difference between hoping the agent routes and knowing it can.

the four questions
# For every @mcp.tool, ask:
#   (a) do the params have real, readable names?   (user_id, not u)
#   (b) do they have type hints?                    (user_id: str, not user_id)
#   (c) does the docstring say WHAT it does?        ("Return all notes for one user")
#   (d) does it say WHEN to call it / what returns? ("Call this when the user names a person")
# Miss any one and a small model will guess wrong or skip the call.

Then stop relying on memory. A capable model never complains about a missing description, so the gap stays hidden until a smaller model hits it in production. This snippet fails loudly the moment any tool ships without one. It uses an assertion, a check that stops the program if a condition is not met. Drop it in a test, or at the bottom of the server.

add to schema_server.py (or a test)
async def assert_every_tool_documented(server):
    """Every tool must carry a non-empty description. A blank one is a silent routing bug."""
    tools = await server.get_tools()  # name -> Tool
    bad = [name for name, t in tools.items() if not (t.description or "").strip()]
    assert not bad, "tools missing a description (invisible to small models): " + ", ".join(bad)

if __name__ == "__main__":
    import asyncio
    asyncio.run(assert_every_tool_documented(mcp))  # raises before you ever serve a blind tool
    mcp.run()

Now a blank docstring is a failed run, not a production mystery. Good routing became something your code checks for you.

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

This is the lesson the rest of the room learns in production, the hard way. The 177,000-tool study (Stein, UK AI Security Institute with the Bank of England) and the TSCG paper agree on one thing. At real catalog sizes, the thing that holds you back is not whether a tool exists. It is whether a small model can read its schema. JSON schemas are written for a parser to validate, not for a model to read, and a smaller model (the 4B to 14B class people run on their own hardware) will quietly pick the wrong tool or skip the call. TSCG reports a fixed, repeatable schema rewrite taking one 14B model from 0 to 84 percent tool-use accuracy: same model, same task, only the schema changed.

Hand the bad proc from step 1 to a small model and you get the failure that never shows up in your logs:

small model, on the bad schema Tool "proc" takes u, q, w. They have no types and the description says nothing. I cannot tell which one holds the user. I will not call it. [task fails silently]

Hand it the rewritten tools from step 2 and the same model routes on the first try. Nothing changed but the schema:

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

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

hand this to your coding agent
Audit every @mcp.tool in <server.py>. For each tool, rewrite the parameter names, type
hints, and docstring so a 7B model could route to it unambiguously. The docstring must say
both what the tool does and when to call it. Flag any tool whose description would not survive
a small model, and show me the before/after schema for the worst one.

checkpoint

Your tools are written for the smallest model you expect to serve. Every one has a real name, real types, and a docstring that says what it does and when to call it. A one-line check fails the run if any of that goes missing. Good routing is now something you built in, not something you hope for. Next we make the whole repo something a passing agent can find on its own.