AI · MCP
Building and using MCP servers that actually help non-technical users.
MCP made it easy to put company data in front of non-technical users, but easy access did not mean good answers. After building several MCP servers, I learned that the fix sits on both sides: the server needs the same engineering discipline as any good API, and the people using it need a few simple habits. This article covers both, with the patterns and code that made the biggest difference for me.
An MCP server is a thin layer over an API, so every API best practice still applies, and it also has to protect the model's context window. Design a few task-level tools, filter and paginate by default, cache in Redis, handle 429s inside the server and return errors the model can fix. Then teach the people asking to start narrow, ask for a summary first and cap the number of tool calls.
The promise and the reality
The Model Context Protocol lets an AI assistant call tools that read real systems: a CRM, a ticketing tool, a sales database. For people who never wrote a query, that felt like a breakthrough.
In practice, I watched the same pattern repeat. The data showed up, the user asked a question, and the AI fired off dozens of tool calls. Responses got slow, results came back half-complete or truncated, rate limits kicked in, and the conversation drifted. Many of these sessions ended the same way: the user gave up and asked an engineer for help.
MCP wraps an API; it does not replace API engineering
An MCP server is a thin layer over an API, so every API best practice still applies inside it. Caching, pagination, filtering, rate-limit handling and clear errors do not disappear because an AI is the caller. They matter more, because an AI caller retries aggressively and never gets tired.
MCP also adds a new constraint: the model's context window. Every tool definition and every response is loaded into that window. A server with 80 tools and verbose JSON responses fills it quickly, and the model gets slower, more expensive and less accurate.
So a good MCP design protects two scarce resources at once:
- The backend, from request floods and repeated identical calls.
- The model's context, from too many tools and oversized responses.
A request that respects both flows like this:
- The user asks a question in plain language.
- The AI picks one well-described, task-level tool.
- The MCP server checks its cache before calling the API.
- On a miss, it calls the API with default filters and a small page size, retrying on 429 with backoff.
- It returns a short, filtered summary plus a cursor for more, and caches the result.
Building the server: practices that made the difference
The biggest gains came from five server-side habits: task-level tools, filtering and pagination by default, a Redis cache, built-in 429 handling, and errors the model can act on.
1. Design tools around tasks, not endpoints
Mirroring every REST endpoint as a tool forces the model to chain many calls. A tool like get_open_orders_summary replaces three or four low-level calls and reads like the question a user would ask. Keep the tool list short, and write each description as if for a new colleague: what it returns, when to use it, and its default limits.
2. Filter and paginate by default
Never let a tool return "everything" unless asked. Default to a recent date range, a small page size and only the fields a person would read. Return a cursor so the model can ask for more only when it needs it.
3. Cache in Redis
AI callers repeat themselves. The same lookup often runs several times in one conversation, and across users. A short-lived Redis cache keyed on the tool name and its arguments absorbs most of that load.
4. Handle 429s inside the server
The model should never see raw rate-limit errors, because it tends to retry immediately. The server should retry with exponential backoff, respect the Retry-After header, and give up with a clear message.
5. Return errors the model can fix
"Invalid date: use YYYY-MM-DD" lets the model correct itself in one step. A stack trace leads to blind retries.
Example: one tool that applies all five
This sketch uses the official MCP Python SDK (FastMCP), redis.asyncio and httpx. The orders API is a placeholder. SDK interfaces evolve, so verify names and signatures against the current documentation before you build on it.
import asyncio, hashlib, json
from datetime import date, timedelta
import httpx
import redis.asyncio as redis
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
cache = redis.Redis.from_url("redis://localhost:6379/0")
http = httpx.AsyncClient(base_url="https://api.example.com", timeout=10)
CACHE_TTL_SECONDS = 300
MAX_PAGE_SIZE = 50
async def get_with_retry(path: str, params: dict, attempts: int = 4) -> dict:
"""Call the API, retrying on 429 with backoff and Retry-After."""
for attempt in range(attempts):
resp = await http.get(path, params=params)
if resp.status_code != 429:
resp.raise_for_status()
return resp.json()
retry_after = resp.headers.get("Retry-After")
wait = float(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt
await asyncio.sleep(wait)
raise RuntimeError("Rate limited by the orders API. Try again in a minute.")
@mcp.tool()
async def get_open_orders_summary(
status: str = "open",
since: str | None = None,
page_size: int = 20,
cursor: str | None = None,
) -> dict:
"""Summarize orders by status. Defaults: open orders from the last
30 days, 20 per page. Use `cursor` from a previous result for more.
`since` must be YYYY-MM-DD."""
try:
since = since or (date.today() - timedelta(days=30)).isoformat()
date.fromisoformat(since)
except ValueError:
return {"error": "Invalid 'since' date. Use YYYY-MM-DD, e.g. 2026-09-01."}
params = {"status": status, "since": since,
"limit": min(page_size, MAX_PAGE_SIZE), "cursor": cursor}
params = {k: v for k, v in params.items() if v is not None} # don't send an empty cursor
key = "orders:" + hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()
if cached := await cache.get(key):
return json.loads(cached)
data = await get_with_retry("/orders", params)
result = {
"orders": [ # only the fields a person reads
{"id": o["id"], "customer": o["customer_name"],
"total": o["total"], "created": o["created_at"]}
for o in data["items"]
],
"next_cursor": data.get("next_cursor"),
"note": "Showing one page. Ask for more with next_cursor.",
}
await cache.set(key, json.dumps(result), ex=CACHE_TTL_SECONDS)
return result
if __name__ == "__main__":
mcp.run()
Pick the TTL per data type: minutes for live orders, hours for reference data like product catalogs. Never cache across users when results depend on permissions; include the user or tenant in the key.
Caching on the front end
If you build your own interface around the MCP server, a browser cache saves round trips for repeated views. It only applies to your own app; clients like desktop AI assistants manage their own state.
Use each browser store for what it is good at:
| Store | Good for | Avoid for |
|---|---|---|
| Cookies | Small flags and preferences, session IDs (HttpOnly) | Cached data: size is limited (commonly about 4 KB per cookie) and cookies are sent with every request |
| localStorage | Small, non-sensitive results with a TTL | Tokens or personal data: any script on the page can read it |
| IndexedDB | Larger cached datasets | Anything you must keep secret |
Cookies work well for small preferences, but cached data in cookies adds bytes to every request, which can cost more than the cache saves. Treat the size figures above as approximate; limits vary by browser, so check current browser documentation.
A simple TTL wrapper keeps localStorage entries from going stale:
const TTL_MS = 5 * 60 * 1000;
function cacheGet(key) {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const { value, expires } = JSON.parse(raw);
if (Date.now() > expires) {
localStorage.removeItem(key);
return null;
}
return value;
} catch {
return null; // storage unavailable or corrupted: fall back to the server
}
}
function cacheSet(key, value, ttl = TTL_MS) {
try {
localStorage.setItem(key, JSON.stringify({ value, expires: Date.now() + ttl }));
} catch {
// quota exceeded or storage blocked: skip caching
}
}
Keep the browser TTL equal to or shorter than the server TTL, so the two layers never disagree for long.
Using MCP well: habits for the people asking
A well-built server still struggles when every question is open-ended. A few habits turned long, failing sessions into short, useful ones.
- Connect only what the task needs. Each connected server adds tool definitions to the model's context. Some AI clients can load tools on demand; check whether yours does.
- Start narrow. Name the time range, the status and the fields you care about.
- Ask for a summary first, details second. Totals and top items rarely need more than one call.
- Cap the work. Tell the AI how many calls or pages it may use before checking back with you.
- Ask before big pulls. Have the AI confirm before it fetches more than one page.
- Reuse earlier results. Ask the AI to work from what it already retrieved in the conversation.
Prompt patterns that worked
A vague prompt invites a flood of calls:
Show me everything about our orders.
A scoped prompt gets an answer in one or two calls:
Using the orders tool, summarize open orders from the last 30 days:
total count, total value, and the 5 largest orders.
Use at most 2 tool calls. If you need more data, ask me first.
For teams, a short standing instruction (in a system prompt, project instructions or a shared template) sets the habit once:
When using data tools:
- Start with the smallest query that answers the question.
- Use default filters and one page of results unless I ask for more.
- Do not repeat a tool call you already made in this conversation.
- If a tool returns an error, read it and fix the input before retrying.
- If the answer needs more than 3 tool calls, explain your plan and ask first.
What changed, and a checklist
With these practices on both sides, sessions got shorter, answers got more complete, and fewer questions ended with a request to engineering.
Building an MCP server
- Tools are task-level, few, and clearly described
- Every list tool filters and paginates by default, with a cursor
- Responses return only the fields a person reads
- Redis cache keyed on tool, arguments and user or tenant, with TTLs per data type
- 429s retried inside the server with backoff and
Retry-After - Errors tell the model exactly how to fix the input
Using an MCP server
- Only the needed servers are connected
- Questions name a time range, filters and fields
- Summary first, details on request
- A cap on tool calls, with a check-in before large pulls
- A shared standing instruction for the team
MCP gives non-technical users a door to their data. Good engineering on the server and a few habits from the people using it are what make that door worth walking through.
The MCP specification and SDKs are evolving quickly, so check the official documentation at modelcontextprotocol.io for current features such as pagination and tool loading before you build.
Get my next case studies and articles in Google Search.