senado-br-mcp 3.18.2 → 3.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/scraper/ecidadania.js +21 -7
- package/dist/scraper/store.js +5 -1
- package/dist/throttle/upstream.js +63 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -572,7 +572,7 @@ In addition to the `provenance` envelope, `structuredContent` carries a top-leve
|
|
|
572
572
|
|
|
573
573
|
**Field-level granularity.** Most tools are single-source, so one envelope suffices. The few that merge reads in one response fill `field_sources` — a list of `{ fields, source_url, dataset_id, data_vintage, retrieved_at, served_from_cache }` attributing specific output fields to their real origin, and the block's `retrieved_at` is the oldest of them. `served_from_cache` is `true`/`false` for reads through the server cache and `null` where the notion does not apply (the bundled org-chart snapshot, the e-Cidadania corpus in D1). The merging tools (since 3.18.2): `senado_obter_materia` `secao=detalhe` (`/processo/{id}` + `ementa` from `/processo` + `relator` from `/processo/relatoria`), `senado_obter_senador` and the Deep Research `fetch` of a senator (biography + `mandatos`/`emExercicio` from `/senador/{codigo}/mandatos`), `senado_reunioes_comissao` over a window that crosses the year (one read per year), `senado_servidores` with `subordinadasA` (payroll + org-chart snapshot), `senado_suprimento_fundos` statistics on `atos-concessao` (acts + beneficiary names from the supridos registry), `senado_ecidadania_obter_evento` (live detail + `comentarios` from the corpus) and `senado_ecidadania_sugerir_tema_enquete` (consultas and ideias corpora). **Only a read that becomes a field of the answer is a sub-source**: a read that only resolves or checks an identifier (a committee acronym → its code, the parent check in `senado_contratacao_detalhe`) is left out, otherwise a cached lookup table would make the whole answer claim to be as old as it. `attribution` always lists every distinct underlying `source_url`.
|
|
574
574
|
|
|
575
|
-
`retrieved_at` fidelity is provided by the cache layer (`cachedFetchWithMeta`), which persists the fetch timestamp alongside the value, so it reflects the real upstream extraction even on a cache hit. Two exceptions report an honest live timestamp instead: the e-Cidadania **list** tools (read from D1) use the corpus's `lastScrapedAt` — the true age of the stored data — while e-Cidadania **detail** tools, scraped live, use the fetch time and a level-3 canonical item URL. The only path that falls back to the build-time default is the in-code static reference catalog (`senado_tabelas_referencia` `tipos-materia`), which has no upstream extraction instant.
|
|
575
|
+
`retrieved_at` fidelity is provided by the cache layer (`cachedFetchWithMeta`), which persists the fetch timestamp alongside the value, so it reflects the real upstream extraction even on a cache hit. Two exceptions report an honest live timestamp instead: the e-Cidadania **list** tools (read from D1) use the corpus's `lastScrapedAt` — the true age of the stored data, or the live scrape instant when the corpus is empty or stale and the list falls back to the portal (since 3.18.3; before, the stale corpus's instant leaked into the live answer) — while e-Cidadania **detail** tools, scraped live, use the fetch time and a level-3 canonical item URL. The only path that falls back to the build-time default is the in-code static reference catalog (`senado_tabelas_referencia` `tipos-materia`), which has no upstream extraction instant.
|
|
576
576
|
|
|
577
577
|
Coverage is **universal**: all 69 tools carry the envelope — the 67 `senado_*` tools via `resultWithProvenance(` (verify with `grep -c 'resultWithProvenance(' src/tools/*.ts`) and the two Deep Research tools via `provenanceExtras` (same block, in `structuredContent`/`_meta`, since their text channel is the contract's JSON). The **⊕** marks in the inventory below denote the original pilot tools (votes, bills, processes); the envelope now extends to every tool, so the marks are historical.
|
|
578
578
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { UpstreamError as PkgUpstreamError } from "@sbissoli/mcp-upstream";
|
|
13
13
|
import { UPSTREAM_TIMEOUT_MS } from "../types.js";
|
|
14
14
|
import { USER_AGENT } from "../version.js";
|
|
15
|
-
import { upstreamCall } from "../throttle/upstream.js";
|
|
15
|
+
import { CorpoForaDoPrazoError, PrazoDoCorpo, upstreamCall } from "../throttle/upstream.js";
|
|
16
16
|
export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
|
|
17
17
|
/**
|
|
18
18
|
* Error carrying a `retryable` flag, mirroring UpstreamError so the tool layer
|
|
@@ -36,10 +36,9 @@ const isTransientStatus = (status) => status >= 500 || status === 429;
|
|
|
36
36
|
* (`X-Requested-With: XMLHttpRequest`, wider `Accept`).
|
|
37
37
|
*/
|
|
38
38
|
export async function fetchPage(path, opts = {}) {
|
|
39
|
-
const
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/scraper/store.js
CHANGED
|
@@ -99,7 +99,11 @@ export async function resolveList(db, entidade, staleMaxMin, liveScrape, now = n
|
|
|
99
99
|
items: live,
|
|
100
100
|
meta: {
|
|
101
101
|
fonte: "ao-vivo",
|
|
102
|
-
lastScrapedAt
|
|
102
|
+
// The items were scraped NOW, so this is their age — not the stale D1's `lastScrapedAt`
|
|
103
|
+
// read above (until 3.18.2 it leaked here, and the tools' provenance said the live data
|
|
104
|
+
// was as old as the corpus, or carried no instant at all). An empty live list extracted
|
|
105
|
+
// nothing (consultas_votos' live path is a no-op `[]`), so it claims no instant.
|
|
106
|
+
lastScrapedAt: live.length > 0 ? new Date().toISOString() : null,
|
|
103
107
|
possivelDesatualizacao: false,
|
|
104
108
|
motivo: !db ? "d1-indisponivel" : d1Items.length === 0 ? "d1-vazio" : "d1-desatualizado",
|
|
105
109
|
},
|
|
@@ -213,6 +213,52 @@ export function withUpstreamCall(fn) {
|
|
|
213
213
|
export function upstreamCall() {
|
|
214
214
|
return currentCall() ?? upstreamSenado.call();
|
|
215
215
|
}
|
|
216
|
+
/** O corpo não chegou inteiro dentro do orçamento da ida (ver `PrazoDoCorpo`). */
|
|
217
|
+
export class CorpoForaDoPrazoError extends Error {
|
|
218
|
+
constructor() {
|
|
219
|
+
super("corpo da resposta fora do prazo");
|
|
220
|
+
this.name = "CorpoForaDoPrazoError";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* O prazo da LEITURA DO CORPO de uma ida no modo `response`.
|
|
225
|
+
*
|
|
226
|
+
* No modo `response` o `@sbissoli/mcp-upstream` (0.4.x) desarma o cronômetro da
|
|
227
|
+
* tentativa quando chegam os CABEÇALHOS: o corpo, lido aqui depois, ficava sem
|
|
228
|
+
* prazo nenhum, e uma fonte que manda os cabeçalhos e trava no corpo pendurava a
|
|
229
|
+
* chamada até o Worker morrer. Medido em 13/10/2026 ao subir o nock do medical
|
|
230
|
+
* (o `delay()` do nock 15 passou a segurar o corpo, não os cabeçalhos).
|
|
231
|
+
*
|
|
232
|
+
* O `signal` entra na ida e o pacote o combina ao dele; quem o aborta é SÓ
|
|
233
|
+
* `texto()`, depois dos cabeçalhos — durante a ida ele nunca dispara, então a
|
|
234
|
+
* classificação do pacote (timeout × abortado, a contagem do `retrieval`) não
|
|
235
|
+
* muda. O prazo do corpo é o que sobra do orçamento TOTAL da ida, contado do
|
|
236
|
+
* início dela: cabeçalhos + corpo cabem nos mesmos 10 s, como sempre se disse.
|
|
237
|
+
*/
|
|
238
|
+
export class PrazoDoCorpo {
|
|
239
|
+
#controller = new AbortController();
|
|
240
|
+
#inicio = Date.now();
|
|
241
|
+
/** Para passar na ida (`call.response(url, { signal })`). */
|
|
242
|
+
get signal() {
|
|
243
|
+
return this.#controller.signal;
|
|
244
|
+
}
|
|
245
|
+
/** Lê o corpo como texto; estourando o orçamento, lança `CorpoForaDoPrazoError`. */
|
|
246
|
+
async texto(response) {
|
|
247
|
+
const resta = UPSTREAM_POLICY.budgetMs - (Date.now() - this.#inicio);
|
|
248
|
+
const timer = setTimeout(() => this.#controller.abort(), Math.max(0, resta));
|
|
249
|
+
try {
|
|
250
|
+
return await response.text();
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
if (this.#controller.signal.aborted)
|
|
254
|
+
throw new CorpoForaDoPrazoError();
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
216
262
|
/**
|
|
217
263
|
* O `retrieval` medido nesta chamada de tool — `null` fora de um coletor ou
|
|
218
264
|
* quando a resposta veio inteira do cache (nenhuma ida). É o que
|
|
@@ -260,6 +306,20 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
260
306
|
for (let i = 1; i < attempts; i++)
|
|
261
307
|
incr("upstreamRetries");
|
|
262
308
|
};
|
|
309
|
+
const prazo = new PrazoDoCorpo();
|
|
310
|
+
const lerCorpo = async (r) => {
|
|
311
|
+
try {
|
|
312
|
+
return await prazo.texto(r);
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
if (e instanceof CorpoForaDoPrazoError) {
|
|
316
|
+
incr("upstreamErrors");
|
|
317
|
+
logger.error("upstream_error", { path, status: 504, message: "corpo fora do prazo" });
|
|
318
|
+
throw new UpstreamError(`[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`, 504, true, true);
|
|
319
|
+
}
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
263
323
|
let response;
|
|
264
324
|
inFlight++;
|
|
265
325
|
try {
|
|
@@ -269,6 +329,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
269
329
|
Accept: options.accept ?? "application/json",
|
|
270
330
|
"User-Agent": USER_AGENT,
|
|
271
331
|
},
|
|
332
|
+
signal: prazo.signal,
|
|
272
333
|
});
|
|
273
334
|
}
|
|
274
335
|
catch (err) {
|
|
@@ -278,7 +339,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
278
339
|
if (err.kind === "not_found" && options.on404 && err.response) {
|
|
279
340
|
incr("upstreamCalls");
|
|
280
341
|
log("upstream", path, 404, Date.now() - startTime, err.attempts - 1);
|
|
281
|
-
const corpo404 = await err.response
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "senado-br-mcp",
|
|
3
|
-
"version": "3.18.
|
|
3
|
+
"version": "3.18.4",
|
|
4
4
|
"description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"@sbissoli/mcp-evals": "^0.2.1",
|
|
84
84
|
"@sbissoli/mcp-surface": "^0.6.0",
|
|
85
85
|
"@types/node": "^26.5.1",
|
|
86
|
-
"agents": "^0.
|
|
86
|
+
"agents": "^0.28.0",
|
|
87
87
|
"exceljs": "^4.4.0",
|
|
88
88
|
"tsx": "^4.23.13",
|
|
89
89
|
"typescript": "^7.0.2",
|