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 +2 -1
- package/dist/instrument.js +7 -1
- package/dist/scraper/ecidadania.js +54 -61
- package/dist/throttle/upstream.js +234 -139
- package/dist/tools/comissoes.js +23 -42
- package/dist/tools/deep-research.js +14 -5
- package/dist/tools/discursos.js +13 -40
- package/dist/utils/provenance.js +2 -0
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](https://lobehub.com/mcp/sidneybissoli-senado-br-mcp-cloudflare)
|
|
9
9
|
[](https://lightnow.ai/servers/io.github.SidneyBissoli/senado-br-mcp-cloudflare)
|
|
10
10
|
[](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare)
|
|
11
|
+
[](https://smithery.ai/servers/sidneybissoli/senado-br-mcp-cloudflare)
|
|
11
12
|
[](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare)
|
|
12
13
|
[](https://github.com/sponsors/SidneyBissoli)
|
|
13
14
|
[](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
|
|
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
|
package/dist/instrument.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
3
|
-
* - Global rate limiting (token bucket)
|
|
4
|
-
*
|
|
5
|
-
* - Retry
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
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
|
-
*
|
|
90
|
-
*
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
incr("
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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,
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
}
|
package/dist/tools/comissoes.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
|
194
|
+
return { results, extras: provenanceExtras(provenanciaDoIndice(baseUrl, indice)) };
|
|
186
195
|
}
|
|
187
196
|
async function fetch(id) {
|
|
188
197
|
if (id.startsWith(PREFIXO_SENADOR)) {
|
package/dist/tools/discursos.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
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 });
|
package/dist/utils/provenance.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "senado-br-mcp",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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": {
|