bcb-mcp 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bcb_mcp/__init__.py +13 -0
- bcb_mcp/bcb_client.py +293 -0
- bcb_mcp/cache.py +633 -0
- bcb_mcp/config.py +63 -0
- bcb_mcp/data/series_dictionary.json +2260 -0
- bcb_mcp/dictionary.py +266 -0
- bcb_mcp/py.typed +0 -0
- bcb_mcp/resources.py +91 -0
- bcb_mcp/server.py +184 -0
- bcb_mcp/service.py +611 -0
- bcb_mcp/tools.py +255 -0
- bcb_mcp-0.1.1.dist-info/METADATA +297 -0
- bcb_mcp-0.1.1.dist-info/RECORD +16 -0
- bcb_mcp-0.1.1.dist-info/WHEEL +4 -0
- bcb_mcp-0.1.1.dist-info/entry_points.txt +2 -0
- bcb_mcp-0.1.1.dist-info/licenses/LICENSE +21 -0
bcb_mcp/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""bcb-mcp: MCP server for Brazilian Central Bank (BCB-SGS) time series.
|
|
2
|
+
|
|
3
|
+
Python counterpart of the R package GetBCBData: same ids, long/wide output,
|
|
4
|
+
chunked downloads for long histories, plus a parquet/DuckDB cache.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .config import __version__
|
|
10
|
+
from .dictionary import SeriesDictionary, get_dictionary
|
|
11
|
+
from .service import SeriesService
|
|
12
|
+
|
|
13
|
+
__all__ = ["SeriesDictionary", "SeriesService", "__version__", "get_dictionary"]
|
bcb_mcp/bcb_client.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Async clients for the two BCB endpoints used by bcb-mcp.
|
|
2
|
+
|
|
3
|
+
1. Data API (JSON): https://api.bcb.gov.br/dados/serie/bcdata.sgs.{id}/dados
|
|
4
|
+
2. SGS catalog search (HTML form): https://www3.bcb.gov.br/sgspub/...
|
|
5
|
+
|
|
6
|
+
Both are public and unauthenticated, but the data API caps a single request at
|
|
7
|
+
about ten years of daily data, so long spans are fetched in chunks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import html as html_lib
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .config import (
|
|
23
|
+
HTTP_TIMEOUT_SECONDS,
|
|
24
|
+
MAX_CONCURRENCY,
|
|
25
|
+
MAX_DAYS_PER_REQUEST,
|
|
26
|
+
MAX_RETRIES,
|
|
27
|
+
MIN_REQUEST_INTERVAL,
|
|
28
|
+
SGS_DATA_URL,
|
|
29
|
+
SGS_SEARCH_HOME,
|
|
30
|
+
SGS_SEARCH_URL,
|
|
31
|
+
USER_AGENT,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("bcb_mcp.client")
|
|
35
|
+
|
|
36
|
+
DATE_FMT_BCB = "%d/%m/%Y"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BCBError(RuntimeError):
|
|
40
|
+
"""Raised when the BCB API returns an unrecoverable error."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SeriesNotFound(BCBError):
|
|
44
|
+
"""Raised when the SGS id does not exist or has no data in the range."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_bcb_date(text: str) -> dt.date:
|
|
48
|
+
"""Parse the dd/mm/yyyy dates returned by the data API."""
|
|
49
|
+
return dt.datetime.strptime(text.strip(), DATE_FMT_BCB).date()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def split_date_range(
|
|
53
|
+
first_date: dt.date, last_date: dt.date, max_days: int = MAX_DAYS_PER_REQUEST
|
|
54
|
+
) -> list[tuple[dt.date, dt.date]]:
|
|
55
|
+
"""Split a range into chunks that the BCB API accepts."""
|
|
56
|
+
if last_date < first_date:
|
|
57
|
+
raise ValueError("last_date must be >= first_date")
|
|
58
|
+
chunks: list[tuple[dt.date, dt.date]] = []
|
|
59
|
+
start = first_date
|
|
60
|
+
while start <= last_date:
|
|
61
|
+
end = min(start + dt.timedelta(days=max_days), last_date)
|
|
62
|
+
chunks.append((start, end))
|
|
63
|
+
start = end + dt.timedelta(days=1)
|
|
64
|
+
return chunks
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _RateLimiter:
|
|
68
|
+
"""Serialises requests so we never hammer the BCB servers."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, min_interval: float) -> None:
|
|
71
|
+
self._min_interval = max(0.0, min_interval)
|
|
72
|
+
self._lock = asyncio.Lock()
|
|
73
|
+
self._last_call = 0.0
|
|
74
|
+
|
|
75
|
+
async def wait(self) -> None:
|
|
76
|
+
if self._min_interval <= 0:
|
|
77
|
+
return
|
|
78
|
+
async with self._lock:
|
|
79
|
+
elapsed = time.monotonic() - self._last_call
|
|
80
|
+
delay = self._min_interval - elapsed
|
|
81
|
+
if delay > 0:
|
|
82
|
+
await asyncio.sleep(delay)
|
|
83
|
+
self._last_call = time.monotonic()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BCBClient:
|
|
87
|
+
"""Thin async wrapper around the BCB SGS endpoints."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
timeout: float = HTTP_TIMEOUT_SECONDS,
|
|
93
|
+
max_retries: int = MAX_RETRIES,
|
|
94
|
+
min_interval: float = MIN_REQUEST_INTERVAL,
|
|
95
|
+
max_concurrency: int = MAX_CONCURRENCY,
|
|
96
|
+
client: httpx.AsyncClient | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.max_retries = max(1, max_retries)
|
|
99
|
+
self._limiter = _RateLimiter(min_interval)
|
|
100
|
+
self._semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
|
101
|
+
self._owns_client = client is None
|
|
102
|
+
self._client = client or httpx.AsyncClient(
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
follow_redirects=True,
|
|
105
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json, text/html"},
|
|
106
|
+
limits=httpx.Limits(max_connections=max_concurrency + 2),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def close(self) -> None:
|
|
110
|
+
if self._owns_client and not self._client.is_closed:
|
|
111
|
+
await self._client.aclose()
|
|
112
|
+
|
|
113
|
+
async def __aenter__(self) -> BCBClient:
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
117
|
+
await self.close()
|
|
118
|
+
|
|
119
|
+
# -- data API ------------------------------------------------------------
|
|
120
|
+
async def fetch_series_range(
|
|
121
|
+
self, series_id: int, first_date: dt.date, last_date: dt.date
|
|
122
|
+
) -> list[tuple[dt.date, float | None]]:
|
|
123
|
+
"""Download one chunk of a series (no chunking logic here)."""
|
|
124
|
+
url = SGS_DATA_URL.format(series_id=series_id)
|
|
125
|
+
params = {
|
|
126
|
+
"formato": "json",
|
|
127
|
+
"dataInicial": first_date.strftime(DATE_FMT_BCB),
|
|
128
|
+
"dataFinal": last_date.strftime(DATE_FMT_BCB),
|
|
129
|
+
}
|
|
130
|
+
payload = await self._get_json(url, params)
|
|
131
|
+
if isinstance(payload, dict):
|
|
132
|
+
message = payload.get("error") or payload.get("message") or str(payload)
|
|
133
|
+
raise BCBError(f"SGS {series_id}: {message}")
|
|
134
|
+
if not isinstance(payload, list):
|
|
135
|
+
raise BCBError(f"SGS {series_id}: unexpected response {type(payload).__name__}")
|
|
136
|
+
|
|
137
|
+
rows: list[tuple[dt.date, float | None]] = []
|
|
138
|
+
for item in payload:
|
|
139
|
+
if not isinstance(item, dict) or "data" not in item:
|
|
140
|
+
continue
|
|
141
|
+
raw_value = str(item.get("valor", "")).strip()
|
|
142
|
+
value: float | None
|
|
143
|
+
try:
|
|
144
|
+
value = float(raw_value.replace(",", ".")) if raw_value not in ("", "-") else None
|
|
145
|
+
except ValueError:
|
|
146
|
+
value = None
|
|
147
|
+
rows.append((parse_bcb_date(str(item["data"])), value))
|
|
148
|
+
return rows
|
|
149
|
+
|
|
150
|
+
async def fetch_series(
|
|
151
|
+
self, series_id: int, first_date: dt.date, last_date: dt.date
|
|
152
|
+
) -> list[tuple[dt.date, float | None]]:
|
|
153
|
+
"""Download a series, splitting long spans into API-friendly chunks."""
|
|
154
|
+
chunks = split_date_range(first_date, last_date)
|
|
155
|
+
collected: dict[dt.date, float | None] = {}
|
|
156
|
+
for start, end in chunks:
|
|
157
|
+
for ref_date, value in await self.fetch_series_range(series_id, start, end):
|
|
158
|
+
collected[ref_date] = value
|
|
159
|
+
return sorted(collected.items())
|
|
160
|
+
|
|
161
|
+
async def _get_json(self, url: str, params: dict[str, Any]) -> Any:
|
|
162
|
+
last_error: Exception | None = None
|
|
163
|
+
for attempt in range(1, self.max_retries + 1):
|
|
164
|
+
try:
|
|
165
|
+
await self._limiter.wait()
|
|
166
|
+
async with self._semaphore:
|
|
167
|
+
response = await self._client.get(url, params=params)
|
|
168
|
+
if response.status_code == 404:
|
|
169
|
+
raise SeriesNotFound(f"BCB returned 404 for {url}")
|
|
170
|
+
if response.status_code == 429 or response.status_code >= 500:
|
|
171
|
+
raise BCBError(f"HTTP {response.status_code} from BCB")
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
try:
|
|
174
|
+
return response.json()
|
|
175
|
+
except ValueError:
|
|
176
|
+
text = response.text[:200]
|
|
177
|
+
raise BCBError(f"BCB returned non-JSON payload: {text!r}") from None
|
|
178
|
+
except SeriesNotFound:
|
|
179
|
+
raise
|
|
180
|
+
except (httpx.HTTPError, BCBError) as exc:
|
|
181
|
+
last_error = exc
|
|
182
|
+
if attempt >= self.max_retries:
|
|
183
|
+
break
|
|
184
|
+
wait = 2.0**attempt
|
|
185
|
+
logger.warning("request failed (%s); retrying in %.1fs", exc, wait)
|
|
186
|
+
await asyncio.sleep(wait)
|
|
187
|
+
raise BCBError(f"failed to fetch {url}: {last_error}")
|
|
188
|
+
|
|
189
|
+
# -- catalog search ------------------------------------------------------
|
|
190
|
+
async def search_catalog(self, text: str, max_results: int = 50) -> list[dict[str, Any]]:
|
|
191
|
+
"""Search the official SGS catalog by text (English catalog names)."""
|
|
192
|
+
await self._ensure_session()
|
|
193
|
+
rows: list[dict[str, Any]] = []
|
|
194
|
+
seen: set[int] = set()
|
|
195
|
+
page = 1
|
|
196
|
+
total = 0
|
|
197
|
+
while len(rows) < max_results:
|
|
198
|
+
method = "localizarSeriesPorTexto" if page == 1 else "getPagina"
|
|
199
|
+
html_text = await self._post_search(method, text, page)
|
|
200
|
+
page_rows, total = parse_search_page(html_text)
|
|
201
|
+
fresh = [r for r in page_rows if r["id"] not in seen]
|
|
202
|
+
for row in fresh:
|
|
203
|
+
seen.add(row["id"])
|
|
204
|
+
rows.append(row)
|
|
205
|
+
if not fresh or not total or len(rows) >= total or page >= 40:
|
|
206
|
+
break
|
|
207
|
+
page += 1
|
|
208
|
+
return rows[:max_results]
|
|
209
|
+
|
|
210
|
+
async def _ensure_session(self) -> None:
|
|
211
|
+
"""The legacy search service needs a session cookie."""
|
|
212
|
+
try:
|
|
213
|
+
await self._limiter.wait()
|
|
214
|
+
async with self._semaphore:
|
|
215
|
+
await self._client.get(SGS_SEARCH_HOME)
|
|
216
|
+
except httpx.HTTPError as exc: # pragma: no cover - network dependent
|
|
217
|
+
raise BCBError(f"could not start SGS search session: {exc}") from exc
|
|
218
|
+
|
|
219
|
+
async def _post_search(self, method: str, text: str, page: int) -> str:
|
|
220
|
+
data = {
|
|
221
|
+
"texto": text,
|
|
222
|
+
"hdTipoPesquisa": "6",
|
|
223
|
+
"hdTipoOrdenacao": "0",
|
|
224
|
+
"hdNumPagina": str(page),
|
|
225
|
+
"bilQtde": "1",
|
|
226
|
+
"periodicidade": "0",
|
|
227
|
+
"fonte": "341",
|
|
228
|
+
}
|
|
229
|
+
last_error: Exception | None = None
|
|
230
|
+
for attempt in range(1, self.max_retries + 1):
|
|
231
|
+
try:
|
|
232
|
+
await self._limiter.wait()
|
|
233
|
+
async with self._semaphore:
|
|
234
|
+
response = await self._client.post(
|
|
235
|
+
SGS_SEARCH_URL, params={"method": method}, data=data
|
|
236
|
+
)
|
|
237
|
+
response.raise_for_status()
|
|
238
|
+
text_body = response.content.decode("iso-8859-1", errors="replace").replace(
|
|
239
|
+
"\x00", ""
|
|
240
|
+
)
|
|
241
|
+
if "Session expired" in text_body:
|
|
242
|
+
await self._ensure_session()
|
|
243
|
+
raise BCBError("SGS search session expired")
|
|
244
|
+
return text_body
|
|
245
|
+
except (httpx.HTTPError, BCBError) as exc:
|
|
246
|
+
last_error = exc
|
|
247
|
+
if attempt >= self.max_retries:
|
|
248
|
+
break
|
|
249
|
+
await asyncio.sleep(2.0**attempt)
|
|
250
|
+
raise BCBError(f"SGS catalog search failed: {last_error}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- HTML parsing -----------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
_TOTAL_RE = re.compile(r"Amount of series found:.*?<b>(\d+)</b>", re.S)
|
|
256
|
+
_TABLE_RE = re.compile(r"id=.tabelaSeries.", re.S)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _clean(fragment: str) -> str:
|
|
260
|
+
text = re.sub(r"<[^>]+>", "", fragment)
|
|
261
|
+
return " ".join(html_lib.unescape(text).split())
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def parse_search_page(page_html: str) -> tuple[list[dict[str, Any]], int]:
|
|
265
|
+
"""Parse one HTML page of the SGS catalog search results."""
|
|
266
|
+
total_match = _TOTAL_RE.search(page_html)
|
|
267
|
+
total = int(total_match.group(1)) if total_match else 0
|
|
268
|
+
|
|
269
|
+
parts = _TABLE_RE.split(page_html, maxsplit=1)
|
|
270
|
+
if len(parts) < 2:
|
|
271
|
+
return [], total
|
|
272
|
+
|
|
273
|
+
rows: list[dict[str, Any]] = []
|
|
274
|
+
for chunk in parts[1].split("<tr")[1:]:
|
|
275
|
+
if "cbxSelecionaSerie" not in chunk:
|
|
276
|
+
continue
|
|
277
|
+
cells = re.findall(r"<td[^>]*>(.*?)</td>", chunk, re.S)
|
|
278
|
+
if len(cells) < 8:
|
|
279
|
+
continue
|
|
280
|
+
id_match = re.search(r'value="(\d+)"', cells[0])
|
|
281
|
+
freq_match = re.search(r'title="([^"]+)"', cells[4])
|
|
282
|
+
rows.append(
|
|
283
|
+
{
|
|
284
|
+
"id": int(id_match.group(1)) if id_match else None,
|
|
285
|
+
"name": _clean(cells[2]),
|
|
286
|
+
"unit": _clean(cells[3]),
|
|
287
|
+
"frequency": _clean(freq_match.group(1)) if freq_match else _clean(cells[4]),
|
|
288
|
+
"first_date": _clean(cells[5]),
|
|
289
|
+
"last_date": _clean(cells[6]),
|
|
290
|
+
"source": _clean(cells[7]),
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
return [row for row in rows if row["id"] is not None], total
|