senado-br-mcp 3.11.0 → 3.12.1

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
@@ -14,7 +14,7 @@
14
14
  [![License: MIT](https://img.shields.io/github/license/SidneyBissoli/senado-br-mcp-cloudflare)](LICENSE)
15
15
  [![Status](https://img.shields.io/website?url=https%3A%2F%2Fsenado.sidneybissoli.com%2Fhealth&up_message=online&down_message=offline&label=status)](https://senado.sidneybissoli.com/status)
16
16
 
17
- 🇧🇷 [Leia em Português](README.pt-BR.md)
17
+ 🇧🇷 [Leia em Português](LEIA-ME.md)
18
18
 
19
19
  A **public, hosted** MCP server that gives AI assistants live, structured access to **Brazilian Senate open data** — **no installation, no account, no API key**. Point your MCP client at the hosted endpoint and start asking about senators, bills, votes, expenses, and more. It runs on Cloudflare Workers over Streamable HTTP.
20
20
 
@@ -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,7 +29,7 @@
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
35
  import { withUpstreamCall } from "./throttle/upstream.js";
@@ -80,8 +80,10 @@ export function instrumentTool(name, cb, analytics, tag, gravados) {
80
80
  const result = await withUpstreamCall(() => callCache.run(stats, () => Promise.resolve(cb(...args))));
81
81
  isError =
82
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.
83
85
  if (isError)
84
- classe = classifyError(errorText(result));
86
+ classe = classeAnexada(result) ?? classifyError(errorText(result));
85
87
  return result;
86
88
  }
87
89
  catch (e) {
@@ -20,8 +20,14 @@ export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
20
20
  * (not `upstreamFetch`), so transient conditions are classified here: 5xx / 429 /
21
21
  * timeout / network → retryable; 4xx → not.
22
22
  */
23
- function ecidadaniaFetchError(message, retryable) {
24
- 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 });
25
31
  }
26
32
  const isTransientStatus = (status) => status >= 500 || status === 429;
27
33
  /**
@@ -83,14 +89,14 @@ async function idaEcidadania(path, headers, rotulo) {
83
89
  switch (e.kind) {
84
90
  case "timeout":
85
91
  case "aborted":
86
- throw ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true);
92
+ throw ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true, "fonte");
87
93
  case "network": {
88
94
  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);
95
+ throw ecidadaniaFetchError(`${rotulo}: falha de rede ao acessar ${path} (${causa})`, true, "fonte");
90
96
  }
91
97
  default: {
92
98
  const status = e.status ?? 502;
93
- throw ecidadaniaFetchError(`${rotulo} retornou HTTP ${status} para ${path}`, isTransientStatus(status));
99
+ throw ecidadaniaFetchError(`${rotulo} retornou HTTP ${status} para ${path}`, isTransientStatus(status), status === 404 ? "nao_encontrado" : "fonte");
94
100
  }
95
101
  }
96
102
  }
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),
@@ -84,13 +84,24 @@ export class UpstreamError extends Error {
84
84
  * cannot carry it — 502 is used for both a real Bad Gateway and a body
85
85
  * that failed to parse.
86
86
  */
87
- transport = false) {
87
+ transport = false,
88
+ /**
89
+ * Classe de telemetria pelo TIPO, não pela frase (ver `CLASSE_DO_ERRO` em
90
+ * src/call-shape.ts). Medido em 30/09/2026: pela frase, "Erro de rede: ..."
91
+ * e o 429 do nosso próprio balde ("Taxa de requisições excedida") caíam em
92
+ * `outro`. Padrão: 404 é ausência respondida, o resto é a fonte falhando;
93
+ * quem sabe mais passa explícito (rota inexistente é `defeito`, corpo vazio
94
+ * é `nao_encontrado`).
95
+ */
96
+ classe) {
88
97
  super(message);
89
98
  this.status = status;
90
99
  this.retryable = retryable;
91
100
  this.transport = transport;
92
101
  this.name = "UpstreamError";
102
+ this.classe = classe ?? (status === 404 ? "nao_encontrado" : "fonte");
93
103
  }
104
+ classe;
94
105
  }
95
106
  /**
96
107
  * Frase-chave da mensagem de rota inexistente. Vive numa constante porque
@@ -269,7 +280,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
269
280
  log("upstream", path, 404, Date.now() - startTime, err.attempts - 1);
270
281
  const corpo404 = await err.response.text();
271
282
  if (rotaInexistente(corpo404)) {
272
- throw new UpstreamError(`[${path}] ${MSG_ROTA_INEXISTENTE} — o caminho montado não existe na API do Senado.`, 404, false);
283
+ throw new UpstreamError(`[${path}] ${MSG_ROTA_INEXISTENTE} — o caminho montado não existe na API do Senado.`, 404, false, false, "defeito");
273
284
  }
274
285
  if (options.on404 === "empty")
275
286
  return [];
@@ -301,7 +312,9 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
301
312
  incr("upstreamCalls");
302
313
  log("upstream", path, response.status, latency, call.attempts - attemptsBefore - 1);
303
314
  if (!text.trim()) {
304
- throw new UpstreamError(`[${path}] Resposta upstream vazia`, 502, true);
315
+ // `nao_encontrado`, como a frase já dava ("vazi"): um corpo vazio é como o
316
+ // `/comissao/reuniao/{codigo}` responde a código inexistente.
317
+ throw new UpstreamError(`[${path}] Resposta upstream vazia`, 502, true, false, "nao_encontrado");
305
318
  }
306
319
  if (options.text)
307
320
  return text;
@@ -175,7 +175,7 @@ export function registerComissoesTools(server, baseUrl) {
175
175
  const secao = params.secao ?? "resumo";
176
176
  const codigo = await resolveComissaoCodigo(sigla, baseUrl);
177
177
  if (!codigo)
178
- return toolError(`Comissão com sigla "${sigla}" não encontrada.`);
178
+ return toolError(`Comissão com sigla "${sigla}" não encontrada.`, "nao_encontrado");
179
179
  if (secao === "membros") {
180
180
  const membrosPath = `/composicao/comissao/${codigo}`;
181
181
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_membros_comissao", { codigo, ativas: "S" }, CACHE_SEMI_STATIC,
@@ -199,7 +199,7 @@ export function registerComissoesTools(server, baseUrl) {
199
199
  }
200
200
  const { path: resumoPath, fetchedAt, colegiado } = await fetchComissaoColegiado(codigo, baseUrl);
201
201
  if (!colegiado)
202
- return toolError("Dados da comissão não encontrados.");
202
+ return toolError("Dados da comissão não encontrados.", "nao_encontrado");
203
203
  const prov = provenanceFor("SENADO_LEGIS", baseUrl, resumoPath, {
204
204
  dataset_id: `comissao=${sigla}; codigo=${codigo}`, retrieved_at: fetchedAt,
205
205
  });
@@ -330,7 +330,7 @@ export function registerComissoesTools(server, baseUrl) {
330
330
  const sigla = (params.sigla || "").toUpperCase();
331
331
  if (!sigla) {
332
332
  return toolError("Informe `codigoReuniao`, ou `sigla` da comissão (com `data`, opcional). " +
333
- "Sem nenhum dos dois não há o que detalhar.", false);
333
+ "Sem nenhum dos dois não há o que detalhar.", "contrato");
334
334
  }
335
335
  const hoje = new Date();
336
336
  const inicio = new Date(hoje);
@@ -348,7 +348,7 @@ export function registerComissoesTools(server, baseUrl) {
348
348
  }));
349
349
  const escolha = escolherReuniao(candidatas, sigla, params.data ? di : `${di}–${df}`);
350
350
  if (escolha.tipo !== "codigo")
351
- return toolError(escolha.mensagem, false);
351
+ return toolError(escolha.mensagem, escolha.classe);
352
352
  codigo = escolha.codigo;
353
353
  }
354
354
  const reuniaoPath = `/comissao/reuniao/${codigo}`;
@@ -405,7 +405,7 @@ export function registerComissoesTools(server, baseUrl) {
405
405
  // maioria dos endpoints e mandava o agente repetir uma chamada que
406
406
  // nunca ia funcionar.
407
407
  if (ehReuniaoInexistente(e)) {
408
- return toolError(mensagemCodigoInexistente(params.codigoReuniao ?? 0), false);
408
+ return toolError(mensagemCodigoInexistente(params.codigoReuniao ?? 0), "nao_encontrado");
409
409
  }
410
410
  return errorFrom(e, "Reunião de comissão não encontrada");
411
411
  }
@@ -205,12 +205,12 @@ export function registerContratacoesTools(server, admBaseUrl) {
205
205
  let path;
206
206
  if (params.secao === "aditivos") {
207
207
  if (tipo !== "contratos")
208
- return toolError("A seção 'aditivos' só existe para tipo=contratos.");
208
+ return toolError("A seção 'aditivos' só existe para tipo=contratos.", "contrato");
209
209
  path = `/contratacoes/contratos/${params.id}/aditivos`;
210
210
  }
211
211
  else if (params.secao === "acionamentos") {
212
212
  if (tipo !== "atas_registro_preco")
213
- return toolError("A seção 'acionamentos' só existe para tipo=atas_registro_preco.");
213
+ return toolError("A seção 'acionamentos' só existe para tipo=atas_registro_preco.", "contrato");
214
214
  path = `/contratacoes/atas_registro_preco/${params.id}/acionamentos`;
215
215
  }
216
216
  else {
@@ -229,7 +229,7 @@ export function registerContratacoesTools(server, admBaseUrl) {
229
229
  const existe = await paiExiste(tipo, params.id, admBaseUrl);
230
230
  if (existe === false) {
231
231
  return toolError(`Não existe ${tipo} com id ${params.id}. Obtenha o id em ` +
232
- (tipo === "contratos" ? "senado_contratos" : "senado_contratacoes_lista") + ".");
232
+ (tipo === "contratos" ? "senado_contratos" : "senado_contratacoes_lista") + ".", "nao_encontrado");
233
233
  }
234
234
  }
235
235
  const limite = params.limite ?? 100;
@@ -261,7 +261,7 @@ export function registerContratacoesTools(server, admBaseUrl) {
261
261
  objetoContains: params.objeto,
262
262
  });
263
263
  if (Object.keys(qp).length === 0) {
264
- return toolError("Informe 'numero' ou 'objeto' para a busca.");
264
+ return toolError("Informe 'numero' ou 'objeto' para a busca.", "contrato");
265
265
  }
266
266
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_licitacoes", qp, CACHE_SEMI_STATIC, () => admFetch("/contratacoes/licitacoes", qp, admBaseUrl));
267
267
  const todos = ensureArray(response);
@@ -329,7 +329,7 @@ export function registerContratacoesTools(server, admBaseUrl) {
329
329
  }, async (params) => {
330
330
  try {
331
331
  if (!params.nome && !params.cnpj) {
332
- return toolError("Informe 'nome' ou 'cnpj' para a busca.");
332
+ return toolError("Informe 'nome' ou 'cnpj' para a busca.", "contrato");
333
333
  }
334
334
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_empresas_contratadas", {}, CACHE_SEMI_STATIC, () => admFetchLarge("/contratacoes/empresas", {}, admBaseUrl));
335
335
  let empresas = ensureArray(response);
@@ -39,6 +39,7 @@ import { provenanceExtras, provenanceFor } from "../utils/provenance.js";
39
39
  import { CACHE_SEMI_STATIC } from "../types.js";
40
40
  import { extractParlamentares, fetchSenadorDetalhe, parseSenadorResumo } from "./senadores.js";
41
41
  import { RootNotFoundError } from "../utils/upstream-parse.js";
42
+ import { classifyThrown } from "../call-shape.js";
42
43
  import { fetchComissaoColegiado, parseComissaoItem, parseComissaoResumo } from "./comissoes.js";
43
44
  import { UFS } from "./referencia.js";
44
45
  export { DEEP_RESEARCH_TOOLS };
@@ -286,6 +287,10 @@ export function capturarDeepResearchTools(baseUrl) {
286
287
  corpus: "Brazilian Federal Senate open data (senators in office and active committees of the Senate and the National Congress)",
287
288
  richTools: "the `senado_*` tools",
288
289
  limit: DEEP_RESEARCH_LIMIT,
290
+ // A classe de uma EXCEÇÃO pelo tipo (mcp-search 0.8.0): a declarada
291
+ // (`error.classe`) o pacote lê primeiro; `TypeError` nosso -> `defeito`.
292
+ // Ela viaja no resultado (chave-símbolo da frota) até o hook do shim.
293
+ classifyThrown,
289
294
  });
290
295
  const porNome = (name) => {
291
296
  const reg = capturados.find((c) => c.name === name);
@@ -11,10 +11,9 @@
11
11
  */
12
12
  import { z } from "zod";
13
13
  import { cachedFetchWithMeta } from "../cache/manager.js";
14
- import { toolError } from "../utils/validation.js";
14
+ import { errorFrom } from "../utils/validation.js";
15
15
  import { provenanceEcidadania, provenanceArquimedesVotos, resultWithProvenance } from "../utils/provenance.js";
16
16
  import { tagUntrustedFields, tagUntrustedList, neutralizeUntrustedText, sanitizeScrapedText } from "../utils/untrusted.js";
17
- import { logger } from "../utils/logger.js";
18
17
  import { CACHE_ON_DEMAND } from "../types.js";
19
18
  import { resolveList, writeDetalheThrough } from "../scraper/store.js";
20
19
  import { ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, } from "../scraper/ecidadania.js";
@@ -80,14 +79,9 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
80
79
  return provenanceEcidadania(alvo, { dataset_id, retrieved_at: fetchedAt });
81
80
  }
82
81
  function ecidadaniaError(e) {
83
- const msg = e instanceof Error ? e.message : "Erro ao acessar e-Cidadania";
84
- const retryable = e instanceof Error && "retryable" in e && typeof e.retryable === "boolean"
85
- ? e.retryable
86
- : false;
87
- // Emit the same structured tool_error log the other tools get via errorFrom(); log the raw
88
- // message (no suffix), but keep the reassuring suffix in the user-facing tool error.
89
- logger.error("tool_error", { message: msg, retryable });
90
- return toolError(`${msg}. As demais funcionalidades (senadores, matérias, votações) continuam operacionais.`, retryable);
82
+ // Mesmo caminho de toda tool (errorFrom: log cru, classe pelo TIPO da exceção via
83
+ // anexarClasse); o sufixo tranquilizador vai só na mensagem que o cliente lê.
84
+ return errorFrom(e, "Erro ao acessar e-Cidadania", ". As demais funcionalidades (senadores, matérias, votações) continuam operacionais.");
91
85
  }
92
86
  // G1. senado_ecidadania_listar_consultas
93
87
  server.tool("senado_ecidadania_listar_consultas", "Lista consultas públicas do e-Cidadania (conjunto completo das **abertas** — toda matéria em tramitação, ~7,7 mil), em que cidadãos votam sim/não. Retorna `{ count, consultas }`, cada consulta com `id`, `materia`, `ementa`, `votosSim`/`votosNao`/`totalVotos`, `percentualSim`/`percentualNao`, `status` e `url`. Toda consulta entra como `aberta`; quando a matéria sai de tramitação ela passa a `encerrada` (o conjunto `encerrada`/`todas` cresce com o tempo). Consultas encerradas antes da 1ª ingestão não são capturadas. Aceita `limite` (padrão 20). Para o detalhe de uma consulta chame `senado_ecidadania_obter_consulta` com o `id`; para recortes analíticos (consenso/polarização) use `senado_ecidadania_consultas_analise`.", {
@@ -49,7 +49,7 @@ export function registerEstruturaTools(server) {
49
49
  const dica = sugestoes.length
50
50
  ? ` Você quis dizer: ${sugestoes.map((s) => (s.sigla ? `${s.sigla} (${s.nome})` : s.nome)).join("; ")}?`
51
51
  : "";
52
- return toolError(`Unidade '${params.unidade}' não encontrada na estrutura organizacional.${dica}`);
52
+ return toolError(`Unidade '${params.unidade}' não encontrada na estrutura organizacional.${dica}`, "nao_encontrado");
53
53
  }
54
54
  const caminho = ancestrais(indice, alvo.cod).reverse().map(formaOrgao); // da cúpula até o superior imediato
55
55
  // Subárvore com nível relativo (BFS a partir do alvo; nível 0 = o próprio alvo, omitido da lista).
@@ -66,7 +66,7 @@ export function registerLegislacaoTools(server, baseUrl) {
66
66
  data: params.data,
67
67
  });
68
68
  if (Object.keys(qp).length === 0) {
69
- return toolError("É obrigatório informar pelo menos um parâmetro de busca.");
69
+ return toolError("É obrigatório informar pelo menos um parâmetro de busca.", "contrato");
70
70
  }
71
71
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_buscar_legislacao", qp, CACHE_ON_DEMAND, () => upstreamFetch("/legislacao/lista", qp, baseUrl));
72
72
  const normas = digArrayRoot(response, [["ListaDocumento", "documentos", "documento"]], "senado_buscar_legislacao").map(parseLegislacaoResumo);
@@ -89,7 +89,7 @@ export function registerLegislacaoTools(server, baseUrl) {
89
89
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_obter_legislacao", { codigo: params.codigo }, CACHE_ON_DEMAND, () => upstreamFetch(path, {}, baseUrl));
90
90
  const docs = digArrayRoot(response, [["DetalheDocumento", "documentos", "documento"]], "senado_obter_legislacao");
91
91
  if (docs.length === 0)
92
- return toolError("Norma não encontrada.");
92
+ return toolError("Norma não encontrada.", "nao_encontrado");
93
93
  const prov = provenanceFor("SENADO_LEGIS", baseUrl, path, {
94
94
  dataset_id: `norma=${params.codigo}`, retrieved_at: fetchedAt,
95
95
  });
@@ -177,7 +177,7 @@ export function registerMateriasTools(server, baseUrl) {
177
177
  dataFimApresentacao: df,
178
178
  });
179
179
  if (Object.keys(qp).length === 0) {
180
- return toolError("É obrigatório informar pelo menos um critério de busca.");
180
+ return toolError("É obrigatório informar pelo menos um critério de busca.", "contrato");
181
181
  }
182
182
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_buscar_materias", qp, CACHE_ON_DEMAND, () => upstreamFetch("/processo", qp, baseUrl));
183
183
  let todos = ensureArray(response).map(parseProcessoResumo);
@@ -235,7 +235,7 @@ export function registerPlenarioTools(server, baseUrl) {
235
235
  path = `/plenario/votacao/orientacaoBancada/${params.data}`;
236
236
  }
237
237
  else {
238
- return toolError("Informe 'data' ou o período 'dataInicio'/'dataFim'.");
238
+ return toolError("Informe 'data' ou o período 'dataInicio'/'dataFim'.", "contrato");
239
239
  }
240
240
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_orientacao_bancada", { path }, CACHE_ON_DEMAND, () => upstreamFetch(path, {}, baseUrl));
241
241
  const r = response;
@@ -217,7 +217,7 @@ export function registerProcessosTools(server, baseUrl) {
217
217
  dataFimApresentacao: ensureISODate(params.dataFimApresentacao),
218
218
  });
219
219
  if (Object.keys(qp).length === 0) {
220
- return toolError("É obrigatório informar pelo menos um parâmetro de busca.");
220
+ return toolError("É obrigatório informar pelo menos um parâmetro de busca.", "contrato");
221
221
  }
222
222
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_search_processos", qp, CACHE_ON_DEMAND, () => upstreamFetch("/processo", qp, baseUrl));
223
223
  const todos = ensureArray(response).map(parseProcessoResumo);
@@ -322,7 +322,7 @@ export function registerProcessosTools(server, baseUrl) {
322
322
  mapper = parseEmendaProcesso;
323
323
  }
324
324
  if (Object.keys(qp).length === 0) {
325
- return toolError("Informe pelo menos um filtro (idProcesso, codigoMateria ou período).");
325
+ return toolError("Informe pelo menos um filtro (idProcesso, codigoMateria ou período).", "contrato");
326
326
  }
327
327
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_processo_detalhe", { secao: params.secao, ...qp }, CACHE_ON_DEMAND, () => upstreamFetch(path, qp, baseUrl));
328
328
  const todos = ensureArray(response).map(mapper);
@@ -27,6 +27,7 @@ export function escolherReuniao(reunioes, sigla, periodo) {
27
27
  if (reunioes.length === 0) {
28
28
  return {
29
29
  tipo: "nenhuma",
30
+ classe: "nao_encontrado",
30
31
  mensagem: `Nenhuma reunião da comissão ${sigla} em ${periodo}. Confira a sigla com ` +
31
32
  `senado_listar_comissoes ou amplie o período com senado_reunioes_comissao.`,
32
33
  };
@@ -39,6 +40,7 @@ export function escolherReuniao(reunioes, sigla, periodo) {
39
40
  .join("; ");
40
41
  return {
41
42
  tipo: "ambigua",
43
+ classe: "contrato",
42
44
  mensagem: `${reunioes.length} reuniões da comissão ${sigla} em ${periodo}. Repita informando ` +
43
45
  `codigoReuniao com uma destas: ${lista}${reunioes.length > 10 ? "; …" : ""}`,
44
46
  };
@@ -365,7 +365,7 @@ export function registerServidoresTools(server, admBaseUrl) {
365
365
  const dica = sugestoes.length
366
366
  ? ` Você quis dizer: ${sugestoes.map((s) => (s.sigla ? `${s.sigla} (${s.nome})` : s.nome)).join("; ")}?`
367
367
  : "";
368
- return toolError(`Unidade '${params.subordinadasA}' não encontrada na estrutura organizacional.${dica}`);
368
+ return toolError(`Unidade '${params.subordinadasA}' não encontrada na estrutura organizacional.${dica}`, "nao_encontrado");
369
369
  }
370
370
  const { sob, naoClassificados, afastadosOuEmTransito } = particionarPorUnidade(lista, indice, alvo.cod);
371
371
  // A classificação cruza a folha (fonte administrativa) com o organograma (fonte institucional).
@@ -82,7 +82,7 @@ export function registerVotacaoComissaoTools(server, baseUrl) {
82
82
  const por = params.por ?? "comissao";
83
83
  if (por === "senador") {
84
84
  if (!params.codigoSenador)
85
- return toolError("Para por=senador, informe 'codigoSenador'.");
85
+ return toolError("Para por=senador, informe 'codigoSenador'.", "contrato");
86
86
  const qp = buildParams({
87
87
  comissao: params.comissao?.toUpperCase(),
88
88
  dataInicio: params.dataInicio,
@@ -101,7 +101,7 @@ export function registerVotacaoComissaoTools(server, baseUrl) {
101
101
  }
102
102
  if (por === "materia") {
103
103
  if (!params.sigla || !params.numero || !params.ano) {
104
- return toolError("Para por=materia, informe 'sigla', 'numero' e 'ano'.");
104
+ return toolError("Para por=materia, informe 'sigla', 'numero' e 'ano'.", "contrato");
105
105
  }
106
106
  const sigla = params.sigla.toUpperCase();
107
107
  const qp = buildParams({
@@ -121,7 +121,7 @@ export function registerVotacaoComissaoTools(server, baseUrl) {
121
121
  }
122
122
  // por === "comissao" (padrão)
123
123
  if (!params.siglaComissao)
124
- return toolError("Para por=comissao, informe 'siglaComissao'.");
124
+ return toolError("Para por=comissao, informe 'siglaComissao'.", "contrato");
125
125
  const qp = buildParams({
126
126
  dataInicio: params.dataInicio,
127
127
  dataFim: params.dataFim,
@@ -168,7 +168,7 @@ export function registerVotacoesTools(server, baseUrl) {
168
168
  ? " Confira o ano e o código em senado_search_votacoes."
169
169
  : " Se a votação é anterior, informe o `ano`; ou obtenha o código em" +
170
170
  " senado_search_votacoes. O `codigoVotacao` de senado_orientacao_bancada" +
171
- " pertence a outro espaço de numeração e não é aceito aqui."));
171
+ " pertence a outro espaço de numeração e não é aceito aqui."), "nao_encontrado");
172
172
  }
173
173
  catch (e) {
174
174
  return errorFrom(e, "Votação não encontrada");
@@ -1,6 +1,7 @@
1
1
  /** Shared validation helpers and response builders. */
2
2
  import { incr } from "../metrics.js";
3
3
  import { logger } from "./logger.js";
4
+ import { anexarClasse, CLASSE_DO_ERRO } from "../call-shape.js";
4
5
  /** Actionable next-step guidance, derived from whether retrying can help. */
5
6
  function defaultHint(isRetryable) {
6
7
  return isRetryable
@@ -13,8 +14,11 @@ function defaultHint(isRetryable) {
13
14
  * override). The same payload is also returned as `structuredContent` so clients can parse
14
15
  * errors deterministically — symmetric with toolResult() — the permissive outputSchema is
15
16
  * skipped on isError results anyway, and a plain object satisfies it regardless.
17
+ *
18
+ * Sem classe: é o miolo comum de `toolError` (classe declarada) e `errorFrom` (classe
19
+ * pelo tipo da exceção). Não exportado de propósito — ver `toolError`.
16
20
  */
17
- export function toolError(message, isRetryable = false, hint) {
21
+ function envelopeDeErro(message, isRetryable, hint) {
18
22
  incr("toolErrors");
19
23
  const payload = { error: message, retryable: isRetryable, hint: hint ?? defaultHint(isRetryable) };
20
24
  return {
@@ -24,17 +28,39 @@ export function toolError(message, isRetryable = false, hint) {
24
28
  };
25
29
  }
26
30
  /**
27
- * Build a toolError from any caught value, preserving UpstreamError's retryable flag.
31
+ * Erro que o HANDLER decide, com a classe de telemetria DECLARADA por quem o escreve.
32
+ *
33
+ * Por que a classe é obrigatória. Até 30/09/2026 este helper não anexava classe nenhuma, e
34
+ * o hook de `instrumentTool` caía na frase (`classifyError`). A frase ecoa argumento do
35
+ * chamador: "Nenhuma reunião da comissão X em <período>" saía `outro`, e "N reuniões …
36
+ * Repita informando codigoReuniao" também — a primeira é ausência respondida, a segunda é
37
+ * pedido ambíguo. Quem escreve a mensagem SABE o que ela significa; com o parâmetro
38
+ * obrigatório, o compilador cobra isso de toda chamada nova. A classe viaja fora do fio
39
+ * (chave-símbolo não enumerável `CLASSE_DO_ERRO`): o que o cliente recebe não muda.
40
+ *
41
+ * - `contrato`: parâmetro que falta, combinação proibida, pedido ambíguo;
42
+ * - `nao_encontrado`: a origem respondeu, e respondeu que não existe.
43
+ */
44
+ export function toolError(message, classe, isRetryable = false, hint) {
45
+ const r = envelopeDeErro(message, isRetryable, hint);
46
+ Object.defineProperty(r, CLASSE_DO_ERRO, { value: classe, enumerable: false });
47
+ return r;
48
+ }
49
+ /**
50
+ * Build an error result from any caught value, preserving UpstreamError's retryable flag.
28
51
  * Avoids the repetitive `e instanceof Error ? e.message : "..."` pattern.
29
- * Note: does NOT call incr("toolErrors") directly — toolError() handles that.
52
+ * `sufixo` is appended to the user-facing message only (the log keeps the raw message).
30
53
  */
31
- export function errorFrom(e, fallbackMessage) {
54
+ export function errorFrom(e, fallbackMessage, sufixo = "") {
32
55
  const message = e instanceof Error ? e.message : fallbackMessage;
33
56
  const retryable = e instanceof Error && "retryable" in e && typeof e.retryable === "boolean"
34
57
  ? e.retryable
35
58
  : false;
36
59
  logger.error("tool_error", { message, retryable });
37
- return toolError(message, retryable);
60
+ // A classe de telemetria vai pelo TIPO do erro, fora do fio (ver
61
+ // CLASSE_DO_ERRO em src/call-shape.ts); sem ela, vale a frase — último
62
+ // recurso, só para exceção que não declara classe.
63
+ return anexarClasse(envelopeDeErro(message + sufixo, retryable), e);
38
64
  }
39
65
  export function toolResult(data) {
40
66
  // Tools advertise an outputSchema (a permissive object schema), so the SDK requires a
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.11.0";
2
+ export const VERSION = "3.12.1";
3
3
  export const USER_AGENT = `senado-br-mcp/${VERSION} (+https://senado.sidneybissoli.com; sbissoli76@gmail.com)`;