senado-br-mcp 3.18.2 → 3.18.4

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.
package/README.md CHANGED
@@ -572,7 +572,7 @@ In addition to the `provenance` envelope, `structuredContent` carries a top-leve
572
572
 
573
573
  **Field-level granularity.** Most tools are single-source, so one envelope suffices. The few that merge reads in one response fill `field_sources` — a list of `{ fields, source_url, dataset_id, data_vintage, retrieved_at, served_from_cache }` attributing specific output fields to their real origin, and the block's `retrieved_at` is the oldest of them. `served_from_cache` is `true`/`false` for reads through the server cache and `null` where the notion does not apply (the bundled org-chart snapshot, the e-Cidadania corpus in D1). The merging tools (since 3.18.2): `senado_obter_materia` `secao=detalhe` (`/processo/{id}` + `ementa` from `/processo` + `relator` from `/processo/relatoria`), `senado_obter_senador` and the Deep Research `fetch` of a senator (biography + `mandatos`/`emExercicio` from `/senador/{codigo}/mandatos`), `senado_reunioes_comissao` over a window that crosses the year (one read per year), `senado_servidores` with `subordinadasA` (payroll + org-chart snapshot), `senado_suprimento_fundos` statistics on `atos-concessao` (acts + beneficiary names from the supridos registry), `senado_ecidadania_obter_evento` (live detail + `comentarios` from the corpus) and `senado_ecidadania_sugerir_tema_enquete` (consultas and ideias corpora). **Only a read that becomes a field of the answer is a sub-source**: a read that only resolves or checks an identifier (a committee acronym → its code, the parent check in `senado_contratacao_detalhe`) is left out, otherwise a cached lookup table would make the whole answer claim to be as old as it. `attribution` always lists every distinct underlying `source_url`.
574
574
 
575
- `retrieved_at` fidelity is provided by the cache layer (`cachedFetchWithMeta`), which persists the fetch timestamp alongside the value, so it reflects the real upstream extraction even on a cache hit. Two exceptions report an honest live timestamp instead: the e-Cidadania **list** tools (read from D1) use the corpus's `lastScrapedAt` — the true age of the stored data — while e-Cidadania **detail** tools, scraped live, use the fetch time and a level-3 canonical item URL. The only path that falls back to the build-time default is the in-code static reference catalog (`senado_tabelas_referencia` `tipos-materia`), which has no upstream extraction instant.
575
+ `retrieved_at` fidelity is provided by the cache layer (`cachedFetchWithMeta`), which persists the fetch timestamp alongside the value, so it reflects the real upstream extraction even on a cache hit. Two exceptions report an honest live timestamp instead: the e-Cidadania **list** tools (read from D1) use the corpus's `lastScrapedAt` — the true age of the stored data, or the live scrape instant when the corpus is empty or stale and the list falls back to the portal (since 3.18.3; before, the stale corpus's instant leaked into the live answer) — while e-Cidadania **detail** tools, scraped live, use the fetch time and a level-3 canonical item URL. The only path that falls back to the build-time default is the in-code static reference catalog (`senado_tabelas_referencia` `tipos-materia`), which has no upstream extraction instant.
576
576
 
577
577
  Coverage is **universal**: all 69 tools carry the envelope — the 67 `senado_*` tools via `resultWithProvenance(` (verify with `grep -c 'resultWithProvenance(' src/tools/*.ts`) and the two Deep Research tools via `provenanceExtras` (same block, in `structuredContent`/`_meta`, since their text channel is the contract's JSON). The **⊕** marks in the inventory below denote the original pilot tools (votes, bills, processes); the envelope now extends to every tool, so the marks are historical.
578
578
 
@@ -12,7 +12,7 @@
12
12
  import { UpstreamError as PkgUpstreamError } from "@sbissoli/mcp-upstream";
13
13
  import { UPSTREAM_TIMEOUT_MS } from "../types.js";
14
14
  import { USER_AGENT } from "../version.js";
15
- import { upstreamCall } from "../throttle/upstream.js";
15
+ import { CorpoForaDoPrazoError, PrazoDoCorpo, upstreamCall } from "../throttle/upstream.js";
16
16
  export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
17
17
  /**
18
18
  * Error carrying a `retryable` flag, mirroring UpstreamError so the tool layer
@@ -36,10 +36,9 @@ const isTransientStatus = (status) => status >= 500 || status === 429;
36
36
  * (`X-Requested-With: XMLHttpRequest`, wider `Accept`).
37
37
  */
38
38
  export async function fetchPage(path, opts = {}) {
39
- const resp = await idaEcidadania(path, opts.ajax
39
+ const text = await idaEcidadania(path, opts.ajax
40
40
  ? { Accept: "text/html,*/*", "User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest" }
41
41
  : { Accept: "text/html", "User-Agent": USER_AGENT }, "e-Cidadania");
42
- const text = await resp.text();
43
42
  // AJAX comment fragments are legitimately empty for events with zero comments — allowEmpty
44
43
  // lets the caller treat "" as a valid (empty) result instead of a transient failure.
45
44
  if (!opts.allowEmpty && !text.trim()) {
@@ -52,10 +51,10 @@ export async function fetchPage(path, opts = {}) {
52
51
  * Ensures the response is valid JSON and an array.
53
52
  */
54
53
  export async function fetchEcidadaniaJson(endpoint) {
55
- const resp = await idaEcidadania(endpoint, { Accept: "application/json", "User-Agent": USER_AGENT }, "e-Cidadania REST API");
54
+ const corpo = await idaEcidadania(endpoint, { Accept: "application/json", "User-Agent": USER_AGENT }, "e-Cidadania REST API");
56
55
  let data;
57
56
  try {
58
- data = await resp.json();
57
+ data = JSON.parse(corpo);
59
58
  }
60
59
  catch {
61
60
  throw ecidadaniaFetchError(`e-Cidadania REST API retornou JSON inválido para ${endpoint}`, true);
@@ -77,11 +76,18 @@ export async function fetchEcidadaniaJson(endpoint) {
77
76
  * descartável e a política vale igual. O token bucket NÃO se aplica: é outro
78
77
  * host, e o portal nunca passou por ele. As mensagens e o `retryable` são os
79
78
  * de antes — `ecidadaniaError` (tools) e o pipeline os leem.
79
+ *
80
+ * Devolve o CORPO, lido aqui sob o que sobra dos 10 s (`PrazoDoCorpo`): no modo
81
+ * `response` o pacote só cobre até os cabeçalhos, e um portal que trava no
82
+ * corpo pendurava a chamada (e o cron) sem prazo.
80
83
  */
81
84
  async function idaEcidadania(path, headers, rotulo) {
82
85
  const url = `${ECIDADANIA_BASE}${path}`;
86
+ const prazo = new PrazoDoCorpo();
87
+ const foraDoPrazo = () => ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true, "fonte");
88
+ let resp;
83
89
  try {
84
- return await upstreamCall().response(url, { headers });
90
+ resp = await upstreamCall().response(url, { headers, signal: prazo.signal });
85
91
  }
86
92
  catch (e) {
87
93
  if (!(e instanceof PkgUpstreamError))
@@ -89,7 +95,7 @@ async function idaEcidadania(path, headers, rotulo) {
89
95
  switch (e.kind) {
90
96
  case "timeout":
91
97
  case "aborted":
92
- throw ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true, "fonte");
98
+ throw foraDoPrazo();
93
99
  case "network": {
94
100
  const causa = e.cause instanceof Error ? e.cause.message : String(e.cause ?? "desconhecido");
95
101
  throw ecidadaniaFetchError(`${rotulo}: falha de rede ao acessar ${path} (${causa})`, true, "fonte");
@@ -108,6 +114,14 @@ async function idaEcidadania(path, headers, rotulo) {
108
114
  }
109
115
  }
110
116
  }
117
+ try {
118
+ return await prazo.texto(resp);
119
+ }
120
+ catch (e) {
121
+ if (e instanceof CorpoForaDoPrazoError)
122
+ throw foraDoPrazo();
123
+ throw e;
124
+ }
111
125
  }
112
126
  /** Parse Brazilian number: 1.234.567 -> 1234567 */
113
127
  export function parseBrNum(s) {
@@ -99,7 +99,11 @@ export async function resolveList(db, entidade, staleMaxMin, liveScrape, now = n
99
99
  items: live,
100
100
  meta: {
101
101
  fonte: "ao-vivo",
102
- lastScrapedAt,
102
+ // The items were scraped NOW, so this is their age — not the stale D1's `lastScrapedAt`
103
+ // read above (until 3.18.2 it leaked here, and the tools' provenance said the live data
104
+ // was as old as the corpus, or carried no instant at all). An empty live list extracted
105
+ // nothing (consultas_votos' live path is a no-op `[]`), so it claims no instant.
106
+ lastScrapedAt: live.length > 0 ? new Date().toISOString() : null,
103
107
  possivelDesatualizacao: false,
104
108
  motivo: !db ? "d1-indisponivel" : d1Items.length === 0 ? "d1-vazio" : "d1-desatualizado",
105
109
  },
@@ -213,6 +213,52 @@ export function withUpstreamCall(fn) {
213
213
  export function upstreamCall() {
214
214
  return currentCall() ?? upstreamSenado.call();
215
215
  }
216
+ /** O corpo não chegou inteiro dentro do orçamento da ida (ver `PrazoDoCorpo`). */
217
+ export class CorpoForaDoPrazoError extends Error {
218
+ constructor() {
219
+ super("corpo da resposta fora do prazo");
220
+ this.name = "CorpoForaDoPrazoError";
221
+ }
222
+ }
223
+ /**
224
+ * O prazo da LEITURA DO CORPO de uma ida no modo `response`.
225
+ *
226
+ * No modo `response` o `@sbissoli/mcp-upstream` (0.4.x) desarma o cronômetro da
227
+ * tentativa quando chegam os CABEÇALHOS: o corpo, lido aqui depois, ficava sem
228
+ * prazo nenhum, e uma fonte que manda os cabeçalhos e trava no corpo pendurava a
229
+ * chamada até o Worker morrer. Medido em 13/10/2026 ao subir o nock do medical
230
+ * (o `delay()` do nock 15 passou a segurar o corpo, não os cabeçalhos).
231
+ *
232
+ * O `signal` entra na ida e o pacote o combina ao dele; quem o aborta é SÓ
233
+ * `texto()`, depois dos cabeçalhos — durante a ida ele nunca dispara, então a
234
+ * classificação do pacote (timeout × abortado, a contagem do `retrieval`) não
235
+ * muda. O prazo do corpo é o que sobra do orçamento TOTAL da ida, contado do
236
+ * início dela: cabeçalhos + corpo cabem nos mesmos 10 s, como sempre se disse.
237
+ */
238
+ export class PrazoDoCorpo {
239
+ #controller = new AbortController();
240
+ #inicio = Date.now();
241
+ /** Para passar na ida (`call.response(url, { signal })`). */
242
+ get signal() {
243
+ return this.#controller.signal;
244
+ }
245
+ /** Lê o corpo como texto; estourando o orçamento, lança `CorpoForaDoPrazoError`. */
246
+ async texto(response) {
247
+ const resta = UPSTREAM_POLICY.budgetMs - (Date.now() - this.#inicio);
248
+ const timer = setTimeout(() => this.#controller.abort(), Math.max(0, resta));
249
+ try {
250
+ return await response.text();
251
+ }
252
+ catch (e) {
253
+ if (this.#controller.signal.aborted)
254
+ throw new CorpoForaDoPrazoError();
255
+ throw e;
256
+ }
257
+ finally {
258
+ clearTimeout(timer);
259
+ }
260
+ }
261
+ }
216
262
  /**
217
263
  * O `retrieval` medido nesta chamada de tool — `null` fora de um coletor ou
218
264
  * quando a resposta veio inteira do cache (nenhuma ida). É o que
@@ -260,6 +306,20 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
260
306
  for (let i = 1; i < attempts; i++)
261
307
  incr("upstreamRetries");
262
308
  };
309
+ const prazo = new PrazoDoCorpo();
310
+ const lerCorpo = async (r) => {
311
+ try {
312
+ return await prazo.texto(r);
313
+ }
314
+ catch (e) {
315
+ if (e instanceof CorpoForaDoPrazoError) {
316
+ incr("upstreamErrors");
317
+ logger.error("upstream_error", { path, status: 504, message: "corpo fora do prazo" });
318
+ throw new UpstreamError(`[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`, 504, true, true);
319
+ }
320
+ throw e;
321
+ }
322
+ };
263
323
  let response;
264
324
  inFlight++;
265
325
  try {
@@ -269,6 +329,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
269
329
  Accept: options.accept ?? "application/json",
270
330
  "User-Agent": USER_AGENT,
271
331
  },
332
+ signal: prazo.signal,
272
333
  });
273
334
  }
274
335
  catch (err) {
@@ -278,7 +339,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
278
339
  if (err.kind === "not_found" && options.on404 && err.response) {
279
340
  incr("upstreamCalls");
280
341
  log("upstream", path, 404, Date.now() - startTime, err.attempts - 1);
281
- const corpo404 = await err.response.text();
342
+ const corpo404 = await lerCorpo(err.response);
282
343
  if (rotaInexistente(corpo404)) {
283
344
  throw new UpstreamError(`[${path}] ${MSG_ROTA_INEXISTENTE} — o caminho montado não existe na API do Senado.`, 404, false, false, "defeito");
284
345
  }
@@ -305,7 +366,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
305
366
  if (contentLength && parseInt(contentLength, 10) > maxSize) {
306
367
  throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
307
368
  }
308
- const text = await response.text();
369
+ const text = await lerCorpo(response);
309
370
  if (text.length > maxSize) {
310
371
  throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
311
372
  }
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.18.2";
2
+ export const VERSION = "3.18.4";
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.18.2",
3
+ "version": "3.18.4",
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",
@@ -83,7 +83,7 @@
83
83
  "@sbissoli/mcp-evals": "^0.2.1",
84
84
  "@sbissoli/mcp-surface": "^0.6.0",
85
85
  "@types/node": "^26.5.1",
86
- "agents": "^0.26.0",
86
+ "agents": "^0.28.0",
87
87
  "exceljs": "^4.4.0",
88
88
  "tsx": "^4.23.13",
89
89
  "typescript": "^7.0.2",