AI Agents and MCP in Production · The Model Context Protocol (MCP)

Designing MCP Servers Agents Use Well

A server that returns whole tables floods the context; filtered, paginated, concise tools with annotations work far better.

Create a free account to save this page, take notes and track your progress. Sign in

Scenario

Example company: the data team publishes an MCP server for the customer database. Its first version mirrors the REST API: list_customers(), get_customer(id), list_orders().

What you see in production

Agents using the server are slow and expensive, and often wrong. To answer "which customers in Berlin have overdue invoices?", the agent calls list_customers(), receives 12,000 customers as JSON (nearly a million tokens, far beyond what fits), and the call fails. When it does fit (in a test database), the agent then calls get_customer hundreds of times.

The naive code

@mcp.tool()
def list_customers() -> list[dict]:
    '''List customers.'''
    return db.query("SELECT * FROM customers")          # every row, every column

Reproduce it

Response sizes for the naive tool and for a task-shaped, paginated tool:

Whole-table tool versus a filtered, paginated, concise toolTry it yourself
import json, random
random.seed(7)
cities = ["Berlin", "Paris", "Madrid", "Rome"]
customers = [{"id": i, "name": f"Customer {i}", "city": random.choice(cities),
              "overdue_invoices": random.choice([0, 0, 0, 1, 2]),
              "address": "Example Street " + str(i), "notes": "x" * 200} for i in range(12_000)]

def tokens(obj):
    return len(json.dumps(obj)) // 4                     # rough: 4 characters per token

def list_customers():
    return customers

def find_customers(city=None, has_overdue=None, limit=20, cursor=0):
    rows = [c for c in customers if (city is None or c["city"] == city)
            and (has_overdue is None or (c["overdue_invoices"] > 0) == has_overdue)]
    page = rows[cursor:cursor + limit]
    return {"customers": [{"id": c["id"], "name": c["name"], "overdue_invoices": c["overdue_invoices"]} for c in page],
            "total": len(rows), "nextCursor": cursor + limit if cursor + limit < len(rows) else None}

print("list_customers():", f"{tokens(list_customers()):,} tokens")
first = find_customers(city="Berlin", has_overdue=True)
print("find_customers(Berlin, overdue):", f"{tokens(first):,} tokens,", first["total"], "matches, next cursor", first["nextCursor"])

Output

list_customers(): 979,397 tokens
find_customers(Berlin, overdue): 303 tokens, 1134 matches, next cursor 20

Root cause

An API designed for programs is not a good interface for a model. Programs can page through 12,000 rows cheaply; a model pays for every token in its context, and a large response either overflows the window or crowds out the instructions. Thin CRUD wrappers also force the model to do joins and filtering itself, one call at a time.

The fix

Design tools around the tasks agents perform, keep responses small, and describe the tools well:

from mcp.server import MCPServer
from mcp.types import ToolAnnotations

mcp = MCPServer("Customers")

@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def find_customers(city: str | None = None, has_overdue_invoices: bool | None = None,
                   limit: int = 20, cursor: str | None = None) -> dict:
    '''Find customers by city and invoice status. Returns id, name and overdue count, 20 per page.
    Use next_cursor to get more; prefer narrowing the filters over paging through everything.'''
    ...

@mcp.tool(annotations=ToolAnnotations(destructive_hint=True))
def write_off_invoice(invoice_id: str, reason: str) -> dict:
    '''Write off an unpaid invoice. Irreversible: requires a reason and should be confirmed by a human.'''
    ...

Guidelines:

  • Filter and aggregate on the server: find_customers(city=..., has_overdue=...), count_overdue_by_city().
  • Return only useful fields by default, with an option for more detail.
  • Paginate with a cursor and cap page sizes.
  • Annotate tools (read_only_hint, destructive_hint) so hosts can auto-approve safe calls and ask before risky ones.
  • Return actionable errors, like any other tool.

Trade-offs

  • Task-shaped tools are less general; add new tools as new tasks appear rather than one tool that does everything.
  • Annotations are hints from the server; a host should not trust them from servers it does not trust.
  • Aggressive truncation can hide data; say in the response when results were cut and how to get more.

Detect it in production

  • Response size distribution per tool (tokens); alarm on large outliers.
  • Calls per task: many sequential calls to the same tool suggest a missing filter or aggregate.
  • Validation errors per parameter, as with any tool.

Key points

  • Design MCP tools for agent tasks, not as a mirror of your REST API.
  • Filter, aggregate, paginate and keep responses small.
  • Use annotations to mark read-only and destructive tools.

Further reading: MCP tools · MCP pagination · Writing tools for agents

Practice questions

Why is mirroring a REST API as MCP tools often a poor design?

Answer

REST endpoints return data for programs that can page cheaply; a model pays for every token and must do joins and filters one call at a time. Task-shaped tools that filter and aggregate on the server give smaller, more useful results.

What do the MCP tool annotations read_only_hint and destructive_hint enable?

Answer

They let a host treat tools differently, for example auto-approving read-only calls and asking a human before destructive ones. They are hints and should only be trusted from trusted servers.

How should an MCP tool handle large result sets?

Answer

Return a small page of concise records with a cursor for the next page, state the total, and offer filters so the agent can narrow the query instead of paging through everything.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?