Skip to main content
Glama
b3-dfp

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/>.