From c9df9b773db92450fd5ae3c9fac74d3a1e5b7098 Mon Sep 17 00:00:00 2001 From: Roberto Date: Sun, 18 Oct 2026 14:03:50 -0300 Subject: [PATCH 1/6] fix(mcp): reject unknown args, bound SICONFI output, explain empty PTAX Three defects found by probing the live public MCP at openfindata.com.br. 1. Unknown arguments were accepted silently. Calling bcb_series with `n=3` (a natural guess for `last_n`) returned 10 values and isError=None, so an agent got a wrong answer with no signal. All 10 tools now use z.strictObject, which emits additionalProperties:false and rejects the call with "Unrecognized key". 2. tesouro_siconfi(report=entes) returned 504,955 characters in one response, roughly 126k tokens, enough to blow most agent context windows. It now filters by uf/esfera/q (accent-insensitive) and returns a bounded envelope with total_matched/returned/limit/truncated, so truncation is visible. Default limit 100 brings the same call down to about 9k characters. The same envelope covers rreo/rgf, which could silently cut at 2000 rows. 3. bcb_ptax returned a bare [] for weekends, holidays, future dates and before the ~13h BRT publication, all indistinguishable. Day and period queries now return a consistent envelope, and an empty one carries a reason field: non_business_day, not_published_yet, future_date or no_quote_for_date. Also adds public/llms.txt and rewrites the landing page to explain what MCP is for readers who do not already know, with concrete example questions, a tool table and explicit limits. Drops the "pip install openfindata" line, which was false: the package is not on PyPI under that name or findata-br. Verified against a local wrangler dev: 10/10 tools strict, unknown key rejected, last_n=3 still returns 3, entes 504,955 -> 9,019 chars, accent insensitive lookup resolves Varzea Grande, and all four PTAX reasons fire correctly. Worker suite 29/29. Co-Authored-By: Claude Opus 5 --- workers/mcp/public/index.html | 275 ++++++++++++++++++++++++++----- workers/mcp/public/llms.txt | 47 ++++++ workers/mcp/src/server.ts | 54 +++--- workers/mcp/src/tools/bcb.ts | 61 +++++-- workers/mcp/src/tools/tesouro.ts | 71 ++++++-- 5 files changed, 424 insertions(+), 84 deletions(-) create mode 100644 workers/mcp/public/llms.txt diff --git a/workers/mcp/public/index.html b/workers/mcp/public/index.html index b1eefbb..0c21711 100644 --- a/workers/mcp/public/index.html +++ b/workers/mcp/public/index.html @@ -3,61 +3,262 @@ - 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):

-
    -
  • bcb_series bcb_ptax bcb_focus
  • -
  • ibge_indicator ibge_ipca_breakdown
  • -
  • ipea_series ipea_search
  • -
  • tesouro_siconfi
  • -
  • openfinance_directory
  • -
  • cvm_fund — cadastro RCVM 175, cotas INF_DIARIO e carteira CDA
  • -
-

- 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 Desktop, Claude Code, Cursor e similares

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

+ Em clientes que pedem só a URL, use + https://openfindata.com.br/mcp 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

+
    +
  • + Não calcula indicador derivado. Sem Sharpe, sem CAGR, sem drawdown. + Ele entrega o número que o órgão publicou, normalizado, e a conta fica + com você ou com o seu assistente. +
  • +
  • + Não acessa dado de cliente. O Open Finance aqui é só o diretório + público de participantes, nunca informação de conta de ninguém. +
  • +
  • Não revende dado de terceiros nem executa código no endpoint público.
  • +
  • Não é recomendação de investimento.
  • +
+ +

Limites e transparência

+
    +
  • Uso justo: cerca de 60 requisições por minuto por IP, com pico de 20 a cada 10 segundos. O excesso recebe HTTP 429 com Retry-After.
  • +
  • Resposta longa vem paginada, com total_matched, returned e truncated. Se truncated for verdadeiro, você não viu tudo.
  • +
  • Parâmetro desconhecido é recusado com erro explícito, nunca ignorado em silêncio.
  • +
  • Lista vazia não é erro: quando cabe, um campo reason explica o motivo, como PTAX ainda não publicado no dia ou fim de semana.
  • +
  • Projeto em alpha. A superfície pode mudar, e o repositório é a fonte da verdade.
  • +
+ +

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.

+ +
+

+ github.com/robertoecf/OpenFinData + · Licença MIT · Dados públicos de BCB, IBGE, IPEA, Tesouro Nacional, + CVM e Open Finance Brasil, que permanecem de seus órgãos de origem. +

+
diff --git a/workers/mcp/public/llms.txt b/workers/mcp/public/llms.txt new file mode 100644 index 0000000..9223fa4 --- /dev/null +++ b/workers/mcp/public/llms.txt @@ -0,0 +1,47 @@ +# 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": { "url": "https://openfindata.com.br/mcp" } } } + +## 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 paginadas, com total_matched, returned, limit e truncated. + Se truncated for true, você não viu tudo: filtre melhor ou aumente limit. +- 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ê. + +## 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/server.ts b/workers/mcp/src/server.ts index fec1940..0628d75 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,16 @@ 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 " + + "(non_business_day, not_published_yet, future_date, no_quote_for_date) — 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 +66,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 +80,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 +92,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 +103,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 +116,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 +127,22 @@ 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, entes|rows}: when truncated is true you did NOT see everything.", + 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(6000).optional().describe("entes default 100, reports default 2000"), + }), }, wrap((args) => tesouroSiconfi(args)), ); @@ -147,7 +157,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 +180,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 +189,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..2287d7a 100644 --- a/workers/mcp/src/tools/bcb.ts +++ b/workers/mcp/src/tools/bcb.ts @@ -74,6 +74,35 @@ export async function bcbSeries(args: { return jsonResult(parseSgs(raw)); } +/** Brazil has no DST, so BRT is a fixed UTC-3 offset. */ +function brtNow(): { date: string; hour: number } { + const brt = new Date(Date.now() - 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. + */ +function emptyDayReason(day: string): { 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(); + 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: `the closing PTAX for ${day} is published around 13h BRT; it is ${String(now.hour).padStart(2, "0")}h now`, + }; + } + return { reason: "no_quote_for_date", note: `no PTAX for ${day}; most likely a national holiday` }; +} + export async function bcbPtax(args: { currency?: string; date?: string; @@ -93,22 +122,26 @@ export async function bcbPtax(args: { $format: "json", }, ); - return jsonResult(odataValue(raw)); + const cotacoes = odataValue(raw); + return jsonResult({ currency, start: args.start, end: args.end, count: cotacoes.length, cotacoes }); } - 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", - }); - 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 }); } - const raw = await getJson(`${PTAX_URL}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)`, { - "@moeda": `'${currency}'`, - "@dataCotacao": `'${fmtPtaxDate(day)}'`, - $format: "json", - }); - return jsonResult(odataValue(raw)); + return jsonResult({ currency, date: day, count: 0, cotacoes, ...emptyDayReason(day) }); } function focusIndicator(name: string): string | undefined { diff --git a/workers/mcp/src/tools/tesouro.ts b/workers/mcp/src/tools/tesouro.ts index f7df20d..34402d2 100644 --- a/workers/mcp/src/tools/tesouro.ts +++ b/workers/mcp/src/tools/tesouro.ts @@ -4,6 +4,8 @@ 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, @@ -28,6 +30,28 @@ async function paginate( return rows; } +/** Bounded payload plus the counters an agent needs to know it saw everything. */ +function envelope(key: string, matched: unknown[], limit: number, hint: string) { + const rows = matched.slice(0, limit); + const truncated = matched.length > rows.length; + return jsonResult({ + total_matched: matched.length, + returned: rows.length, + limit, + truncated, + ...(truncated ? { hint } : {}), + [key]: rows, + }); +} + +/** Lowercase and strip accents so "sao paulo" matches "São Paulo". */ +function fold(value: unknown): string { + return String(value ?? "") + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase(); +} + export async function tesouroSiconfi(args: { report?: "rreo" | "rgf" | "entes"; year?: number; @@ -35,23 +59,48 @@ 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, - })), + let matched = 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); + } + 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 envelope( + "entes", + matched, + args.limit ?? ENTES_DEFAULT_LIMIT, + "narrow with uf (e.g. uf=SP), esfera (M/E/U/D) or q (name fragment), or raise limit", ); } 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 = "raise limit, or narrow with anexo"; if (report === "rgf") { if (args.period < 1 || args.period > 3) { return errorResult("RGF period is the quadrimestre 1-3"); @@ -68,7 +117,7 @@ export async function tesouroSiconfi(args: { }, MAX_PAGES_REPORT, ); - return jsonResult(rows.slice(0, 2000)); + return envelope("rows", rows, limit, hint); } const rows = await paginate( "rreo", @@ -81,5 +130,5 @@ export async function tesouroSiconfi(args: { }, MAX_PAGES_REPORT, ); - return jsonResult(rows.slice(0, 2000)); + return envelope("rows", rows, limit, hint); } From a5b61c1034d702626a66bb85206039c83fc93a11 Mon Sep 17 00:00:00 2001 From: Roberto Date: Sun, 18 Oct 2026 14:17:48 -0300 Subject: [PATCH 2/6] fix(mcp): repair bcb_focus, name non-JSON upstream, cover contract in tests bcb_focus was broken on every path in production. `URLSearchParams` serialises a space as `+`, and BCB's Olinda OData gateway does not decode `+` inside a query option, so `$orderby=Data desc` arrived as `Data+desc` and Olinda answered 400. Every branch of the tool sends that `$orderby`, so all five paths failed. `lib/http.ts` now re-encodes those spaces as `%20` in a `buildUrl` helper. Verified against the live gateway: literal space and `%20` return 200, `+` returns 400. BCB's SGS gateway answers HTTP 200 with an HTML error page when it is degraded. The worker surfaced the raw parser message ("Unexpected token '<'"), which tells an agent nothing. `parseJsonBody` now names the condition and quotes the first bytes. Follow-ups from the MCP Trust review: - MT-1: document the Worker response contract in docs/MCP_SURFACE.md and flag the divergence from the Python surface. - MT-2: cover the envelope, the accent-insensitive filter and the PTAX empty-day reasons in tests (29 -> 44 passing). - MT-3: `brtNow`/`emptyDayReason` move to lib/ptaxDay.ts and take an injectable clock, so the reasons are testable without wall time. Co-Authored-By: Claude Opus 5 --- docs/MCP_SURFACE.md | 28 +++++- workers/mcp/package.json | 2 +- workers/mcp/src/lib/http.ts | 43 ++++++++- workers/mcp/src/lib/ptaxDay.ts | 42 ++++++++ workers/mcp/src/tools/bcb.ts | 30 +----- workers/mcp/src/tools/contract.test.ts | 129 +++++++++++++++++++++++++ workers/mcp/src/tools/tesouro.ts | 74 +++++++++----- 7 files changed, 289 insertions(+), 59 deletions(-) create mode 100644 workers/mcp/src/lib/ptaxDay.ts create mode 100644 workers/mcp/src/tools/contract.test.ts diff --git a/docs/MCP_SURFACE.md b/docs/MCP_SURFACE.md index 7a96a9a..64111d4 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,30 @@ 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, 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. Unbounded `report=entes` used to return about 500 KB in one response. +- **An empty result explains itself.** `bcb_ptax` returns + `{currency, date|start+end, count, cotacoes}`, and when `count` is 0 it adds + `reason` (`non_business_day`, `not_published_yet`, `future_date`, + `no_quote_for_date`) plus a human-readable `note`. An empty list is not an + error, and the caller should not retry blindly. + ## Tradeoffs - **Fewer but "fatter" tools.** Each carries a `dataset` enum and more doc. The @@ -120,6 +145,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 25d49ef..64a2e91 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" + "test": "node --test --experimental-strip-types src/rateLimit.test.ts src/tools/cvm.test.ts src/tools/contract.test.ts" }, "dependencies": { "@cloudflare/workers-types": "^5.20260813.1", diff --git a/workers/mcp/src/lib/http.ts b/workers/mcp/src/lib/http.ts index abdd285..f0e43f0 100644 --- a/workers/mcp/src/lib/http.ts +++ b/workers/mcp/src/lib/http.ts @@ -45,11 +45,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 +65,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 +90,25 @@ 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 { + const head = body.trimStart().slice(0, 120).replace(/\s+/g, " "); + throw new UpstreamLimitError( + `upstream returned a non-JSON body for ${url} (likely an error page or an outage); first bytes: ${head}`, + ); + } } 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..d5e8fb8 --- /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: `the closing PTAX for ${day} is published around 13h BRT; it is ${String(now.hour).padStart(2, "0")}h now`, + }; + } + return { reason: "no_quote_for_date", note: `no PTAX for ${day}; most likely a national holiday` }; +} diff --git a/workers/mcp/src/tools/bcb.ts b/workers/mcp/src/tools/bcb.ts index 2287d7a..d77c45f 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"; @@ -74,35 +75,6 @@ export async function bcbSeries(args: { return jsonResult(parseSgs(raw)); } -/** Brazil has no DST, so BRT is a fixed UTC-3 offset. */ -function brtNow(): { date: string; hour: number } { - const brt = new Date(Date.now() - 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. - */ -function emptyDayReason(day: string): { 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(); - 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: `the closing PTAX for ${day} is published around 13h BRT; it is ${String(now.hour).padStart(2, "0")}h now`, - }; - } - return { reason: "no_quote_for_date", note: `no PTAX for ${day}; most likely a national holiday` }; -} - export async function bcbPtax(args: { currency?: string; date?: string; diff --git a/workers/mcp/src/tools/contract.test.ts b/workers/mcp/src/tools/contract.test.ts new file mode 100644 index 0000000..7405007 --- /dev/null +++ b/workers/mcp/src/tools/contract.test.ts @@ -0,0 +1,129 @@ +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 }); +}); diff --git a/workers/mcp/src/tools/tesouro.ts b/workers/mcp/src/tools/tesouro.ts index 34402d2..7a3ec5f 100644 --- a/workers/mcp/src/tools/tesouro.ts +++ b/workers/mcp/src/tools/tesouro.ts @@ -1,4 +1,4 @@ -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; @@ -31,27 +31,71 @@ async function paginate( } /** Bounded payload plus the counters an agent needs to know it saw everything. */ -function envelope(key: string, matched: unknown[], limit: number, hint: string) { +export function envelopeBody( + key: string, + matched: unknown[], + limit: number, + hint: string, +): Record { const rows = matched.slice(0, limit); const truncated = matched.length > rows.length; - return jsonResult({ + return { total_matched: matched.length, returned: rows.length, limit, truncated, ...(truncated ? { hint } : {}), [key]: rows, - }); + }; +} + +function envelope(key: string, matched: unknown[], limit: number, hint: string) { + return jsonResult(envelopeBody(key, matched, limit, hint)); } /** Lowercase and strip accents so "sao paulo" matches "São Paulo". */ -function fold(value: unknown): string { +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); + } + 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: { report?: "rreo" | "rgf" | "entes"; year?: number; @@ -67,25 +111,7 @@ export async function tesouroSiconfi(args: { const report = args.report ?? "entes"; if (report === "entes") { const rows = await paginate("entes", {}, MAX_PAGES_ENTES); - let matched = 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); - } - 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)); - } + const matched = filterEntes(rows, args); return envelope( "entes", matched, From d06ceb7dfa5af38c58a7fdef7d03a7a4b3916e6d Mon Sep 17 00:00:00 2001 From: Roberto Date: Sun, 18 Oct 2026 14:19:39 -0300 Subject: [PATCH 3/6] fix(mcp): state no-API-key, 60 req/min and no-code-execution on the landing The landing page contract test requires these three disclosures and the rewritten page had dropped them. Co-Authored-By: Claude Opus 5 --- workers/mcp/public/index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/workers/mcp/public/index.html b/workers/mcp/public/index.html index 0c21711..6c66373 100644 --- a/workers/mcp/public/index.html +++ b/workers/mcp/public/index.html @@ -234,7 +234,9 @@

O que este serviço não faz

Limites e transparência

    -
  • Uso justo: cerca de 60 requisições por minuto por IP, com pico de 20 a cada 10 segundos. O excesso recebe HTTP 429 com Retry-After.
  • +
  • Não pede API key, cadastro nem cartão. É só apontar o cliente para a URL.
  • +
  • Uso justo: 60 req/min por IP, com pico de 20 a cada 10 segundos. O excesso recebe HTTP 429 com Retry-After.
  • +
  • Sem execução de código: o servidor só lê fontes públicas e devolve dados. Ele não roda snippet, não escreve em lugar nenhum e não guarda o que você perguntou.
  • Resposta longa vem paginada, com total_matched, returned e truncated. Se truncated for verdadeiro, você não viu tudo.
  • Parâmetro desconhecido é recusado com erro explícito, nunca ignorado em silêncio.
  • Lista vazia não é erro: quando cabe, um campo reason explica o motivo, como PTAX ainda não publicado no dia ou fim de semana.
  • From dba5e248330c97f1866507625e71827b4b6df88e Mon Sep 17 00:00:00 2001 From: Roberto Date: Sun, 18 Oct 2026 14:25:59 -0300 Subject: [PATCH 4/6] fix(mcp): stop certifying completeness, cap limit, explain empty ranges Adversarial review (GPT-6 Astra xhigh, cross-family) found three real defects in the previous commits. 1. `truncated: false` could certify an incomplete result. `paginate` dropped SICONFI's `hasMore` when it hit the page cap, so a report cut short by `MAX_PAGES_REPORT = 1` still reported an exact `total_matched` and `truncated: false`. It now returns `{rows, complete}`, and the envelope carries `upstream_complete`. `truncated` is true whenever the response was cut to `limit` OR the upstream was not exhausted. 2. `limit` accepted up to 6000, which rebuilt the ~500 KB payload this branch set out to bound, and the hint told the agent to raise it. `limit` is capped at 2000 and the hint now points at the filters. 3. An empty PTAX range returned a bare envelope with no `reason`, while the tool description promised one whenever `count` is 0. Ranges now resolve to `no_quotes_in_range`; a range is not classified with a single day's diagnosis. Also from the review: `parseJsonBody` raised `UpstreamLimitError`, which was the wrong class and dropped the cause. It raises `UpstreamFormatError` with `{cause}`. And the pre-13h PTAX note asserted a cause the clock cannot establish, so it now suggests rather than states, and mentions the holiday case. node:test 44 -> 48 passing. Co-Authored-By: Claude Opus 5 --- docs/MCP_SURFACE.md | 30 ++++++++++--- workers/mcp/src/lib/http.ts | 12 +++++- workers/mcp/src/lib/ptaxDay.ts | 2 +- workers/mcp/src/server.ts | 5 ++- workers/mcp/src/tools/bcb.ts | 14 +++++- workers/mcp/src/tools/contract.test.ts | 29 +++++++++++++ workers/mcp/src/tools/tesouro.ts | 59 +++++++++++++++++++------- 7 files changed, 122 insertions(+), 29 deletions(-) diff --git a/docs/MCP_SURFACE.md b/docs/MCP_SURFACE.md index 64111d4..cd221b5 100644 --- a/docs/MCP_SURFACE.md +++ b/docs/MCP_SURFACE.md @@ -109,15 +109,31 @@ yet, which is the divergence noted at the top. 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, 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. Unbounded `report=entes` used to return about 500 KB in one response. + `{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` (`non_business_day`, `not_published_yet`, `future_date`, - `no_quote_for_date`) plus a human-readable `note`. An empty list is not an - error, and the caller should not retry blindly. + `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 diff --git a/workers/mcp/src/lib/http.ts b/workers/mcp/src/lib/http.ts index f0e43f0..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; @@ -103,10 +110,11 @@ export async function getJson( export function parseJsonBody(body: string, url: string): unknown { try { return JSON.parse(body) as unknown; - } catch { + } catch (cause) { const head = body.trimStart().slice(0, 120).replace(/\s+/g, " "); - throw new UpstreamLimitError( + throw new UpstreamFormatError( `upstream returned a non-JSON body for ${url} (likely an error page or an outage); first bytes: ${head}`, + { cause }, ); } } diff --git a/workers/mcp/src/lib/ptaxDay.ts b/workers/mcp/src/lib/ptaxDay.ts index d5e8fb8..fc7343a 100644 --- a/workers/mcp/src/lib/ptaxDay.ts +++ b/workers/mcp/src/lib/ptaxDay.ts @@ -35,7 +35,7 @@ export function emptyDayReason( if (day === now.date && now.hour < 13) { return { reason: "not_published_yet", - note: `the closing PTAX for ${day} is published around 13h BRT; it is ${String(now.hour).padStart(2, "0")}h now`, + 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 0628d75..3b11b48 100644 --- a/workers/mcp/src/server.ts +++ b/workers/mcp/src/server.ts @@ -130,7 +130,7 @@ export function createServer() { 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, entes|rows}: when truncated is true you did NOT see everything.", + "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(), @@ -141,7 +141,8 @@ export function createServer() { 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(6000).optional().describe("entes default 100, reports default 2000"), + 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)), diff --git a/workers/mcp/src/tools/bcb.ts b/workers/mcp/src/tools/bcb.ts index d77c45f..3250ce2 100644 --- a/workers/mcp/src/tools/bcb.ts +++ b/workers/mcp/src/tools/bcb.ts @@ -95,7 +95,19 @@ export async function bcbPtax(args: { }, ); const cotacoes = odataValue(raw); - return jsonResult({ currency, start: args.start, end: args.end, count: cotacoes.length, cotacoes }); + 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`, + }); } const day = args.date ?? brtNow().date; const raw = diff --git a/workers/mcp/src/tools/contract.test.ts b/workers/mcp/src/tools/contract.test.ts index 7405007..12d8c56 100644 --- a/workers/mcp/src/tools/contract.test.ts +++ b/workers/mcp/src/tools/contract.test.ts @@ -127,3 +127,32 @@ test("parseJsonBody names the real condition when upstream returns HTML", () => 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 7a3ec5f..f5b1ae2 100644 --- a/workers/mcp/src/tools/tesouro.ts +++ b/workers/mcp/src/tools/tesouro.ts @@ -11,8 +11,9 @@ 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, @@ -25,32 +26,57 @@ async function paginate( rows.push(item as Record); } } - if (!raw.hasMore) break; + if (!raw.hasMore) { + complete = true; + break; + } } - return rows; + return { rows, complete }; } -/** Bounded payload plus the counters an agent needs to know it saw everything. */ +/** 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 truncated = matched.length > rows.length; + 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, - ...(truncated ? { hint } : {}), + upstream_complete: upstreamComplete, + ...(hints.length > 0 ? { hint: hints.join("; ") } : {}), [key]: rows, }; } -function envelope(key: string, matched: unknown[], limit: number, hint: string) { - return jsonResult(envelopeBody(key, matched, limit, hint)); +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". */ @@ -110,13 +136,14 @@ export async function tesouroSiconfi(args: { }) { const report = args.report ?? "entes"; if (report === "entes") { - const rows = await paginate("entes", {}, MAX_PAGES_ENTES); - const matched = filterEntes(rows, args); + 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), or raise 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) { @@ -126,12 +153,12 @@ export async function tesouroSiconfi(args: { ); } const limit = args.limit ?? REPORT_DEFAULT_LIMIT; - const hint = "raise limit, or narrow with anexo"; + 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, @@ -143,9 +170,9 @@ export async function tesouroSiconfi(args: { }, MAX_PAGES_REPORT, ); - return envelope("rows", rows, limit, hint); + return envelope("rows", page.rows, limit, hint, page.complete); } - const rows = await paginate( + const page = await paginate( "rreo", { an_exercicio: args.year, @@ -156,5 +183,5 @@ export async function tesouroSiconfi(args: { }, MAX_PAGES_REPORT, ); - return envelope("rows", rows, limit, hint); + return envelope("rows", page.rows, limit, hint, page.complete); } From c6f5f64f85df57be95de717b9a7b0788911fc5ea Mon Sep 17 00:00:00 2001 From: Roberto Date: Sun, 18 Oct 2026 14:33:29 -0300 Subject: [PATCH 5/6] docs(mcp): list every reason, drop the pagination claim, fix the client setup From CodeRabbit review on #61. All four were real. - `bcb_ptax` can return `no_quotes_in_range`, which the tool description did not list. An agent reads that description as the contract, so it got a value the contract denied. Now split by day and range. - The landing and llms.txt called long responses "paginada". There is no page or offset argument, so that told readers to continue through results they cannot reach. They are bounded, not paginated. - Neither file documented `upstream_complete`, added in the previous commit, nor said that raising `limit` cannot recover rows the upstream never delivered. - The connection JSON omitted `"type": "http"`, so Claude Code treats the entry as a local server and ignores the URL. Claude Desktop does not take a remote server from that JSON at all; it needs the custom connector screen. Both documented separately. Co-Authored-By: Claude Opus 5 --- workers/mcp/public/index.html | 21 ++++++++++++++++----- workers/mcp/public/llms.txt | 21 +++++++++++++++++---- workers/mcp/src/server.ts | 3 ++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/workers/mcp/public/index.html b/workers/mcp/public/index.html index 6c66373..fdc82db 100644 --- a/workers/mcp/public/index.html +++ b/workers/mcp/public/index.html @@ -182,18 +182,28 @@

    Como conectar em um minuto

    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.

    -

    Claude Desktop, Claude Code, Cursor e similares

    +

    Claude Code, Cursor e clientes que leem um JSON

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

    - Em clientes que pedem só a URL, use - https://openfindata.com.br/mcp e escolha o transporte - Streamable HTTP. Não há etapa de login. + 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. +

    +

    + Em qualquer outro 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

    @@ -237,7 +247,8 @@

    Limites e transparência

  • Não pede API key, cadastro nem cartão. É só apontar o cliente para a URL.
  • Uso justo: 60 req/min por IP, com pico de 20 a cada 10 segundos. O excesso recebe HTTP 429 com Retry-After.
  • Sem execução de código: o servidor só lê fontes públicas e devolve dados. Ele não roda snippet, não escreve em lugar nenhum e não guarda o que você perguntou.
  • -
  • Resposta longa vem paginada, com total_matched, returned e truncated. Se truncated for verdadeiro, você não viu tudo.
  • +
  • Resposta longa vem limitada, não paginada: não existe argumento de página ou offset. Ela traz total_matched, returned, truncated e upstream_complete. Se truncated for verdadeiro, você não viu tudo; estreite a busca com os filtros da ferramenta, como uf, esfera ou q em tesouro_siconfi.
  • +
  • Se upstream_complete for falso, a fonte ainda tinha páginas quando a busca parou. Nesse caso total_matched conta só o que foi buscado e não é um total geral.
  • Parâmetro desconhecido é recusado com erro explícito, nunca ignorado em silêncio.
  • Lista vazia não é erro: quando cabe, um campo reason explica o motivo, como PTAX ainda não publicado no dia ou fim de semana.
  • Projeto em alpha. A superfície pode mudar, e o repositório é a fonte da verdade.
  • diff --git a/workers/mcp/public/llms.txt b/workers/mcp/public/llms.txt index 9223fa4..35320d1 100644 --- a/workers/mcp/public/llms.txt +++ b/workers/mcp/public/llms.txt @@ -13,7 +13,11 @@ entrega é o que o órgão publicou, normalizado. Qualquer cliente MCP. Configuração mínima: - { "mcpServers": { "openfindata": { "url": "https://openfindata.com.br/mcp" } } } + { "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. ## Ferramentas @@ -30,10 +34,19 @@ Qualquer cliente MCP. Configuração mínima: ## Contrato de resposta -- Respostas longas vêm paginadas, com total_matched, returned, limit e truncated. - Se truncated for true, você não viu tudo: filtre melhor ou aumente limit. +- 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ê. +- 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 diff --git a/workers/mcp/src/server.ts b/workers/mcp/src/server.ts index 3b11b48..a790236 100644 --- a/workers/mcp/src/server.ts +++ b/workers/mcp/src/server.ts @@ -50,7 +50,8 @@ export function createServer() { 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 " + - "(non_business_day, not_published_yet, future_date, no_quote_for_date) — an empty list is not an error.", + "(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"), From 8a7136230bb709d6bc3966de907fb62a551dd5ca Mon Sep 17 00:00:00 2001 From: Roberto Date: Thu, 22 Oct 2026 21:40:56 -0300 Subject: [PATCH 6/6] docs(landing): give ChatGPT a real connection path From CodeRabbit review on #61. The hero invites a ChatGPT user ("Conecte o ChatGPT, o Claude ou o Cursor"), but the setup section offered only the JSON example and Claude Desktop. ChatGPT reads neither, so that visitor reached the end with nothing to do. Added a ChatGPT section stating what is stable: the connector needs Developer Mode, which is beta, web only, absent from the free plan, and gated by the workspace admin on a work account. Deliberately not stating the exact plan name or menu path. Sources disagree on which paid plans qualify, and the menu has moved at least twice, so freezing either into a public page would be inventing precision and would rot. The page links OpenAI's own help article for the current steps instead. Same addition in llms.txt. Co-Authored-By: Claude Opus 5 --- workers/mcp/public/index.html | 20 ++++++++++++++++++-- workers/mcp/public/llms.txt | 7 +++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/workers/mcp/public/index.html b/workers/mcp/public/index.html index fdc82db..2359453 100644 --- a/workers/mcp/public/index.html +++ b/workers/mcp/public/index.html @@ -201,9 +201,25 @@

    Claude Desktop

    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 qualquer outro cliente que peça só a URL, use o mesmo endereço e - escolha o transporte Streamable HTTP. Não há etapa de login. + 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

    diff --git a/workers/mcp/public/llms.txt b/workers/mcp/public/llms.txt index 35320d1..628bfbf 100644 --- a/workers/mcp/public/llms.txt +++ b/workers/mcp/public/llms.txt @@ -18,6 +18,13 @@ Qualquer cliente MCP. Configuração mínima: 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