senado-br-mcp 3.14.0 → 3.15.2

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.
@@ -182,10 +182,10 @@ const ideiasSchema = {
182
182
  {
183
183
  name: "status",
184
184
  type: "string",
185
- description: "Situação da ideia: \"aberta\" | \"encerrada\" | \"convertida\".",
185
+ description: "Situação da ideia: \"aberta\" | \"encerrada\" | \"convertida\" | \"removida\" (retirada do portal; demais campos = último estado conhecido).",
186
186
  sourceEndpoint: EP_IDEIAS_LISTING,
187
187
  sourceField: "(derivado) parâmetro GET situacao da listagem varrida",
188
- operationalization: "A listagem é varrida por situacao=N; o status é o mapa SITUACAO_STATUS ({5,6,8}→aberta, {7,9}→encerrada, 10→convertida) do valor usado no crawl.",
188
+ operationalization: "A listagem é varrida por situacao=N; o status é o mapa SITUACAO_STATUS ({5,6,8}→aberta, {7,9}→encerrada, 10→convertida) do valor usado no crawl. Ideia que sai das listas vivas tem a página de detalhe lida e recebe o status do texto de situação (mesmo mapa); \"removida\" quando o detalhe responde HTTP 410 (desde 06/10/2026).",
189
189
  derived: true,
190
190
  },
191
191
  {
@@ -26,8 +26,8 @@ export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
26
26
  * frase, "falha de rede ao acessar" e "retornou HTTP 429/400/403" caíam em
27
27
  * `outro`. Sem ela, vale a frase, como sempre.
28
28
  */
29
- function ecidadaniaFetchError(message, retryable, classe) {
30
- return Object.assign(new Error(message), classe ? { retryable, classe } : { retryable });
29
+ function ecidadaniaFetchError(message, retryable, classe, hint) {
30
+ return Object.assign(new Error(message), classe ? { retryable, classe } : { retryable }, hint ? { hint } : {});
31
31
  }
32
32
  const isTransientStatus = (status) => status >= 500 || status === 429;
33
33
  /**
@@ -96,6 +96,14 @@ async function idaEcidadania(path, headers, rotulo) {
96
96
  }
97
97
  default: {
98
98
  const status = e.status ?? 502;
99
+ if (status === 410) {
100
+ // 410 Gone = o portal REMOVEU o recurso (medido 06/10/2026: ideias retiradas respondem 410
101
+ // com "Ideia Legislativa não encontrada!", de forma estável). Não é parâmetro errado nem
102
+ // fonte fora do ar — a mensagem genérica abaixo dizia as duas coisas.
103
+ throw ecidadaniaFetchError(
104
+ // Sem ponto final: `ecidadaniaError` acrescenta ". As demais funcionalidades…".
105
+ `${rotulo}: o recurso ${path} foi removido do portal (HTTP 410). O identificador existiu, mas não está mais publicado; não é erro de parâmetro nem indisponibilidade da fonte`, false, "nao_encontrado", "Não repita a chamada nem corrija o identificador: o portal retirou o recurso. Se for uma ideia legislativa, o último estado conhecido segue no corpus: `senado_ecidadania_listar_ideias` com `status: \"removida\"`.");
106
+ }
99
107
  throw ecidadaniaFetchError(`${rotulo} retornou HTTP ${status} para ${path}`, isTransientStatus(status), status === 404 ? "nao_encontrado" : "fonte");
100
108
  }
101
109
  }
@@ -269,6 +277,43 @@ export function buildIdeiaResumo(fields) {
269
277
  url: fields.url || `${ECIDADANIA_BASE}/visualizacaoideia?id=${fields.id}`,
270
278
  };
271
279
  }
280
+ /**
281
+ * Texto da situação na página de detalhe (`<em>` em section[title="Situação da Ideia"]) → vocabulário
282
+ * de status do corpus (aberta | encerrada | convertida), o MESMO que `SITUACAO_STATUS` atribui por
283
+ * bucket da listagem. Uma função só, usada pela tool ao vivo e pelo crawl de transições — antes
284
+ * cada um tinha o seu mapeamento, e o da tool lia "Não acatada" (bucket 9, encerrada) como aberta.
285
+ *
286
+ * Textos REAIS medidos em 06/10/2026, uma ideia por bucket:
287
+ * s5 "Aberta" · s6 "Na comissão" · s8 "Aguardando envio à CDH" → aberta
288
+ * s7 "Encerrada - Sem apoio suficiente" · s9 "Não acatada" → encerrada
289
+ * s10 "Convertida em Proposição" → convertida
290
+ *
291
+ * Comparação EXATA (sem caixa e sem acento), decisão do dono em 06/10/2026: texto novo do portal vira
292
+ * null e aparece no log, em vez de ser gravado no histórico com um status adivinhado por palavra-chave.
293
+ * Se o log mostrar um texto novo, acrescente-o aqui e no teste dos textos medidos.
294
+ */
295
+ /**
296
+ * Status de corpus de uma ideia que o portal REMOVEU (detalhe responde HTTP 410). Não vem de texto de
297
+ * situação — o portal não publica nada sobre ela —, só da resposta 410. Decisão do dono (06/10/2026):
298
+ * manter o registro e o histórico no banco com este status, em vez de apagar ou deixar "aberta".
299
+ */
300
+ export const STATUS_IDEIA_REMOVIDA = "removida";
301
+ export function statusFromSituacaoIdeia(texto) {
302
+ const t = texto.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
303
+ if (t === "aberta" || t === "na comissao" || t === "aguardando envio a cdh")
304
+ return "aberta";
305
+ if (t === "encerrada - sem apoio suficiente" || t === "nao acatada")
306
+ return "encerrada";
307
+ if (t === "convertida em proposicao")
308
+ return "convertida";
309
+ return null;
310
+ }
311
+ /** Texto cru da situação da ideia na página de detalhe, ou null se a seção não existe. */
312
+ export function extractSituacaoIdeia(html) {
313
+ const m = html.match(/title="Situa[çc][ãa]o da Ideia"[\s\S]*?<em>([^<]+)<\/em>/i);
314
+ // O portal manda o texto em UTF-8 literal ("Não acatada", medido 06/10/2026) — sem entidades.
315
+ return m ? m[1].replace(/\s+/g, " ").trim() : null;
316
+ }
272
317
  /**
273
318
  * Pure parser of an idea detail page (`visualizacaoideia?id=`) for the CORPUS — extracts only the
274
319
  * four detail fields, keeping ONLY the UF of the citizen author (name discarded at source, never
@@ -288,7 +333,11 @@ export function parseIdeiaDetalheCorpus(html) {
288
333
  const plConvertido = plMatch
289
334
  ? `${plMatch[1].toUpperCase().includes("SUGEST") ? "SUG" : plMatch[1].toUpperCase()} ${plMatch[2]}/${plMatch[3]}`
290
335
  : null;
291
- return { dataPublicacao, autorUf, descricao, plConvertido };
336
+ const apoiosMatch = html.match(/class="contabilizacao"[^>]*>([^<]+)</);
337
+ const apoios = apoiosMatch ? parseBrNum(apoiosMatch[1]) : null;
338
+ const situacao = extractSituacaoIdeia(html);
339
+ const status = situacao === null ? null : statusFromSituacaoIdeia(situacao);
340
+ return { dataPublicacao, autorUf, descricao, plConvertido, apoios, status };
292
341
  }
293
342
  /** Fetch + parse the corpus-facing (UF-only) idea detail. */
294
343
  export async function obterIdeiaDetalheCorpus(id) {
@@ -323,16 +372,11 @@ export async function obterIdeiaInternal(id) {
323
372
  // Support count: <span class="contabilizacao">17.978</span>
324
373
  const apoiosMatch = html.match(/class="contabilizacao"[^>]*>([^<]+)</);
325
374
  const apoios = apoiosMatch ? parseBrNum(apoiosMatch[1]) : 0;
326
- // Status: <em> inside section[title="Situação da Ideia"]
327
- const statusMatch = html.match(/title="Situa[çc][ãa]o da Ideia"[\s\S]*?<em>([^<]+)<\/em>/i);
328
- let status = "aberta";
329
- if (statusMatch) {
330
- const st = statusMatch[1].toLowerCase().trim();
331
- if (st.includes("convertid") || st.includes("transformad"))
332
- status = "convertida";
333
- else if (st.includes("encerrad"))
334
- status = "encerrada";
335
- }
375
+ // Status: o mesmo mapeamento do corpus (statusFromSituacaoIdeia). Sem seção ou texto não
376
+ // reconhecido, mantém o "aberta" histórico desta tool — o default antigo, agora só para o
377
+ // desconhecido, não mais para "Não acatada".
378
+ const situacao = extractSituacaoIdeia(html);
379
+ const status = (situacao !== null ? statusFromSituacaoIdeia(situacao) : null) ?? "aberta";
336
380
  // Author: after "Ideia proposta por"
337
381
  const autorMatch = html.match(/Ideia proposta por<\/div>\s*<div[^>]*>\s*<span>([^<]+)<\/span>\s*<span>\s*\(([A-Z]{2})\)<\/span>/);
338
382
  // Date limit
@@ -163,8 +163,8 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
163
163
  }
164
164
  });
165
165
  // G5. senado_ecidadania_listar_ideias
166
- server.tool("senado_ecidadania_listar_ideias", "Lista ideias legislativas propostas por cidadãos no e-Cidadania — **conjunto completo** (corpus persistido em D1, atualizado semanalmente; ~114 mil ideias, incluindo encerradas e convertidas em proposição). Retorna `{ count, ideias }`, cada ideia com `id`, `titulo`, `apoios`, `status` (`aberta`/`encerrada`/`convertida`) e `url` (`autor` e `dataPublicacao` só aparecem no detalhe, vêm `null` aqui). Aceita filtro por `status` e `limite` (padrão 20). Para um ranking das mais apoiadas, ordene por apoios (`ordenarPor: \"apoios\"`, `ordem: \"desc\"`). Para o detalhe completo de uma ideia (texto, autor, se virou projeto de lei) chame `senado_ecidadania_obter_ideia` com o `id`.", {
167
- status: z.enum(["aberta", "encerrada", "convertida", "todas"]).optional().describe("Filtrar por status"),
166
+ server.tool("senado_ecidadania_listar_ideias", "Lista ideias legislativas propostas por cidadãos no e-Cidadania — **conjunto completo** (corpus persistido em D1; ideias em andamento atualizadas diariamente; ~118 mil ideias, incluindo encerradas e convertidas em proposição). Retorna `{ count, ideias }`, cada ideia com `id`, `titulo`, `apoios`, `status` (`aberta`/`encerrada`/`convertida`/`removida` — `removida` = retirada do portal, que não a publica mais; o corpus guarda o último estado conhecido) e `url` (`autor` e `dataPublicacao` só aparecem no detalhe, vêm `null` aqui). Aceita filtro por `status` e `limite` (padrão 20). Para um ranking das mais apoiadas, ordene por apoios (`ordenarPor: \"apoios\"`, `ordem: \"desc\"`). Para o detalhe completo de uma ideia (texto, autor, se virou projeto de lei) chame `senado_ecidadania_obter_ideia` com o `id`.", {
167
+ status: z.enum(["aberta", "encerrada", "convertida", "removida", "todas"]).optional().describe("Filtrar por status (`removida` = retirada do portal)"),
168
168
  ordenarPor: z.enum(["apoios", "data", "comentarios"]).optional().describe("Campo para ordenação (apoios é o disponível no corpus; data/comentarios só no detalhe)"),
169
169
  ordem: z.enum(["asc", "desc"]).optional().describe("Ordem de ordenação"),
170
170
  limite: z.number().int().min(1).max(100).optional().default(20).describe("Número máximo de resultados"),
@@ -56,11 +56,18 @@ export function errorFrom(e, fallbackMessage, sufixo = "") {
56
56
  const retryable = e instanceof Error && "retryable" in e && typeof e.retryable === "boolean"
57
57
  ? e.retryable
58
58
  : false;
59
+ // O `hint` da exceção, quando ela traz um, vence o default — mesmo trato do `retryable`.
60
+ // Sem isto, um erro que sabe o que aconteceu (ex.: e-Cidadania 410 = recurso retirado)
61
+ // saía com a mensagem certa e, AO LADO, o hint genérico "verifique os parâmetros … a
62
+ // fonte pode estar indisponível" — que o contradizia (medido no ar em 06/10/2026).
63
+ const hint = e instanceof Error && "hint" in e && typeof e.hint === "string"
64
+ ? e.hint
65
+ : undefined;
59
66
  logger.error("tool_error", { message, retryable });
60
67
  // A classe de telemetria vai pelo TIPO do erro, fora do fio (ver
61
68
  // CLASSE_DO_ERRO em src/call-shape.ts); sem ela, vale a frase — último
62
69
  // recurso, só para exceção que não declara classe.
63
- return anexarClasse(envelopeDeErro(message + sufixo, retryable), e);
70
+ return anexarClasse(envelopeDeErro(message + sufixo, retryable, hint), e);
64
71
  }
65
72
  export function toolResult(data) {
66
73
  // 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.14.0";
2
+ export const VERSION = "3.15.2";
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.14.0",
3
+ "version": "3.15.2",
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",
@@ -61,10 +61,10 @@
61
61
  "@cloudflare/workers-types": "^5.20260830.1",
62
62
  "@modelcontextprotocol/client": "^2.0.0",
63
63
  "@modelcontextprotocol/sdk": "^1.30.0",
64
- "@sbissoli/mcp-evals": "^0.1.0",
64
+ "@sbissoli/mcp-evals": "^0.2.1",
65
65
  "@sbissoli/mcp-surface": "^0.3.0",
66
66
  "@types/node": "^26.5.1",
67
- "agents": "^0.24.0",
67
+ "agents": "^0.26.0",
68
68
  "exceljs": "^4.4.0",
69
69
  "tsx": "^4.23.13",
70
70
  "typescript": "^7.0.2",