Designing Tools Agents Can Actually Use
The interview-gold lesson: most MCP servers fail not at the protocol layer but at the design layer — twelve thin CRUD wrappers, novel-length responses, and errors that read like stack traces. Few good tools beat many thin ones; here's what 'good' means concretely.
| Endpoint-mirroring (bad) | Task-level (good) | |
|---|---|---|
| Shape | get_order(id), get_customer(id), list_shipments(order_id), get_shipment(id)… | search_orders(query, status, date_range) returning joined, shaped summaries |
| Calls per user question | 4–6 chained calls, model does the joins | 1–2 calls, server does the joins |
| Tokens | Full JSON payloads × every call | Pre-summarized fields the task actually needs |
| Failure modes | Model forgets an ID mid-chain, passes wrong FK, wanders | One call, one schema, one place to fail |
| Tool-count pressure | Dozens of tools dilute selection accuracy | A handful of tools the model picks reliably |
# Colab cell 1 — run once. No external API needed: a stub CRM lets the
# well-designed tool actually run so you can see each branch's output.
!pip install -q mcp
from collections import namedtuple
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("crm-server")
Customer = namedtuple("Customer", "id name")
Issue = namedtuple("Issue", "id title status opened")
class _CRM: # stands in for your real CRM API
_customers = [Customer("c1", "Acme Corp"), Customer("c2", "Acme Labs"),
Customer("c3", "Globex")]
_issues = {"c3": [Issue("I-9", "checkout 500s", "open", "2026-07-10"),
Issue("I-7", "slow search", "open", "2026-07-02")]}
def search_customers(self, name):
return [c for c in self._customers if name.lower() in c.name.lower()]
def issues(self, customer_id, status="open"):
return [i for i in self._issues.get(customer_id, []) if i.status == status]
crm = _CRM()
# BAD: the model must already know an ID, gets a raw JSON dump,
# and learns nothing from the name or description.
@mcp.tool()
def get_data(id: str) -> str:
"""Gets data."""
return str(crm._issues.get(id)) # nested JSON dump, good luck
# GOOD: named for the task, searchable by what the model actually has
# (words, not IDs), returns shaped text, documents its own limits.
@mcp.tool()
def find_customer_issues(
customer_name: str,
status: str = "open",
max_results: int = 5,
) -> str:
"""Find a customer's support issues by company name.
Searches customers by name (fuzzy), then returns up to max_results
issues as lines of: issue_id | title | status | opened_date.
Use when the user asks about a customer's problems or tickets.
NOT for creating or editing issues (use create_issue).
If several customers match the name, returns the candidate list
instead -- call again with a more specific name.
"""
customers = crm.search_customers(customer_name)
if len(customers) > 1:
names = ", ".join(c.name for c in customers[:5])
return (f"Ambiguous: {len(customers)} customers match. "
f"Candidates: {names}. Call again with a full name.")
if not customers:
return (f"No customer found matching '{customer_name}'. "
f"Check spelling, or try a shorter fragment of the name.")
issues = crm.issues(customers[0].id, status=status)[:max_results]
return "\n".join(f"{i.id} | {i.title} | {i.status} | {i.opened}"
for i in issues)
# @mcp.tool() leaves each a normal callable — run all three branches:
print("ambiguous ->", find_customer_issues("Acme"))
print("no match ->", find_customer_issues("Nonexistent"))
print("success ->\n" + find_customer_issues("Globex"))Response budgeting
A tool that can return 200k tokens is a denial-of-service attack on your own agent: one call evicts the system prompt's influence, drowns the actual task, and may simply overflow the window. Every tool needs a response budget: a hard cap on what it returns, pagination or filtering to stay under it, and — critically — an explicit signal that more exists and how to get it. Silent truncation is the worst option, because the model concludes the data doesn't exist and reports wrong answers confidently.
# Colab cell 2 — run cell 1 first (it defines mcp). Stubbed log store.
from collections import namedtuple
LogLine = namedtuple("LogLine", "line")
class _LogStore: # stands in for your real log store
_lines = [LogLine(f"2026-07-17T10:{i:02d}:00 ERROR timeout on shard {i}")
for i in range(57)] # 57 matches, to force paging
def search(self, query):
return [x for x in self._lines if query.lower() in x.line.lower()]
log_store = _LogStore()
@mcp.tool()
def search_logs(query: str, page: int = 1, page_size: int = 20) -> str:
"""Search application logs. Returns one page of matching lines.
page_size max is 50. If the response says more pages exist,
call again with page+1 -- or better, refine the query.
"""
page_size = min(page_size, 50) # server-enforced cap
hits = log_store.search(query)
total = len(hits)
start = (page - 1) * page_size
page_hits = hits[start:start + page_size]
if not page_hits:
return (f"No results on page {page} for '{query}' "
f"({total} total). Try page 1 or broaden the query.")
body = "\n".join(h.line[:300] for h in page_hits) # per-item cap too
remaining = total - (start + len(page_hits))
if remaining > 0:
return (f"Showing {len(page_hits)} of {total} results "
f"(page {page}).\n{body}\n"
f"MORE AVAILABLE: {remaining} further results -- "
f"request page {page + 1}, or refine the query to narrow.")
return f"Showing all {total} results.\n{body}"
# 57 matches, page_size 20 -> page 1 shows the MORE AVAILABLE trailer:
print(search_logs("timeout", page=1, page_size=20))page_size ceiling (never trust the model's arguments to be reasonable), a per-item length cap, and a loud MORE AVAILABLE trailer that tells the model both that it's seeing a partial view and what to do about it. The 'refine the query' nudge matters — paging through 40 pages is almost never what the user wanted, and the model will take the hint. Run the demo and read the trailer: that last line is the difference between the model knowing it saw 20 of 57 and silently concluding there were only 20.One more description trick that fixes real behavior: negative guidance. If your server has both search_orders and process_refund, and the model keeps calling search when the user wants a refund, adding "NOT for refunds — use process_refund" to search's docstring usually fixes it outright. Descriptions steer selection; when selection is wrong, the cheapest fix is almost always the description, not the code. Treat every wrong-tool-choice bug as a docstring bug until proven otherwise.
The tool-count tax, and how to pay less of it
Every principle above compounds badly at scale, because the tool-count problem isn't about any single tool's design — it's an ecosystem cost that grows with the number of servers connected, not the number you personally wrote. This is the same context-bloat mechanism Lesson 1 introduced: every tool's full schema rides in every request regardless of relevance, and selection accuracy degrades as that count grows — so a genuinely well-designed five-tool server still contributes to the problem once it sits alongside seven other servers on the same host. The mitigation isn't limited to writing fewer tools per server, though that helps: hosts that support deferred loading or a tool-search capability let the model discover and load only the schemas relevant to the current task, keeping the always-visible set small no matter how many servers are connected. Where a host doesn't support that, the manual lever is curation — connect the servers relevant to the current project or session, not every server you've ever configured — and treat 'how many tools does the model currently see' as a metric you monitor, not an afterthought.
orders-server from Lesson 2 has five well-designed, task-level tools with excellent docstrings — by itself, agents use it flawlessly. After connecting it alongside six other equally well-designed servers (35 tools total), the same agent starts occasionally calling search_orders when the user actually asked about shipments, a mistake it never made before. Nothing about orders-server changed. What's the bug, and where do you fix it?Whiteboard drills
execute_query(sql: str), docstring 'Runs a SQL query against the database.' What do you say?"search_logs tool from this lesson pages beautifully. A user asks a question that takes 40 pages to fully answer. What actually happens, and is pagination still the right design here?"- ▸Fewer, task-level tools beat many endpoint mirrors: fewer calls, fewer tokens, better selection accuracy.
- ▸Tools should accept what the model has (names, natural queries), not what the API wants (internal IDs).
- ▸Errors are instructive text with a next move ("ambiguous — call again with full name"), never bare exceptions.
- ▸Every tool gets a response budget: server-enforced caps, pagination, and an explicit MORE AVAILABLE signal — silent truncation causes confident wrong answers.
- ▸Wrong tool selection is a docstring bug first: add when-to-use and when-NOT-to-use guidance before touching code.
- ▸The tool-design canon here IS Module 1's — MCP relocates it, doesn't replace or relax it.
- ▸Tool count is an ecosystem-level cost: a well-designed server can still degrade selection once combined with others. Deferred loading / tool search / server curation fixes it — not another docstring edit.