senado-br-mcp 3.21.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -114,16 +114,16 @@ const consultasSchema = {
114
114
  sourceEndpoint: EP_CONSULTAS_DETALHE,
115
115
  sourceField: "página de detalhe: <b>Autoria:</b> <span>…</span>",
116
116
  operationalization: "Lido da página de detalhe (visualizacaomateria) no crawl enriquecido; null quando o detalhe não expõe. Nome MANTIDO (informação pública por função — ver postura de privacidade).",
117
- caveat: "Detail-only (v2): populado pelo crawl de detalhe; null nas linhas ainda não enriquecidas.",
117
+ caveat: "Detail-only (v2): populado pelo crawl de detalhe; null nas linhas ainda não enriquecidas. Até a 3.21.0 o crawl só preencheu as ~3,4 mil consultas do primeiro lote: as demais ficaram null com a autoria publicada na página e são relidas desde então, até o teto por execução.",
118
118
  },
119
119
  {
120
120
  name: "relator",
121
121
  type: "string",
122
- description: "Relator(a) da matéria (agente público).",
122
+ description: "Relator(a) da matéria (agente público). Sempre null: a página da consulta não publica relatoria.",
123
123
  sourceEndpoint: EP_CONSULTAS_DETALHE,
124
- sourceField: "página de detalhe: <b>Relator(a):</b> <span>…</span>",
125
- operationalization: "Lido da página de detalhe (visualizacaomateria); null quando não há relatoria ou o detalhe não a expõe. Nome MANTIDO (público por função).",
126
- caveat: "Detail-only (v2): populado pelo crawl de detalhe; null nas linhas ainda não enriquecidas.",
124
+ sourceField: "página de detalhe: <b>Relator(a):</b> <span>…</span> (rótulo que a página não traz)",
125
+ operationalization: "O leitor procura o rótulo Relator(a) na página de detalhe (visualizacaomateria), mas a página publica só Autoria e Ementa: 0 de 8.145 consultas e 0 de 40.548 versões do histórico tiveram valor, e 42 páginas reais de anos e situações diferentes (medição de 18/10/2026) não traziam o rótulo. O relator da matéria está na API legislativa (/processo/relatoria), fora deste corpus.",
126
+ caveat: "Campo mantido por compatibilidade do formato; não use como dado. Vazio não significa matéria sem relator.",
127
127
  },
128
128
  {
129
129
  name: "status",
@@ -16,7 +16,7 @@
16
16
  * semântica deste servidor, medida contra a fonte). O pacote CLASSIFICA; este
17
17
  * módulo DECIDE. A classe `UpstreamError` local, as mensagens e o `transport`
18
18
  * continuam os mesmos: `errorFrom`, `ehReuniaoInexistente`, `classifyError` e o
19
- * disjuntor do contrato noturno (`scripts/contract/outage.ts`) leem todos eles.
19
+ * repórter do contrato noturno (`@sbissoli/mcp-live-source`, pelo `cause`) leem todos eles.
20
20
  *
21
21
  * POLÍTICA (`UPSTREAM_POLICY`) — medida em 27/09/2026 com `curl` e o User-Agent
22
22
  * do portfólio: a API legislativa responde em 0,3–1 s (`/senador/lista/atual`
@@ -93,8 +93,17 @@ export class UpstreamError extends Error {
93
93
  * quem sabe mais passa explícito (rota inexistente é `defeito`, corpo vazio
94
94
  * é `nao_encontrado`).
95
95
  */
96
- classe) {
97
- super(message);
96
+ classe,
97
+ /**
98
+ * `cause`: o erro do PACOTE (`@sbissoli/mcp-upstream`) que este erro local
99
+ * traduz. É por ele que o repórter do contrato noturno
100
+ * (`@sbissoli/mcp-live-source`, via `entregaDaOrigem`) decide se a origem
101
+ * entregou algo medível — pelo `kind`, nunca pela frase. Sem `cause`, toda
102
+ * falha traduzida seria "alheia à origem" (medido em 22/10/2026: um 502 nas
103
+ * três tentativas caía em "other" e zerava o disjuntor).
104
+ */
105
+ opcoes = {}) {
106
+ super(message, opcoes.cause === undefined ? undefined : { cause: opcoes.cause });
98
107
  this.status = status;
99
108
  this.retryable = retryable;
100
109
  this.transport = transport;
@@ -376,7 +385,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
376
385
  if (!text.trim()) {
377
386
  // `nao_encontrado`, como a frase já dava ("vazi"): um corpo vazio é como o
378
387
  // `/comissao/reuniao/{codigo}` responde a código inexistente.
379
- throw new UpstreamError(`[${path}] Resposta upstream vazia`, 502, true, false, "nao_encontrado");
388
+ throw new UpstreamError(`[${path}] Resposta upstream vazia`, 502, true, false, "nao_encontrado", { cause: corpoInesperado(url.toString(), response.status, text) });
380
389
  }
381
390
  if (options.text)
382
391
  return text;
@@ -384,7 +393,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
384
393
  return JSON.parse(text);
385
394
  }
386
395
  catch {
387
- throw new UpstreamError(`[${path}] Resposta upstream não é JSON válido`, 502, false);
396
+ throw new UpstreamError(`[${path}] Resposta upstream não é JSON válido`, 502, false, false, undefined, { cause: corpoInesperado(url.toString(), response.status, text.slice(0, 2048)) });
388
397
  }
389
398
  }
390
399
  /**
@@ -394,28 +403,40 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
394
403
  * `transport` alimenta o disjuntor do contrato noturno.
395
404
  */
396
405
  export function traduzirErro(err, path) {
406
+ // O erro do pacote vai em `cause`: é por ele (pelo `kind`) que o repórter do
407
+ // contrato noturno sabe se a origem entregou algo medível (3.21.2).
408
+ const cause = { cause: err };
397
409
  switch (err.kind) {
398
410
  case "timeout":
399
411
  case "aborted":
400
- return new UpstreamError(`[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`, 504, true, true);
412
+ return new UpstreamError(`[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`, 504, true, true, undefined, cause);
401
413
  case "network": {
402
- const cause = err.cause;
403
- const detalhe = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "";
404
- return new UpstreamError(`[${path}] Erro de rede: ${detalhe || "desconhecido"}`, 502, true, true);
414
+ const raiz = err.cause;
415
+ const detalhe = raiz instanceof Error ? raiz.message : typeof raiz === "string" ? raiz : "";
416
+ return new UpstreamError(`[${path}] Erro de rede: ${detalhe || "desconhecido"}`, 502, true, true, undefined, cause);
405
417
  }
406
418
  case "rate_limited":
407
- return new UpstreamError(`[${path}] Upstream retornou 429`, 429, true);
419
+ return new UpstreamError(`[${path}] Upstream retornou 429`, 429, true, false, undefined, cause);
408
420
  case "http_5xx": {
409
421
  const status = err.status ?? 502;
410
422
  return status === 503
411
- ? new UpstreamError(`[${path}] Upstream retornou 503`, 503, true)
412
- : new UpstreamError(`[${path}] Upstream retornou HTTP ${status}`, status, true);
423
+ ? new UpstreamError(`[${path}] Upstream retornou 503`, 503, true, false, undefined, cause)
424
+ : new UpstreamError(`[${path}] Upstream retornou HTTP ${status}`, status, true, false, undefined, cause);
413
425
  }
414
426
  case "http_4xx":
415
427
  case "not_found":
416
- return new UpstreamError(`[${path}] Upstream retornou HTTP ${err.status ?? 404}`, err.status ?? 404, false);
428
+ return new UpstreamError(`[${path}] Upstream retornou HTTP ${err.status ?? 404}`, err.status ?? 404, false, false, undefined, cause);
417
429
  case "malformed_body":
418
430
  // Não acontece no modo `response` (o corpo é lido por `upstreamFetch`).
419
- return new UpstreamError(`[${path}] Resposta upstream não é JSON válido`, 502, false);
431
+ return new UpstreamError(`[${path}] Resposta upstream não é JSON válido`, 502, false, false, undefined, cause);
420
432
  }
421
433
  }
434
+ /**
435
+ * O corpo chegou, mas não é o esperado (vazio, ou não é JSON). Para o repórter do
436
+ * contrato noturno isso é `malformed_body` da origem — veredito "ambíguo": ou o
437
+ * formato mudou, ou a origem serviu a página de erro num pedido bom; repetir decide.
438
+ * Lido AQUI, depois da ida, porque o modo `response` do pacote não lê o corpo.
439
+ */
440
+ function corpoInesperado(url, status, body) {
441
+ return new PkgUpstreamError({ url, kind: "malformed_body", status, retryable: false, transport: false, attempts: 1, body });
442
+ }
@@ -239,7 +239,7 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
239
239
  }
240
240
  });
241
241
  // G2. senado_ecidadania_obter_consulta
242
- server.tool("senado_ecidadania_obter_consulta", "Obtém o detalhe de uma consulta pública específica do e-Cidadania. Retorna um objeto com `id`, `materia`, `ementa`, `votosSim`/`votosNao`/`totalVotos`, `percentualSim`/`percentualNao`, `status`, `autor`, `relator`, `url`. O portal não publica o período da consulta: `dataAbertura`/`dataEncerramento` (e `comissao`/`linkMateria`) vêm sempre `null` — a consulta abre quando a matéria entra em tramitação e encerra quando sai, refletido em `status`. O campo `comentarios` vem `null`: a página de consulta não possui recurso de comentários. Obtenha o `id` antes via `senado_ecidadania_listar_consultas` ou `senado_ecidadania_consultas_analise`.", { id: z.number().int().positive().describe("ID da consulta pública") }, async (params) => {
242
+ server.tool("senado_ecidadania_obter_consulta", "Obtém o detalhe de uma consulta pública específica do e-Cidadania. Retorna um objeto com `id`, `materia`, `ementa`, `votosSim`/`votosNao`/`totalVotos`, `percentualSim`/`percentualNao`, `status`, `autor`, `relator`, `url`. O portal não publica o período da consulta: `dataAbertura`/`dataEncerramento` (e `comissao`/`linkMateria`) vêm sempre `null` — a consulta abre quando a matéria entra em tramitação e encerra quando sai, refletido em `status`. O campo `comentarios` vem `null`: a página de consulta não possui recurso de comentários. O campo `relator` vem `null`: a página da consulta publica só autoria e ementa, não a relatoria — o relator da matéria sai de `senado_obter_materia` com o mesmo número (`codigoMateria` = `id`). Obtenha o `id` antes via `senado_ecidadania_listar_consultas` ou `senado_ecidadania_consultas_analise`.", { id: z.number().int().positive().describe("ID da consulta pública") }, async (params) => {
243
243
  try {
244
244
  const { value: r, fetchedAt } = await cachedFetchWithMeta("ecidadania_consulta", { id: params.id }, CACHE_ON_DEMAND, () => obterConsultaInternal(params.id));
245
245
  writeDetalheThrough(db, ctx, "consultas", params.id, r);
@@ -123,7 +123,7 @@ export function estatisticasCeaps(filtrado, opts) {
123
123
  }
124
124
  export function registerSenadoresAdminTools(server, admBaseUrl) {
125
125
  // O1. senado_ceaps
126
- server.tool("senado_ceaps", "Despesas da Cota para Exercício da Atividade Parlamentar (CEAPS) dos senadores em um ano. Para perguntas de **maior/menor/média/mediana/distribuição/ranking** ('quem gastou mais CEAPS', 'gasto mediano', 'distribuição das despesas') use `estatisticas=true`: computa min/máx/média/mediana/desvio/percentis sobre TODAS as despesas filtradas e devolve `top`/`bottom` (padrão 10) com identificadores — os modos agregados só somam por grupo e não revelam a distribuição nem o extremo individual. Sem `agruparPor` → `distribuicao` das despesas individuais + `top`/`bottom`; com `agruparPor` (`senador`/`tipo`/`mes`/`fornecedor`) → `grupos[]` ranqueados por soma decrescente (`grupos[0]` = maior gastador), cada um com sua mini-distribuição. Sem `estatisticas`: nos modos agregados (`por-senador`/`por-tipo`/`por-mes`/`por-fornecedor`, padrão `por-senador`) traz `agregado[]` ordenado por `total` desc com `chave`, `total` e `despesas` (contagem); em `modo='detalhe'` traz `despesas[]` (mês, data, senador, tipoDespesa, fornecedor, cnpjCpf, valor). Filtre por `mes`, `codSenador`, `nomeSenador`, `tipoDespesa` ou `fornecedor` (busca parcial); `limite` cap 100 com `aviso` ao truncar. Obtenha `codSenador` via `senado_listar_senadores`.", {
126
+ server.tool("senado_ceaps", "Despesas da Cota para Exercício da Atividade Parlamentar (CEAPS) dos senadores em um ano. A fonte também publica lançamentos NEGATIVOS, sem rótulo próprio (a maioria em passagens aéreas, com jeito de crédito ou estorno): eles entram nas somas e totais como desconto e, nas estatísticas, aparecem como mínimo e no `bottom` — não leia um valor negativo como erro nem como 'a menor despesa'. Para perguntas de **maior/menor/média/mediana/distribuição/ranking** ('quem gastou mais CEAPS', 'gasto mediano', 'distribuição das despesas') use `estatisticas=true`: computa min/máx/média/mediana/desvio/percentis sobre TODAS as despesas filtradas e devolve `top`/`bottom` (padrão 10) com identificadores — os modos agregados só somam por grupo e não revelam a distribuição nem o extremo individual. Sem `agruparPor` → `distribuicao` das despesas individuais + `top`/`bottom`; com `agruparPor` (`senador`/`tipo`/`mes`/`fornecedor`) → `grupos[]` ranqueados por soma decrescente (`grupos[0]` = maior gastador), cada um com sua mini-distribuição. Sem `estatisticas`: nos modos agregados (`por-senador`/`por-tipo`/`por-mes`/`por-fornecedor`, padrão `por-senador`) traz `agregado[]` ordenado por `total` desc com `chave`, `total` e `despesas` (contagem); em `modo='detalhe'` traz `despesas[]` (mês, data, senador, tipoDespesa, fornecedor, cnpjCpf, valor). Filtre por `mes`, `codSenador`, `nomeSenador`, `tipoDespesa` ou `fornecedor` (busca parcial); `limite` cap 100 com `aviso` ao truncar. Obtenha `codSenador` via `senado_listar_senadores`.", {
127
127
  ano: z.number().int().min(2008).max(2100).describe("Ano das despesas"),
128
128
  modo: z.enum(["por-senador", "por-tipo", "por-mes", "por-fornecedor", "detalhe"]).optional().default("por-senador").describe("Agregação ou detalhe (padrão: por-senador). Ignorado quando estatisticas=true"),
129
129
  estatisticas: z.boolean().optional().default(false).describe("Computa estatísticas (min/máx/média/mediana/percentis) + ranking top/bottom sobre todas as despesas filtradas. Use para 'quem gastou mais/menos', 'gasto médio/mediano', 'distribuição', 'ranking'"),
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Single source of truth for the server version (kept in sync with package.json). */
2
- export const VERSION = "3.21.0";
2
+ export const VERSION = "3.22.0";
3
3
  export const USER_AGENT = `senado-br-mcp/${VERSION} (+https://senado.sidneybissoli.com; sbissoli76@gmail.com)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "senado-br-mcp",
3
- "version": "3.21.0",
3
+ "version": "3.22.0",
4
4
  "description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -74,13 +74,14 @@
74
74
  "@sbissoli/mcp-provenance": "^0.4.0",
75
75
  "@sbissoli/mcp-search": "^0.9.0",
76
76
  "@sbissoli/mcp-stats": "^0.3.0",
77
- "@sbissoli/mcp-upstream": "^0.4.2",
77
+ "@sbissoli/mcp-upstream": "^0.5.1",
78
78
  "zod": "^4.5.4"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@cloudflare/workers-types": "^5.20260830.1",
82
82
  "@modelcontextprotocol/client": "^2.0.0",
83
83
  "@sbissoli/mcp-evals": "^0.2.1",
84
+ "@sbissoli/mcp-live-source": "^0.1.1",
84
85
  "@sbissoli/mcp-surface": "^0.6.0",
85
86
  "@types/node": "^26.5.1",
86
87
  "agents": "^0.28.0",