senado-br-mcp 3.10.0 → 3.12.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
@@ -171,8 +171,70 @@ export function classifyThrown(error) {
171
171
  error instanceof SyntaxError) {
172
172
  return "defeito";
173
173
  }
174
+ const declarada = classeDeclarada(error);
175
+ if (declarada)
176
+ return declarada;
174
177
  return classifyError(error instanceof Error ? error.message : String(error));
175
178
  }
179
+ const CLASSES = new Set(["contrato", "nao_encontrado", "fonte", "defeito", "outro"]);
180
+ function ehClasse(x) {
181
+ return typeof x === "string" && CLASSES.has(x);
182
+ }
183
+ /**
184
+ * A classe com que o erro NASCEU (`UpstreamError.classe`, o erro do
185
+ * e-Cidadania), ou `undefined` quando ele não declara nenhuma — aí vale a
186
+ * frase, como sempre.
187
+ */
188
+ export function classeDeclarada(error) {
189
+ const c = error?.classe;
190
+ return ehClasse(c) ? c : undefined;
191
+ }
192
+ /**
193
+ * Onde um resultado de erro leva a classe decidida pelo TIPO da exceção.
194
+ *
195
+ * Por que existe. Medido em 30/09/2026, rodando este classificador sobre as
196
+ * mensagens de falha da origem: "Erro de rede: ..." (base legislativa), o 429
197
+ * do nosso próprio balde ("Taxa de requisições excedida") e, no e-Cidadania,
198
+ * "falha de rede ao acessar" e "retornou HTTP 429/400/403" caíam em `outro`.
199
+ * O tipo da falha existia no `catch` e só o texto chegava ao `instrumentTool`.
200
+ *
201
+ * O mesmo defeito de fundo foi consertado no bcb-br-mcp (#45), no
202
+ * ilo-mcp-server (#24), no uis-mcp-server (#22), no ibge-br-mcp (#62) e no
203
+ * medical-terminologies-mcp (#58). O conserto não reescreve frase nem mexe em
204
+ * regex: a classe viaja AO LADO do texto, nesta chave-símbolo não enumerável,
205
+ * que o `JSON.stringify` não vê — o que o cliente recebe não muda.
206
+ * `instrumentTool` a lê antes de cair na frase.
207
+ */
208
+ export const CLASSE_DO_ERRO = Symbol.for("br.com.sidneybissoli.mcp/classe-do-erro");
209
+ /** A classe anexada a um resultado de erro, ou `undefined` quando não há. */
210
+ export function classeAnexada(result) {
211
+ if (!result || typeof result !== "object")
212
+ return undefined;
213
+ const c = result[CLASSE_DO_ERRO];
214
+ return ehClasse(c) ? c : undefined;
215
+ }
216
+ /**
217
+ * Anexa ao resultado, fora do fio, a classe que o erro declara — ou `defeito`,
218
+ * quando é bug nosso (`TypeError` & cia., como em `classifyThrown`). Devolve o
219
+ * próprio resultado.
220
+ *
221
+ * Medido em 30/09/2026 na varredura da frota: os ~60 `catch` que passam por
222
+ * `errorFrom` engolem a exceção antes do hook, e um "Cannot read properties of
223
+ * undefined" saía `outro`. A falha de rede crua da undici também é `TypeError`
224
+ * ("fetch failed"); toda ida passa por throttle/upstream.ts, que a tipa — a
225
+ * exclusão é guarda, para uma ida crua futura não virar `defeito`.
226
+ */
227
+ export function anexarClasse(result, error) {
228
+ const classe = classeDeclarada(error) ?? (ehBugNosso(error) ? "defeito" : undefined);
229
+ if (classe !== undefined)
230
+ Object.defineProperty(result, CLASSE_DO_ERRO, { value: classe, enumerable: false });
231
+ return result;
232
+ }
233
+ function ehBugNosso(error) {
234
+ if (error instanceof TypeError)
235
+ return error.message !== "fetch failed";
236
+ return error instanceof RangeError || error instanceof ReferenceError || error instanceof SyntaxError;
237
+ }
176
238
  /**
177
239
  * Classe de um erro JSON-RPC do PROTOCOLO, pelo código antes da mensagem.
178
240
  *
@@ -1,7 +1,7 @@
1
1
  export const ESTRUTURA_ORGANIZACIONAL = {
2
- "extraidoEm": "2026-09-01T10:56:16.383Z",
2
+ "extraidoEm": "2026-10-01T12:13:07.708Z",
3
3
  "fonteUrl": "https://www12.senado.leg.br/institucional/estrutura/orgaosenado",
4
- "total": 823,
4
+ "total": 821,
5
5
  "orgaos": [
6
6
  {
7
7
  "cod": -2072346465,
@@ -3129,12 +3129,6 @@ export const ESTRUTURA_ORGANIZACIONAL = {
3129
3129
  "nome": "Escritório de Apoio Nº 01 do Senador Chico Rodrigues",
3130
3130
  "codSuperior": 10057
3131
3131
  },
3132
- {
3133
- "cod": 10156,
3134
- "sigla": null,
3135
- "nome": "Escritório de Apoio Nº 01 do Senador Angelo Coronel",
3136
- "codSuperior": 10046
3137
- },
3138
3132
  {
3139
3133
  "cod": 10157,
3140
3134
  "sigla": null,
@@ -4631,7 +4625,7 @@ export const ESTRUTURA_ORGANIZACIONAL = {
4631
4625
  },
4632
4626
  {
4633
4627
  "cod": 10749,
4634
- "sigla": null,
4628
+ "sigla": "CSADVOSF",
4635
4629
  "nome": "Conselho Superior da Advocacia do Senado Federal",
4636
4630
  "codSuperior": 67
4637
4631
  },
@@ -4887,18 +4881,6 @@ export const ESTRUTURA_ORGANIZACIONAL = {
4887
4881
  "nome": "Gabinete da Liderança do Avante",
4888
4882
  "codSuperior": 109
4889
4883
  },
4890
- {
4891
- "cod": 10847,
4892
- "sigla": "GSHKLANN",
4893
- "nome": "Gabinete do Senador Hermes Klann",
4894
- "codSuperior": 108
4895
- },
4896
- {
4897
- "cod": 10850,
4898
- "sigla": null,
4899
- "nome": "Escritório de Apoio Nº 01 do Senador Hermes Klann",
4900
- "codSuperior": 10847
4901
- },
4902
4884
  {
4903
4885
  "cod": 10857,
4904
4886
  "sigla": null,
@@ -4940,6 +4922,12 @@ export const ESTRUTURA_ORGANIZACIONAL = {
4940
4922
  "sigla": null,
4941
4923
  "nome": "Escritório de Apoio Nº 01 do Senador Sargento Reginauro",
4942
4924
  "codSuperior": 10862
4925
+ },
4926
+ {
4927
+ "cod": 10866,
4928
+ "sigla": "GSRBRAZ",
4929
+ "nome": "Gabinete do Senador Renzo Braz",
4930
+ "codSuperior": 108
4943
4931
  }
4944
4932
  ]
4945
4933
  };
@@ -29,9 +29,10 @@
29
29
  * (SELF_HEADER, value = the SELF_MARKER Wrangler secret).
30
30
  */
31
31
  import { resolveDesfecho } from "./envelope.js";
32
- import { classifyError, classifyThrown, errorText, paramNames } from "./call-shape.js";
32
+ import { classeAnexada, 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,11 +72,18 @@ 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;
83
+ // A classe que o handler anexou pelo TIPO do erro vence a frase; a frase
84
+ // fica para o erro que nasceu sem tipo. Ver CLASSE_DO_ERRO.
77
85
  if (isError)
78
- classe = classifyError(errorText(result));
86
+ classe = classeAnexada(result) ?? classifyError(errorText(result));
79
87
  return result;
80
88
  }
81
89
  catch (e) {
@@ -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
@@ -18,8 +20,14 @@ export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
18
20
  * (not `upstreamFetch`), so transient conditions are classified here: 5xx / 429 /
19
21
  * timeout / network → retryable; 4xx → not.
20
22
  */
21
- function ecidadaniaFetchError(message, retryable) {
22
- return Object.assign(new Error(message), { retryable });
23
+ /**
24
+ * `classe`, quando dada, é a classe de telemetria decidida pelo TIPO da falha
25
+ * (ver `CLASSE_DO_ERRO` em src/call-shape.ts). Medido em 30/09/2026: pela
26
+ * frase, "falha de rede ao acessar" e "retornou HTTP 429/400/403" caíam em
27
+ * `outro`. Sem ela, vale a frase, como sempre.
28
+ */
29
+ function ecidadaniaFetchError(message, retryable, classe) {
30
+ return Object.assign(new Error(message), classe ? { retryable, classe } : { retryable });
23
31
  }
24
32
  const isTransientStatus = (status) => status >= 500 || status === 429;
25
33
  /**
@@ -28,78 +36,69 @@ const isTransientStatus = (status) => status >= 500 || status === 429;
28
36
  * (`X-Requested-With: XMLHttpRequest`, wider `Accept`).
29
37
  */
30
38
  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);
39
+ const resp = await idaEcidadania(path, opts.ajax
40
+ ? { Accept: "text/html,*/*", "User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest" }
41
+ : { Accept: "text/html", "User-Agent": USER_AGENT }, "e-Cidadania");
42
+ const text = await resp.text();
43
+ // AJAX comment fragments are legitimately empty for events with zero comments — allowEmpty
44
+ // lets the caller treat "" as a valid (empty) result instead of a transient failure.
45
+ if (!opts.allowEmpty && !text.trim()) {
46
+ throw ecidadaniaFetchError(`e-Cidadania retornou página vazia para ${path}`, true);
61
47
  }
48
+ return text;
62
49
  }
63
50
  /**
64
51
  * Fetch JSON from an e-Cidadania REST endpoint with validation.
65
52
  * Ensures the response is valid JSON and an array.
66
53
  */
67
54
  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);
55
+ const resp = await idaEcidadania(endpoint, { Accept: "application/json", "User-Agent": USER_AGENT }, "e-Cidadania REST API");
56
+ let data;
71
57
  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;
58
+ data = await resp.json();
59
+ }
60
+ catch {
61
+ throw ecidadaniaFetchError(`e-Cidadania REST API retornou JSON inválido para ${endpoint}`, true);
62
+ }
63
+ if (!Array.isArray(data)) {
64
+ // API returned an object or other non-array — wrap or return empty
65
+ if (data && typeof data === "object")
66
+ return [data];
67
+ return [];
68
+ }
69
+ return data;
70
+ }
71
+ /**
72
+ * A ida ao portal, pelo fetch comum do portfólio (desde a 3.11.0; antes um
73
+ * `fetch` cru com timeout de 10 s e sem retry). Mesma política da API do Senado
74
+ * (`upstreamSenado`: teto 10 s, 5xx/429/rede repetem, timeout não) e mesmo
75
+ * coletor da chamada de tool — o detalhe raspado ao vivo entra no `retrieval`
76
+ * do bloco e-Cidadania. No cron (`scheduled`, fora de uma chamada) o coletor é
77
+ * descartável e a política vale igual. O token bucket NÃO se aplica: é outro
78
+ * host, e o portal nunca passou por ele. As mensagens e o `retryable` são os
79
+ * de antes — `ecidadaniaError` (tools) e o pipeline os leem.
80
+ */
81
+ async function idaEcidadania(path, headers, rotulo) {
82
+ const url = `${ECIDADANIA_BASE}${path}`;
83
+ try {
84
+ return await upstreamCall().response(url, { headers });
94
85
  }
95
86
  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);
87
+ if (!(e instanceof PkgUpstreamError))
88
+ throw e;
89
+ switch (e.kind) {
90
+ case "timeout":
91
+ case "aborted":
92
+ throw ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true, "fonte");
93
+ case "network": {
94
+ const causa = e.cause instanceof Error ? e.cause.message : String(e.cause ?? "desconhecido");
95
+ throw ecidadaniaFetchError(`${rotulo}: falha de rede ao acessar ${path} (${causa})`, true, "fonte");
96
+ }
97
+ default: {
98
+ const status = e.status ?? 502;
99
+ throw ecidadaniaFetchError(`${rotulo} retornou HTTP ${status} para ${path}`, isTransientStatus(status), status === 404 ? "nao_encontrado" : "fonte");
100
+ }
99
101
  }
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
102
  }
104
103
  }
105
104
  /** Parse Brazilian number: 1.234.567 -> 1234567 */
package/dist/server.js CHANGED
@@ -50,14 +50,14 @@ import { announceServedVersions } from "./discover.js";
50
50
  * `.strict()` publica `additionalProperties: false` e faz o SDK responder
51
51
  * `Unrecognized key: "<nome>"`, que NOMEIA a chave.
52
52
  *
53
- * `search` e `fetch` ficam ABERTAS de propósito: o contrato é da OpenAI, elas
54
- * entram pelo mesmo funil (registerDeepResearchToolsSenado) e fechá-las seria
55
- * mexer num contrato que não é nosso.
53
+ * Vale para TODAS, `search` e `fetch` inclusive. Até a 3.11.0 as duas ficavam
54
+ * abertas por serem contrato da OpenAI; desde o `@sbissoli/mcp-search` 0.9.0
55
+ * o próprio contrato do portfólio é estrito (`z.strictObject`), e as duas
56
+ * entram por este funil (registerDeepResearchToolsSenado passa a shape) —
57
+ * sem o `.strict()` aqui, a estrição do pacote se perderia na passagem.
56
58
  */
57
- const TOOLS_DE_CONTRATO_ALHEIO = new Set(["search", "fetch"]);
58
- function esquemaDeEntrada(name, shape) {
59
- const objeto = z.object(shape);
60
- return TOOLS_DE_CONTRATO_ALHEIO.has(name) ? objeto : objeto.strict();
59
+ function esquemaDeEntrada(shape) {
60
+ return z.object(shape).strict();
61
61
  }
62
62
  export function createServer(env, ctx, options = {}) {
63
63
  const toolProfile = options.toolProfile ?? "full";
@@ -102,7 +102,7 @@ export function createServer(env, ctx, options = {}) {
102
102
  return registerTool(name, {
103
103
  title: titleForTool(name),
104
104
  description,
105
- inputSchema: esquemaDeEntrada(name, shape),
105
+ inputSchema: esquemaDeEntrada(shape),
106
106
  outputSchema: outputSchema,
107
107
  annotations: {
108
108
  title: titleForTool(name),