← Field Notes

43 Tools Later: What Production MCP Work Taught Me

I built an MCP server for my own affiliate operation. It talks to Voluum, my tracker, and it’s grown into 43 tools spread across 9 categories: campaigns, offers, traffic sources, reports, flows, landers, and a handful of admin utilities I keep forgetting the exact count of. It’s Python, FastMCP 2, 293 tests. Every one of those numbers I just checked before writing this sentence, because I’ve been burned before by trusting my own memory of my own codebase. More on that in a minute.

This isn’t a tutorial. It’s what actually broke, what actually worked, and what I’d tell someone starting the same build today.

Tool granularity: atomic beats swiss-army

My first instinct, like everyone’s, was to build fewer, smarter tools. One manage_campaign tool that takes an action parameter — “create,” “pause,” “update_bid,” “archive.” Feels efficient. Feels like good API design if you’re thinking like a REST architect.

It’s wrong for MCP, and it took me actually watching a model use the thing to understand why. A model calling manage_campaign(action="pause", campaign_id="x") has to hold the entire action vocabulary in its head every time it reasons about what to do next. It has to get the parameter shape right for whichever branch it’s hitting. When it screws up, the error comes back generic, because your handler had to be generic to cover seven different operations behind one signature.

Split that into pause_campaign, create_campaign, update_campaign_bid, archive_campaign — four tools, four names, four signatures — and the model’s job gets radically simpler. It’s not parsing a menu, it’s picking a verb. The tool name itself becomes part of your prompt engineering. That’s the whole insight: in MCP, the tool list IS context the model reasons over, so granularity isn’t an implementation detail, it’s an interface decision with the same weight as your system prompt.

43 tools sounds like a lot until you realize the alternative was maybe 12 “smart” tools that would have been worse at every single call site.

Auth lifecycle is where MCP servers die

Nobody warns you about this part. You build the happy path — token works, calls succeed, demo goes great — and then six hours into a long-running agent session the token expires and everything falls over in a way that’s ugly to debug because the failure surfaces three layers away from the actual cause.

I built a resilient async httpx client specifically around this problem. On a 401, it auto-refreshes the token and retries the call — but critically, that retry does not consume the call’s normal retry budget. If refresh-and-retry ate from the same counter as your exponential backoff logic for 429s and 5xxs, a token expiry would burn through your retry allowance and then a genuinely flaky upstream call right after would fail with nothing left in reserve. Those are two different failure classes — “my credentials went stale” versus “the server is overloaded” — and conflating their recovery budgets means one silent failure mode (auth) steals resilience from a visible one (rate limiting).

If you’re building an MCP server against any OAuth-flavored or token-based API, treat auth refresh as its own state machine, not a special case inside your retry loop. It’ll save you a debugging session that starts with “why did this fail on the fourth call when it worked fine for three hours.”

Caching semantics for read-heavy agent traffic

Agents read a lot more than they write. A model doing campaign analysis might call get_campaign_stats a dozen times in one reasoning chain, re-checking state between decisions, second-guessing itself the way models do. Without caching, that’s a dozen round trips to Voluum for data that hasn’t changed in the last four seconds.

I used a TTLCache for campaign state — short-lived, invalidated on writes, cheap. It’s not a sophisticated system. It doesn’t need to be. The win isn’t clever cache invalidation logic, it’s just accepting that “read-heavy agent traffic” is a real traffic pattern distinct from “read-heavy human traffic,” because a model doesn’t feel impatient waiting for a page to load — it just calls the tool again. Design for that pattern explicitly or your upstream API will design it for you, badly, via rate limits.

Error types over error messages

This is the one I’d put first if I were rewriting this piece. The caller of your tool is a model. It cannot read a stack trace and intuit what to do. It can, however, pattern-match on structured error types if you give it a typed exception hierarchy instead of a pile of strings.

AuthenticationError, RateLimitError, ValidationError, NotFoundError — distinct types, distinct recovery semantics. A model that gets a RateLimitError back can reasonably decide to wait and retry. A model that gets a ValidationError should stop retrying and fix its input. A model that gets a string like "Error: could not complete request" has to guess, and guessing is exactly the failure mode you’re trying to engineer away from an agent that’s making decisions with consequences attached.

The message is for me, reading logs. The type is for the model, deciding what to do next. Build for both audiences separately.

The README said 25+. The code had 43.

Here’s the anecdote I promised myself I’d include because it’s true and mildly embarrassing. For a stretch of this project, my own README said “25+ tools.” The actual tool count in the code was 43. Nobody lied, nobody was hiding anything — I just kept shipping tools faster than I updated the doc that counts them, and “25+” felt safely vague enough that I never went back to check if it was still true. It wasn’t, by a wide margin.

Docs drift is not a discipline problem you solve with willpower. It’s a structural problem — the doc and the code are two separate artifacts with no mechanism forcing them to agree. If a number in your README can go stale by 18 tools without anyone noticing, that number should probably be generated from the code, not typed by a human who has other things to do. I haven’t fully automated that yet. I’ve fixed the README. That’s the honest status.

Forty-three tools later, the pattern I keep coming back to is this: MCP isn’t really an API design problem, even though it looks like one. It’s a “what does a model need to reason correctly at this call site” problem. Every lesson above is a variation on that same question, asked at a different layer of the stack.