Back to News & Insights
Artificial Intelligence September 26, 2026 · 9 min read

Building an MCP server for financial data: lessons learned

Originally published on Medium. I maintain bcb-br-mcp, an open-source MCP server that gives AI...

Building an MCP server for financial data: lessons learned

I maintain bcb-br-mcp, an open-source MCP server that gives AI agents the Brazilian Central Bank's public data: the SGS time series (Selic, IPCA, exchange rates, GDP, credit), the Focus market-expectations survey and the PTAX reference rates. It runs over stdio from npm and over Streamable HTTP on Cloudflare Workers, has 17 tools, 388 tests, and serves a couple of thousand npm downloads a month plus a hosted endpoint.

None of the lessons below are about the MCP protocol itself. The protocol was the easy part. The hard parts were the data source, the two transports, and the difference between a response that looks right and one that is right. Here they are, roughly in the order they cost me. Your source will lie to you with HTTP 200

The single most expensive class of bug: the upstream API answers a bad request with a success code.

The SGS endpoint, asked for a series code that does not exist, returns HTTP 200 with an HTML page ("invalid request"), after about 30 seconds. A valid series answers in 0.2 to 0.4 seconds. So the naive server waited half a minute and then tried to parse HTML as JSON, and the error message blamed the source for being down.

Worse: the same HTML page occasionally comes back for a series that does exist. I measured it on series 432, the Selic target: 3 HTML pages out of 9 calls within a few minutes, then 20 consecutive good JSON answers. My first fix treated the page as a verdict ("this series does not exist") and raised without retrying. That meant a hiccup at the source made the server assert that the Selic target rate did not exist.

The page proves only that that attempt brought no data. The server now resolves the suspicion by repetition: a code that does not exist fails every attempt; a good series comes back on the next one. And small requests (ultimos/N, at most 20 observations) get their own 6-second budget, because they can never be a legitimate long query — which cuts the 30-second silence without touching the long windows.

Lesson: classify errors by the shape of the body, not by the status code, and never let a single anomalous response become a permanent verdict. "Last minus first" is wrong for half of the interesting series

Price indexes — IPCA, IGP-M, INPC — are published as monthly rates, not levels. Compute the variation of IPCA over 2024 as (last − first) / first and you get +23.81%. The real accumulated inflation was 4.83%. For the IGP-M in 2023 the naive formula gave +252.38% for an index that actually fell 3.18%.

Nobody reported this. A paid evaluation run, where a real model picked tools for 44 Portuguese prompts, caught it by accident, because one answer was absurd enough to notice.

The fix is a per-series decision (level vs chained rate vs already accumulated), and it is partial by construction: some series are recognised by the unit the open-data portal publishes, some by their curated name, some by an explicit list of period rates. The index heads (433, 189…) have no unit at all in the source; only the name identifies them. A code outside the catalogue is treated as a level, and the contract says so.

Lesson: a numeric tool has domain semantics. If your server computes anything, the computation needs to know what kind of number it is holding, and the response must say which convention it used. Ordering is not a property of the endpoint

ultimos/N — "the last N observations" — returns 22 of the 169 curated series newest-first and the other 147 oldest-first. The direction is not deducible from the code or the family: series 4390 comes reversed, series 433 does not. Meanwhile, the date-window endpoint returned ascending order in 151 of 151 series.

The visible symptom was a variation published with the wrong sign: −7.40% for a period in which the series rose 8.00%.

Every observation that enters the server now passes through one sort-by-date function. Nothing else reads data[0] as "the oldest".

Lesson: measure invariants you assume, across the whole catalogue, not on the three series you tested with. Two transports, one registry — or you will ship two products

At the start the server had two independent surfaces: the stdio entry derived JSON Schemas from zod through the SDK, and the Cloudflare Worker re-implemented JSON-RPC by hand with its own copy of the schemas. They had genuinely diverged:

| | stdio (npm) | hosted (Worker) | |---|---|---| | version in production | 1.3.5 | 1.3.1 | | tool description length | ~1,200 chars | ~100 chars | | default, minimum, maximum, minItems, additionalProperties | present | missing | | resource names | one set | a different set |

The agent-readable descriptions I had spent a release writing never reached the hosted endpoint, because there was no deploy pipeline for the Worker. And the HTTP client got a weaker contract than the stdio client, because the JSON Schema was maintained twice.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation