Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions docs/DEPLOY_WORKERS_MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@ As tools deste Worker chamam fontes públicas oficiais: APIs JSON (BCB,
IBGE, IPEA, SICONFI, Open Finance Directory) e os ZIPs/CSV da CVM em
`dados.cvm.gov.br` para fundos abertos (`cvm_fund`).

Fora deste Worker (CDA/carteira, lâmina, perfil, B3 COTAHIST, ANBIMA XLS,
Fora deste Worker (lâmina, perfil, B3 COTAHIST, ANBIMA XLS,
registry FTS5, code mode): `pip install openfindata` ou FastAPI interno.

`cvm_fund` no Worker:

- `dataset=catalog` + `cnpj` ou `q` — cadastro oficial RCVM 175
(`registro_fundo_classe.zip`: fundo + classe + subclasse). `cad_fi.csv`
não lista fundos já adaptados à Resolução 175.
- `dataset=daily` + `cnpj` — série de cotas INF_DIARIO do mês
(`year`/`month`; default = mês UTC corrente).
- Não inclui CDA (composição da carteira): feed mensal atrasado e pesado,
separado do informe diário. Não usa Mais Retorno.
- `dataset=daily` + `cnpj` — série de cotas INF_DIARIO. Sem `year`/`month`
usa o mês mais recente no diretório CVM; `months` (1–3) olha para trás.
- `dataset=periods` — stamps YYYYMM publicados (`product=CDA` ou `INF_DIARIO`).
- `dataset=holdings` + `cnpj` — CDA (carteira). Sem `year`/`month` usa o
CDA mais recente. Scan em stream por CNPJ; linhas `CONFID` são sigilo,
não carteira aberta completa. Não usa Mais Retorno.

## Deploy

Expand Down Expand Up @@ -57,9 +59,9 @@ curl -sS https://openfindata.com.br/health
```

Upstream calls no Worker têm timeout (15s) e teto de payload (2 MB;
8 MB no Directory Open Finance; 16 MB / 45s só em `cvm_fund`, porque o
cadastro e o INF_DIARIO mensal vêm em ZIP). O Worker faz scan em stream
do CSV deflate (não materializa os ~48 MB do INF_DIARIO). Séries BCB sem
8 MB no Directory Open Finance; 32 MB / 45s só em `cvm_fund`, porque
cadastro, INF_DIARIO e CDA vêm em ZIP). O Worker faz scan em stream do
CSV deflate (não materializa o CDA descompactado). Séries BCB sem
intervalo caem em `last_n≤200`. Rate limits de `/mcp` não mudam.

`/mcp` usa Workers Rate Limit bindings (não Cloudflare Queues): 60 req /
Expand Down
10 changes: 6 additions & 4 deletions docs/MCP_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
> and [`docs/DEPLOY_WORKERS_MCP.md`](DEPLOY_WORKERS_MCP.md). Public `/mcp` is
> 60 req/60s per IP with a 20/10s burst; overflow is 429 + Retry-After (no queue,
> no code mode, no API key). Worker tools: the 9 JSON macro sources plus
> `cvm_fund` (RCVM 175 cadastro + INF_DIARIO). CDA/lâmina/perfil stay on
> the internal FastAPI catalog below.
> `cvm_fund` (RCVM 175 cadastro + INF_DIARIO + CDA holdings/periods).
> Lâmina/perfil stay on the internal FastAPI catalog below.

## Problem

Expand Down Expand Up @@ -86,7 +86,7 @@ findata_run_code (code mode, opt-in)
| `bcb_ptax` | `/ptax/usd`, `/ptax/usd/period`, `/ptax/{currency}` | `start`+`end` → period |
| `bcb_focus` | `/focus/{indicators,annual,monthly,selic,top5}` | `horizon`, `panel`, `indicator` |
| `cvm_company` | companies search/list, `fca/*`, `ipe` | `dataset=search\|list\|fca_*\|filings` |
| `cvm_fund` | `funds`, `funds/cadastro`, `funds/{daily,holdings,lamina,profile,periods}`, returns | `dataset`; `cnpj`/`q` → RCVM 175 |
| `cvm_fund` | `funds`, `funds/cadastro`, `funds/{daily,holdings,lamina,profile,periods}`, returns | `dataset`; `cnpj`/`q` → RCVM 175; omit year/month → latest CDA/INF_DIARIO |
| `cvm_structured_fund` | `funds/{fii,fidc,fip}/*` | `kind` + `dataset` |
| `b3_index` | index portfolio + monthly + list | `dataset`, omit `symbol` to list |
| `tesouro_bonds` | bonds list/search/history | `dataset` |
Expand All @@ -101,7 +101,7 @@ findata_run_code (code mode, opt-in)
deliverable, not an afterthought.
- **Consolidation can hide endpoint-specific params behind an enum.** Mitigated
by documenting each `dataset`/`kind` value and validating bad combinations with
a `400` (e.g. `cvm_fund dataset=holdings` requires `cnpj`+`month`), matching the
a `400` (e.g. `cvm_fund dataset=holdings` requires `cnpj`; month defaults to latest), matching the
REST API's `ValueError → 400` behaviour.
- **Discoverability of rare endpoints.** A handful of niche REST routes are not
individually surfaced as tools. They remain fully reachable over REST and via
Expand All @@ -122,4 +122,6 @@ local/agent use. A production deployment should run it in a real sandbox
- `bcb_ptax(start=2024-01-02, end=2024-01-05)` → daily PTAX USD series (the handoff's headline flow).
- `cvm_fund(dataset=catalog, cnpj="38.729.027/0001-92")` → cadastro RCVM 175 (classe, condomínio, PL).
- `cvm_fund(dataset=daily, cnpj="38729027000192", year=2026, month=8)` → INF_DIARIO (cota/PL/cotistas).
- `cvm_fund(dataset=periods, product="CDA")` → YYYYMM stamps + `latest`.
- `cvm_fund(dataset=holdings, cnpj="38729027000192")` → latest CDA carteira (CONFID = sigilo).
- `findata_run_code("import findata; ...")` → runs in the sandbox, returns captured stdout.
2 changes: 1 addition & 1 deletion docs/agents/orientation.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ mortos, fronteira atual, openfindata, findata.
| Rede nos unit tests? | Proibido. `respx` nos unitários; live só `@pytest.mark.integration` | `AGENTS.md`, CI nightly |
| Credenciais no repo? | Nunca. Fontes públicas preferidas; BdD usa billing project do operador via env | `AGENTS.md`, `docs/SOURCES_WITH_AUTH.md` |
| MCP: 1:1 com REST ou curado? | Catálogo curado em `mcp_app` (~25 tools); REST intacto | `docs/MCP_SURFACE.md` |
| MCP público vs interno? | Worker `openfindata.com.br/mcp` (macro JSON + `cvm_fund`); FastAPI/Tailscale tem o catálogo completo | `docs/DEPLOY_WORKERS_MCP.md` |
| MCP público vs interno? | Worker `openfindata.com.br/mcp` (macro JSON + `cvm_fund` catalog/daily/holdings/periods); FastAPI/Tailscale tem o catálogo completo | `docs/DEPLOY_WORKERS_MCP.md` |
| Code mode no MCP? | Opt-in via `FINDATA_MCP_CODE_MODE=1`; off por default | `docs/MCP_SURFACE.md`, `mcp_app.py` |
| Charts: quais deps de plot? | Não adicionar matplotlib/pandas/plotly etc. só para gráfico | `AGENTS.md`, `docs/CHART_STANDARDS.md` |
| Publicar no PyPI? | Só com aprovação humana explícita | `AGENTS.md` |
Expand Down
65 changes: 56 additions & 9 deletions src/findata/api/mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
holdings,
ipe,
lamina,
latest_period,
list_periods,
profile,
)
Expand All @@ -73,6 +74,36 @@

_MIN_YEAR_B3_COTAHIST = 1986 # B3 publishes COTAHIST since 1986
_RGF_MAX_PERIOD = 3 # RGF quadrimestre runs 1..3
_DAILY_MONTHS_MAX = 12
_YYYYMM_LEN = 6


def _stamp_to_year_month(stamp: str) -> tuple[int, int]:
if len(stamp) != _YYYYMM_LEN or not stamp.isdigit():
raise HTTPException(404, f"invalid CVM period stamp {stamp!r}")
return int(stamp[:4]), int(stamp[_YYYYMM_LEN - 2 :])


def _add_months(year: int, month: int, delta: int) -> tuple[int, int]:
absolute = year * 12 + (month - 1) + delta
return absolute // 12, absolute % 12 + 1


def _lookback_months(end_year: int, end_month: int, count: int) -> list[tuple[int, int]]:
return [_add_months(end_year, end_month, offset) for offset in range(-(count - 1), 1)]


async def _resolve_cvm_month(year: int | None, month: int | None, product: str) -> tuple[int, int]:
if (year is None) != (month is None):
raise HTTPException(
400, "pass both `year` and `month`, or omit both for the latest published file"
)
if year is not None and month is not None:
return year, month
latest = await latest_period("FI", f"DOC/{product}")
if not latest:
raise HTTPException(404, f"no published {product} period")
return _stamp_to_year_month(latest)


# ── Registry: the entry point ─────────────────────────────────────
Expand Down Expand Up @@ -316,8 +347,13 @@ async def cvm_fund(
q: str | None = Query(
None, min_length=2, description="catalog: name fragment when CNPJ is unknown"
),
year: int | None = Query(None, description="Reference year (required except catalog/periods)"),
year: int | None = Query(
None, description="Reference year; omit with month for latest CDA/INF_DIARIO"
),
month: int | None = Query(None, ge=1, le=12, description="Reference month (monthly datasets)"),
months: int = Query(
1, ge=1, le=_DAILY_MONTHS_MAX, description="daily: lookback months including the end month"
),
horizon: Literal["monthly", "yearly"] = Query(
"monthly", description="returns granularity (dataset=returns)"
),
Expand All @@ -339,26 +375,37 @@ async def cvm_fund(
"""Open funds in one tool. ``catalog`` with ``cnpj`` or ``q`` reads the official
RCVM 175 registro (fundo+classe+subclasse). Bare ``catalog`` still pages the
legacy ``cad_fi.csv`` (non-adapted funds only). ``periods`` lists YYYYMM
stamps. ``daily`` is INF_DIARIO (cota/PL/cotistas). CDA ``holdings`` is a
separate monthly delayed feed and is not the cota series.
stamps. ``daily`` is INF_DIARIO (cota/PL/cotistas); omit ``year``/``month``
for the latest published month, or pass ``months`` to look back. CDA
``holdings`` is a separate monthly delayed feed — omit ``year``/``month``
for the latest CDA. CONFID rows are sigilo, not a complete open book.
"""
if dataset == "catalog":
if cnpj or q:
return await get_fund_cadastro(cnpj=cnpj, q=q, limit=limit)
return (await funds.get_fund_catalog(True, None))[:limit]
if dataset == "periods":
return await list_periods("FI", f"DOC/{product}")
if year is None:
raise HTTPException(400, f"dataset={dataset} requires `year`")
if dataset == "holdings":
if not cnpj or month is None:
raise HTTPException(400, "dataset=holdings requires `cnpj` and `month`")
if not cnpj:
raise HTTPException(400, "dataset=holdings requires `cnpj`")
year, month = await _resolve_cvm_month(year, month, "CDA")
block_list = [b.strip() for b in blocks.split(",") if b.strip()] if blocks else None
return await holdings.get_fund_holdings(cnpj, year, month, block_list)
if dataset == "daily":
year, month = await _resolve_cvm_month(year, month, "INF_DIARIO")
if months == 1:
return (await funds.get_fund_daily(year, month, cnpj))[:limit]
series: list[Any] = []
for stamp_year, stamp_month in _lookback_months(year, month, months):
series.extend(await funds.get_fund_daily(stamp_year, stamp_month, cnpj))
if len(series) >= limit:
break
return series[:limit]
if year is None:
raise HTTPException(400, f"dataset={dataset} requires `year`")
if month is None:
raise HTTPException(400, f"dataset={dataset} requires `month`")
if dataset == "daily":
return (await funds.get_fund_daily(year, month, cnpj))[:limit]
if dataset == "lamina":
return (await lamina.get_fund_lamina(year, month, cnpj))[:limit]
if dataset == "profile":
Expand Down
63 changes: 61 additions & 2 deletions tests/test_mcp_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@
from __future__ import annotations

import importlib
import re

import httpx
import pytest
import respx
from fastapi.testclient import TestClient

from findata.api.app import app
from findata.api.mcp_app import mcp_app
from findata.http_client import clear_cache
from findata.sources.cvm._directory import _listing_cache
from tests.test_cvm_fund_cadastro import _daily_zip
from tests.test_cvm_funds import _LISTING_HTML, _make_cda_zip

EXPECTED_TOOLS = 25 # curated tools with code mode OFF (the default)
EXPECTED_REST_OPERATIONS = 97 # all REST routes (unconditional); bump when the surface changes
Expand Down Expand Up @@ -103,12 +110,21 @@ def test_consolidated_tool_validates_missing_selector_args() -> None:
assert "year" in r.json()["detail"]


def test_cvm_fund_holdings_requires_cnpj_and_month() -> None:
r = TestClient(mcp_app).get("/cvm/fund", params={"dataset": "holdings", "year": 2024})
def test_cvm_fund_holdings_requires_cnpj() -> None:
r = TestClient(mcp_app).get("/cvm/fund", params={"dataset": "holdings"})
assert r.status_code == 400
assert "cnpj" in r.json()["detail"]


def test_cvm_fund_holdings_year_without_month_is_400() -> None:
r = TestClient(mcp_app).get(
"/cvm/fund",
params={"dataset": "holdings", "cnpj": "12.345.678/0001-99", "year": 2024},
)
assert r.status_code == 400
assert "year" in r.json()["detail"] or "month" in r.json()["detail"]


# ── code-mode gating ───────────────────────────────────────────────


Expand Down Expand Up @@ -154,3 +170,46 @@ def test_structured_fund_fip_rejects_dataset() -> None:
"/cvm/structured-fund", params={"kind": "fip", "year": 2024, "dataset": "geral"}
)
assert r.status_code == 400


@respx.mock
def test_cvm_fund_holdings_defaults_to_latest_cda() -> None:
clear_cache()
_listing_cache.invalidate()
respx.get("https://dados.cvm.gov.br/dados/FI/DOC/CDA/DADOS/").mock(
return_value=httpx.Response(200, text=_LISTING_HTML)
)
respx.get(re.compile(r"https://.*cda_fi_202603\.zip")).mock(
return_value=httpx.Response(200, content=_make_cda_zip())
)
r = TestClient(mcp_app).get(
"/cvm/fund", params={"dataset": "holdings", "cnpj": "12.345.678/0001-99"}
)
assert r.status_code == 200
body = r.json()
assert len(body) == 3
assert {row["bloco"] for row in body} == {"BLC_4", "BLC_8"}


@respx.mock
def test_cvm_fund_daily_months_lookback() -> None:
clear_cache()
payload = _daily_zip()
respx.get(re.compile(r"https://.*inf_diario_fi_202607\.zip")).mock(
return_value=httpx.Response(200, content=payload)
)
respx.get(re.compile(r"https://.*inf_diario_fi_202608\.zip")).mock(
return_value=httpx.Response(200, content=payload)
)
r = TestClient(mcp_app).get(
"/cvm/fund",
params={
"dataset": "daily",
"cnpj": "38729027000192",
"year": 2026,
"month": 8,
"months": 2,
},
)
assert r.status_code == 200
assert len(r.json()) == 4
2 changes: 1 addition & 1 deletion workers/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openfindata-mcp",
"private": true,
"version": "0.3.1",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "wrangler dev",
Expand Down
4 changes: 2 additions & 2 deletions workers/mcp/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ <h1>MCP público no Cloudflare Workers</h1>
<li><code>ipea_series</code> <code>ipea_search</code></li>
<li><code>tesouro_siconfi</code></li>
<li><code>openfinance_directory</code></li>
<li><code>cvm_fund</code> — cadastro RCVM 175 + série de cotas INF_DIARIO (não CDA)</li>
<li><code>cvm_fund</code> — cadastro RCVM 175, cotas INF_DIARIO e carteira CDA</li>
</ul>
<p class="muted">
B3/ANBIMA/registry, CDA/lâmina/perfil e a API REST completa continuam no
B3/ANBIMA/registry, lâmina/perfil e a API REST completa continuam no
pacote Python (<code>pip install openfindata</code>) e no FastAPI interno.
</p>
<p>Cliente MCP:</p>
Expand Down
2 changes: 1 addition & 1 deletion workers/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default {
return Response.json({
status: "ok",
surface: "mcp-worker",
version: "0.3.1",
version: "0.3.2",
mcp: "/mcp",
});
}
Expand Down
2 changes: 1 addition & 1 deletion workers/mcp/src/lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const USER_AGENT = "openfindata-mcp/0.3.1 (+https://github.com/robertoecf/OpenFinData)";
const USER_AGENT = "openfindata-mcp/0.3.2 (+https://github.com/robertoecf/OpenFinData)";

export const FETCH_TIMEOUT_MS = 15_000;
export const MAX_RESPONSE_BYTES = 2_000_000;
Expand Down
21 changes: 21 additions & 0 deletions workers/mcp/src/lib/zipCsv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ function findZipEntry(zip: Uint8Array, name: string): ZipEntry {
throw new Error(`zip entry not found: ${name}`);
}

export function listZipEntryNames(zip: Uint8Array): string[] {
const names: string[] = [];
let offset = 0;
while (offset + 30 <= zip.length) {
const sig = u32(zip, offset);
if (sig === CENTRAL_SIG) {
break;
}
if (sig !== LOCAL_SIG) {
throw new Error("invalid zip local header");
}
const compSize = u32(zip, offset + 18);
const nameLen = u16(zip, offset + 26);
const extraLen = u16(zip, offset + 28);
const nameStart = offset + 30;
names.push(LATIN1.decode(zip.subarray(nameStart, nameStart + nameLen)));
offset = nameStart + nameLen + extraLen + compSize;
}
return names;
}

function inflateRawStream(data: Uint8Array): ReadableStream<Uint8Array> {
return new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
}
Expand Down
14 changes: 10 additions & 4 deletions workers/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function wrap<T>(run: (args: T) => Promise<ToolResult>) {
export function createServer() {
const server = new McpServer({
name: "openfindata",
version: "0.3.1",
version: "0.3.2",
websiteUrl: "https://openfindata.com.br",
});

Expand Down Expand Up @@ -141,13 +141,19 @@ export function createServer() {
"cvm_fund",
{
description:
"CVM fund registry + open-fund cota series. catalog: RCVM 175 cadastro by CNPJ or name (any registered type; forma_condominio says Aberto/Fechado). daily: INF_DIARIO cota/PL/cotistas for one month (fundos abertos). Not CDA carteira. Not Mais Retorno.",
"CVM open-fund raw layer. catalog: RCVM 175 cadastro by CNPJ or name. daily: INF_DIARIO cota/PL/cotistas (omit year/month for latest published month; months=1..3 lookback). periods: available CDA or INF_DIARIO YYYYMM stamps. holdings: CDA carteira for one month (omit year/month for latest; CONFID is sigilo, not a complete open book). Not Mais Retorno.",
inputSchema: {
dataset: z.enum(["catalog", "daily"]).default("catalog"),
dataset: z.enum(["catalog", "daily", "holdings", "periods"]).default("catalog"),
cnpj: z.string().optional().describe("Fund CNPJ, punctuated or digits"),
q: z.string().optional().describe("catalog: name fragment when CNPJ is unknown"),
year: z.number().int().min(2021).optional(),
year: z.number().int().min(2018).optional(),
month: z.number().int().min(1).max(12).optional(),
months: z.number().int().min(1).max(3).optional().describe("daily: lookback months including the end month"),
product: z.enum(["CDA", "INF_DIARIO"]).optional().describe("periods: which directory to list"),
blocks: z
.string()
.optional()
.describe("holdings: comma list such as BLC_1,BLC_4 (BLC_1..8, CONFID, PL, FIE)"),
limit: z.number().int().min(1).max(2000).optional(),
},
},
Expand Down
Loading
Loading