senado-br-mcp 3.9.0 → 3.10.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.
@@ -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,6 +28,7 @@
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";
@@ -62,7 +63,7 @@ export function tagRequest(request, selfSecret, sessao = "") {
62
63
  sessao,
63
64
  };
64
65
  }
65
- export function instrumentTool(name, cb, analytics, tag) {
66
+ export function instrumentTool(name, cb, analytics, tag, gravados) {
66
67
  return async (...args) => {
67
68
  incr("toolCalls");
68
69
  let isError = false;
@@ -93,6 +94,12 @@ export function instrumentTool(name, cb, analytics, tag) {
93
94
  // chamada nem como erro. Medido em 10/09/2026 pelo /metrics: três
94
95
  // chamadas com argumento de forma errada não moveram o contador.
95
96
  recordToolCall(name, isError, stats, analytics, tag, classe, paramNames(args));
97
+ // Recibo desta requisicao: e contra ele que recordProtocolMethods
98
+ // reconcilia. Fica no `finally` junto da gravacao, para os dois nunca
99
+ // discordarem. A recusa de esquema NAO passa por aqui -- e exatamente
100
+ // por isso que ela nao deixa recibo, e a linha dela e escrita la.
101
+ if (gravados)
102
+ gravados.set(name, (gravados.get(name) ?? 0) + 1);
96
103
  }
97
104
  };
98
105
  }
@@ -135,71 +142,52 @@ function recordToolCall(name, isError, stats, analytics, tag, errorClass = "", p
135
142
  }
136
143
  }
137
144
  /**
138
- * Métodos de PROTOCOLO, gravados na camada HTTP.
145
+ * As mensagens do corpo que ainda precisam de linha, já descontado o recibo.
139
146
  *
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.
147
+ * `gravados` é CONSUMIDO (decrementado) durante a varredura. Chamar sem ele
148
+ * significa "a instrumentTool não gravou nada".
169
149
  */
170
- export function protocolNamesFromBody(body, status) {
150
+ export function protocolMessagesFromBody(body, gravados) {
171
151
  const itens = Array.isArray(body) ? body : [body];
172
- const nomes = [];
152
+ const mensagens = [];
173
153
  for (const item of itens) {
174
154
  if (!item || typeof item !== "object")
175
155
  continue;
176
156
  const msg = item;
177
157
  if (typeof msg.method !== "string" || msg.method === "")
178
158
  continue;
159
+ const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : "";
179
160
  if (msg.method === "tools/call") {
180
- if (status < 400)
181
- continue; // instrumentTool já gravou esta
182
161
  const params = msg.params;
183
- nomes.push(typeof params?.name === "string" && params.name !== "" ? params.name : "tools/call");
162
+ const nome = typeof params?.name === "string" && params.name !== "" ? params.name : "tools/call";
163
+ const recibo = gravados?.get(nome) ?? 0;
164
+ if (recibo > 0) {
165
+ gravados?.set(nome, recibo - 1); // a instrumentTool já gravou esta
166
+ continue;
167
+ }
168
+ mensagens.push({ nome, id });
184
169
  }
185
170
  else {
186
- nomes.push(msg.method);
171
+ mensagens.push({ nome: msg.method, id });
187
172
  }
188
173
  }
189
- return nomes;
174
+ return mensagens;
190
175
  }
191
176
  /**
192
177
  * 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.
178
+ * MCP (ver acima). Devolve os nomes gravados. Sem binding, não grava nada.
179
+ *
180
+ * `desfechos` é o que a leitura do envelope colheu por `id`; sem ele — corpo
181
+ * não teado, stream cortado — vale o HTTP, que é o critério de reserva.
195
182
  */
196
- export function recordProtocolMethods(analytics, tag, body, status) {
183
+ export function recordProtocolMethods(analytics, tag, body, status, desfechos = new Map(), gravados) {
197
184
  if (!analytics || body === undefined)
198
185
  return [];
199
186
  const cliente = clientNameFromBody(body);
200
- const nomes = protocolNamesFromBody(body, status);
201
- const isError = status >= 400;
202
- for (const name of nomes) {
187
+ const mensagens = protocolMessagesFromBody(body, gravados);
188
+ const falhouHttp = status >= 400;
189
+ for (const { nome: name, id } of mensagens) {
190
+ const { erro: isError, classe } = resolveDesfecho(id, desfechos, falhouHttp);
203
191
  try {
204
192
  analytics.writeDataPoint({
205
193
  indexes: [name],
@@ -210,7 +198,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
210
198
  tag?.self ? "self" : "",
211
199
  tag?.country ?? "",
212
200
  tag?.asOrg ?? "",
213
- "",
201
+ classe,
214
202
  "",
215
203
  tag?.sessao ?? "",
216
204
  name === "initialize" ? cliente : "",
@@ -222,7 +210,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
222
210
  // Falha de telemetria nunca quebra nem atrasa a resposta.
223
211
  }
224
212
  }
225
- return nomes;
213
+ return mensagens.map((m) => m.nome);
226
214
  }
227
215
  /**
228
216
  * SESSÃO E CLIENTE (blobs 9 e 10, desde 2026-09-17).
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";
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Differences from the legislative API:
6
6
  * - no `.json` suffix on paths
7
- * - some collections 404 instead of returning an empty array
7
+ * - a chave fora da cobertura responde 404, não array vazio
8
8
  * - some datasets are very large (CEAPS year ≈ 10 MB), hence the raised guard
9
9
  *
10
10
  * Reuses upstreamFetch, so the global token bucket, concurrency limit,
@@ -16,7 +16,15 @@ export async function admFetch(path, params = {}, baseUrl, options = {}) {
16
16
  const base = baseUrl || SENADO_ADM_BASE_URL_DEFAULT;
17
17
  return upstreamFetch(`/api/v1${path}`, params, base, {
18
18
  noJsonSuffix: true,
19
- treat404AsEmpty: true,
19
+ // `absent`, e não o `treat404AsEmpty: true` que estava aqui até 24/09/2026.
20
+ // A flag antiga transformava TODO 404 em `[]`, e com isso 16 pontos de
21
+ // chamada — CEAPS, supridos, remunerações, horas extras, contratos — diziam
22
+ // "count: 0" para ano que a fonte NÃO PUBLICA. Medido: `/supridos/2005` e
23
+ // `/senadores/despesas_ceaps/2007` respondem 404, enquanto a chave válida
24
+ // sem dado responde `200 []`. Logo o 404 aqui nunca significou "vazio".
25
+ // Quem precisa do `[]` pede `on404: "empty"` explicitamente, pelo
26
+ // `options` abaixo, e assume o ônus de desfazer a ambiguidade.
27
+ on404: "absent",
20
28
  ...options,
21
29
  });
22
30
  }
@@ -42,6 +42,49 @@ export class UpstreamError extends Error {
42
42
  this.name = "UpstreamError";
43
43
  }
44
44
  }
45
+ /**
46
+ * Frase-chave da mensagem de rota inexistente. Vive numa constante porque
47
+ * `classifyError` casa contra ela para devolver `defeito`: se o texto mudar
48
+ * num lugar só, a telemetria volta a mentir em silêncio.
49
+ */
50
+ export const MSG_ROTA_INEXISTENTE = "Rota inexistente na fonte (defeito do servidor MCP)";
51
+ /**
52
+ * A fonte respondeu, e respondeu que a CHAVE pedida não existe no acervo.
53
+ *
54
+ * Separada de `UpstreamError` porque não é falha da fonte: é resposta dela.
55
+ * A mensagem é escrita para cair em `nao_encontrado` no `classifyError`.
56
+ */
57
+ export class RecursoAusenteError extends UpstreamError {
58
+ constructor(path) {
59
+ super(`[${path}] A fonte não tem registro para esta chave: o recurso não existe no acervo publicado.`, 404, false);
60
+ this.name = "RecursoAusenteError";
61
+ }
62
+ }
63
+ /**
64
+ * Distingue os DOIS 404 que as APIs do Senado emitem — medido em 24/09/2026
65
+ * nas duas bases, legislativa e administrativa:
66
+ *
67
+ * | caso | corpo |
68
+ * |-------------------------------|------------------------------------------------------|
69
+ * | rota não existe (erro NOSSO) | problem+json com `detail: "No static resource ..."` |
70
+ * | chave sem registro | corpo vazio, ou problem+json SEM `detail` |
71
+ *
72
+ * `/servidores/xxxx` traz o `detail`; `/supridos/2005` vem com
73
+ * `Content-Length: 0`; `/taquigrafia/notas/sessao/99999999` traz
74
+ * `{instance,status,title}` sem `detail`. Só o primeiro é defeito nosso.
75
+ */
76
+ export function rotaInexistente(corpo) {
77
+ if (!corpo.trim())
78
+ return false;
79
+ let detail;
80
+ try {
81
+ detail = JSON.parse(corpo).detail;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ return typeof detail === "string" && /^No static resource\b/i.test(detail.trim());
87
+ }
45
88
  /**
46
89
  * Parse an HTTP Retry-After header (RFC 9110 §10.2.3) into milliseconds to wait.
47
90
  * Accepts the delta-seconds form ("5") and the HTTP-date form; returns null when
@@ -138,10 +181,16 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
138
181
  }
139
182
  break;
140
183
  }
141
- if (response.status === 404 && options.treat404AsEmpty) {
184
+ if (response.status === 404 && options.on404) {
142
185
  incr("upstreamCalls");
143
186
  log("upstream", path, 404, Date.now() - startTime, attempt);
144
- return [];
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);
145
194
  }
146
195
  if (!response.ok) {
147
196
  throw new UpstreamError(`[${path}] Upstream retornou HTTP ${response.status}`, response.status, response.status >= 500);
@@ -86,6 +86,56 @@ export function ordenarEPaginar(lista, ordem, offset, limite) {
86
86
  const ordenada = ordem === "desc" ? [...lista].reverse() : lista;
87
87
  return ordenada.slice(offset, offset + limite);
88
88
  }
89
+ /**
90
+ * A lista-pai crua contém o `id`?
91
+ *
92
+ * Separada de `paiExiste` para ser testável pela convenção da casa (unitário,
93
+ * sem rede): a decisão que importa é a COMPARAÇÃO, não o fetch.
94
+ *
95
+ * Compara como NÚMERO dos dois lados — o id vem `number` do esquema da tool e a
96
+ * fonte já o serviu como string em outras coleções —, e ausência (`null`,
97
+ * `undefined`, `""`) nunca vira 0: `Number("")` é 0, e sem a guarda um registro
98
+ * sem id casaria com `id: 0` ([[notacao-do-codigo-limpa-dos-dois-lados]]).
99
+ */
100
+ export function contemId(lista, id) {
101
+ return ensureArray(lista).some((c) => {
102
+ const bruto = c?.id;
103
+ if (bruto === null || bruto === undefined || bruto === "")
104
+ return false;
105
+ return Number(bruto) === id;
106
+ });
107
+ }
108
+ /**
109
+ * A contratação `id` existe na lista-pai de `tipo`?
110
+ *
111
+ * POR QUE ISTO EXISTE. A API administrativa responde **404 com corpo vazio**
112
+ * tanto para sub-recurso de pai INEXISTENTE quanto para seção legitimamente
113
+ * VAZIA — medido em 24/09/2026: `/contratos/99999999/itens` e
114
+ * `/contratos/541/itens` (pai real, seção sem registro) dão o mesmo 404, e
115
+ * `/contratos/632/itens` dá 200. A fonte não distingue os dois, então a borda
116
+ * da rede também não pode: é a única rota adm que fica em `on404: "empty"`, e
117
+ * o preço disso é desfazer a ambiguidade AQUI.
118
+ *
119
+ * Custo: nenhum, na prática. Confirmar o pai é procurar o id na lista completa
120
+ * que `senado_contratos` / `senado_contratacoes_lista` já baixam e cacheiam —
121
+ * a chamada usa as MESMAS chaves de cache, então é acerto de cache. Não há
122
+ * rota de item único: conferido no Swagger, `/contratos/632` dá 404 e `?id=632`
123
+ * é ignorado (devolve a base inteira).
124
+ *
125
+ * Em falha de rede devolve `null` (indeterminado), não `false`: a fonte fora do
126
+ * ar não é prova de que o contrato não existe.
127
+ */
128
+ export async function paiExiste(tipo, id, admBaseUrl) {
129
+ try {
130
+ const { value } = tipo === "contratos"
131
+ ? await cachedFetchWithMeta("senado_contratos", {}, CACHE_SEMI_STATIC, () => admFetchLarge("/contratacoes/contratos", {}, admBaseUrl))
132
+ : await cachedFetchWithMeta("senado_contratacoes_lista", { tipo }, CACHE_SEMI_STATIC, () => admFetch(`/contratacoes/${tipo}`, {}, admBaseUrl));
133
+ return contemId(value, id);
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
89
139
  export function registerContratacoesTools(server, admBaseUrl) {
90
140
  // Q1. senado_contratos
91
141
  server.tool("senado_contratos", "Busca contratos administrativos do Senado por fornecedor, CNPJ, ano, número, objeto ou mão de obra (base completa baixada e filtrada no Worker; busca parcial sem acento em objeto/fornecedor/número). Retorna `{ count, total, contratos }`, onde cada item traz `id`, `numero`, `objeto`, `empresa {nome, cnpj}`, `subEspecie`, `dataAssinatura`, `vigencia` e `unidadeGestora`. Atenção: a fonte NÃO publica o valor do contrato (nenhum campo monetário aqui nem nas seções de detalhe; apenas a seção `aditivos` de `senado_contratacao_detalhe` traz `valor`, referente ao termo aditivo) — não gaste chamadas procurando valor. A lista mistura subespécies (CT = contratos, AC = acordos de cooperação, TD = termos de doação etc.); distinga pelo campo `subEspecie` retornado. Limitado a `limite` itens (padrão 50, máx 500), com `aviso` quando há truncamento. Use o `id` retornado em `senado_contratacao_detalhe` para itens, pagamentos, garantias ou aditivos.", {
@@ -144,7 +194,7 @@ export function registerContratacoesTools(server, admBaseUrl) {
144
194
  }
145
195
  });
146
196
  // Q2. senado_contratacao_detalhe
147
- server.tool("senado_contratacao_detalhe", "Detalha uma seção específica de uma contratação já identificada pelo `id`. `tipo` indica a natureza do registro: `contratos` (contrato firmado; padrão), `atas_registro_preco` (compromisso de preços para compras futuras) ou `notas_empenho` (reserva orçamentária do gasto). `secao` escolhe o aspecto: `itens`, `pagamentos`, `garantias` (qualquer `tipo`), `aditivos` (só `contratos`) ou `acionamentos` (só `atas_registro_preco`). Retorna `{ id, tipo, secao, count, total, itens }` com os registros brutos da seção (campos conforme a API administrativa), limitados a `limite` (padrão 100, máx 500) — `count < total` indica truncagem; seção sem registros retorna `count` 0 e `itens` vazio; combinações `secao`×`tipo` inválidas (ex.: `aditivos` fora de contratos) retornam erro. Atenção: a fonte NÃO publica o valor do contrato em nenhuma seção; apenas `aditivos` traz `valor`, referente ao termo aditivo (às vezes nulo) — não procure valor monetário em itens/pagamentos/garantias. Obtenha o `id` via `senado_contratos` ou `senado_contratacoes_lista` — para localizar a contratação (não detalhá-la) use aquelas ferramentas.", {
197
+ server.tool("senado_contratacao_detalhe", "Detalha uma seção específica de uma contratação já identificada pelo `id`. `tipo` indica a natureza do registro: `contratos` (contrato firmado; padrão), `atas_registro_preco` (compromisso de preços para compras futuras) ou `notas_empenho` (reserva orçamentária do gasto). `secao` escolhe o aspecto: `itens`, `pagamentos`, `garantias` (qualquer `tipo`), `aditivos` (só `contratos`) ou `acionamentos` (só `atas_registro_preco`). Retorna `{ id, tipo, secao, count, total, itens }` com os registros brutos da seção (campos conforme a API administrativa), limitados a `limite` (padrão 100, máx 500) — `count < total` indica truncagem; seção sem registros retorna `count` 0 e `itens` vazio (o `id` é conferido na lista-pai antes, então `count` 0 significa mesmo seção vazia, não contratação inexistente); `id` que não existe e combinações `secao`×`tipo` inválidas (ex.: `aditivos` fora de contratos) retornam erro. Atenção: a fonte NÃO publica o valor do contrato em nenhuma seção; apenas `aditivos` traz `valor`, referente ao termo aditivo (às vezes nulo) — não procure valor monetário em itens/pagamentos/garantias. Obtenha o `id` via `senado_contratos` ou `senado_contratacoes_lista` — para localizar a contratação (não detalhá-la) use aquelas ferramentas.", {
148
198
  id: z.number().int().positive().describe("ID da contratação (campo 'id' das listas de contratos/atas/empenhos)"),
149
199
  tipo: z.enum(["contratos", "atas_registro_preco", "notas_empenho"]).optional().default("contratos").describe("contratos = contrato firmado (padrão); atas_registro_preco = compromisso de preços p/ compras futuras; notas_empenho = reserva orçamentária do gasto"),
150
200
  secao: z.enum(["itens", "pagamentos", "garantias", "aditivos", "acionamentos"]).describe("Aspecto a detalhar: itens/pagamentos/garantias (qualquer tipo); aditivos (só contratos); acionamentos (só atas_registro_preco)"),
@@ -166,8 +216,22 @@ export function registerContratacoesTools(server, admBaseUrl) {
166
216
  else {
167
217
  path = `/contratacoes/${tipo}/${params.id}/${params.secao}`;
168
218
  }
169
- const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_contratacao_detalhe", { tipo, id: params.id, secao: params.secao }, CACHE_ON_DEMAND, () => admFetch(path, {}, admBaseUrl));
219
+ const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_contratacao_detalhe", { tipo, id: params.id, secao: params.secao }, CACHE_ON_DEMAND,
220
+ // `empty` sobrescreve o `absent` que `admFetch` passou a usar em
221
+ // 24/09/2026: aqui o 404 é mesmo ambíguo (ver `paiExiste`), e quem
222
+ // desfaz a ambiguidade é a conferência do pai, logo abaixo.
223
+ () => admFetch(path, {}, admBaseUrl, { on404: "empty" }));
170
224
  const todos = ensureArray(response);
225
+ // Seção vazia: só agora vale pagar a conferência do pai — e ela é
226
+ // acerto de cache. Pai ausente => ausência tipada (`nao_encontrado`);
227
+ // pai presente => o zero é VERDADE e segue como `count: 0`.
228
+ if (todos.length === 0) {
229
+ const existe = await paiExiste(tipo, params.id, admBaseUrl);
230
+ if (existe === false) {
231
+ return toolError(`Não existe ${tipo} com id ${params.id}. Obtenha o id em ` +
232
+ (tipo === "contratos" ? "senado_contratos" : "senado_contratacoes_lista") + ".");
233
+ }
234
+ }
171
235
  const limite = params.limite ?? 100;
172
236
  const prov = provenanceFor("SENADO_ADM", admBaseUrl, `/api/v1${path}`, {
173
237
  dataset_id: `${tipo}=${params.id}; secao=${params.secao}`, retrieved_at: fetchedAt,
@@ -112,7 +112,14 @@ export function parseSessaoResultado(s) {
112
112
  /** Parse an orientacaoBancada votacao item (flat camelCase). */
113
113
  export function parseOrientacaoVotacao(v) {
114
114
  return {
115
- codigoVotacao: v.codigoVotacaoSve ?? null,
115
+ // `codigoVotacaoSve`, com o nome da FONTE, e não `codigoVotacao`. Medido em
116
+ // 24/09/2026: é um TERCEIRO espaço de numeração — as 3 votações de
117
+ // 12/08/2026 são 13059/13159/13161 aqui, 7101/7102/7103 em
118
+ // `senado_votacoes_senador` e a sessão 581816 em `senado_obter_votacao`. O
119
+ // campo `codigoVotacaoSve` não aparece UMA vez em `/votacao`, então nenhuma
120
+ // tool o resolve; chamá-lo de `codigoVotacao` convidava a alimentar o
121
+ // `senado_obter_votacao` com ele e receber `count: 0` calado.
122
+ codigoVotacaoSve: v.codigoVotacaoSve ?? null,
116
123
  descricao: v.descricaoVotacao || null,
117
124
  materia: v.descricaoMateria ||
118
125
  (v.siglaTipoMateria ? `${v.siglaTipoMateria} ${v.numeroMateria}/${v.anoMateria}` : null),
@@ -214,7 +221,7 @@ export function registerPlenarioTools(server, baseUrl) {
214
221
  }
215
222
  });
216
223
  // F3. senado_orientacao_bancada
217
- server.tool("senado_orientacao_bancada", "Orientação de bancada nas votações de plenário: como cada liderança partidária orientou o voto, com placar — essencial para análise de disciplina partidária. Retorna `{ count, votacoes }`, com cada votação trazendo `codigoVotacao`, `descricao`, `materia`, `dataInicio`, `dataTermino`, `sessao`, totais (`totalSim`, `totalNao`, `totalAbstencao`, `obstrucoes`), `quorumInicial`/`quorumFinal` e `orientacoes` (`partido`, `voto`). Informe `data` (um dia) ou o período `dataInicio`/`dataFim`. Para o resultado das sessões use `senado_resultado_plenario`.", {
224
+ server.tool("senado_orientacao_bancada", "Orientação de bancada nas votações de plenário: como cada liderança partidária orientou o voto, com placar — essencial para análise de disciplina partidária. Retorna `{ count, votacoes }`, com cada votação trazendo `codigoVotacaoSve`, `descricao`, `materia`, `dataInicio`, `dataTermino`, `sessao`, totais (`totalSim`, `totalNao`, `totalAbstencao`, `obstrucoes`), `quorumInicial`/`quorumFinal` e `orientacoes` (`partido`, `voto`). Informe `data` (um dia) ou o período `dataInicio`/`dataFim`. O `codigoVotacaoSve` é o identificador interno do sistema de votação eletrônica e pertence a um espaço de numeração próprio: NÃO serve como entrada de `senado_obter_votacao` (use a `data` com `senado_search_votacoes` para chegar ao código da votação). Para o resultado das sessões use `senado_resultado_plenario`.", {
218
225
  data: z.string().regex(/^\d{8}$/).optional().describe("Data da sessão (YYYYMMDD)"),
219
226
  dataInicio: z.string().regex(/^\d{8}$/).optional().describe("Data início do período (YYYYMMDD)"),
220
227
  dataFim: z.string().regex(/^\d{8}$/).optional().describe("Data fim do período (YYYYMMDD)"),
@@ -115,6 +115,14 @@ export function parseSenadorDetalhe(dados) {
115
115
  export function parseVotoSenador(v, codigoSenador) {
116
116
  const voto = ensureArray(v.votos).find((vt) => vt.codigoParlamentar === codigoSenador);
117
117
  return {
118
+ // O PAR, não um dos dois. Até 24/09/2026 este campo servia só o
119
+ // `codigoSessaoVotacao` (4 dígitos) e a descrição mandava usar
120
+ // `senado_obter_votacao` com ele — que filtrava por `codigoSessao` e
121
+ // respondia `count: 0`. Medido: 48 de 48 votos de um senador em 2026 saíam
122
+ // com código de 4 dígitos, ou seja, o caminho documentado quebrava sempre.
123
+ // Hoje as duas pontas resolvem, e emitir os dois poupa a varredura da
124
+ // janela: com `codigoSessao` o `obter_votacao` acerta de primeira.
125
+ codigoSessao: v.codigoSessao || null,
118
126
  codigoVotacao: v.codigoSessaoVotacao || v.codigoSessao || 0,
119
127
  data: v.dataSessao ? String(v.dataSessao).split("T")[0] : "",
120
128
  materia: v.identificacao || (v.sigla ? `${v.sigla} ${v.numero}/${v.ano}` : ""),
@@ -291,7 +299,7 @@ export function registerSenadoresTools(server, baseUrl) {
291
299
  }
292
300
  });
293
301
  // A4. senado_votacoes_senador (migrated to v3 /votacao?codigoParlamentar — legacy endpoint deprecated)
294
- server.tool("senado_votacoes_senador", "Lista as votações nominais de um senador, mostrando como votou em cada matéria. Retorna `{ periodo, count, votos }`, cada voto com `codigoVotacao`, `data`, `materia`, `descricao`, `voto` e `resultado`, ordenados da mais recente para a mais antiga. Sem período usa o ano corrente; informe `ano` ou o par `dataInicio`/`dataFim` (YYYYMMDD). Requer `codigoSenador` (obtenha via `senado_listar_senadores`); para detalhes de uma votação específica use `senado_obter_votacao`.", {
302
+ server.tool("senado_votacoes_senador", "Lista as votações nominais de um senador, mostrando como votou em cada matéria. Retorna `{ periodo, count, votos }`, cada voto com `codigoSessao`, `codigoVotacao`, `data`, `materia`, `descricao`, `voto` e `resultado`, ordenados da mais recente para a mais antiga. Sem período usa o ano corrente; informe `ano` ou o par `dataInicio`/`dataFim` (YYYYMMDD). Requer `codigoSenador` (obtenha via `senado_listar_senadores`); para detalhes de uma votação específica (votos de todos os senadores) use `senado_obter_votacao` — os dois códigos são aceitos, e `codigoSessao` resolve direto.", {
295
303
  codigoSenador: z.number().int().positive().describe("Código único do senador"),
296
304
  ano: z.number().int().min(1900).max(2100).optional().describe("Ano das votações"),
297
305
  dataInicio: z.string().regex(/^\d{8}$/).optional().describe("Data início (YYYYMMDD)"),
@@ -191,8 +191,13 @@ export async function nomesSupridos(ano, admBaseUrl) {
191
191
  }
192
192
  export function registerSupridosTools(server, admBaseUrl) {
193
193
  // R1. senado_suprimento_fundos
194
- server.tool("senado_suprimento_fundos", "Suprimento de fundos do Senado (adiantamentos a supridos): relação anual de supridos, atos de concessão, empenhos, movimentações ou transações de cartão corporativo, conforme `tipo`. Retorna `{ ano, tipo, count, total, registros }` (snake_case da API administrativa), filtrável por `filtro` textual e limitado por `limite` (padrão 100, máx 500); ao truncar, inclui `aviso`. Para maior/menor/média/mediana/distribuição/ranking ('quem mais recebeu', 'fornecedor com maior gasto', 'valor mediano') use `estatisticas=true` (só nos tipos `transacoes`, `empenhos`, `atos-concessao` — os demais não têm coluna de valor): SEM `agruparPor` = distribuição das linhas (min/máx/média/mediana/percentis) + top/bottom; COM `agruparPor` = grupos ranqueados por soma decrescente (grupos[0]=maior). A coluna de valor analisada é escolhida automaticamente conforme o `tipo`; o resultado já traz o rótulo legível dela em `campoAnalisado`. Registros sem valor são excluídos das estatísticas. Em atos de concessão, cada beneficiário é identificado pelo NOME (cruzado com o cadastro de supridos) e pode-se usar `agruparPor='suprido'` para ranquear por beneficiário. Informe o `ano` (>=2010); use os mesmos códigos administrativos vistos em `senado_contratacoes_lista` ou `senado_execucao_orcamentaria` para cruzar gastos.", {
195
- ano: z.number().int().min(2010).max(2100).describe("Ano de referência"),
194
+ server.tool("senado_suprimento_fundos", "Suprimento de fundos do Senado (adiantamentos a supridos): relação anual de supridos, atos de concessão, empenhos, movimentações ou transações de cartão corporativo, conforme `tipo`. Retorna `{ ano, tipo, count, total, registros }` (snake_case da API administrativa), filtrável por `filtro` textual e limitado por `limite` (padrão 100, máx 500); ao truncar, inclui `aviso`. Para maior/menor/média/mediana/distribuição/ranking ('quem mais recebeu', 'fornecedor com maior gasto', 'valor mediano') use `estatisticas=true` (só nos tipos `transacoes`, `empenhos`, `atos-concessao` — os demais não têm coluna de valor): SEM `agruparPor` = distribuição das linhas (min/máx/média/mediana/percentis) + top/bottom; COM `agruparPor` = grupos ranqueados por soma decrescente (grupos[0]=maior). A coluna de valor analisada é escolhida automaticamente conforme o `tipo`; o resultado já traz o rótulo legível dela em `campoAnalisado`. Registros sem valor são excluídos das estatísticas. Em atos de concessão, cada beneficiário é identificado pelo NOME (cruzado com o cadastro de supridos) e pode-se usar `agruparPor='suprido'` para ranquear por beneficiário. Informe o `ano` (a fonte publica de 2013 em diante); use os mesmos códigos administrativos vistos em `senado_contratacoes_lista` ou `senado_execucao_orcamentaria` para cruzar gastos.", {
195
+ // 2013, e não o 2010 que estava aqui: medido em 24/09/2026, `/supridos/`
196
+ // de 2009 a 2012 responde 404 e 2013 responde 200. Com o
197
+ // `treat404AsEmpty` antigo, pedir 2011 devolvia `count: 0` — lia-se
198
+ // "o Senado não adiantou nada em 2011". O limite fecha a porta antes da
199
+ // rede (classe `contrato`); fora dela, a ausência agora é tipada.
200
+ ano: z.number().int().min(2013).max(2100).describe("Ano de referência (a fonte publica de 2013 em diante)"),
196
201
  tipo: z.enum(["supridos", "atos-concessao", "empenhos", "movimentacoes", "transacoes"]).optional().default("supridos").describe("Qual relação consultar (padrão: supridos)"),
197
202
  filtro: z.string().optional().describe("Filtro textual (nome, unidade...)"),
198
203
  estatisticas: z.boolean().optional().default(false).describe("Distribuição/ranking sobre as linhas: min/máx/média/mediana/percentis + top/bottom, ou grupos ranqueados por soma via agruparPor. Só para tipo transacoes/empenhos/atos-concessao"),
@@ -85,7 +85,15 @@ export function registerTaquigrafiaTools(server, baseUrl) {
85
85
  // The acervo answers 404 for codes without transcript (CN joint sessions,
86
86
  // cancelled/not-held sessions, some solemn ones) — serve the promised
87
87
  // empty result instead of an upstream error.
88
- () => upstreamFetch(path, {}, baseUrl, { treat404AsEmpty: true }));
88
+ //
89
+ // `empty` DE PROPÓSITO, ao contrário da classe administrativa que a
90
+ // varredura de 24/09/2026 converteu para `absent`: aqui o zero não é
91
+ // calado. Ele vem com `NOTAS_TAQUIGRAFICAS_AVISO_VAZIO`, que nomeia os
92
+ // buracos estruturais da cobertura e manda conferir o `tipo` — e a
93
+ // descrição da tool promete esse formato. O ganho da borda nova chega
94
+ // mesmo assim: rota montada errada agora falha alto em vez de virar
95
+ // "sem notas".
96
+ () => upstreamFetch(path, {}, baseUrl, { on404: "empty" }));
89
97
  const nt = response?.notasTaquigraficas ?? response;
90
98
  const quartosTodos = ensureArray(nt?.quartos);
91
99
  let quartos = quartosTodos;
@@ -147,8 +155,9 @@ export function registerTaquigrafiaTools(server, baseUrl) {
147
155
  const tipo = params.tipo ?? "sessao";
148
156
  const path = `/taquigrafia/videos/${tipo}/${params.id}`;
149
157
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_videos_taquigrafia", { tipo, id: params.id }, CACHE_ON_DEMAND,
150
- // Same 404-as-empty posture as the notes acervo (missing media -> 404).
151
- () => upstreamFetch(path, {}, baseUrl, { treat404AsEmpty: true }));
158
+ // Same 404-as-empty posture as the notes acervo (missing media -> 404),
159
+ // e pelo mesmo motivo: o zero aqui fala, via VIDEOS_TAQUIGRAFIA_AVISO_VAZIO.
160
+ () => upstreamFetch(path, {}, baseUrl, { on404: "empty" }));
152
161
  const videosTodos = ensureArray(response).map(parseVideoUnidade);
153
162
  let videos = videosTodos;
154
163
  if (params.orador) {
@@ -9,7 +9,7 @@
9
9
  import { z } from "zod";
10
10
  import { cachedFetchWithMeta } from "../cache/manager.js";
11
11
  import { upstreamFetch } from "../throttle/upstream.js";
12
- import { errorFrom, buildParams, ensureArray } from "../utils/validation.js";
12
+ import { toolError, errorFrom, buildParams, ensureArray } from "../utils/validation.js";
13
13
  import { computarPlacar } from "../utils/placar.js";
14
14
  import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
15
15
  import { CACHE_ON_DEMAND } from "../types.js";
@@ -86,23 +86,89 @@ export function parseVotacaoItem(v, includeVotos = false) {
86
86
  }
87
87
  return result;
88
88
  }
89
+ /**
90
+ * Os DOIS espaços de numeração que o item de `/votacao` carrega ao mesmo tempo.
91
+ *
92
+ * | campo da fonte | exemplo | o que é |
93
+ * |-----------------------|----------|----------------------------------|
94
+ * | `codigoSessao` | 581816 | a sessão plenária (6 dígitos) |
95
+ * | `codigoSessaoVotacao` | 7101 | a votação dentro dela (4 dígitos)|
96
+ *
97
+ * `/votacao` só filtra pelo primeiro: medido em 24/09/2026,
98
+ * `?codigoSessao=581816` devolve 3 votações e 81 votos nominais, enquanto
99
+ * `?codigoSessao=7101` devolve `[]`. E a fonte **ignora filtro desconhecido**
100
+ * em vez de recusá-lo — `?codigoSessaoVotacao=7101` devolve a base inteira
101
+ * (1,77 MB) —, então não existe filtro server-side pelo código da votação:
102
+ * resolver é do nosso lado ([[esquema-que-nao-recusa-responde-outra-pergunta]]).
103
+ */
104
+ export const CAMPO_SESSAO = "codigoSessao";
105
+ export const CAMPO_VOTACAO = "codigoSessaoVotacao";
106
+ /**
107
+ * Acha, numa lista crua de `/votacao`, o item cujo `codigoSessaoVotacao` é
108
+ * `codigo`. Devolve `undefined` quando não está na janela.
109
+ *
110
+ * Existe separada do handler para a guarda poder exercitá-la sem montar URL —
111
+ * o conferidor não pode reusar o caminho do defeito
112
+ * ([[guarda-que-reusa-o-padrao-do-defeito]]).
113
+ */
114
+ export function acharPorCodigoVotacao(lista, codigo) {
115
+ return ensureArray(lista).find((v) => {
116
+ const bruto = v?.[CAMPO_VOTACAO];
117
+ if (bruto === null || bruto === undefined || bruto === "")
118
+ return false;
119
+ return Number(bruto) === codigo;
120
+ });
121
+ }
89
122
  export function registerVotacoesTools(server, baseUrl) {
90
123
  // D3. senado_obter_votacao
91
- server.tool("senado_obter_votacao", "Obtém detalhes de uma votação de **plenário** pelo `codigoVotacao` (que é o `codigoSessao` da sessão plenária), incluindo votos nominais. Retorna o objeto da votação (placar, `resultado` legível + `resultadoCodigo` bruto, `secreta`) com `votos[]` (`codigoSenador`, `nomeSenador`, `partido`, `uf`, `voto`); se a sessão tiver várias votações, retorna `{ codigoSessao, count, votacoes }`. Obtenha o `codigoSessao` via `senado_search_votacoes` antes de chamar. Atenção: este endpoint só aceita códigos de votação de **plenário** — códigos de `senado_votacao_comissao` pertencem a outro espaço de numeração e NÃO são válidos aqui (podem coincidir numericamente, mas apontam para outra votação).", {
92
- codigoVotacao: z.number().int().positive().describe("Código único da votação (codigoSessao da sessão plenária)"),
124
+ server.tool("senado_obter_votacao", "Obtém detalhes de uma votação de **plenário**, incluindo votos nominais. `codigoVotacao` aceita OS DOIS códigos que a fonte publica para a mesma votação: o `codigoVotacao` de 4 dígitos (ex.: 7101 — o que `senado_search_votacoes` e `senado_votacoes_senador` devolvem nesse campo) ou o `codigoSessao` de 6 dígitos da sessão plenária (ex.: 581816). Com o código da votação retorna aquela votação; com o da sessão retorna `{ codigoSessao, count, votacoes }` com todas as votações da sessão. Cada votação traz placar, `resultado` legível + `resultadoCodigo` bruto, `secreta` e `votos[]` (`codigoSenador`, `nomeSenador`, `partido`, `uf`, `voto`). Resolver o código de 4 dígitos exige varrer uma janela temporal: por padrão a janela recente da fonte (~12 meses); para votação mais antiga informe `ano`. Código que não existe em nenhum dos dois espaços retorna erro — nunca lista vazia. Atenção: códigos de `senado_votacao_comissao` e o `codigoVotacao` de `senado_orientacao_bancada` pertencem a OUTROS espaços de numeração e não são válidos aqui.", {
125
+ codigoVotacao: z.number().int().positive().describe("Código da votação (4 dígitos, ex. 7101) OU o codigoSessao da sessão plenária (6 dígitos, ex. 581816) — os dois são aceitos"),
126
+ ano: z.number().int().min(1990).max(2100).optional().describe("Só para código de votação de 4 dígitos ANTERIOR aos últimos ~12 meses: o ano em que a votação ocorreu, para abrir a janela de busca"),
93
127
  }, async (params) => {
94
128
  try {
95
- const qp = { codigoSessao: String(params.codigoVotacao) };
96
- const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_obter_votacao", { codigo: params.codigoVotacao }, CACHE_ON_DEMAND, () => upstreamFetch("/votacao", qp, baseUrl));
129
+ const codigo = params.codigoVotacao;
130
+ // PASSO 1 — tentar como codigoSessao, o ÚNICO filtro que a fonte honra.
131
+ const qp = { [CAMPO_SESSAO]: String(codigo) };
132
+ const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_obter_votacao", { codigo }, CACHE_ON_DEMAND, () => upstreamFetch("/votacao", qp, baseUrl));
97
133
  const votacoes = ensureArray(response).map((v) => parseVotacaoItem(v, true));
98
- const prov = provenanceFor("SENADO_LEGIS", baseUrl, "/votacao", {
99
- dataset_id: `codigoSessao=${params.codigoVotacao}`,
100
- reference_period: votacoes[0]?.data || undefined,
101
- retrieved_at: fetchedAt,
102
- });
103
- if (votacoes.length === 1)
104
- return resultWithProvenance(votacoes[0], prov);
105
- return resultWithProvenance({ codigoSessao: params.codigoVotacao, count: votacoes.length, votacoes }, prov);
134
+ if (votacoes.length > 0) {
135
+ const prov = provenanceFor("SENADO_LEGIS", baseUrl, "/votacao", {
136
+ dataset_id: `${CAMPO_SESSAO}=${codigo}`,
137
+ reference_period: votacoes[0]?.data || undefined,
138
+ retrieved_at: fetchedAt,
139
+ });
140
+ if (votacoes.length === 1)
141
+ return resultWithProvenance(votacoes[0], prov);
142
+ return resultWithProvenance({ codigoSessao: codigo, count: votacoes.length, votacoes }, prov);
143
+ }
144
+ // PASSO 2 — vazio não é resposta: o número pode ser do OUTRO espaço.
145
+ // Até 24/09/2026 o handler paravaaqui e devolvia `{count: 0}` — para
146
+ // 7101, que é a votação do PLP 124/2022 com 69 Sim. Pior que zero
147
+ // calado: indistinguível do zero de um id inventado.
148
+ const janela = params.ano
149
+ ? { dataInicio: `${params.ano}-01-01`, dataFim: `${params.ano}-12-31` }
150
+ : {};
151
+ const { value: cru, fetchedAt: fetchedAtJanela } = await cachedFetchWithMeta("senado_obter_votacao_janela", janela, CACHE_ON_DEMAND, () => upstreamFetch("/votacao", janela, baseUrl));
152
+ const achado = acharPorCodigoVotacao(cru, codigo);
153
+ if (achado) {
154
+ const votacao = parseVotacaoItem(achado, true);
155
+ return resultWithProvenance(votacao, provenanceFor("SENADO_LEGIS", baseUrl, "/votacao", {
156
+ dataset_id: `${CAMPO_VOTACAO}=${codigo}`,
157
+ reference_period: votacao.data || undefined,
158
+ retrieved_at: fetchedAtJanela,
159
+ }));
160
+ }
161
+ // PASSO 3 — não está em nenhum dos dois espaços: ausência TIPADA.
162
+ const ondeProcurou = params.ano
163
+ ? `no ano ${params.ano}`
164
+ : "na janela recente da fonte (~12 meses)";
165
+ return toolError(`Não existe votação de plenário com o código ${codigo}: ele não é ` +
166
+ `codigoSessao de nenhuma sessão nem codigoVotacao ${ondeProcurou}.` +
167
+ (params.ano
168
+ ? " Confira o ano e o código em senado_search_votacoes."
169
+ : " Se a votação é anterior, informe o `ano`; ou obtenha o código em" +
170
+ " senado_search_votacoes. O `codigoVotacao` de senado_orientacao_bancada" +
171
+ " pertence a outro espaço de numeração e não é aceito aqui."));
106
172
  }
107
173
  catch (e) {
108
174
  return errorFrom(e, "Votação não encontrada");
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Single source of truth for the server version (kept in sync with package.json). */
2
- export const VERSION = "3.9.0";
2
+ export const VERSION = "3.10.0";
3
3
  export const USER_AGENT = `senado-br-mcp/${VERSION} (+https://senado.sidneybissoli.com; sbissoli76@gmail.com)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "senado-br-mcp",
3
- "version": "3.9.0",
3
+ "version": "3.10.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",
@@ -45,7 +45,8 @@
45
45
  "build:dataset": "tsx scripts/build-dataset/index.ts",
46
46
  "dataset:dictionary": "tsx scripts/build-dataset/index.ts --dictionary-only",
47
47
  "release:dataset": "tsx scripts/build-dataset/release.ts",
48
- "release:zenodo": "tsx scripts/build-dataset/zenodo-publish.ts"
48
+ "release:zenodo": "tsx scripts/build-dataset/zenodo-publish.ts",
49
+ "manifest:lhm": "node scripts/gen-lhm-manifest.mjs"
49
50
  },
50
51
  "dependencies": {
51
52
  "@modelcontextprotocol/server": "^2.0.0",