b3-dfp
by msperlin
README.md
# b3-dfp
An MCP server for the **annual (DFP) and quarterly (ITR) financial statements of
Brazilian listed companies**, built for LLM agents.
It reads CVM's open data (`dados.cvm.gov.br`) directly — the same source behind
the R package [`GetDFPData2`](https://github.com/msperlin/GetDFPData2) — and
turns 300 MB of semicolon-separated Latin-1 CSVs into fast, token-efficient
queries.
## Why this server
- **No R dependency.** Runs anywhere Python 3.10+ runs.
- **Official MCP SDK.** Tools, resources and a prompt over stdio or HTTP.
- **Parquet + predicate push-down.** Each `(document, format, year)` archive is
parsed once into Parquet; queries filter by company and account before
anything is materialized in Python. A 17-year query takes milliseconds after
the first build.
- **Handles CVM's quirks.** Latin-1 encoding, decimal commas, unbalanced quotes
inside account labels, the 14/15/16-column schema drift across years, and
amended filings (`VERSAO`).
- **Token-efficient output.** CSV, headline-account summaries, and single-account
time series instead of dumping whole statements into the context window.
- **Quarterly income statements.** ITR files report year-to-date figures; the
server can difference them into quarter-only numbers, deriving Q4 from the
annual DFP.
## Tools
| Tool | What it does |
| --- | --- |
| `search_company` | Name → `cd_cvm`. Accent-insensitive. Entry point for everything. |
| `list_companies` | Browse the registry by sector and situation. |
| `list_available_years` | Which years CVM publishes, and what is cached locally. |
| `get_tickers` | Ticker (`PETR4`) → `cd_cvm` + company, from CVM's FCA form. |
| `get_statement_summary` | Headline accounts (assets, equity, revenue, EBIT, net income) for one company-year. |
| `get_account_history` | One account across years — the smallest useful answer. |
| `get_financial_statements` | Full long-form table, capped by `max_rows`. |
| `list_account_codes` | Curated account codes and role names. |
Resources: `b3dfp://accounts`,
`b3dfp://companies/{cvm_code}/profile`. Prompt: `analyze_company`.
## Install
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```sh
git clone git@github.com:msperlin/b3-dfp.git
cd b3-dfp
uv sync
```
Run it over stdio:
```sh
uv run b3-dfp
```
Or install it as a tool:
```sh
uv tool install .
b3-dfp
```
## Register with OpenCode
Add to `~/.config/opencode/opencode.json` (or a project `opencode.json`):
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"b3-dfp": {
"type": "local",
"command": [
"uv",
"run",
"--directory",
"/home/msperlin/GitRepo/mcp-servers/b3-dfp",
"b3-dfp"
]
}
}
}
}
```
See `opencode.example.json`.
## Usage examples
Ask the agent:
- *"What was WEG's net income in 2023?"*
→ `search_company("weg")` → `get_statement_summary(5410, 2023)`
- *"Plot Petrobras' revenue, EBIT and net income from 2019 to 2024."*
→ `get_account_history([9512], "revenue", first_year=2019, last_year=2024)`
- *"Compare Itaú and Bradesco's total assets."*
→ `get_account_history([19348, 906], "total_assets", doc_type="BPA")`
- *"Give me the quarterly income statement of Vale for 2023."*
→ `get_financial_statements([4170], 2023, 2023, data_type="itr", doc_type="DRE", format="ind", quarterly=True)`
## Configuration
| Variable | Default | Meaning |
| --- | --- | --- |
| `B3DFP_CACHE_DIR` | `~/.cache/b3-dfp` | Where zips, Parquet and the registry live. |
| `B3DFP_REFRESH_HOURS` | `12` | How long a downloaded archive is considered fresh. `0` disables revalidation. |
| `MCP_TRANSPORT` | `stdio` | `stdio`, `streamable-http` or `sse`. |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8000` | Bind address for HTTP transports. |
Disk usage: one year of DFP is a 13-25 MB download; the documents you actually
query are then cached as tens of megabytes of Parquet. Only the documents you
ask for are built, and the cache lives outside the repository.
### Performance (measured)
From an empty cache, on a normal connection:
| Operation | Time |
| --- | --- |
| `search_company` (registry download, 1.5 MB) | 0.45 s |
| `get_statement_summary` (2023 DFP, 3 documents) | 2.5 s |
| `get_account_history`, 3 years | 3.0 s |
| Same queries, cache warm | 0.01–0.05 s |
The full `scripts/smoke_test.py` run, including every download it needs, takes
about 7 seconds from scratch and leaves a 42 MB cache.
## Data notes
- **Values.** `VL_CONTA` is the number exactly as published, usually in
thousands (`ESCALA_MOEDA = MIL`). The tools also return `VL_CONTA_BRL` /
`value_brl`, already multiplied out, which is what you want to quote.
- **Account codes.** `CD_CONTA` follows CVM's standardized plan: `1` assets,
`2` liabilities and equity, `3.xx` income statement, `6.xx` cash flow,
`7.xx` value added. The meaning of a code depends on the industry chart (bank,
insurer, industrial), so `DS_CONTA` — always returned — is authoritative.
Role names (`revenue`, `net_income`, `equity`, ...) are resolved from the
label rather than the code alone, so they behave consistently across charts:
Itaú's net income is `3.09`, Bradesco's is `3.11`, and neither is reported
as `EBIT` (banks have no such line).
- **Consolidated vs individual.** `con` is the consolidated statement (default),
`ind` is the parent-only statement.
- **Amended filings.** When a company files the same account twice, the highest
`VERSAO` wins.
- **ITR is not one number per account.** Quarterly filings publish *both* a
year-to-date column and a quarter-only column for the same account and date.
The `period` column (`ytd`, `quarter`, `annual`, `snapshot`) tells them apart,
and `quarterly=True` differences the year-to-date figures (Q4 comes from the
annual DFP, since no ITR covers it).
- **Freshness.** CVM republishes the yearly archives during the filing season;
the server revalidates with `If-Modified-Since` instead of re-downloading.
## Development
```sh
uv sync
uv run pytest -q # 66 unit tests, no network
uv run python scripts/smoke_test.py # real MCP handshake + live CVM queries
uv run python scripts/parity_check.py # cell-by-cell diff against GetDFPData2 (needs R)
```
### Correctness
`scripts/parity_check.py` runs the R package `GetDFPData2` and this server on the
same filings, then compares every row. It currently matches exactly — same row
count, same account codes, same labels, same values — for Banco do Brasil
(bank chart), BRF (a filing with an unbalanced quote inside `DS_CONTA`) and Vale
in 2023, across BPA, BPP and DRE: **609 rows, zero differences**.
## License
MIT. Data is public and provided by CVM; see
<https://dados.cvm.gov.br/dados/CIA_ABERTA/DOC/DFP/>.