senado-br-mcp 3.10.0 → 3.11.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.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  [![LobeHub](https://lobehub.com/badge/mcp/sidneybissoli-senado-br-mcp-cloudflare)](https://lobehub.com/mcp/sidneybissoli-senado-br-mcp-cloudflare)
9
9
  [![LightNow capabilities](https://lightnow.ai/badge/io.github.SidneyBissoli/senado-br-mcp-cloudflare)](https://lightnow.ai/servers/io.github.SidneyBissoli/senado-br-mcp-cloudflare)
10
10
  [![senado-br-mcp-cloudflare MCP server](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare/badges/score.svg)](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare)
11
+ [![smithery badge](https://smithery.ai/badge/sidneybissoli/senado-br-mcp-cloudflare)](https://smithery.ai/servers/sidneybissoli/senado-br-mcp-cloudflare)
11
12
  [![GitHub stars](https://img.shields.io/github/stars/SidneyBissoli/senado-br-mcp-cloudflare?style=flat&logo=github)](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare)
12
13
  [![GitHub Sponsors](https://img.shields.io/github/sponsors/SidneyBissoli?logo=githubsponsors&label=Sponsor&color=db61a2)](https://github.com/sponsors/SidneyBissoli)
13
14
  [![License: MIT](https://img.shields.io/github/license/SidneyBissoli/senado-br-mcp-cloudflare)](LICENSE)
@@ -153,7 +154,7 @@ Claude Code auto-discovers it when you work in this repo. To use it elsewhere, c
153
154
  - **Caching:** 2-layer (L0 memory + L1 Cache API) with SHA-256 keying
154
155
  - **e-Cidadania store:** D1 database refreshed by a Cron Trigger (every 2h) — list tools read from D1 with a live-scrape fallback and a staleness flag; detail tools stay live with write-through (see [e-Cidadania](#e-cidadania-d1-backed-cron-refreshed))
155
156
  - **Rate limiting:** Token bucket — global (8 req/s) + per-client (2 req/s)
156
- - **Upstream throttle:** Max 6 concurrent requests, 10s timeout, retry with exponential backoff
157
+ - **Upstream throttle:** Max 6 concurrent requests, 10s budget per trip, retry on 429/5xx/network with exponential backoff and `Retry-After` (portfolio shared fetch `@sbissoli/mcp-upstream`); every response's provenance carries the measured `retrieval` diagnostics (trips, attempts, anomalies)
157
158
  - **Auth:** Optional Bearer token (set the `API_KEY` secret; open access when unset). Constant-time comparison.
158
159
  - **Observability:** Structured JSON logging + in-memory counters at `/metrics`; per-tool call telemetry (selection, error rate, cache-vs-live) in Cloudflare Analytics Engine, PII-free
159
160
  - **Liveness:** Runs on Cloudflare's own global network behind a custom domain — no third-party host that can go dark. Public `/health` and `/status` (version + last-deploy id/timestamp) make uptime and the current build verifiable; the **status** badge above pings the live endpoint
@@ -32,6 +32,7 @@ import { resolveDesfecho } from "./envelope.js";
32
32
  import { classifyError, classifyThrown, errorText, paramNames } from "./call-shape.js";
33
33
  import { incr, incrTool } from "./metrics.js";
34
34
  import { callCache, cacheClass } from "./observability/call-context.js";
35
+ import { withUpstreamCall } from "./throttle/upstream.js";
35
36
  /** Header the owner's MCP clients send (value = the SELF_MARKER secret). */
36
37
  export const SELF_HEADER = "x-mcp-self";
37
38
  /**
@@ -71,7 +72,12 @@ export function instrumentTool(name, cb, analytics, tag, gravados) {
71
72
  // Per-call store the cache layer increments per upstream fetch (see call-context.ts).
72
73
  const stats = { fetches: 0, hits: 0 };
73
74
  try {
74
- const result = await callCache.run(stats, () => cb(...args));
75
+ // Dois contextos por chamada, aninhados: o coletor de REDE do fetch comum
76
+ // (idas, tentativas, anomalias → `retrieval` da proveniência; ver
77
+ // src/throttle/upstream.ts) e o acumulador de CACHE (acertos → blob3).
78
+ // Este é o único lugar em que uma chamada de tool começa — stdio e Worker
79
+ // passam pelo mesmo funil (`host.tool` em src/server.ts).
80
+ const result = await withUpstreamCall(() => callCache.run(stats, () => Promise.resolve(cb(...args))));
75
81
  isError =
76
82
  typeof result === "object" && result !== null && result.isError === true;
77
83
  if (isError)
@@ -9,8 +9,10 @@
9
9
  * Everything here is pure parsing + fetch, with no MCP/tool/cache dependency, so it can be
10
10
  * driven by the Cron pipeline (step 3) and covered by fixture-based contract tests.
11
11
  */
12
+ import { UpstreamError as PkgUpstreamError } from "@sbissoli/mcp-upstream";
12
13
  import { UPSTREAM_TIMEOUT_MS } from "../types.js";
13
14
  import { USER_AGENT } from "../version.js";
15
+ import { upstreamCall } from "../throttle/upstream.js";
14
16
  export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
15
17
  /**
16
18
  * Error carrying a `retryable` flag, mirroring UpstreamError so the tool layer
@@ -28,78 +30,69 @@ const isTransientStatus = (status) => status >= 500 || status === 429;
28
30
  * (`X-Requested-With: XMLHttpRequest`, wider `Accept`).
29
31
  */
30
32
  export async function fetchPage(path, opts = {}) {
31
- const url = `${ECIDADANIA_BASE}${path}`;
32
- const controller = new AbortController();
33
- const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
34
- try {
35
- const resp = await fetch(url, {
36
- headers: opts.ajax
37
- ? { Accept: "text/html,*/*", "User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest" }
38
- : { Accept: "text/html", "User-Agent": USER_AGENT },
39
- signal: controller.signal,
40
- });
41
- clearTimeout(timeout);
42
- if (!resp.ok) {
43
- throw ecidadaniaFetchError(`e-Cidadania retornou HTTP ${resp.status} para ${path}`, isTransientStatus(resp.status));
44
- }
45
- const text = await resp.text();
46
- // AJAX comment fragments are legitimately empty for events with zero comments — allowEmpty
47
- // lets the caller treat "" as a valid (empty) result instead of a transient failure.
48
- if (!opts.allowEmpty && !text.trim()) {
49
- throw ecidadaniaFetchError(`e-Cidadania retornou página vazia para ${path}`, true);
50
- }
51
- return text;
52
- }
53
- catch (e) {
54
- clearTimeout(timeout);
55
- if (e.name === "AbortError") {
56
- throw ecidadaniaFetchError(`e-Cidadania: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true);
57
- }
58
- if (e instanceof Error && "retryable" in e)
59
- throw e; // already classified above
60
- throw ecidadaniaFetchError(`e-Cidadania: falha de rede ao acessar ${path} (${e.message})`, true);
33
+ const resp = await idaEcidadania(path, opts.ajax
34
+ ? { Accept: "text/html,*/*", "User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest" }
35
+ : { Accept: "text/html", "User-Agent": USER_AGENT }, "e-Cidadania");
36
+ const text = await resp.text();
37
+ // AJAX comment fragments are legitimately empty for events with zero comments — allowEmpty
38
+ // lets the caller treat "" as a valid (empty) result instead of a transient failure.
39
+ if (!opts.allowEmpty && !text.trim()) {
40
+ throw ecidadaniaFetchError(`e-Cidadania retornou página vazia para ${path}`, true);
61
41
  }
42
+ return text;
62
43
  }
63
44
  /**
64
45
  * Fetch JSON from an e-Cidadania REST endpoint with validation.
65
46
  * Ensures the response is valid JSON and an array.
66
47
  */
67
48
  export async function fetchEcidadaniaJson(endpoint) {
68
- const url = `${ECIDADANIA_BASE}${endpoint}`;
69
- const controller = new AbortController();
70
- const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
49
+ const resp = await idaEcidadania(endpoint, { Accept: "application/json", "User-Agent": USER_AGENT }, "e-Cidadania REST API");
50
+ let data;
71
51
  try {
72
- const resp = await fetch(url, {
73
- headers: { Accept: "application/json", "User-Agent": USER_AGENT },
74
- signal: controller.signal,
75
- });
76
- clearTimeout(timeout);
77
- if (!resp.ok) {
78
- throw ecidadaniaFetchError(`e-Cidadania REST API retornou HTTP ${resp.status} para ${endpoint}`, isTransientStatus(resp.status));
79
- }
80
- let data;
81
- try {
82
- data = await resp.json();
83
- }
84
- catch {
85
- throw ecidadaniaFetchError(`e-Cidadania REST API retornou JSON inválido para ${endpoint}`, true);
86
- }
87
- if (!Array.isArray(data)) {
88
- // API returned an object or other non-array — wrap or return empty
89
- if (data && typeof data === "object")
90
- return [data];
91
- return [];
92
- }
93
- return data;
52
+ data = await resp.json();
53
+ }
54
+ catch {
55
+ throw ecidadaniaFetchError(`e-Cidadania REST API retornou JSON inválido para ${endpoint}`, true);
56
+ }
57
+ if (!Array.isArray(data)) {
58
+ // API returned an object or other non-array — wrap or return empty
59
+ if (data && typeof data === "object")
60
+ return [data];
61
+ return [];
62
+ }
63
+ return data;
64
+ }
65
+ /**
66
+ * A ida ao portal, pelo fetch comum do portfólio (desde a 3.11.0; antes um
67
+ * `fetch` cru com timeout de 10 s e sem retry). Mesma política da API do Senado
68
+ * (`upstreamSenado`: teto 10 s, 5xx/429/rede repetem, timeout não) e mesmo
69
+ * coletor da chamada de tool — o detalhe raspado ao vivo entra no `retrieval`
70
+ * do bloco e-Cidadania. No cron (`scheduled`, fora de uma chamada) o coletor é
71
+ * descartável e a política vale igual. O token bucket NÃO se aplica: é outro
72
+ * host, e o portal nunca passou por ele. As mensagens e o `retryable` são os
73
+ * de antes — `ecidadaniaError` (tools) e o pipeline os leem.
74
+ */
75
+ async function idaEcidadania(path, headers, rotulo) {
76
+ const url = `${ECIDADANIA_BASE}${path}`;
77
+ try {
78
+ return await upstreamCall().response(url, { headers });
94
79
  }
95
80
  catch (e) {
96
- clearTimeout(timeout);
97
- if (e.name === "AbortError") {
98
- throw ecidadaniaFetchError(`e-Cidadania REST API: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${endpoint}`, true);
81
+ if (!(e instanceof PkgUpstreamError))
82
+ throw e;
83
+ switch (e.kind) {
84
+ case "timeout":
85
+ case "aborted":
86
+ throw ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true);
87
+ case "network": {
88
+ const causa = e.cause instanceof Error ? e.cause.message : String(e.cause ?? "desconhecido");
89
+ throw ecidadaniaFetchError(`${rotulo}: falha de rede ao acessar ${path} (${causa})`, true);
90
+ }
91
+ default: {
92
+ const status = e.status ?? 502;
93
+ throw ecidadaniaFetchError(`${rotulo} retornou HTTP ${status} para ${path}`, isTransientStatus(status));
94
+ }
99
95
  }
100
- if (e instanceof Error && "retryable" in e)
101
- throw e; // already classified above
102
- throw ecidadaniaFetchError(`e-Cidadania REST API: falha de rede ao acessar ${endpoint} (${e.message})`, true);
103
96
  }
104
97
  }
105
98
  /** Parse Brazilian number: 1.234.567 -> 1234567 */
@@ -1,12 +1,62 @@
1
1
  /**
2
- * Upstream fetch wrapper with:
3
- * - Global rate limiting (token bucket)
4
- * - Concurrency limiting (max in-flight)
5
- * - Retry with bounded exponential backoff + jitter on 429/503, honoring the
6
- * upstream Retry-After header when present (gives up early if it exceeds the budget)
7
- * - AbortController timeout (10s total)
8
- * - Response size guard (5 MB)
2
+ * Upstream fetch wrapper — a ida à API do Senado com:
3
+ * - Global rate limiting (token bucket) e concurrency limiting (max in-flight),
4
+ * ANTES da ida, fora do pacote (política deste servidor);
5
+ * - Retry com backoff exponencial limitado + jitter em 429/503/5xx/rede, honrando
6
+ * `Retry-After` e desistindo cedo se a espera estoura o orçamento;
7
+ * - Orçamento TOTAL de 10 s por ida (`UPSTREAM_TIMEOUT_MS`), esperas incluídas;
8
+ * - Guarda de tamanho da resposta (5 MB; 20 MB para os datasets grandes da ADM);
9
+ * - Contagem de idas, tentativas e anomalias para o bloco `retrieval` da
10
+ * proveniência (contrato v1.1).
11
+ *
12
+ * Desde a 3.11.0 (2026-09-27) a ida em si — timeout, retry, orçamento, contagem —
13
+ * é feita pelo fetch comum do portfólio, `@sbissoli/mcp-upstream`, no modo
14
+ * `response` (o corpo continua sendo lido AQUI: as guardas de tamanho, o
15
+ * `rotaInexistente(corpo)` dos dois 404, o corpo vazio e o JSON inválido são
16
+ * semântica deste servidor, medida contra a fonte). O pacote CLASSIFICA; este
17
+ * módulo DECIDE. A classe `UpstreamError` local, as mensagens e o `transport`
18
+ * continuam os mesmos: `errorFrom`, `ehReuniaoInexistente`, `classifyError` e o
19
+ * disjuntor do contrato noturno (`scripts/contract/outage.ts`) leem todos eles.
20
+ *
21
+ * POLÍTICA (`UPSTREAM_POLICY`) — medida em 27/09/2026 com `curl` e o User-Agent
22
+ * do portfólio: a API legislativa responde em 0,3–1 s (`/senador/lista/atual`
23
+ * 0,7 s com 129 KB; `/plenario/lista/votacao` de um mês 0,9 s; a busca de matérias
24
+ * por palavra-chave teve UMA cauda fria de 7,4 s e depois 0,4–1 s); a API
25
+ * administrativa entrega 2,7 MB de contratos em 1,5 s e 9 MB de CEAPS em 1,9 s;
26
+ * o texto integral de um discurso sai em 0,35 s. Nenhuma origem chegou perto
27
+ * dos 10 s — o teto existe para a conexão pendurada, não para apressar a fonte.
28
+ * - teto de UMA tentativa = orçamento TOTAL da ida = 10 s (paridade com o
29
+ * desenho que este servidor já tinha: cada tentativa recebe o que sobra do
30
+ * orçamento, e uma primeira tentativa pendurada gasta tudo);
31
+ * - 2 retries (3 tentativas), backoff 1 s → 2 s → 4 s (teto) + jitter 0–500 ms.
32
+ *
33
+ * O QUE REPETE E O QUE NÃO (`retryUpstream`):
34
+ * - 429 e 503 repetem, honrando `Retry-After`, como antes;
35
+ * - **500/502 genéricos passam a repetir** (novo na 3.11.0): a classe já era
36
+ * marcada `retryable: true` para o agente e este servidor não a repetia por
37
+ * conta própria — o agente pagava a repetição. Paridade com os irmãos
38
+ * (bcb/ibge/ilo/uis/medical repetem 5xx);
39
+ * - **rede** (DNS/TCP/TLS) repete, como antes;
40
+ * - **timeout NÃO repete**: a tentativa que pendurou já gastou o orçamento
41
+ * inteiro (como antes: `AbortError` encerrava a ida);
42
+ * - 4xx (404 incluído) não repete: é resposta da fonte, não falha;
43
+ * - **corpo vazio e JSON inválido NÃO repetem** (como antes): são lidos aqui,
44
+ * depois da ida, e há pelo menos um caso em que o corpo vazio é
45
+ * determinístico (`/comissao/reuniao/{codigo}` inexistente — ver
46
+ * `ehReuniaoInexistente`), em que repetir só atrasaria a resposta certa.
47
+ *
48
+ * O COLETOR. `instrumentTool` (src/instrument.ts) abre UM coletor por chamada de
49
+ * tool — aninhado ao ALS de cache que já existia — e toda ida feita aqui, a
50
+ * qualquer profundidade, cai nele; `provenanceFor` lê `currentRetrieval()`. Fora
51
+ * de uma chamada (cron do e-Cidadania, testes que chamam a função direto) a ida
52
+ * ganha um coletor descartável: a política vale igual, e o bloco diz
53
+ * `retrieval: null` ("não medido"), nunca quebra. Um servidor com UMA origem
54
+ * (a API do Senado, nas duas bases) não precisa do coletor por origem do
55
+ * medical — o `search` do Deep Research emite dois blocos da MESMA origem, e os
56
+ * dois carregam a medição da chamada.
9
57
  */
58
+ import { createUpstream, UpstreamError as PkgUpstreamError, } from "@sbissoli/mcp-upstream";
59
+ import { currentCall, withCall } from "@sbissoli/mcp-upstream/als";
10
60
  import { globalBucket } from "./token-bucket.js";
11
61
  import { UPSTREAM_TIMEOUT_MS, MAX_RESPONSE_SIZE, SENADO_BASE_URL_DEFAULT } from "../types.js";
12
62
  import { log, logger } from "../utils/logger.js";
@@ -85,30 +135,80 @@ export function rotaInexistente(corpo) {
85
135
  }
86
136
  return typeof detail === "string" && /^No static resource\b/i.test(detail.trim());
87
137
  }
138
+ // ── Política de rede ──────────────────────────────────────────────────────────
139
+ /** A política de rede deste servidor (ver o cabeçalho: números medidos em 27/09/2026). */
140
+ export const UPSTREAM_POLICY = {
141
+ /** Teto de UMA tentativa (cabeçalhos + corpo), em ms. */
142
+ timeoutMs: UPSTREAM_TIMEOUT_MS,
143
+ /** Orçamento TOTAL de uma ida, esperas incluídas — o mesmo teto (paridade). */
144
+ budgetMs: UPSTREAM_TIMEOUT_MS,
145
+ /** Retries além da primeira tentativa. */
146
+ retries: MAX_RETRIES,
147
+ /** Esperas: 1 s, 2 s, 4 s (teto), mais jitter uniforme de até 500 ms. */
148
+ backoff: { baseMs: 1_000, maxMs: 4_000, jitterMs: 500 },
149
+ };
150
+ /**
151
+ * I/O da espera entre tentativas e do jitter, num objeto para os testes trocarem
152
+ * (`vi.spyOn(upstreamIo, "sleep")`): um 503 permanente num dublê custaria 3 s de
153
+ * backoff real por ida, e o jitter aleatório impediria contar o relógio.
154
+ */
155
+ export const upstreamIo = {
156
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
157
+ random: () => Math.random(),
158
+ };
159
+ /** A decisão de repetir uma tentativa que falhou (o pacote diz a classe). */
160
+ export function retryUpstream(ctx) {
161
+ switch (ctx.kind) {
162
+ case "rate_limited":
163
+ case "http_5xx":
164
+ case "network": {
165
+ logger.warn("upstream_retry", { url: ctx.url, attempt: ctx.attempt, kind: ctx.kind, status: ctx.status });
166
+ return true;
167
+ }
168
+ // A tentativa que pendurou já gastou o orçamento inteiro; 4xx é resposta da
169
+ // fonte; `malformed_body` não acontece no modo `response`.
170
+ default:
171
+ return false;
172
+ }
173
+ }
88
174
  /**
89
- * Parse an HTTP Retry-After header (RFC 9110 §10.2.3) into milliseconds to wait.
90
- * Accepts the delta-seconds form ("5") and the HTTP-date form; returns null when
91
- * absent or unparseable, so the caller falls back to its own backoff.
175
+ * A política no formato do pacote, com ligação TARDIA ao `fetch` global — os
176
+ * testes dublam `globalThis.fetch` depois de este módulo carregar.
92
177
  */
93
- export function parseRetryAfterMs(value, now = Date.now()) {
94
- if (!value)
95
- return null;
96
- const trimmed = value.trim();
97
- if (/^\d+$/.test(trimmed)) {
98
- return parseInt(trimmed, 10) * 1000;
99
- }
100
- const date = Date.parse(trimmed);
101
- if (Number.isNaN(date))
102
- return null;
103
- return Math.max(0, date - now);
178
+ export const upstreamSenado = createUpstream({
179
+ userAgent: USER_AGENT,
180
+ timeoutMs: UPSTREAM_POLICY.timeoutMs,
181
+ retries: UPSTREAM_POLICY.retries,
182
+ budgetMs: UPSTREAM_POLICY.budgetMs,
183
+ backoff: UPSTREAM_POLICY.backoff,
184
+ honorRetryAfter: true,
185
+ retryOn: retryUpstream,
186
+ sleep: (ms) => upstreamIo.sleep(ms),
187
+ random: () => upstreamIo.random(),
188
+ fetchImpl: (input, init) => globalThis.fetch(input, init),
189
+ });
190
+ /**
191
+ * Abre o coletor de UMA chamada de tool e roda `fn` dentro dele — ou reusa o que
192
+ * já está aberto, se `fn` é um passo de uma chamada maior (a tool de fora não
193
+ * pode perder as idas de dentro). É chamado por `instrumentTool`.
194
+ */
195
+ export function withUpstreamCall(fn) {
196
+ return currentCall() ? fn() : withCall(upstreamSenado, () => fn());
104
197
  }
105
198
  /**
106
- * Wait before the next retry attempt: never less than the server's Retry-After
107
- * (when sent), never less than the client's own bounded exponential backoff.
199
+ * O coletor da chamada corrente; fora de uma chamada, um descartável (a ida
200
+ * continua com a política, a contagem simplesmente não é lida).
108
201
  */
109
- export function computeRetryWaitMs(attempt, retryAfterMs, jitterMs) {
110
- const backoff = Math.min(1000 * Math.pow(2, attempt), 4000);
111
- return Math.max(retryAfterMs ?? 0, backoff) + jitterMs;
202
+ export function upstreamCall() {
203
+ return currentCall() ?? upstreamSenado.call();
204
+ }
205
+ /**
206
+ * O `retrieval` medido nesta chamada de tool — `null` fora de um coletor ou
207
+ * quando a resposta veio inteira do cache (nenhuma ida). É o que
208
+ * `provenanceFor` põe no bloco quando a tool não passa outro.
209
+ */
210
+ export function currentRetrieval() {
211
+ return currentCall()?.retrieval() ?? null;
112
212
  }
113
213
  /**
114
214
  * Fetch from the Senado API upstream.
@@ -128,124 +228,119 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
128
228
  url.searchParams.set(key, params[key]);
129
229
  }
130
230
  }
231
+ // Check global rate limit — uma vez por IDA (as repetições de uma ida já
232
+ // estão limitadas em número e em orçamento; o balde protege a fonte da rajada
233
+ // de chamadas, não de uma tentativa a mais).
234
+ if (!globalBucket.tryConsume()) {
235
+ incr("upstreamErrors");
236
+ logger.warn("upstream_rate_limited", { path });
237
+ throw new UpstreamError(`[${path}] Taxa de requisições excedida. Tente novamente em alguns segundos.`, 429, true);
238
+ }
239
+ // Check concurrency limit
240
+ if (inFlight >= MAX_CONCURRENT) {
241
+ incr("upstreamErrors");
242
+ logger.warn("upstream_concurrency_limited", { path });
243
+ throw new UpstreamError(`[${path}] Muitas requisições simultâneas ao upstream. Tente novamente em breve.`, 503, true);
244
+ }
131
245
  const startTime = Date.now();
132
- let lastError;
133
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
134
- // Check global rate limit
135
- if (!globalBucket.tryConsume()) {
136
- incr("upstreamErrors");
137
- logger.warn("upstream_rate_limited", { path });
138
- throw new UpstreamError(`[${path}] Taxa de requisições excedida. Tente novamente em alguns segundos.`, 429, true);
139
- }
140
- // Check concurrency limit
141
- if (inFlight >= MAX_CONCURRENT) {
142
- incr("upstreamErrors");
143
- logger.warn("upstream_concurrency_limited", { path });
144
- throw new UpstreamError(`[${path}] Muitas requisições simultâneas ao upstream. Tente novamente em breve.`, 503, true);
145
- }
146
- // Check remaining time budget
147
- const elapsed = Date.now() - startTime;
148
- const remaining = UPSTREAM_TIMEOUT_MS - elapsed;
149
- if (remaining <= 0) {
150
- lastError = lastError || new UpstreamError(`[${path}] Timeout: orçamento de ${UPSTREAM_TIMEOUT_MS}ms esgotado antes da tentativa ${attempt + 1}`, 504, true, true);
151
- break;
152
- }
153
- const controller = new AbortController();
154
- const timeout = setTimeout(() => controller.abort(), remaining);
155
- inFlight++;
156
- try {
157
- const response = await fetch(url.toString(), {
158
- method: "GET",
159
- headers: {
160
- Accept: "application/json",
161
- "User-Agent": USER_AGENT,
162
- },
163
- signal: controller.signal,
164
- });
165
- clearTimeout(timeout);
166
- if (response.status === 429 || response.status === 503) {
167
- lastError = new UpstreamError(`[${path}] Upstream retornou ${response.status}`, response.status, true);
168
- if (attempt < MAX_RETRIES) {
169
- const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
170
- const wait = computeRetryWaitMs(attempt, retryAfterMs, Math.random() * 500);
171
- // Honoring a Retry-After longer than the remaining budget would just delay
172
- // the inevitable timeout — give up now and surface the retryable error.
173
- if (wait >= UPSTREAM_TIMEOUT_MS - (Date.now() - startTime)) {
174
- logger.warn("upstream_retry_abandoned", { path, attempt, status: response.status, retryAfterMs });
175
- break;
176
- }
177
- incr("upstreamRetries");
178
- logger.warn("upstream_retry", { path, attempt, status: response.status, retryAfterMs });
179
- await sleep(wait);
180
- continue;
181
- }
182
- break;
183
- }
184
- if (response.status === 404 && options.on404) {
185
- incr("upstreamCalls");
186
- log("upstream", path, 404, Date.now() - startTime, attempt);
187
- const corpo404 = await response.text();
188
- if (rotaInexistente(corpo404)) {
189
- throw new UpstreamError(`[${path}] ${MSG_ROTA_INEXISTENTE} — o caminho montado não existe na API do Senado.`, 404, false);
190
- }
191
- if (options.on404 === "empty")
192
- return [];
193
- throw new RecursoAusenteError(path);
194
- }
195
- if (!response.ok) {
196
- throw new UpstreamError(`[${path}] Upstream retornou HTTP ${response.status}`, response.status, response.status >= 500);
197
- }
198
- // Check response size via Content-Length header
199
- const contentLength = response.headers.get("content-length");
200
- if (contentLength && parseInt(contentLength, 10) > maxSize) {
201
- throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
202
- }
203
- const text = await response.text();
204
- if (text.length > maxSize) {
205
- throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
206
- }
207
- const latency = Date.now() - startTime;
246
+ const call = upstreamCall();
247
+ const attemptsBefore = call.attempts;
248
+ const retriesFeitos = (attempts) => {
249
+ for (let i = 1; i < attempts; i++)
250
+ incr("upstreamRetries");
251
+ };
252
+ let response;
253
+ inFlight++;
254
+ try {
255
+ response = await call.response(url.toString(), {
256
+ method: "GET",
257
+ headers: {
258
+ Accept: options.accept ?? "application/json",
259
+ "User-Agent": USER_AGENT,
260
+ },
261
+ });
262
+ }
263
+ catch (err) {
264
+ if (!(err instanceof PkgUpstreamError))
265
+ throw err;
266
+ retriesFeitos(err.attempts);
267
+ if (err.kind === "not_found" && options.on404 && err.response) {
208
268
  incr("upstreamCalls");
209
- log("upstream", path, response.status, latency, attempt);
210
- if (!text.trim()) {
211
- throw new UpstreamError(`[${path}] Resposta upstream vazia`, 502, true);
212
- }
213
- try {
214
- return JSON.parse(text);
215
- }
216
- catch {
217
- throw new UpstreamError(`[${path}] Resposta upstream não é JSON válido`, 502, false);
218
- }
219
- }
220
- catch (err) {
221
- clearTimeout(timeout);
222
- if (err instanceof UpstreamError)
223
- throw err;
224
- // Network-level errors (DNS, TCP, TLS, AbortError)
225
- const isAbort = err.name === "AbortError";
226
- lastError = new UpstreamError(isAbort
227
- ? `[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`
228
- : `[${path}] Erro de rede: ${err.message || "desconhecido"}`, isAbort ? 504 : 502, true, // Network errors are always retryable
229
- true);
230
- if (isAbort)
231
- break; // AbortError means time budget is spent
232
- if (attempt < MAX_RETRIES) {
233
- incr("upstreamRetries");
234
- logger.warn("upstream_retry", { path, attempt, message: lastError.message });
235
- const backoff = Math.min(1000 * Math.pow(2, attempt), 4000);
236
- await sleep(backoff + Math.random() * 500);
237
- continue;
269
+ log("upstream", path, 404, Date.now() - startTime, err.attempts - 1);
270
+ const corpo404 = await err.response.text();
271
+ if (rotaInexistente(corpo404)) {
272
+ throw new UpstreamError(`[${path}] ${MSG_ROTA_INEXISTENTE} — o caminho montado não existe na API do Senado.`, 404, false);
238
273
  }
274
+ if (options.on404 === "empty")
275
+ return [];
276
+ throw new RecursoAusenteError(path);
239
277
  }
240
- finally {
241
- inFlight--;
278
+ const traduzido = traduzirErro(err, path);
279
+ if (traduzido.retryable) {
280
+ // O que hoje termina a ida por esgotamento (429/503/5xx/rede/timeout) é o
281
+ // que sempre contou como erro de upstream; 4xx e 404 são resposta da fonte.
282
+ incr("upstreamErrors");
283
+ logger.error("upstream_error", { path, status: traduzido.status, message: traduzido.message });
242
284
  }
285
+ throw traduzido;
286
+ }
287
+ finally {
288
+ inFlight--;
289
+ }
290
+ retriesFeitos(call.attempts - attemptsBefore);
291
+ // Check response size via Content-Length header
292
+ const contentLength = response.headers.get("content-length");
293
+ if (contentLength && parseInt(contentLength, 10) > maxSize) {
294
+ throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
295
+ }
296
+ const text = await response.text();
297
+ if (text.length > maxSize) {
298
+ throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
299
+ }
300
+ const latency = Date.now() - startTime;
301
+ incr("upstreamCalls");
302
+ log("upstream", path, response.status, latency, call.attempts - attemptsBefore - 1);
303
+ if (!text.trim()) {
304
+ throw new UpstreamError(`[${path}] Resposta upstream vazia`, 502, true);
305
+ }
306
+ if (options.text)
307
+ return text;
308
+ try {
309
+ return JSON.parse(text);
310
+ }
311
+ catch {
312
+ throw new UpstreamError(`[${path}] Resposta upstream não é JSON válido`, 502, false);
243
313
  }
244
- const finalError = lastError || new UpstreamError(`[${path}] Falha ao acessar upstream após retries`, 502, true);
245
- incr("upstreamErrors");
246
- logger.error("upstream_error", { path, status: finalError.status, message: finalError.message });
247
- throw finalError;
248
314
  }
249
- function sleep(ms) {
250
- return new Promise((resolve) => setTimeout(resolve, ms));
315
+ /**
316
+ * Do erro do pacote (classe + contagem) para o `UpstreamError` LOCAL que o resto
317
+ * do servidor lê — com as MESMAS mensagens de antes da 3.11.0: `errorFrom` mostra
318
+ * a mensagem ao agente, `classifyError` casa "upstream"/"timeout"/"5xx" nela, e
319
+ * `transport` alimenta o disjuntor do contrato noturno.
320
+ */
321
+ export function traduzirErro(err, path) {
322
+ switch (err.kind) {
323
+ case "timeout":
324
+ case "aborted":
325
+ return new UpstreamError(`[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`, 504, true, true);
326
+ case "network": {
327
+ const cause = err.cause;
328
+ const detalhe = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "";
329
+ return new UpstreamError(`[${path}] Erro de rede: ${detalhe || "desconhecido"}`, 502, true, true);
330
+ }
331
+ case "rate_limited":
332
+ return new UpstreamError(`[${path}] Upstream retornou 429`, 429, true);
333
+ case "http_5xx": {
334
+ const status = err.status ?? 502;
335
+ return status === 503
336
+ ? new UpstreamError(`[${path}] Upstream retornou 503`, 503, true)
337
+ : new UpstreamError(`[${path}] Upstream retornou HTTP ${status}`, status, true);
338
+ }
339
+ case "http_4xx":
340
+ case "not_found":
341
+ return new UpstreamError(`[${path}] Upstream retornou HTTP ${err.status ?? 404}`, err.status ?? 404, false);
342
+ case "malformed_body":
343
+ // Não acontece no modo `response` (o corpo é lido por `upstreamFetch`).
344
+ return new UpstreamError(`[${path}] Resposta upstream não é JSON válido`, 502, false);
345
+ }
251
346
  }
@@ -13,13 +13,12 @@
13
13
  */
14
14
  import { z } from "zod";
15
15
  import { cachedFetch, cachedFetchWithMeta } from "../cache/manager.js";
16
- import { upstreamFetch } from "../throttle/upstream.js";
16
+ import { upstreamFetch, UpstreamError } from "../throttle/upstream.js";
17
17
  import { toolError, errorFrom, ensureArray, safeInt, toBool, normalizeText } from "../utils/validation.js";
18
18
  import { digArrayRoot } from "../utils/upstream-parse.js";
19
19
  import { escolherReuniao, ehReuniaoInexistente, mensagemCodigoInexistente } from "./resolver-reuniao.js";
20
20
  import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
21
- import { CACHE_SEMI_STATIC, CACHE_DYNAMIC, CACHE_ON_DEMAND, UPSTREAM_TIMEOUT_MS } from "../types.js";
22
- import { USER_AGENT } from "../version.js";
21
+ import { CACHE_SEMI_STATIC, CACHE_DYNAMIC, CACHE_ON_DEMAND } from "../types.js";
23
22
  export function formatDateYMD(d) {
24
23
  return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
25
24
  }
@@ -423,50 +422,32 @@ export function registerComissoesTools(server, baseUrl) {
423
422
  const sigla = params.siglaCpi.toUpperCase();
424
423
  const pagina = params.pagina ?? 0;
425
424
  const { value: requerimentos, fetchedAt } = await cachedFetchWithMeta("senado_requerimentos_cpi", { sigla, pagina }, CACHE_DYNAMIC, async () => {
426
- const url = `${baseUrl}/comissao/cpi/${sigla}/requerimentos.json?pagina=${pagina}`;
427
- const controller = new AbortController();
428
- const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
425
+ // Pelo `upstreamFetch` desde a 3.11.0: antes era um `fetch` cru, sem
426
+ // token bucket, sem retry e fora da contagem do `retrieval`. O corpo
427
+ // vazio que esta rota devolve para CPI sem requerimentos vira `[]`
428
+ // aqui, porque o `upstreamFetch` o trata como resposta vazia (502).
429
+ let data;
429
430
  try {
430
- const resp = await fetch(url, {
431
- headers: { Accept: "application/json", "User-Agent": USER_AGENT },
432
- signal: controller.signal,
433
- });
434
- clearTimeout(timeout);
435
- if (!resp.ok) {
436
- const body = await resp.text();
437
- const detail = (() => { try {
438
- return JSON.parse(body)?.detail;
439
- }
440
- catch {
441
- return null;
442
- } })();
443
- throw new Error(detail || `Upstream retornou HTTP ${resp.status} para requerimentos da ${sigla}`);
444
- }
445
- const text = await resp.text();
446
- if (!text.trim())
447
- return [];
448
- const data = JSON.parse(text);
449
- if (Array.isArray(data))
450
- return data;
451
- for (const v of Object.values(data)) {
452
- if (Array.isArray(v))
453
- return v;
454
- if (v && typeof v === "object") {
455
- for (const v2 of Object.values(v)) {
456
- if (Array.isArray(v2))
457
- return v2;
458
- }
459
- }
460
- }
461
- return [data];
431
+ data = await upstreamFetch(`/comissao/cpi/${sigla}/requerimentos`, { pagina: String(pagina) }, baseUrl);
462
432
  }
463
433
  catch (e) {
464
- clearTimeout(timeout);
465
- if (e.name === "AbortError") {
466
- throw new Error(`Timeout ao obter requerimentos da ${sigla}`);
467
- }
434
+ if (e instanceof UpstreamError && /vazia/.test(e.message))
435
+ return [];
468
436
  throw e;
469
437
  }
438
+ if (Array.isArray(data))
439
+ return data;
440
+ for (const v of Object.values(data)) {
441
+ if (Array.isArray(v))
442
+ return v;
443
+ if (v && typeof v === "object") {
444
+ for (const v2 of Object.values(v)) {
445
+ if (Array.isArray(v2))
446
+ return v2;
447
+ }
448
+ }
449
+ }
450
+ return [data];
470
451
  });
471
452
  const prov = provenanceFor("SENADO_LEGIS", baseUrl, `/comissao/cpi/${sigla}/requerimentos`, {
472
453
  dataset_id: `cpi=${sigla}; pagina=${pagina}`, retrieved_at: fetchedAt,
@@ -103,13 +103,22 @@ async function construirIndice(baseUrl) {
103
103
  const comissoes = ensureArray(com.value?.ListaColegiados?.Colegiados?.Colegiado).map(parseComissaoItem);
104
104
  return {
105
105
  index: createIndex([...entradasSenadores(senadores), ...entradasComissoes(comissoes)]),
106
- provenance: [
107
- provenanceFor("SENADO_LEGIS", baseUrl, PATH_SENADORES, { dataset_id: "lista/atual", retrieved_at: sen.fetchedAt }),
108
- provenanceFor("SENADO_LEGIS", baseUrl, PATH_COLEGIADOS, { retrieved_at: com.fetchedAt }),
109
- ],
106
+ extraidoEm: { senadores: sen.fetchedAt, colegiados: com.fetchedAt },
110
107
  criadoEm: Date.now(),
111
108
  };
112
109
  }
110
+ /**
111
+ * Proveniência das duas listas do índice — dois blocos da MESMA origem, e por
112
+ * isso os dois com o `retrieval` medido nesta chamada (o coletor é por chamada;
113
+ * ver src/throttle/upstream.ts). Chamada que só achou o índice pronto não foi
114
+ * à fonte, e os blocos dizem `retrieval: null`.
115
+ */
116
+ function provenanciaDoIndice(baseUrl, indice) {
117
+ return [
118
+ provenanceFor("SENADO_LEGIS", baseUrl, PATH_SENADORES, { dataset_id: "lista/atual", retrieved_at: indice.extraidoEm.senadores }),
119
+ provenanceFor("SENADO_LEGIS", baseUrl, PATH_COLEGIADOS, { retrieved_at: indice.extraidoEm.colegiados }),
120
+ ];
121
+ }
113
122
  /**
114
123
  * O índice da base, construído no primeiro uso e mantido por `INDICE_TTL_MS`.
115
124
  * Chamadas concorrentes compartilham a construção; falha não fica em cache.
@@ -182,7 +191,7 @@ function handlers(baseUrl) {
182
191
  async function search(query) {
183
192
  const indice = await obterIndice(baseUrl);
184
193
  const results = indice.index.search(query, { limit: DEEP_RESEARCH_LIMIT }).map(({ id, title, url }) => ({ id, title, url }));
185
- return { results, extras: provenanceExtras(indice.provenance) };
194
+ return { results, extras: provenanceExtras(provenanciaDoIndice(baseUrl, indice)) };
186
195
  }
187
196
  async function fetch(id) {
188
197
  if (id.startsWith(PREFIXO_SENADOR)) {
@@ -10,8 +10,7 @@ import { upstreamFetch } from "../throttle/upstream.js";
10
10
  import { errorFrom, ensureArray, safeInt } from "../utils/validation.js";
11
11
  import { digArrayRoot } from "../utils/upstream-parse.js";
12
12
  import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
13
- import { CACHE_DYNAMIC, CACHE_ON_DEMAND, UPSTREAM_TIMEOUT_MS } from "../types.js";
14
- import { USER_AGENT } from "../version.js";
13
+ import { CACHE_DYNAMIC, CACHE_ON_DEMAND } from "../types.js";
15
14
  /**
16
15
  * Aviso anexado quando `tipo=discursos` é chamado sem período. O upstream
17
16
  * `/senador/{cod}/discursos` limita a resposta aos últimos 30 dias quando
@@ -143,47 +142,21 @@ export function registerDiscursosTools(server, baseUrl) {
143
142
  const { value: texto, fetchedAt } = await cachedFetchWithMeta("senado_discurso_texto", { codigo: params.codigoPronunciamento }, CACHE_ON_DEMAND, async () => {
144
143
  // This endpoint serves ONLY text/plain; the ".json" suffix (or Accept:
145
144
  // application/json) forces content negotiation to JSON -> HTTP 406. No suffix
146
- // + Accept including text/plain -> 200.
147
- const url = `${baseUrl}/discurso/texto-integral/${params.codigoPronunciamento}`;
148
- const controller = new AbortController();
149
- const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
145
+ // + Accept including text/plain -> 200. Pelo `upstreamFetch` em modo texto
146
+ // desde a 3.11.0 (antes: `fetch` cru, sem retry nem contagem no `retrieval`).
147
+ const text = (await upstreamFetch(`/discurso/texto-integral/${params.codigoPronunciamento}`, {}, baseUrl, { noJsonSuffix: true, text: true, accept: "text/plain, application/json" }));
148
+ // Try to parse as JSON first — API might wrap the text
150
149
  try {
151
- const resp = await fetch(url, {
152
- method: "GET",
153
- headers: {
154
- Accept: "text/plain, application/json",
155
- "User-Agent": USER_AGENT,
156
- },
157
- signal: controller.signal,
158
- });
159
- clearTimeout(timeout);
160
- if (!resp.ok) {
161
- throw new Error(`Upstream retornou HTTP ${resp.status} para texto do discurso ${params.codigoPronunciamento}`);
162
- }
163
- const text = await resp.text();
164
- if (!text.trim()) {
165
- throw new Error(`Texto do discurso ${params.codigoPronunciamento} vazio`);
166
- }
167
- // Try to parse as JSON first — API might wrap the text
168
- try {
169
- const json = JSON.parse(text);
170
- // If it's an object with a text field, extract it
171
- if (json && typeof json === "object") {
172
- return json.TextoIntegral || json.textoIntegral || json.texto || text;
173
- }
174
- return text;
175
- }
176
- catch {
177
- // Not JSON — return raw text
178
- return text;
150
+ const json = JSON.parse(text);
151
+ // If it's an object with a text field, extract it
152
+ if (json && typeof json === "object") {
153
+ return json.TextoIntegral || json.textoIntegral || json.texto || text;
179
154
  }
155
+ return text;
180
156
  }
181
- catch (e) {
182
- clearTimeout(timeout);
183
- if (e.name === "AbortError") {
184
- throw new Error(`Timeout ao obter texto do discurso ${params.codigoPronunciamento}`);
185
- }
186
- throw e;
157
+ catch {
158
+ // Not JSON — return raw text
159
+ return text;
187
160
  }
188
161
  });
189
162
  const prov = provenanceFor("SENADO_LEGIS", baseUrl, `/discurso/texto-integral/${params.codigoPronunciamento}`, { dataset_id: `codigoPronunciamento=${params.codigoPronunciamento}`, retrieved_at: fetchedAt });
@@ -39,6 +39,7 @@
39
39
  * mostram o IP do backend do provedor de IA, não o da pessoa).
40
40
  */
41
41
  import { createProvenanceContext, ptBR, toCanonicalIso, } from "@sbissoli/mcp-provenance";
42
+ import { currentRetrieval } from "../throttle/upstream.js";
42
43
  /** Contexto único do servidor: namespace de `_meta`, idioma, fuso e modo default. */
43
44
  export const provenanceContext = createProvenanceContext({
44
45
  metaNamespace: "com.sidneybissoli.senado",
@@ -74,6 +75,7 @@ const extraToCanonical = (extra = {}) => ({
74
75
  ...(extra.field_sources !== undefined
75
76
  ? { field_sources: extra.field_sources.map(toCanonicalFieldSource) }
76
77
  : {}),
78
+ retrieval: extra.retrieval !== undefined ? extra.retrieval : currentRetrieval(),
77
79
  });
78
80
  /** Metadados estáticos por fonte upstream. source_url é montado por chamada (endpoint real). */
79
81
  export const SOURCES = {
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.10.0";
2
+ export const VERSION = "3.11.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.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,9 +50,10 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/server": "^2.0.0",
53
- "@sbissoli/mcp-provenance": "^0.1.0",
53
+ "@sbissoli/mcp-provenance": "^0.2.0",
54
54
  "@sbissoli/mcp-search": "^0.5.0",
55
55
  "@sbissoli/mcp-stats": "^0.3.0",
56
+ "@sbissoli/mcp-upstream": "^0.3.0",
56
57
  "zod": "^4.5.4"
57
58
  },
58
59
  "devDependencies": {