senado-br-mcp 3.18.3 → 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.
@@ -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) {
@@ -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.3";
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.3",
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",