Production MCP Server in FastAPI: What 22 Tools Taught Me
A case study in single-agent AI engineering: durable memory in Postgres, guardrails that record instead of block, and a self-grading evaluation loop.
Earlier this year I designed — and then built in about twelve weeks — a personal equity-valuation platform whose primary user is not a human: it is an AI agent. Claude connects to it over the Model Context Protocol (MCP), pulls live market data and SEC fundamentals, runs valuation models, and writes every decision back into a database that outlives any single conversation.
This post is the write-up I went looking for and couldn’t find: what running a production MCP server involves once you get past the hello-world tutorials. Building one meant deciding — before the field had settled any of this — how many tools is too many, where agent memory should live, and which guardrails survive contact with a model that will happily route around them. What follows is what I chose, what the ecosystem later validated or contradicted, and where the jury is still out.
Everything below is running in production today — which means every mistake in it is running too. Where my choices diverge from published guidance I try to say so plainly; some divergences have held up better than I expected, others are just gaps I haven’t closed yet. If you’ve solved any of these differently, I’d genuinely like to hear how.
The system: one agent, 22 tools, a database that remembers
The stack is deliberately boring:
- An MCP server (Python, FastMCP, streamable HTTP) exposing 22 tools. It is a thin, stateless proxy: no business logic, every call forwarded to the API.
- A FastAPI service that owns all domain logic: valuation rules, caching, compliance checks, and integrations with SEC EDGAR, a brokerage’s trading API, and market-data feeds.
- Postgres as both the cache and the agent’s long-term memory — 30 tables, 20 Alembic migrations.
- A Next.js dashboard for the human reading along.
Four containers, Docker Compose, self-hosted, deployed by a CI pipeline that rebuilds only the services a merge actually touched. The MCP endpoint is the only publicly reachable service, behind an identity-aware proxy that validates a signed identity token at the edge before a request ever reaches the server.
The unusual part is where the intelligence lives: nowhere in this codebase. There is not a single LLM API call in the system. The server supplies structured evidence — fundamentals, price history, a controlled taxonomy, computed compliance facts — and the judgment happens entirely in the calling agent, steered by a versioned policy prompt. The server’s job is to make that judgment auditable, not to have opinions of its own.
That split turned out to be the most important architectural decision in the project, and it is the thread running through everything below.
Why a single agent instead of a multi-agent system
Multi-agent architectures are the default demo in 2026, so it is worth saying how this system ended up single-agent: not by conviction, but by sequence. One agent is where you start — and months of production use never produced the pressure to take the next step.
What I found along the way is a useful test for when that step becomes worth it. Google’s own positioning of A2A versus MCP draws the line well: agent-to-agent protocols earn their complexity when the counterparty sits across an organizational trust boundary, is genuinely opaque (you cannot enumerate its tools), and the work is long-running and asynchronously negotiated. None of those conditions hold when you own every capability in the system. The ecosystem’s revealed preference agrees — the mcp package sees roughly 20× the monthly downloads of the A2A SDK, and MCP’s own Agents Working Group is now standardizing agent-as-tool patterns over MCP rather than beside it.
Staying single-agent has also made failures cheaper to understand. There is one context window to reason about, one policy prompt to version, one trajectory to audit when something goes wrong. Every failure I have debugged was legible precisely because there was no orchestration layer between the decision and the tool call that expressed it.
So multi-agent is a next step, not a rejected path. The first real candidate — fanning out independent research across many tickers — is a parallelism problem, and it sits on the roadmap behind the fundamentals above. When it arrives, it will have to clear the trust-boundary test rather than arrive because it’s the default demo.
How many tools is too many for one MCP server?
Twenty-two tools sounds modest until you learn they cover more than forty operations. Several tools are action-multiplexed: a single manage_valuation tool takes an action parameter (save, get, decide, fill, flag, score, …) instead of shipping seven separate tools. Anthropic’s guidance endorses exactly this consolidation, and MCP’s client best-practices page adds the argument nobody mentions: a stable tools array keeps the prompt cache warm. Definitions that never change are amortized to near-zero tokens after the first request.
But the most instructive precedent runs the other way. GitHub’s MCP server spent eighteen months walking every branch of this decision tree: it partitioned tools into opt-in toolsets (too many tools cause “tool confusion”), then consolidated aggressively — six pull-request tools became one with a method argument — after users reported the server consuming 80K tokens of context. Then, in 2026, it partially de-consolidated, because of a problem that has nothing to do with the model:
“This consolidation plays nicely with context/token budgets, but it makes permissions in MCP clients effectively all-or-nothing, because most clients attach permissions at the tool name level, not on the method argument.” — github-mcp-server#2525
That is the tradeoff I have not seen written up anywhere: tool granularity is argued as a model problem — context budget, selection accuracy — but it is also an authorization problem, and the two pull in opposite directions. Multiplexing optimizes the agent’s context while destroying the host’s permission granularity, because every mainstream MCP client gates on tool name and none gates on an argument’s value.
My own manage_valuation has this exact defect: its read actions (get, book) share a tool name with retire, which withdraws a valuation model as wrong. A host that wants to grant free reads must also grant the destructive write. The fix — splitting the tool on the read/write seam and annotating the read half readOnlyHint: true — costs one extra tool and is at the top of my roadmap. MCP’s annotation defaults make the case sharper than most people realize: an unannotated tool is assumed destructive, non-idempotent, and open-world by a spec-conformant client.
What I currently believe — held loosely, and revised as evidence arrives: consolidate operations that share a permission profile; never consolidate across the read/write seam; keep the tool count inside the range where selection stays reliable — Anthropic’s docs put the degradation threshold at 30–50 available tools, and independent MCP benchmarks find tool retrieval, not reasoning, becomes the dominant failure mode past a few dozen — so 22 sits comfortably inside the line; and when an action parameter exists, the invalid-action error must enumerate the valid actions — a multiplexed tool without that is a dead end the model cannot back out of.
Tool errors an LLM can act on
Every error this system returns is a plain sentence the model reads verbatim, naming the rule that failed and what would satisfy it:
save rejected by rule rfr_within_7_days: risk_free_rate_date is 2026-08-12,
which is more than 7 days old. Re-pull the current risk-free rate and rebuild
the WACC before saving.
The design intent, stated in the tool’s own docs: read it and fix that, rather than retrying a different shape.
When I built this it felt like a pragmatic hack. The MCP spec then spent three revisions converging on it. The current schema states the rationale outright — tool errors belong inside the result, not at the protocol layer, because “otherwise the LLM would not be able to see that an error occurred and self-correct” (MCP schema, CallToolResult.isError). Even input-validation errors were reclassified from protocol errors to in-band results (SEP-1303) for precisely this reason: the model can only learn from feedback that lands in its context window.
Two related conventions did as much for reliability as the errors themselves:
- Per-item status in every batch tool. A request for ten tickers where one symbol is bad returns nine results and one
{"status": "unavailable"}entry — never a failed call. The seam matters: validation errors (you sent something malformed) fail the whole call; availability errors (the world didn’t cooperate) fail only the item. - Hard caps instead of pagination. Batch tools cap at 10 symbols, news at 100 items, trades at 500. MCP has no pagination primitive for tool results, any cursor scheme is bespoke, and long tool responses measurably degrade model retrieval. A documented cap the model can plan around beats a page-2 it may never fetch.
AI agent memory in Postgres — no vector database
Ask about agent memory in 2026 and you will be sold a vector database within two paragraphs. This system’s memory is typed rows in Postgres — a choice that started as the path of least resistance, and that I’ve since come to think is more defensible than I realized when I made it.
The agent’s memory is the domain state itself: a valuation book (models, assumptions, method weights), a tier ladder of planned entry prices, an append-only decision log, kill signals with their scores, and data-quality flags that are never edited or deleted — a defect and its correction stay readable forever. Every completed analysis is written back through a tool call. And the tool contract instructs the agent to re-read state before acting instead of trusting its own recollection: responses carry an explicit exists field so there is no guessing from an empty result.
This matches what Anthropic’s engineering guidance calls the multisession pattern — durable artifacts written by one session, re-read at the start of the next, because otherwise “the agent would then have to guess at what had happened”. It also sidesteps a failure mode that free-text memory systems are only now naming: iterative summarization gradually eroding domain specifics (“context collapse”). A typed row cannot drift. A fair_value column does not get paraphrased into vagueness by its fifth compaction.
The context-rot research settled the other half of the argument: a bigger context window is not a memory strategy, because retrieval quality degrades non-uniformly with input length. Memory has to live outside the window and be pulled back in deliberately — the only real question is the storage shape. For domain memory with a natural schema, relational rows with constraints beat embeddings: you get provenance, audit history, and a CHECK constraint that makes an unaccounted-for trade unrepresentable even to a direct SQL writer. Where I would add vectors is episodic memory — “have I seen a setup like this before?” — which is genuinely a similarity query. That is on the roadmap, as a complement, not a replacement.
Guardrails that record instead of block: an audit trail for agent decisions
The compliance layer was built from failure, not foresight: it exists because of three real bad trades that slipped through as ordinary buys. It is also the part of the system I’d most like a security reviewer to challenge — the reasoning below convinced me, but it hasn’t yet survived anyone whose job is to break it.
It has two layers with different philosophies:
Layer 1 — hard rules that block. Sixteen named save-rules reject a valuation that is internally invalid: method weights must sum to 100, fair-value bounds must be ordered, the discount rate must be built on a risk-free rate no more than seven days old, tier prices may never be hand-supplied by the caller — the server computes them from the model. These are data-integrity invariants. The model cannot argue with them, and each rejection names the rule and the fix.
Layer 2 — judgment calls that get recorded. When the agent (or I) make a decision that violates policy — buying above fair value, acting inside an earnings blackout — the compliance engine computes the violation from stored facts and records it rather than blocking it. The rationale, from the module’s own docs:
A store that refused would just be routed around, and then the override would leave no trace at all.
Crucially, compliance is never accepted from the caller. The agent does not self-report whether a decision was compliant — “compliance a caller asserts is an opinion, and the opinion is always that it was fine.” The server derives it from the price, the saved model, the blackout calendar, and the thesis state, and stores its own verdict next to the decision.
This maps onto the classic preventive/detective control split from security engineering, applied to an agent: block what must never happen (integrity violations), detect and log what is a defensible judgment call (OWASP’s Excessive Agency guidance lists exactly this pairing). The underrated insight is the second half. A blocking control on a judgment call does not prevent the judgment — it just pushes the override off the record, which is the worst possible outcome for a system whose entire value is an honest audit trail. Prompt-level rules alone are not a security boundary; anything that must hold gets enforced server-side, and the schema itself carries the last line of defense.
The self-grading loop: kill signals scored after every earnings print
The system’s most unusual feature is an evaluation loop on the agent’s judgment, not its outputs.
Every saved valuation must state its kill signals up front: falsifiable conditions that would refute the thesis, declared before they can happen. After every earnings report, the agent is required to score every prior kill signal — fired or not fired, thesis intact, damaged, or broken — and the scores are stored permanently next to the original predictions.
This is forecast calibration applied to an agent, and it borrows the strongest property in the forecasting literature: a prediction recorded before the outcome exists cannot be contaminated, cherry-picked, or quietly rewritten. As one recent paper puts it, “time creates free supervision: forecasts about real-world events resolve to verifiable outcomes” — the passage of time provides labels that require no annotation. The scientific antecedent is preregistration, and the effect size there is startling: standard psychology literature reports 96% positive results versus 44% once hypotheses are locked before the data. Locking the prediction first is most of the honesty.
The design also defeats the self-grading trap structurally rather than by asking nicely. Scoring is all-or-nothing and enforced in code — every signal must be scored, no partial passes — the thesis state is derived worst-reading-wins, and the score history is append-only. The agent keeps discretion over how it reads each signal, but none over the bookkeeping. That matters because LLM self-evaluation is measurably biased: judges mark criteria as satisfied more than 50% more often when grading their own output, even against verifiable rubrics.
I will be honest about what this is not: it is not a conventional eval harness, the sample sizes are far too small for statistical claims, and the scores are categorical (fired / not fired) where a properly scorable record would attach a stated probability at signal-creation time — that one-column upgrade would unlock real calibration scoring and is on the roadmap. Still, if I were starting over I’d build this before any eval suite: it costs a schema table and a habit, and it has already caught the failure mode I most needed to see — the agent’s (and my own) confident reasoning being simply wrong.
Results: what twelve weeks of production looks like
Build and scale, measured from the repository:
| Metric | Value |
|---|---|
| Build time | ~12 weeks (designed in February; gateway, API, MCP server, and CI/CD all landed the first day of development), solo, alongside a day job |
| Commits / merged PRs | 175 / ~117 |
| Code | ~28,600 lines of Python, ~13,200 of TypeScript |
| MCP tools | 22 (covering 40+ operations) |
| API routes / DB tables / migrations | 59 / 30 / 20 |
| Tests | 1,101 collected (≈1:1 test-to-app code ratio in the API) |
| Latency harness | opt-in suite measuring live upstream calls, excluded from CI by marker |
Operational discipline, from configuration rather than benchmarks: layered TTL caching (15-second live quotes, 24-hour fundamentals and historical bars, 5-minute news) so the agent can be chatty without hammering upstreams; client-side rate limiting tuned to the strictest upstream budget (~60 historical-data requests per 10 minutes); deep health checks and a watchdog around the one fragile external session, with a workflow-triggered recovery runbook for when it drops anyway.
Trading through the agent, measured against SPY
The question that matters is not “did the code ship” but “did trading through an agent hold up against just buying the index.” So I measured it, from the fill archive, over the agent era — from the day the first services landed to the time of writing (86 days). The denominator is the capital actually deployed in trades, with positions carried into the era rebased to their era-start marks so only era-attributable P&L counts. Since capital at risk varied 4× across the window, the return is a range across denominator conventions, not a cherry-picked point:
| Metric (agent era, ~3 months) | Risk-exposed portfolio | SPY, same window |
|---|---|---|
| Return on deployed capital | +5.0% to +11.6% (most conservative → average denominator) | +1.3% |
| Realized losses, entire era | smaller than the commissions paid | — |
| Losses over the tail threshold | zero | — |
| Win rate | 83% of 29 closed trades (small sample, stated as such) | — |
| Options share of activity | 13% of fills | — |
| Worst stretch | ≈ −6% (est.), the first week’s tech selloff, on positions carried in | −4.5% in the same selloff |
The number that says the most is the second row: across three months of agent-mediated trading, total realized losses came to less than the commission bill. The tail — the thing the guardrails exist for — simply did not happen in this window.
The origin story explains why that matters. The pre-agent archive contains a month with 543 fills, a profit factor of 0.57, and five large losses that were all short-dated index options — including a same-day-expiry put. Those trades, not a textbook, are why the compliance engine, the earnings blackouts, and the tier-ladder discipline were built.
Honesty footnotes, because a results section without them is marketing: 86 days and 29 closed trades is a small sample, in a rising market; the win rate is realized-only while open positions carry the live risk (the open book is roughly flat, so no losses are hiding there); and the worst-stretch figure is an estimate — the system stores no historical daily marks per position, so a server-computed return series is on the roadmap to replace this approximation with the exact number. Three months of not losing is discipline, not alpha; sustained alpha takes years to demonstrate, and this post doesn’t claim it.
What is still missing is system outcome telemetry — per-tool latency distributions, token cost per session, tool-call success rates. Not because they don’t matter, but because the system has no tracing yet. That is the roadmap’s first item, and I would rather publish an honest gap than a vanity number.
What I got wrong, or haven’t built yet
Most of the gaps below I only saw after reading what better-resourced teams have published. Ranked by payoff over effort:
- Observability. The one gap I will not defend. The plan: OpenTelemetry spans for every tool call — the Python MCP SDK now emits them by default — with the trace structure captured and payloads redacted. One caveat the vendor decks skip: nothing in OTel’s GenAI semantic conventions is stable yet, so I will trace against the moving conventions and expect renames.
- Test the prompt surface. Here is the fact that reframed my whole view of the test suite: those 1,101 tests cover the code under the tool descriptions — and zero of them cover the descriptions and schemas themselves, which are the only part the model actually reads. Tool-interface changes are prompt changes: mutating descriptions and schemas has been measured to degrade agent success by ~14%. The fix costs an afternoon: snapshot the server’s
tools/listoutput into a committed fixture and diff it in CI, so every description edit becomes a visible, reviewed change. - An eval harness — outcomes, not trajectories. Anthropic’s published floor is 20–50 tasks drawn from real failures, graded on outcome and treated as routine maintenance. Three months of transcripts have handed me the task list already, and this domain makes grading unusually easy: most tasks end in a database state that either exists or doesn’t. Assert that tools were used, never that an exact sequence was followed — agents regularly find valid paths the designer didn’t anticipate. Run each task several times and report the all-runs-pass rate, because single-run scores flatter agents badly (the MCPMark benchmark measured 52.6% single-run success collapsing to 33.9% when all four runs must pass). And keep LLM judges out of the pass/fail path: across every published configuration, judges top out at AUROC 0.65 at detecting an agent’s false claims of success — the one question an MCP eval most needs answered.
- Split read from write on the multiplexed tools, and annotate all 22 tools — the authorization fix from the GitHub story above.
- Enum-constrain every
actionparameter and make invalid-action errors enumerate the valid ones. - Probabilities on kill signals. One nullable column — the agent’s stated probability that a signal fires before the next print — converts the scorecard from an audit trail into a properly scorable forecast record.
- A reflection pass over the decision log. Nothing currently reads the accumulated decisions back and distills “what does this agent systematically get wrong?” — the highest-payoff memory upgrade available, and cheap next to what is already stored.
- The policy prompt into version control. The client-side mandates live outside the repo today — the only layer with no history, no review, and no tests. The deeper fix is the direction the system already leans: keep moving load off the prompt and into server-side rules and schema constraints.
- Episodic retrieval over filings and past setups — the one place embeddings would earn their keep here.
Further out, and only once the fundamentals above are closed: the multi-ticker research fan-out — the one case on the horizon that could justify multi-agent coordination. Deliberately not on the roadmap: server-side LLM calls (the evidence/judgment split is the design, not a gap in it).
FAQ
What is an MCP server? An MCP server exposes tools, data, and prompts to AI applications over the Model Context Protocol, an open standard governed by the Linux Foundation. It is the standard way to give an agent like Claude controlled access to your systems — the agent calls your tools; you define what they do and what they refuse.
How do I develop my own MCP server? Start from the official SDK in your language (FastMCP in Python), define each capability as a typed tool function, and write the docstrings for the model that will read them — they are your API surface, not comments. The hard parts arrive later and are what this post is about: authentication at the edge, tool granularity versus permissions, errors the model can act on, and state that outlives a session.
When should you build a custom MCP server instead of using an existing one? When the agent needs your domain logic, not just your data: validation rules it must not bypass, state it must write back, and a vocabulary it must stay inside. Off-the-shelf servers cover generic surfaces (files, databases, SaaS APIs); the moment you find yourself wanting the server to refuse things, it is yours to build.
Do AI agents need a vector database for memory? Not for domain state with a natural schema — typed rows in a relational database give you constraints, provenance, and an audit trail that embeddings cannot. Vector search earns its place for similarity queries over unstructured history (“have I seen this before?”), which is a complement to structured memory, not a substitute.
What should an AI agent audit trail include? Every decision with its stated rationale, the facts the system computed at decision time (not the agent’s self-report), violations recorded rather than silently blocked, and append-only corrections — a defect and its fix both stay visible. If an override can leave no trace, the audit trail is decorative.
Single agent or multi-agent — how do I choose? Default to a single agent with good tools. Reach for multiple agents when a counterparty is outside your trust boundary, genuinely opaque, or the work is long-running and parallel — organizational reasons, not aesthetic ones. One agent means one context, one policy, one trajectory to debug.
I write about building production AI systems — agent architecture, MCP, and the unglamorous engineering that makes agents trustworthy.