diff --git a/docs/MCP_SURFACE.md b/docs/MCP_SURFACE.md index 7a96a9a..cd221b5 100644 --- a/docs/MCP_SURFACE.md +++ b/docs/MCP_SURFACE.md @@ -8,6 +8,11 @@ > no code mode, no API key). Worker tools: the 9 JSON macro sources plus > `cvm_fund` (RCVM 175 cadastro + INF_DIARIO history ≤12 months + CDA holdings/periods). > Lâmina/perfil stay on the internal FastAPI catalog below. +> +> **The two surfaces have diverged.** The Worker enforces the response contract +> in [Worker response contract](#worker-response-contract); the internal FastAPI +> catalog does not yet. Tool names are shared, so do not assume a Worker example +> reproduces verbatim on the internal catalog. ## Problem @@ -90,10 +95,46 @@ findata_run_code (code mode, opt-in) | `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` | -| `tesouro_siconfi` | `rreo`, `rgf`, `entes` | `report` | +| `tesouro_siconfi` | `rreo`, `rgf`, `entes` | `report`; Worker also takes `uf`/`esfera`/`q`/`limit` to narrow `entes` | | `anbima` | ima, ettj, debentures, tpf | `dataset=ima\|ettj\|debentures\|tpf` | | `openfinance_directory` | participants/endpoints/resources/roles | `dataset` | +## Worker response contract + +The public Worker holds these invariants. The internal FastAPI catalog does not +yet, which is the divergence noted at the top. + +- **Unknown arguments are rejected.** Every tool schema is a `z.strictObject`, + so `tools/list` advertises `additionalProperties: false` and a misspelled + argument fails loudly instead of being dropped. Guessing `n` for `last_n` + used to return a silently different result. +- **Long results are bounded and say so.** `tesouro_siconfi` returns + `{total_matched, returned, limit, truncated, upstream_complete, entes|rows}`. + `truncated: true` means the caller did not see everything. `entes` defaults to + `limit=100` and narrows with `uf`, `esfera` and `q` (accent-insensitive); + reports default to 2000, and `limit` is capped at 2000 so a large `limit` + cannot rebuild the old payload. Unbounded `report=entes` used to return about + 500 KB in one response. +- **Completeness is never assumed.** Two different things hide rows, and the + envelope separates them. `truncated` says this response was cut; + `upstream_complete: false` says SICONFI still had pages when the page cap + stopped the fetch, so `total_matched` counts only the rows fetched and is not + a global total. `truncated` is true whenever either happened. +- **An empty result explains itself.** `bcb_ptax` returns + `{currency, date|start+end, count, cotacoes}`, and when `count` is 0 it adds + `reason` plus a human-readable `note`. A single day resolves to + `non_business_day`, `not_published_yet`, `future_date` or `no_quote_for_date`; + a range resolves to `no_quotes_in_range`, because one day's diagnosis would be + wrong across many dates. An empty list is not an error, and the caller should + not retry blindly. +- **A non-JSON upstream is named, not leaked.** BCB's SGS gateway answers HTTP + 200 with an HTML error page when it is degraded. `parseJsonBody` raises + `UpstreamFormatError` naming the URL and quoting the first bytes, instead of + surfacing `Unexpected token '<'`. +- **OData query options keep literal spaces.** `URLSearchParams` form-encodes a + space as `+`, and Olinda answers 400 for `$orderby=Data+desc`. `buildUrl` + re-encodes those as `%20`. This broke every `bcb_focus` path in production. + ## Tradeoffs - **Fewer but "fatter" tools.** Each carries a `dataset` enum and more doc. The @@ -120,6 +161,7 @@ local/agent use. A production deployment should run it in a real sandbox - `registry_lookup(q="PETR4")` → PETROBRAS, CNPJ `33.000.167/0001-01`, `[PETR3, PETR4]` (offline). - `bcb_ptax(start=2024-01-02, end=2024-01-05)` → daily PTAX USD series (the handoff's headline flow). + On the Worker the series is under `cotacoes`, wrapped as described below. - `cvm_fund(dataset=catalog, cnpj="38.729.027/0001-92")` → cadastro RCVM 175 (classe, condomínio, PL, subclasses). - `cvm_fund(dataset=daily, cnpj="38729027000192", start="2025-09-01", end="2026-08-31")` → INF_DIARIO history (`served[].nicename`). - `cvm_fund(dataset=periods, product="CDA")` → YYYYMM stamps + `latest`. diff --git a/workers/mcp/package.json b/workers/mcp/package.json index 88d05a5..84610e4 100644 --- a/workers/mcp/package.json +++ b/workers/mcp/package.json @@ -7,7 +7,7 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "types": "wrangler types", - "test": "node --test --experimental-strip-types src/rateLimit.test.ts src/tools/cvm.test.ts src/security.test.ts" + "test": "node --test --experimental-strip-types src/rateLimit.test.ts src/tools/cvm.test.ts src/security.test.ts src/tools/contract.test.ts" }, "dependencies": { "@cloudflare/workers-types": "^5.20260813.1", diff --git a/workers/mcp/public/index.html b/workers/mcp/public/index.html index b1eefbb..2359453 100644 --- a/workers/mcp/public/index.html +++ b/workers/mcp/public/index.html @@ -3,61 +3,291 @@ - openfindata — MCP público + OpenFinData: dados oficiais do Brasil direto no seu assistente de IA + + + + +
-

Dados Financeiros Abertos · alpha

-

MCP público no Cloudflare Workers

-

- Superfície pública: /mcp (Streamable HTTP). - REST/FastAPI, CLI e code mode não ficam na internet — só na rede Tailscale. +

Dados Financeiros Abertos · alpha

+

Dados oficiais do Brasil, direto no seu assistente de IA

+

+ Conecte o ChatGPT, o Claude ou o Cursor a este endereço e pergunte em + português sobre Selic, IPCA, Boletim Focus, fundos da CVM e finanças + públicas. A resposta vem da fonte primária: Banco Central, IBGE, IPEA, + Tesouro Nacional, CVM e Open Finance Brasil.

-

- Uso justo: cerca de 60 req/min por IP (pico 20 / 10s). Sem API key. - Sem execução de código no endpoint público. Excesso em - /mcp responde 429 + Retry-After. +

+ Gratuito + Sem cadastro + Sem chave de API + Código aberto, MIT + 10 ferramentas +
+ +

O que dá para perguntar

+

+ Exemplos reais, respondidos com dado oficial. Você escreve assim mesmo, + em linguagem natural, dentro do seu assistente.

-

Tools neste Worker (fontes públicas, sem processar Python):

- -

- B3/ANBIMA/registry, lâmina/perfil e a API REST completa continuam no - pacote Python (pip install openfindata) e no FastAPI interno. +

+ "Como a Selic evoluiu nos últimos 12 meses?" + bcb_series, série 432 do SGS do Banco Central +
+
+ "O que o Boletim Focus projeta para o IPCA de 2027?" + bcb_focus, expectativas de mercado coletadas pelo BCB +
+
+ "Qual foi a inflação por grupo no último IPCA? O que puxou para cima?" + ibge_ipca_breakdown, abertura que não existe no SGS +
+
+ "Mostre a carteira mais recente do fundo com CNPJ 21.624.757/0001-26." + cvm_fund, informe de composição de carteira (CDA) da CVM +
+
+ "Quais municípios do Mato Grosso aparecem no SICONFI e qual a população deles?" + tesouro_siconfi, dados de finanças públicas do Tesouro Nacional +
+ +

Como conectar em um minuto

+

+ Este serviço fala MCP (Model Context Protocol), o padrão + aberto que permite a um assistente de IA consultar uma fonte de dados + externa com segurança. Na prática: você cola um endereço na configuração + do seu assistente e ele ganha acesso às ferramentas listadas abaixo.

-

Cliente MCP:

-
{
+      

Claude Code, Cursor e clientes que leem um JSON

+
{
   "mcpServers": {
     "openfindata": {
+      "type": "http",
       "url": "https://openfindata.com.br/mcp"
     }
   }
-}
+}
+

+ O "type": "http" é obrigatório. Sem ele, o Claude Code trata + a entrada como servidor local e ignora a URL. +

+

Claude Desktop

+

+ O Desktop não lê esse JSON para servidor remoto. Vá em Configurações, + Conectores, Adicionar conector personalizado e cole + https://openfindata.com.br/mcp. +

+

ChatGPT

+

+ O ChatGPT não lê o JSON acima. Conector personalizado lá depende do + Modo desenvolvedor, que está em beta, só funciona na + versão web e não existe no plano gratuito. Em conta de + trabalho, o administrador precisa liberar. Com o modo ligado, você cria + um conector novo e cola + https://openfindata.com.br/mcp, sem autenticação. +

+

+ O plano exigido e o caminho exato do menu já mudaram mais de uma vez, e + por isso não repetimos aqui: confira o passo atual na + ajuda + oficial da OpenAI. +

+

Qualquer outro cliente MCP

+

+ Em cliente que peça só a URL, use o mesmo endereço e escolha o + transporte Streamable HTTP. Não há etapa de login. +

+ +

As 10 ferramentas

+
+ + + + + + + + + + + + + + + + +
FerramentaO que entregaFonte
bcb_seriesSéries temporais: Selic, IPCA, CDI, câmbio e o catálogo completoBCB / SGS
bcb_ptaxCâmbio oficial PTAX, por dia ou por períodoBCB / Olinda
bcb_focusBoletim Focus: expectativas de mercado para IPCA, Selic, PIB e câmbioBCB
ibge_indicatorIndicadores econômicos do IBGEIBGE / SIDRA
ibge_ipca_breakdownIPCA mensal aberto por grupoIBGE
ipea_seriesSéries macro com histórico longoIPEA Data
ipea_searchBusca textual em cerca de 8 mil sériesIPEA Data
tesouro_siconfiFinanças públicas: RREO, RGF e a lista de entes federativosTesouro / SICONFI
cvm_fundFundos: cadastro RCVM 175, cotas diárias e carteira (CDA)CVM
openfinance_directoryDiretório público de participantes e endpointsOpen Finance Brasil
+
+ +

O que este serviço não faz

+ + +

Limites e transparência

+ + +

Para quem desenvolve

- Código: - github.com/robertoecf/OpenFinData + O endpoint público é só o MCP. A biblioteca Python, a API REST em FastAPI, + a CLI e as fontes adicionais (B3, ANBIMA, SUSEP, ANEEL, Receita Federal e + o registro de CNPJ e ticker) ficam no repositório e rodam localmente. + Instruções de instalação, contribuição e a matriz completa de fontes + estão no + GitHub. + Resumo legível por máquina em /llms.txt.

+ +
diff --git a/workers/mcp/public/llms.txt b/workers/mcp/public/llms.txt new file mode 100644 index 0000000..628bfbf --- /dev/null +++ b/workers/mcp/public/llms.txt @@ -0,0 +1,67 @@ +# OpenFinData + +> MCP público e gratuito com dados financeiros e macroeconômicos oficiais do +> Brasil: Banco Central, IBGE, IPEA, Tesouro Nacional, CVM e Open Finance Brasil. +> Sem cadastro e sem chave de API. Endpoint: https://openfindata.com.br/mcp +> (Streamable HTTP, protocolo MCP 2025-06-18). + +Serve dado oficial de fonte primária, sem indicador derivado. O servidor não +calcula Sharpe, CAGR nem drawdown, e não revende dado de terceiros: o que ele +entrega é o que o órgão publicou, normalizado. + +## Como conectar + +Qualquer cliente MCP. Configuração mínima: + + { "mcpServers": { "openfindata": { "type": "http", "url": "https://openfindata.com.br/mcp" } } } + + O "type": "http" e obrigatorio em clientes que leem esse JSON, como o Claude Code. + O Claude Desktop adiciona servidor remoto em Configuracoes > Conectores > + Adicionar conector personalizado, colando a mesma URL. + O ChatGPT nao le esse JSON: conector personalizado la exige o Modo + desenvolvedor (beta, so na web, indisponivel no plano gratuito, e liberado + pelo administrador em conta de trabalho). O plano exigido e o caminho do + menu mudam, entao confira em + https://help.openai.com/en/articles/12584461-connecting-custom-mcp-servers-in-chatgpt + Em qualquer outro cliente que peca so a URL, escolha o transporte + Streamable HTTP. Nao ha etapa de login. + +## Ferramentas + +- bcb_series: séries temporais do BCB (SGS). Selic, IPCA, CDI, câmbio. Omita argumentos para listar o catálogo. +- bcb_ptax: PTAX oficial. Consulta por dia ou por período (período só em USD). Quando não há cotação, devolve o campo reason explicando o motivo. +- bcb_focus: Boletim Focus, expectativas de mercado. indicator=list lista os nomes disponíveis. +- ibge_indicator: indicadores econômicos do IBGE. Omita name para listar o catálogo. +- ibge_ipca_breakdown: variação mensal do IPCA por grupo, detalhe que não existe no SGS. +- ipea_series: séries do IPEA Data. Omita sercodigo para o catálogo curado. +- ipea_search: busca textual em cerca de 8 mil séries do IPEA. +- tesouro_siconfi: finanças públicas via SICONFI (RREO, RGF e a lista de entes). Comece por report=entes com filtro uf ou q para achar o cod_ibge. +- cvm_fund: camada oficial de fundos da CVM. Cadastro RCVM 175, cotas diárias (INF_DIARIO) e carteira (CDA). +- openfinance_directory: diretório público do Open Finance Brasil. Apenas descoberta pública, nunca dado de cliente. + +## Contrato de resposta + +- Respostas longas vêm limitadas, não paginadas: não há argumento de página ou + offset. Elas trazem total_matched, returned, limit, truncated e + upstream_complete. +- Se truncated for true, você não viu tudo. Estreite a busca com os filtros da + ferramenta (em tesouro_siconfi: uf, esfera, q). Aumentar limit ajuda só até o + teto de 2000, e não busca páginas que a fonte não chegou a entregar. +- Se upstream_complete for false, a fonte ainda tinha páginas quando a busca + parou: total_matched conta só as linhas buscadas e não é um total geral. + Aumentar limit não recupera essas linhas. +- Parâmetro desconhecido é rejeitado com erro explícito, nunca ignorado em silêncio. +- Lista vazia não é erro. Quando couber, um campo reason diz o porquê. Em + bcb_ptax por dia: non_business_day, not_published_yet, future_date ou + no_quote_for_date; por período: no_quotes_in_range. + +## Limites + +- Uso justo de cerca de 60 requisições por minuto por IP, com pico de 20 a cada 10 segundos. +- Excesso responde HTTP 429 com Retry-After. +- Sem chave de API e sem execução de código no endpoint público. + +## Links + +- Código e issues: https://github.com/robertoecf/OpenFinData +- Licença: MIT diff --git a/workers/mcp/src/lib/http.ts b/workers/mcp/src/lib/http.ts index abdd285..cdde516 100644 --- a/workers/mcp/src/lib/http.ts +++ b/workers/mcp/src/lib/http.ts @@ -21,6 +21,13 @@ export class UpstreamLimitError extends Error { } } +export class UpstreamFormatError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "UpstreamFormatError"; + } +} + export type GetJsonOptions = { maxBytes?: number; timeoutMs?: number; @@ -45,11 +52,18 @@ export async function getBytes(url: string, options?: GetJsonOptions): Promise, - options?: GetJsonOptions, -): Promise { +): URL { const target = new URL(url); if (params) { for (const [key, value] of Object.entries(params)) { @@ -58,6 +72,16 @@ export async function getJson( } } } + target.search = target.searchParams.toString().replace(/\+/g, "%20"); + return target; +} + +export async function getJson( + url: string, + params?: Record, + options?: GetJsonOptions, +): Promise { + const target = buildUrl(url, params); const maxBytes = options?.maxBytes ?? MAX_RESPONSE_BYTES; const timeoutMs = options?.timeoutMs ?? FETCH_TIMEOUT_MS; const response = await fetch(target, { @@ -73,7 +97,26 @@ export async function getJson( `upstream response too large (${buffer.byteLength} bytes, max ${maxBytes})`, ); } - return JSON.parse(new TextDecoder().decode(buffer)) as unknown; + return parseJsonBody(new TextDecoder().decode(buffer), target.toString()); +} + +/** Parse an upstream body that is contractually JSON. + * + * BCB's SGS gateway answers HTTP 200 with an HTML error page ("Requisicao + * invalida!") when it is degraded or rejects the request. Surfacing the raw + * parser message ("Unexpected token '<'") tells an agent nothing, so name the + * real condition instead. + */ +export function parseJsonBody(body: string, url: string): unknown { + try { + return JSON.parse(body) as unknown; + } catch (cause) { + const head = body.trimStart().slice(0, 120).replace(/\s+/g, " "); + throw new UpstreamFormatError( + `upstream returned a non-JSON body for ${url} (likely an error page or an outage); first bytes: ${head}`, + { cause }, + ); + } } export function odataValue(raw: unknown): unknown[] { diff --git a/workers/mcp/src/lib/ptaxDay.ts b/workers/mcp/src/lib/ptaxDay.ts new file mode 100644 index 0000000..fc7343a --- /dev/null +++ b/workers/mcp/src/lib/ptaxDay.ts @@ -0,0 +1,42 @@ +/** + * Why a PTAX day can come back empty. + * + * Kept free of imports so it loads under `node --test` without pulling the + * worker's JSON catalogs, which need import attributes Node requires and + * esbuild does not. + */ + +/** Brazil has no DST, so BRT is a fixed UTC-3 offset. */ +export function brtNow(nowMs: number = Date.now()): { date: string; hour: number } { + const brt = new Date(nowMs - 3 * 60 * 60 * 1000); + return { date: brt.toISOString().slice(0, 10), hour: brt.getUTCHours() }; +} + +/** + * PTAX has no quote on weekends, holidays, future dates, or before the + * closing rate is published (~13h BRT). An empty array alone makes those + * indistinguishable, so say which one it was. + */ +export function emptyDayReason( + day: string, + nowMs: number = Date.now(), +): { reason: string; note: string } { + const dow = new Date(`${day}T12:00:00Z`).getUTCDay(); + if (dow === 0 || dow === 6) { + return { + reason: "non_business_day", + note: `${day} is a weekend; PTAX is published on business days only`, + }; + } + const now = brtNow(nowMs); + if (day > now.date) { + return { reason: "future_date", note: `${day} is in the future; today in BRT is ${now.date}` }; + } + if (day === now.date && now.hour < 13) { + return { + reason: "not_published_yet", + note: `it is ${String(now.hour).padStart(2, "0")}h BRT and the closing PTAX for ${day} is normally published around 13h, so it may not be out yet; a holiday would also leave it empty`, + }; + } + return { reason: "no_quote_for_date", note: `no PTAX for ${day}; most likely a national holiday` }; +} diff --git a/workers/mcp/src/server.ts b/workers/mcp/src/server.ts index 3f8e463..35503d0 100644 --- a/workers/mcp/src/server.ts +++ b/workers/mcp/src/server.ts @@ -33,13 +33,13 @@ export function createServer() { { description: "BCB time series (Selic, IPCA, câmbio…): omit args to list the catalog; pass code or name to fetch.", - inputSchema: { + inputSchema: z.strictObject({ code: z.number().int().optional(), name: z.string().optional(), start: z.string().optional().describe("YYYY-MM-DD"), end: z.string().optional().describe("YYYY-MM-DD"), last_n: z.number().int().min(1).max(200).optional(), - }, + }), }, wrap((args) => bcbSeries(args)), ); @@ -47,13 +47,17 @@ export function createServer() { server.registerTool( "bcb_ptax", { - description: "PTAX official exchange rate. Range queries are USD-only.", - inputSchema: { + description: + "PTAX official exchange rate. Range queries are USD-only. " + + "Returns {currency, date|start+end, count, cotacoes}; when count is 0 a reason field says why " + + "(single day: non_business_day, not_published_yet, future_date, no_quote_for_date; " + + "range: no_quotes_in_range) — an empty list is not an error.", + inputSchema: z.strictObject({ currency: z.string().default("USD"), date: z.string().optional().describe("YYYY-MM-DD"), start: z.string().optional(), end: z.string().optional(), - }, + }), }, wrap((args) => bcbPtax(args)), ); @@ -63,12 +67,12 @@ export function createServer() { { description: "Boletim Focus. indicator=list for names; indicator=Selic for COPOM path; else annual/monthly.", - inputSchema: { + inputSchema: z.strictObject({ indicator: z.string().default("IPCA"), horizon: z.enum(["annual", "monthly"]).default("annual"), panel: z.enum(["market", "top5"]).default("market"), top: z.number().int().min(1).max(100).default(20), - }, + }), }, wrap((args) => bcbFocus(args)), ); @@ -77,10 +81,10 @@ export function createServer() { "ibge_indicator", { description: "IBGE economic indicators. Omit name to list the catalog.", - inputSchema: { + inputSchema: z.strictObject({ name: z.string().optional(), periods: z.number().int().min(1).max(120).default(12), - }, + }), }, wrap((args) => ibgeIndicator(args)), ); @@ -89,9 +93,9 @@ export function createServer() { "ibge_ipca_breakdown", { description: "IPCA monthly variation by major groups (not in BCB SGS).", - inputSchema: { + inputSchema: z.strictObject({ periods: z.number().int().min(1).max(60).default(6), - }, + }), }, wrap((args) => ibgeIpcaBreakdown(args)), ); @@ -100,11 +104,11 @@ export function createServer() { "ipea_series", { description: "IPEA series. Omit sercodigo for the curated catalog.", - inputSchema: { + inputSchema: z.strictObject({ sercodigo: z.string().optional(), dataset: z.enum(["values", "metadata"]).default("values"), top: z.number().int().min(1).max(500).optional(), - }, + }), }, wrap((args) => ipeaSeries(args)), ); @@ -113,10 +117,10 @@ export function createServer() { "ipea_search", { description: "Full-text search across the IPEA catalog (~8k series).", - inputSchema: { + inputSchema: z.strictObject({ q: z.string().min(2), top: z.number().int().min(1).max(200).default(25), - }, + }), }, wrap((args) => ipeaSearch(args)), ); @@ -124,15 +128,23 @@ export function createServer() { server.registerTool( "tesouro_siconfi", { - description: "SICONFI public-finance reports. Start with report=entes.", - inputSchema: { + description: + "SICONFI public-finance reports. Start with report=entes to resolve a cod_ibge, " + + "narrowing with uf/esfera/q instead of pulling all ~5.6k entes. " + + "Returns {total_matched, returned, limit, truncated, upstream_complete, entes|rows}: when truncated is true you did NOT see everything, and when upstream_complete is false total_matched counts only the rows fetched.", + inputSchema: z.strictObject({ report: z.enum(["rreo", "rgf", "entes"]).default("entes"), year: z.number().int().min(2013).optional(), period: z.number().int().min(1).max(6).optional(), cod_ibge: z.number().int().optional(), poder: z.string().default("E"), anexo: z.string().optional(), - }, + uf: z.string().length(2).optional().describe("entes: filter by state, e.g. SP"), + esfera: z.string().optional().describe("entes: M municipal, E estadual, U federal, D distrital"), + q: z.string().optional().describe("entes: accent-insensitive name fragment"), + limit: z.number().int().min(1).max(2000).optional() + .describe("rows in THIS response; entes default 100, reports default 2000, hard cap 2000"), + }), }, wrap((args) => tesouroSiconfi(args)), ); @@ -147,7 +159,7 @@ export function createServer() { "get_quotes → dataset=daily (INF_DIARIO; omit year/month for latest; months=1..12 lookback or start/end YYYY-MM-DD, max 12 months; " + "single-class 555→175 continuation is stitched; report served[].nicename/class/subclass actually returned). " + "get_available_wallets → dataset=periods product=CDA. get_wallet_detail → dataset=holdings (omit year/month for latest CDA; CONFID is sigilo).", - inputSchema: { + inputSchema: z.strictObject({ dataset: z.enum(["catalog", "daily", "holdings", "periods"]).default("catalog"), cnpj: z.string().optional().describe("Fund or class CNPJ, punctuated or digits"), q: z.string().optional().describe("catalog: name fragment (fundo, classe or subclasse)"), @@ -170,7 +182,7 @@ export function createServer() { .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(), - }, + }), }, wrap((args) => cvmFund(args)), ); @@ -179,14 +191,14 @@ export function createServer() { "openfinance_directory", { description: "Open Finance Brasil Directory (public discovery only, no customer data).", - inputSchema: { + inputSchema: z.strictObject({ dataset: z.enum(["participants", "endpoints", "resources", "roles"]).default("participants"), role: z.string().optional(), status: z.string().optional(), api_family: z.string().optional(), q: z.string().min(2).optional(), limit: z.number().int().min(1).max(1000).default(100), - }, + }), }, wrap((args) => openfinanceDirectory(args)), ); diff --git a/workers/mcp/src/tools/bcb.ts b/workers/mcp/src/tools/bcb.ts index ca23cd2..3250ce2 100644 --- a/workers/mcp/src/tools/bcb.ts +++ b/workers/mcp/src/tools/bcb.ts @@ -1,4 +1,5 @@ import { errorResult, getJson, jsonResult, odataValue } from "../lib/http"; +import { brtNow, emptyDayReason } from "../lib/ptaxDay"; import sgsCatalog from "../catalog/sgs.json"; import focusIndicators from "../catalog/focus.json"; @@ -93,22 +94,38 @@ export async function bcbPtax(args: { $format: "json", }, ); - return jsonResult(odataValue(raw)); - } - const day = args.date ?? new Date().toISOString().slice(0, 10); - if (currency === "USD") { - const raw = await getJson(`${PTAX_URL}/CotacaoDolarDia(dataCotacao=@dataCotacao)`, { - "@dataCotacao": `'${fmtPtaxDate(day)}'`, - $format: "json", + const cotacoes = odataValue(raw); + if (cotacoes.length > 0) { + return jsonResult({ currency, start: args.start, end: args.end, count: cotacoes.length, cotacoes }); + } + // A range spans many dates, so a single day's diagnosis would be wrong here. + return jsonResult({ + currency, + start: args.start, + end: args.end, + count: 0, + cotacoes, + reason: "no_quotes_in_range", + note: `no PTAX between ${args.start} and ${args.end}; the range may hold only weekends and holidays, or be in the future`, }); - return jsonResult(odataValue(raw)); } - const raw = await getJson(`${PTAX_URL}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)`, { - "@moeda": `'${currency}'`, - "@dataCotacao": `'${fmtPtaxDate(day)}'`, - $format: "json", - }); - return jsonResult(odataValue(raw)); + const day = args.date ?? brtNow().date; + const raw = + currency === "USD" + ? await getJson(`${PTAX_URL}/CotacaoDolarDia(dataCotacao=@dataCotacao)`, { + "@dataCotacao": `'${fmtPtaxDate(day)}'`, + $format: "json", + }) + : await getJson(`${PTAX_URL}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)`, { + "@moeda": `'${currency}'`, + "@dataCotacao": `'${fmtPtaxDate(day)}'`, + $format: "json", + }); + const cotacoes = odataValue(raw); + if (cotacoes.length > 0) { + return jsonResult({ currency, date: day, count: cotacoes.length, cotacoes }); + } + return jsonResult({ currency, date: day, count: 0, cotacoes, ...emptyDayReason(day) }); } function focusIndicator(name: string): string | undefined { diff --git a/workers/mcp/src/tools/contract.test.ts b/workers/mcp/src/tools/contract.test.ts new file mode 100644 index 0000000..12d8c56 --- /dev/null +++ b/workers/mcp/src/tools/contract.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildUrl, parseJsonBody } from "../lib/http.ts"; +import { brtNow, emptyDayReason } from "../lib/ptaxDay.ts"; +import { envelopeBody, filterEntes, fold } from "./tesouro.ts"; + +// 2026-10-15 is a Thursday; 2026-10-10 is a Saturday. +const THU_11H_BRT = Date.parse("2026-10-15T14:00:00Z"); // 11h BRT +const THU_14H_BRT = Date.parse("2026-10-15T17:30:00Z"); // 14h30 BRT + +test("brtNow shifts UTC by a fixed -3 offset", () => { + assert.deepEqual(brtNow(THU_11H_BRT), { date: "2026-10-15", hour: 11 }); + // 01h UTC is still the previous day in BRT + assert.deepEqual(brtNow(Date.parse("2026-10-16T01:00:00Z")), { + date: "2026-10-15", + hour: 22, + }); +}); + +test("emptyDayReason separates the four reasons a PTAX day can be empty", () => { + assert.equal(emptyDayReason("2026-10-10", THU_11H_BRT).reason, "non_business_day"); + assert.equal(emptyDayReason("2027-01-01", THU_11H_BRT).reason, "future_date"); + // same day, before the ~13h BRT publication + assert.equal(emptyDayReason("2026-10-15", THU_11H_BRT).reason, "not_published_yet"); + // same day, after publication: a holiday is the remaining explanation + assert.equal(emptyDayReason("2026-10-15", THU_14H_BRT).reason, "no_quote_for_date"); + // a past business day never reports "not published yet" + assert.equal(emptyDayReason("2026-10-14", THU_11H_BRT).reason, "no_quote_for_date"); +}); + +test("emptyDayReason always carries a human-readable note", () => { + for (const day of ["2026-10-10", "2027-01-01", "2026-10-15", "2026-10-14"]) { + const { note } = emptyDayReason(day, THU_11H_BRT); + assert.ok(note.includes(day), `note should name the date: ${note}`); + } +}); + +test("fold makes matching case and accent insensitive", () => { + assert.equal(fold("Várzea Grande"), "varzea grande"); + assert.equal(fold("SÃO PAULO"), "sao paulo"); + assert.equal(fold(null), ""); + assert.equal(fold(undefined), ""); +}); + +const ROWS: Record[] = [ + { cod_ibge: 3550308, uf: "SP", instituicao: "São Paulo", esfera: "M", populacao: 11451245 }, + { cod_ibge: 3509502, uf: "SP", instituicao: "Campinas", esfera: "M", populacao: 1139047 }, + { cod_ibge: 5108402, uf: "MT", instituicao: "Várzea Grande", esfera: "M", populacao: 318922 }, + { cod_ibge: 35, uf: "SP", ente: "Estado de São Paulo", esfera: "E", populacao: 44420459 }, +]; + +test("filterEntes narrows by uf, esfera and accent-insensitive name", () => { + assert.equal(filterEntes(ROWS, {}).length, 4); + assert.equal(filterEntes(ROWS, { uf: "SP" }).length, 3); + assert.equal(filterEntes(ROWS, { uf: "sp" }).length, 3, "uf match ignores case"); + assert.equal(filterEntes(ROWS, { esfera: "E" }).length, 1); + + const varzea = filterEntes(ROWS, { q: "varzea grande" }); + assert.equal(varzea.length, 1); + assert.equal(varzea[0].cod_ibge, 5108402); + + assert.equal(filterEntes(ROWS, { uf: "SP", esfera: "M" }).length, 2, "filters compose"); +}); + +test("filterEntes falls back to `ente` when `instituicao` is absent", () => { + const estado = filterEntes(ROWS, { esfera: "E" }); + assert.equal(estado[0].instituicao, "Estado de São Paulo"); +}); + +test("filterEntes keeps populacao null rather than dropping the row", () => { + const out = filterEntes([{ cod_ibge: 1, uf: "AC", instituicao: "X", esfera: "M" }], {}); + assert.equal(out.length, 1); + assert.equal(out[0].populacao, null); +}); + +test("envelopeBody reports truncation so an agent knows it did not see everything", () => { + const many = Array.from({ length: 250 }, (_, i) => i); + const cut = envelopeBody("entes", many, 100, "narrow it"); + assert.equal(cut.total_matched, 250); + assert.equal(cut.returned, 100); + assert.equal(cut.limit, 100); + assert.equal(cut.truncated, true); + assert.equal(cut.hint, "narrow it"); + assert.equal((cut.entes as number[]).length, 100); +}); + +test("envelopeBody omits the hint when nothing was cut", () => { + const whole = envelopeBody("rows", [1, 2, 3], 100, "narrow it"); + assert.equal(whole.total_matched, 3); + assert.equal(whole.returned, 3); + assert.equal(whole.truncated, false); + assert.ok(!("hint" in whole), "no hint when the caller saw everything"); +}); + +test("envelopeBody keys the payload under the caller's name", () => { + assert.ok("entes" in envelopeBody("entes", [], 10, "h")); + assert.ok("rows" in envelopeBody("rows", [], 10, "h")); +}); + +test("buildUrl encodes spaces as %20, not + (Olinda OData rejects +)", () => { + const url = buildUrl("https://example.com/odata/Foo", { + $top: 20, + $orderby: "Data desc", + $filter: "Indicador eq 'IPCA'", + }); + assert.ok(!url.search.includes("+"), `found + in ${url.search}`); + assert.ok(url.search.includes("%24orderby=Data%20desc")); + assert.ok(url.search.includes("Indicador%20eq%20%27IPCA%27")); +}); + +test("buildUrl keeps a literal + in a value escaped as %2B", () => { + assert.equal(buildUrl("https://example.com/x", { q: "a+b" }).search, "?q=a%2Bb"); +}); + +test("buildUrl skips undefined and empty values", () => { + const url = buildUrl("https://example.com/x", { a: "1", b: undefined, c: "" }); + assert.equal(url.search, "?a=1"); +}); + +test("parseJsonBody names the real condition when upstream returns HTML", () => { + assert.throws( + () => parseJsonBody('Requisicao invalida!', "https://x/y"), + /non-JSON body for https:\/\/x\/y/, + ); +}); + +test("parseJsonBody passes valid JSON through", () => { + assert.deepEqual(parseJsonBody('{"a":1}', "https://x/y"), { a: 1 }); +}); + +test("envelopeBody refuses to certify completeness the page cap cannot know", () => { + const body = envelopeBody("rows", [1, 2, 3], 10, "narrow it", false); + assert.equal(body.returned, 3); + assert.equal(body.upstream_complete, false); + assert.equal(body.truncated, true, "an unexhausted upstream must not look complete"); + assert.match(String(body.hint), /page cap/); +}); + +test("envelopeBody reports complete when it was cut only to limit", () => { + const body = envelopeBody("rows", [1, 2, 3], 2, "narrow it", true); + assert.equal(body.truncated, true); + assert.equal(body.upstream_complete, true); + assert.equal(body.hint, "narrow it"); +}); + +test("envelopeBody leaves no hint when nothing was hidden", () => { + const body = envelopeBody("rows", [1, 2], 10, "narrow it", true); + assert.equal(body.truncated, false); + assert.equal(body.upstream_complete, true); + assert.ok(!("hint" in body)); +}); + +test("the pre-13h PTAX note suggests rather than asserts a cause", () => { + const { reason, note } = emptyDayReason("2026-10-15", THU_11H_BRT); + assert.equal(reason, "not_published_yet"); + assert.match(note, /may not be out yet/); + assert.match(note, /holiday/); +}); diff --git a/workers/mcp/src/tools/tesouro.ts b/workers/mcp/src/tools/tesouro.ts index f7df20d..f5b1ae2 100644 --- a/workers/mcp/src/tools/tesouro.ts +++ b/workers/mcp/src/tools/tesouro.ts @@ -1,16 +1,19 @@ -import { errorResult, getJson, jsonResult } from "../lib/http"; +import { errorResult, getJson, jsonResult } from "../lib/http.ts"; const SICONFI = "https://apidatalake.tesouro.gov.br/ords/siconfi/tt"; const PAGE = 2000; const MAX_PAGES_ENTES = 3; const MAX_PAGES_REPORT = 1; +const ENTES_DEFAULT_LIMIT = 100; +const REPORT_DEFAULT_LIMIT = 2000; async function paginate( path: string, params: Record, maxPages: number, -): Promise[]> { +): Promise<{ rows: Record[]; complete: boolean }> { const rows: Record[] = []; + let complete = false; for (let page = 0; page < maxPages; page += 1) { const raw = (await getJson(`${SICONFI}/${path}`, { ...params, @@ -23,9 +26,100 @@ async function paginate( rows.push(item as Record); } } - if (!raw.hasMore) break; + if (!raw.hasMore) { + complete = true; + break; + } + } + return { rows, complete }; +} + +/** Bounded payload plus the counters an agent needs to know it saw everything. + * + * Two different things can hide rows, and an agent has to tell them apart: + * this response was cut to `limit`, or SICONFI still had pages left when the + * page cap stopped the fetch. In the second case `total_matched` counts only + * what was retrieved, so it is not a global total and `upstream_complete` is + * false. `truncated` is true whenever either happened. + */ +export function envelopeBody( + key: string, + matched: unknown[], + limit: number, + hint: string, + upstreamComplete = true, +): Record { + const rows = matched.slice(0, limit); + const cutToLimit = matched.length > rows.length; + const truncated = cutToLimit || !upstreamComplete; + const hints = [ + ...(cutToLimit ? [hint] : []), + ...(upstreamComplete + ? [] + : ["the upstream page cap was reached, so total_matched counts only the rows fetched"]), + ]; + return { + total_matched: matched.length, + returned: rows.length, + limit, + truncated, + upstream_complete: upstreamComplete, + ...(hints.length > 0 ? { hint: hints.join("; ") } : {}), + [key]: rows, + }; +} + +function envelope( + key: string, + matched: unknown[], + limit: number, + hint: string, + upstreamComplete = true, +) { + return jsonResult(envelopeBody(key, matched, limit, hint, upstreamComplete)); +} + +/** Lowercase and strip accents so "sao paulo" matches "São Paulo". */ +export function fold(value: unknown): string { + return String(value ?? "") + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase(); +} + +export type Ente = { + cod_ibge: unknown; + uf: unknown; + instituicao: unknown; + esfera: unknown; + populacao: unknown; +}; + +/** Project SICONFI rows to the agent-facing shape, then narrow them. */ +export function filterEntes( + rows: Record[], + args: { uf?: string; esfera?: string; q?: string }, +): Ente[] { + let matched: Ente[] = rows.map((row) => ({ + cod_ibge: row.cod_ibge, + uf: row.uf, + instituicao: row.instituicao ?? row.ente, + esfera: row.esfera, + populacao: row.populacao ?? null, + })); + if (args.uf) { + const uf = fold(args.uf); + matched = matched.filter((row) => fold(row.uf) === uf); } - return rows; + if (args.esfera) { + const esfera = fold(args.esfera); + matched = matched.filter((row) => fold(row.esfera) === esfera); + } + if (args.q) { + const needle = fold(args.q); + matched = matched.filter((row) => fold(row.instituicao).includes(needle)); + } + return matched; } export async function tesouroSiconfi(args: { @@ -35,28 +129,36 @@ export async function tesouroSiconfi(args: { cod_ibge?: number; poder?: string; anexo?: string; + uf?: string; + q?: string; + esfera?: string; + limit?: number; }) { const report = args.report ?? "entes"; if (report === "entes") { - const rows = await paginate("entes", {}, MAX_PAGES_ENTES); - return jsonResult( - rows.map((row) => ({ - cod_ibge: row.cod_ibge, - uf: row.uf, - instituicao: row.instituicao ?? row.ente, - esfera: row.esfera, - populacao: row.populacao ?? null, - })), + const page = await paginate("entes", {}, MAX_PAGES_ENTES); + const matched = filterEntes(page.rows, args); + return envelope( + "entes", + matched, + args.limit ?? ENTES_DEFAULT_LIMIT, + "narrow with uf (e.g. uf=SP), esfera (M/E/U/D) or q (name fragment)", + page.complete, ); } if (args.year === undefined || args.period === undefined || args.cod_ibge === undefined) { - return errorResult(`report=${report} requires year, period, and cod_ibge`); + return errorResult( + `report=${report} requires year, period, and cod_ibge ` + + "(find cod_ibge with report=entes plus uf/q)", + ); } + const limit = args.limit ?? REPORT_DEFAULT_LIMIT; + const hint = "narrow with anexo, or page through with a higher limit"; if (report === "rgf") { if (args.period < 1 || args.period > 3) { return errorResult("RGF period is the quadrimestre 1-3"); } - const rows = await paginate( + const page = await paginate( "rgf", { an_exercicio: args.year, @@ -68,9 +170,9 @@ export async function tesouroSiconfi(args: { }, MAX_PAGES_REPORT, ); - return jsonResult(rows.slice(0, 2000)); + return envelope("rows", page.rows, limit, hint, page.complete); } - const rows = await paginate( + const page = await paginate( "rreo", { an_exercicio: args.year, @@ -81,5 +183,5 @@ export async function tesouroSiconfi(args: { }, MAX_PAGES_REPORT, ); - return jsonResult(rows.slice(0, 2000)); + return envelope("rows", page.rows, limit, hint, page.complete); }