senado-br-mcp 3.9.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
@@ -37,6 +37,13 @@
37
37
  */
38
38
  export function classifyError(message) {
39
39
  const m = message.toLowerCase();
40
+ // PRIMEIRO de todos, e de propósito: a rota que não existe na fonte é
41
+ // defeito NOSSO (montamos um caminho errado), e a palavra que a nomeia —
42
+ // "inexistente" — cairia em `nao_encontrado` logo abaixo, que é exatamente a
43
+ // conflação que o conserto de 24/09/2026 desfez na borda da rede. A frase
44
+ // vem de `MSG_ROTA_INEXISTENTE`; ela não é vocabulário da fonte, é nosso.
45
+ if (m.includes("defeito do servidor mcp"))
46
+ return "defeito";
40
47
  // A fronteira de palavra vai só no INÍCIO. Os padrões são RADICAIS
41
48
  // ("vazi", "obrigatóri", "indisponív") justamente porque a flexão muda o
42
49
  // fim: `\bvazi\b` não casa "vazia", e foi assim que "Resposta upstream
@@ -166,3 +173,43 @@ export function classifyThrown(error) {
166
173
  }
167
174
  return classifyError(error instanceof Error ? error.message : String(error));
168
175
  }
176
+ /**
177
+ * Classe de um erro JSON-RPC do PROTOCOLO, pelo código antes da mensagem.
178
+ *
179
+ * POR QUE ELE CHEGA AQUI AGORA. Até 24/09/2026 este arquivo só era chamado de
180
+ * dentro do handler, com a exceção da tool na mão — e o cabeçalho acima ainda
181
+ * registra o limite daquele desenho: *erro de validação de esquema NÃO chega a
182
+ * esta camada; o SDK o responde antes do handler, e a chamada não é contada nem
183
+ * como chamada nem como erro*. Era verdade, e era o defeito: a recusa de esquema
184
+ * não gerava linha NENHUMA de telemetria. Medido na produção em 24/09/2026 pela
185
+ * rota do dono, ela sai como HTTP **200** com `result.isError: true` e sem
186
+ * código JSON-RPC; e ferramenta inexistente sai como **200** com o código
187
+ * **−32602** (não −32601, como se supunha). Desde então a telemetria lê o
188
+ * ENVELOPE da resposta (src/envelope.ts) e estes casos passam a existir.
189
+ *
190
+ * Os códigos reservados pela especificação JSON-RPC 2.0 dizem, sem ambiguidade
191
+ * de idioma, de quem é o conserto:
192
+ *
193
+ * - −32700 parse error, −32600 invalid request, −32601 method not found e
194
+ * −32602 invalid params são o CHAMADOR mandando algo que o protocolo
195
+ * recusa. É `contrato` — e o painel exclui `contrato` da taxa de erro
196
+ * (`eh_recusa_de_contrato()`), porque ferramenta que recusa chamada
197
+ * malformada está funcionando;
198
+ * - −32603 internal error é erro NOSSO, que escapou. É `defeito`, e por
199
+ * decisão do dono em 24/09/2026 ele entra na fila de saúde do painel: sem
200
+ * isso passaríamos a medir e continuaríamos a não agir.
201
+ *
202
+ * Código fora dessa faixa (erro definido pelo servidor, −32000 e abaixo) cai
203
+ * na mensagem, que é o caminho de `classifyError`.
204
+ *
205
+ * Cópia FIEL de sih-br-mcp/worker/src/call-shape.ts, onde a função estreou:
206
+ * classe medida por servidor com classificador diferente não é comparável, e
207
+ * comparar os sete é a razão de existir da seção de saúde do painel.
208
+ */
209
+ export function classeDoErroRpc(code, message) {
210
+ if (code === -32700 || code === -32600 || code === -32601 || code === -32602)
211
+ return "contrato";
212
+ if (code === -32603)
213
+ return "defeito";
214
+ return classifyError(message);
215
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Leitura do ENVELOPE da resposta — o que transforma "o container devolveu
3
+ * HTTP 200" em "esta mensagem deu certo / esta falhou, e por quê".
4
+ *
5
+ * O PROBLEMA QUE ESTE ARQUIVO RESOLVE, E QUE AQUI É PIOR QUE NO `sih`. A
6
+ * telemetria deste Worker decide o desfecho pelo HTTP da resposta, e em cima
7
+ * disso `protocolNamesFromBody` (em src/instrument.ts) tinha a linha `if (status < 400) continue`, com
8
+ * o comentário "o hook de tools já gravou esta". A reconciliação era por STATUS,
9
+ * e supunha que 200 implica que o hook gravou. Quando a tool NÃO CHEGA A RODAR
10
+ * — recusa de esquema do zod, antes do handler — ninguém grava, e o `continue`
11
+ * fecha a única outra porta. O `sih` carimbava `ok` numa falha: número errado.
12
+ * Aqui não havia número NENHUM: a chamada não era contada nem como chamada.
13
+ *
14
+ * Medido na produção em 24/09/2026, pela rota do dono, no ilo, uis, senado e
15
+ * bcb — os quatro idênticos, e são DUAS formas, não uma: recusa de esquema sai
16
+ * HTTP 200 com `result.isError: true` e SEM código JSON-RPC; ferramenta
17
+ * inexistente sai HTTP 200 com o código −32602 (não −32601). Os dois são 200,
18
+ * então os dois caíam no `continue`.
19
+ *
20
+ * POR QUE NÃO BUFFERIZAR. Este Worker não é proxy — quem produz a resposta é o
21
+ * `createMcpHandler` aqui dentro —, mas a técnica é a mesma e pelo mesmo motivo:
22
+ * segurar bytes para ler o fim trocaria um defeito de medição por um defeito de
23
+ * produto (a lição do `sih`, cuja série de 34 anos leva ~30 s). O corpo é TEADO
24
+ * (`ReadableStream.tee()`): um ramo segue intocado para o cliente, no mesmo
25
+ * ritmo de antes, e o outro é lido aqui em `ctx.waitUntil`, depois que a
26
+ * resposta já saiu.
27
+ *
28
+ * O TEE RESOLVE, DE QUEBRA, UM PROBLEMA DE TEMPO. O hook de tools escreve
29
+ * durante a execução da tool, que num transporte SSE pode terminar DEPOIS de o
30
+ * handler já ter devolvido a Response. Ler o envelope até o fim do stream é,
31
+ * por construção, esperar a tool acabar — é só então que se sabe o que o hook
32
+ * gravou, e a reconciliação por nome (src/instrument.ts) só vale aí.
33
+ *
34
+ * REGRA DE OURO DESTE MÓDULO: ele nunca lança e nunca segura bytes. Qualquer
35
+ * coisa que ele não entenda vira "sem desfecho", e quem chama cai no critério
36
+ * antigo (o HTTP). Medir pior é aceitável; atrapalhar a resposta do usuário não é.
37
+ *
38
+ * DOIS TETOS, pelo mesmo motivo. Um evento SSE acumulado passa de
39
+ * `MAX_EVENTO_BYTES` → é descartado sem parsear (o `isError` pode estar no fim
40
+ * do JSON, mas mensagem de erro é curta: evento gigante é quase certamente
41
+ * resultado feliz, e o teto troca uma classificação improvável por um limite de
42
+ * memória e de CPU garantido). Mais de `MAX_DESFECHOS` respostas no mesmo corpo
43
+ * → o resto é ignorado; lote JSON-RPC de verdade tem punhado de mensagens.
44
+ *
45
+ * O CAMINHO DE ERRO-MOLE FICA DE FORA, de propósito. Ferramenta que responde
46
+ * ausência como DADO — uma resposta de SUCESSO cujo payload diz "não há" — está
47
+ * funcionando, e continua sendo `ok`. O que conta como falha é `isError: true`
48
+ * ou um erro JSON-RPC, nada mais. (No `sih`, de onde este arquivo vem, isso é a
49
+ * decisão 38 e seis ferramentas dependem dela; aqui o erro de tool já nasce de
50
+ * `IlostatUserError` / `IlostatUpstreamError` e vira `isError`.)
51
+ *
52
+ * Cópia de sih-br-mcp/worker/src/envelope.ts, porque o classificador não pode
53
+ * divergir entre os sete. O que muda é só este cabeçalho e o `teeResposta` do
54
+ * fim, que lá não existe: o `sih` monta a Response do zero a partir do upstream
55
+ * e teia no caminho; aqui a Response já vem pronta do handler.
56
+ */
57
+ import { classeDoErroRpc, classifyError, errorText } from "./call-shape.js";
58
+ /** Teto de bytes acumulados por evento SSE (ou por linha) antes de desistir dele. */
59
+ export const MAX_EVENTO_BYTES = 64 * 1024;
60
+ /** Teto de respostas rastreadas por corpo. */
61
+ export const MAX_DESFECHOS = 64;
62
+ /**
63
+ * Desfecho de uma resposta JSON-RPC, com o `id` que a liga ao pedido.
64
+ * `null` quando o objeto não é uma resposta identificável:
65
+ * - sem `id` (ou `id: null`, que é a resposta a um pedido que nem parseou) —
66
+ * não há a que ligar, e o chamador fica com o critério do HTTP;
67
+ * - sem `result` e sem `error` — não é resposta (é notificação, ou lixo).
68
+ */
69
+ export function desfechoDaResposta(msg) {
70
+ if (!msg || typeof msg !== "object" || Array.isArray(msg))
71
+ return null;
72
+ const m = msg;
73
+ if (typeof m.id !== "string" && typeof m.id !== "number")
74
+ return null;
75
+ const id = String(m.id);
76
+ if (m.error && typeof m.error === "object") {
77
+ const e = m.error;
78
+ const code = typeof e.code === "number" ? e.code : undefined;
79
+ const texto = typeof e.message === "string" ? e.message : "";
80
+ return { id, desfecho: { erro: true, classe: classeDoErroRpc(code, texto) } };
81
+ }
82
+ if (m.result && typeof m.result === "object") {
83
+ const r = m.result;
84
+ if (r.isError === true) {
85
+ return { id, desfecho: { erro: true, classe: classifyError(errorText(m.result)) } };
86
+ }
87
+ return { id, desfecho: { erro: false, classe: "" } };
88
+ }
89
+ // `result` primitivo (o SDK responde `{}` em alguns métodos, mas nunca um
90
+ // escalar) ainda é sucesso; o que não é resposta é o objeto sem os dois.
91
+ if (m.result !== undefined)
92
+ return { id, desfecho: { erro: false, classe: "" } };
93
+ return null;
94
+ }
95
+ /**
96
+ * O desfecho de UMA mensagem do pedido, já com o CRITÉRIO DE RESERVA aplicado.
97
+ *
98
+ * Regra: vale o que o envelope disse. Quando o envelope não disse nada — `id`
99
+ * ausente (notificação), stream cortado, evento grande demais, resposta que
100
+ * não parseou —, vale o HTTP do container, que era o critério único até
101
+ * 24/09/2026. Nunca se inventa classe: sem leitura, a classe é "".
102
+ */
103
+ export function resolveDesfecho(id, desfechos, falhouHttp) {
104
+ const lido = id === "" ? undefined : desfechos.get(id);
105
+ return lido ?? { erro: falhouHttp, classe: "" };
106
+ }
107
+ /**
108
+ * Lê o corpo da resposta aos pedaços e vai colhendo desfechos por `id`.
109
+ *
110
+ * Aceita os dois formatos que o transporte Streamable HTTP usa: SSE (`data:`
111
+ * por evento, evento fechado por linha em branco, várias linhas `data:` do
112
+ * mesmo evento juntadas por `\n`) e JSON cru (resposta direta, sem stream).
113
+ * Linhas `event:`, `id:`, `retry:` e comentários (`:`) são ignoradas.
114
+ */
115
+ export class LeitorDeEnvelope {
116
+ resto = "";
117
+ descartandoLinha = false;
118
+ evento = [];
119
+ bytesDoEvento = 0;
120
+ eventoEstourado = false;
121
+ viuSse = false;
122
+ cru = "";
123
+ /** Desfechos colhidos, por `id` de mensagem JSON-RPC. */
124
+ desfechos = new Map();
125
+ /** Entrega mais um pedaço de TEXTO já decodificado. Nunca lança. */
126
+ push(texto) {
127
+ if (!this.viuSse && this.cru.length < MAX_EVENTO_BYTES)
128
+ this.cru += texto;
129
+ this.resto += texto;
130
+ for (;;) {
131
+ const quebra = this.resto.indexOf("\n");
132
+ if (quebra < 0)
133
+ break;
134
+ const linha = this.resto.slice(0, quebra);
135
+ this.resto = this.resto.slice(quebra + 1);
136
+ if (this.descartandoLinha) {
137
+ // Fim da linha gigante que foi descartada: o evento inteiro cai junto,
138
+ // porque o JSON dele está incompleto.
139
+ this.descartandoLinha = false;
140
+ this.eventoEstourado = true;
141
+ continue;
142
+ }
143
+ this.linha(linha.endsWith("\r") ? linha.slice(0, -1) : linha);
144
+ }
145
+ // Linha sem fim à vista e já maior que o teto: para de acumular em vez de
146
+ // crescer com o corpo (uma resposta de 34 anos é uma única linha `data:`).
147
+ if (this.resto.length > MAX_EVENTO_BYTES) {
148
+ this.resto = "";
149
+ this.descartandoLinha = true;
150
+ }
151
+ }
152
+ /** Fecha o que ficou pendente. Chamar uma vez, ao fim do stream. */
153
+ end() {
154
+ if (this.resto !== "" && !this.descartandoLinha) {
155
+ const linha = this.resto.endsWith("\r") ? this.resto.slice(0, -1) : this.resto;
156
+ this.resto = "";
157
+ this.linha(linha);
158
+ }
159
+ this.fechaEvento();
160
+ // Corpo que nunca teve uma linha `data:` é JSON cru.
161
+ if (!this.viuSse)
162
+ this.registra(this.cru);
163
+ }
164
+ linha(linha) {
165
+ if (linha === "") {
166
+ this.fechaEvento();
167
+ return;
168
+ }
169
+ if (!linha.startsWith("data:"))
170
+ return;
171
+ this.viuSse = true;
172
+ const pedaco = linha.slice(5).replace(/^ /, "");
173
+ this.bytesDoEvento += pedaco.length;
174
+ if (this.bytesDoEvento > MAX_EVENTO_BYTES) {
175
+ this.eventoEstourado = true;
176
+ this.evento = [];
177
+ return;
178
+ }
179
+ this.evento.push(pedaco);
180
+ }
181
+ fechaEvento() {
182
+ const partes = this.evento;
183
+ const estourou = this.eventoEstourado;
184
+ this.evento = [];
185
+ this.bytesDoEvento = 0;
186
+ this.eventoEstourado = false;
187
+ if (estourou || partes.length === 0)
188
+ return;
189
+ this.registra(partes.join("\n"));
190
+ }
191
+ registra(json) {
192
+ if (json === "" || this.desfechos.size >= MAX_DESFECHOS)
193
+ return;
194
+ let obj;
195
+ try {
196
+ obj = JSON.parse(json);
197
+ }
198
+ catch {
199
+ return; // pedaço cortado ao meio, ou não era JSON: fica sem desfecho
200
+ }
201
+ for (const item of Array.isArray(obj) ? obj : [obj]) {
202
+ if (this.desfechos.size >= MAX_DESFECHOS)
203
+ return;
204
+ const d = desfechoDaResposta(item);
205
+ if (d)
206
+ this.desfechos.set(d.id, d.desfecho);
207
+ }
208
+ }
209
+ }
210
+ /**
211
+ * Consome um stream inteiro e devolve os desfechos por `id`. Nunca lança: se o
212
+ * stream for cortado no meio (cliente desconectou, container caiu), vale o que
213
+ * deu para ler até ali e o resto fica sem desfecho.
214
+ */
215
+ export async function desfechosDoCorpo(corpo) {
216
+ const leitor = new LeitorDeEnvelope();
217
+ const decoder = new TextDecoder();
218
+ const reader = corpo.getReader();
219
+ try {
220
+ for (;;) {
221
+ const { done, value } = await reader.read();
222
+ if (done)
223
+ break;
224
+ if (value)
225
+ leitor.push(decoder.decode(value, { stream: true }));
226
+ }
227
+ leitor.push(decoder.decode());
228
+ }
229
+ catch {
230
+ // Stream interrompido: o que já foi lido continua valendo.
231
+ }
232
+ finally {
233
+ try {
234
+ reader.releaseLock();
235
+ }
236
+ catch {
237
+ // ignorado
238
+ }
239
+ }
240
+ leitor.end();
241
+ return leitor.desfechos;
242
+ }
243
+ /**
244
+ * Parte o corpo da resposta em dois: o que vai para o cliente e o que a
245
+ * telemetria lê. Só isto, e nunca mais que isto — a Response devolvida tem o
246
+ * mesmo status, o mesmo statusText e os mesmos cabeçalhos da original.
247
+ *
248
+ * Resposta sem corpo (204, HEAD, erro montado na borda) devolve `corpo: null` e
249
+ * a própria Response intacta: não há o que teiar, e `new Response(null, resp)`
250
+ * seria uma cópia à toa.
251
+ */
252
+ export function teeResposta(resposta) {
253
+ if (!resposta.body)
254
+ return { paraCliente: resposta, paraLeitura: null };
255
+ const [cliente, leitura] = resposta.body.tee();
256
+ return { paraCliente: new Response(cliente, resposta), paraLeitura: leitura };
257
+ }
@@ -28,9 +28,11 @@
28
28
  * connector is only identifiable via the secret header his MCP clients send
29
29
  * (SELF_HEADER, value = the SELF_MARKER Wrangler secret).
30
30
  */
31
+ import { resolveDesfecho } from "./envelope.js";
31
32
  import { classifyError, classifyThrown, errorText, paramNames } from "./call-shape.js";
32
33
  import { incr, incrTool } from "./metrics.js";
33
34
  import { callCache, cacheClass } from "./observability/call-context.js";
35
+ import { withUpstreamCall } from "./throttle/upstream.js";
34
36
  /** Header the owner's MCP clients send (value = the SELF_MARKER secret). */
35
37
  export const SELF_HEADER = "x-mcp-self";
36
38
  /**
@@ -62,7 +64,7 @@ export function tagRequest(request, selfSecret, sessao = "") {
62
64
  sessao,
63
65
  };
64
66
  }
65
- export function instrumentTool(name, cb, analytics, tag) {
67
+ export function instrumentTool(name, cb, analytics, tag, gravados) {
66
68
  return async (...args) => {
67
69
  incr("toolCalls");
68
70
  let isError = false;
@@ -70,7 +72,12 @@ export function instrumentTool(name, cb, analytics, tag) {
70
72
  // Per-call store the cache layer increments per upstream fetch (see call-context.ts).
71
73
  const stats = { fetches: 0, hits: 0 };
72
74
  try {
73
- 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))));
74
81
  isError =
75
82
  typeof result === "object" && result !== null && result.isError === true;
76
83
  if (isError)
@@ -93,6 +100,12 @@ export function instrumentTool(name, cb, analytics, tag) {
93
100
  // chamada nem como erro. Medido em 10/09/2026 pelo /metrics: três
94
101
  // chamadas com argumento de forma errada não moveram o contador.
95
102
  recordToolCall(name, isError, stats, analytics, tag, classe, paramNames(args));
103
+ // Recibo desta requisicao: e contra ele que recordProtocolMethods
104
+ // reconcilia. Fica no `finally` junto da gravacao, para os dois nunca
105
+ // discordarem. A recusa de esquema NAO passa por aqui -- e exatamente
106
+ // por isso que ela nao deixa recibo, e a linha dela e escrita la.
107
+ if (gravados)
108
+ gravados.set(name, (gravados.get(name) ?? 0) + 1);
96
109
  }
97
110
  };
98
111
  }
@@ -135,71 +148,52 @@ function recordToolCall(name, isError, stats, analytics, tag, errorClass = "", p
135
148
  }
136
149
  }
137
150
  /**
138
- * Métodos de PROTOCOLO, gravados na camada HTTP.
151
+ * As mensagens do corpo que ainda precisam de linha, já descontado o recibo.
139
152
  *
140
- * `instrumentTool` só vê `tools/call`: ela envolve o callback da tool.
141
- * `initialize`, `tools/list`, `notifications/*`, `ping` e o que mais o
142
- * cliente mande (`server/discover`, por exemplo) atravessam o transporte sem
143
- * tocar tool nenhuma — e são eles que contam o FUNIL DE SESSÃO: quantos
144
- * `initialize` viram chamada de ferramenta de verdade, que é o que separa
145
- * "acharam o servidor" de "usaram o servidor". Medido em 2026-09-10: só o
146
- * sih-br-mcp os gravava, porque lá o Worker é um proxy que lê o corpo
147
- * JSON-RPC antes de encaminhar. Aqui o servidor roda dentro do Worker, e o
148
- * corpo vem de uma CÓPIA tirada antes de o handler consumir o stream
149
- * (src/index.ts). Desde 2026-09-16 a frota inteira grava igual.
150
- *
151
- * Mesmo esquema de blobs, com o método no lugar do nome da tool — igual ao
152
- * sih. O painel separa os dois pelo nome (`metodo_de_protocolo`). Classe de
153
- * cache, classe de erro e parâmetros ficam vazios; fetches e hits, zero.
154
- *
155
- * O que entra, e de onde vem o desfecho:
156
- * - todo método que não é `tools/call` → uma linha, "ok" se o HTTP da
157
- * resposta for < 400, "error" senão. LIMITAÇÃO, a mesma do sih: erro
158
- * JSON-RPC que viaja dentro de um 200 (método desconhecido, -32601) sai
159
- * como "ok" — ler exigiria consumir o corpo que está sendo devolvido;
160
- * - `tools/call` só quando o HTTP é ≥ 400: o transporte recusou antes de
161
- * despachar (Accept errado, sessão inválida, Origin estrangeiro) e a tool
162
- * nunca rodou; sem isto a recusa seria invisível. Com HTTP < 400 a
163
- * `instrumentTool` já gravou a linha, com o desfecho de verdade — não se
164
- * grava de novo;
165
- * - lote JSON-RPC (array) → uma linha por item; item sem `method` (resposta
166
- * do cliente, corpo que não é JSON) → nada.
167
- *
168
- * Só no Analytics Engine: os contadores do /metrics continuam contando tools.
153
+ * `gravados` é CONSUMIDO (decrementado) durante a varredura. Chamar sem ele
154
+ * significa "a instrumentTool não gravou nada".
169
155
  */
170
- export function protocolNamesFromBody(body, status) {
156
+ export function protocolMessagesFromBody(body, gravados) {
171
157
  const itens = Array.isArray(body) ? body : [body];
172
- const nomes = [];
158
+ const mensagens = [];
173
159
  for (const item of itens) {
174
160
  if (!item || typeof item !== "object")
175
161
  continue;
176
162
  const msg = item;
177
163
  if (typeof msg.method !== "string" || msg.method === "")
178
164
  continue;
165
+ const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : "";
179
166
  if (msg.method === "tools/call") {
180
- if (status < 400)
181
- continue; // instrumentTool já gravou esta
182
167
  const params = msg.params;
183
- nomes.push(typeof params?.name === "string" && params.name !== "" ? params.name : "tools/call");
168
+ const nome = typeof params?.name === "string" && params.name !== "" ? params.name : "tools/call";
169
+ const recibo = gravados?.get(nome) ?? 0;
170
+ if (recibo > 0) {
171
+ gravados?.set(nome, recibo - 1); // a instrumentTool já gravou esta
172
+ continue;
173
+ }
174
+ mensagens.push({ nome, id });
184
175
  }
185
176
  else {
186
- nomes.push(msg.method);
177
+ mensagens.push({ nome: msg.method, id });
187
178
  }
188
179
  }
189
- return nomes;
180
+ return mensagens;
190
181
  }
191
182
  /**
192
183
  * Grava no Analytics Engine os métodos de protocolo de um POST no endpoint
193
- * MCP (ver protocolNamesFromBody). Devolve os nomes gravados. Sem binding,
194
- * não grava nada.
184
+ * MCP (ver acima). Devolve os nomes gravados. Sem binding, não grava nada.
185
+ *
186
+ * `desfechos` é o que a leitura do envelope colheu por `id`; sem ele — corpo
187
+ * não teado, stream cortado — vale o HTTP, que é o critério de reserva.
195
188
  */
196
- export function recordProtocolMethods(analytics, tag, body, status) {
189
+ export function recordProtocolMethods(analytics, tag, body, status, desfechos = new Map(), gravados) {
197
190
  if (!analytics || body === undefined)
198
191
  return [];
199
192
  const cliente = clientNameFromBody(body);
200
- const nomes = protocolNamesFromBody(body, status);
201
- const isError = status >= 400;
202
- for (const name of nomes) {
193
+ const mensagens = protocolMessagesFromBody(body, gravados);
194
+ const falhouHttp = status >= 400;
195
+ for (const { nome: name, id } of mensagens) {
196
+ const { erro: isError, classe } = resolveDesfecho(id, desfechos, falhouHttp);
203
197
  try {
204
198
  analytics.writeDataPoint({
205
199
  indexes: [name],
@@ -210,7 +204,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
210
204
  tag?.self ? "self" : "",
211
205
  tag?.country ?? "",
212
206
  tag?.asOrg ?? "",
213
- "",
207
+ classe,
214
208
  "",
215
209
  tag?.sessao ?? "",
216
210
  name === "initialize" ? cliente : "",
@@ -222,7 +216,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
222
216
  // Falha de telemetria nunca quebra nem atrasa a resposta.
223
217
  }
224
218
  }
225
- return nomes;
219
+ return mensagens.map((m) => m.nome);
226
220
  }
227
221
  /**
228
222
  * SESSÃO E CLIENTE (blobs 9 e 10, desde 2026-09-17).
@@ -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 */
package/dist/server.js CHANGED
@@ -112,7 +112,7 @@ export function createServer(env, ctx, options = {}) {
112
112
  openWorldHint: true,
113
113
  },
114
114
  _meta: toolMetaForProfile(toolProfile),
115
- }, instrumentTool(name, profiledCallback, analytics, options.requestTag));
115
+ }, instrumentTool(name, profiledCallback, analytics, options.requestTag, options.gravados));
116
116
  };
117
117
  const baseUrl = env.SENADO_BASE_URL || "https://legis.senado.leg.br/dadosabertos";
118
118
  const admBaseUrl = env.SENADO_ADM_BASE_URL || "https://adm.senado.gov.br/adm-dadosabertos";