senado-br-mcp 3.7.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![CI](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare/actions/workflows/ci.yml/badge.svg)](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare/actions/workflows/ci.yml)
7
7
  [![MCP Registry](https://img.shields.io/badge/MCP-Registry-blue)](https://registry.modelcontextprotocol.io)
8
8
  [![LobeHub](https://lobehub.com/badge/mcp/sidneybissoli-senado-br-mcp-cloudflare)](https://lobehub.com/mcp/sidneybissoli-senado-br-mcp-cloudflare)
9
- [![smithery badge](https://smithery.ai/badge/sidneybissoli/senado-br-mcp-cloudflare)](https://smithery.ai/servers/sidneybissoli/senado-br-mcp-cloudflare)
9
+ [![LightNow capabilities](https://lightnow.ai/badge/io.github.SidneyBissoli/senado-br-mcp-cloudflare)](https://lightnow.ai/servers/io.github.SidneyBissoli/senado-br-mcp-cloudflare)
10
10
  [![senado-br-mcp-cloudflare MCP server](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare/badges/score.svg)](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare)
11
11
  [![GitHub stars](https://img.shields.io/github/stars/SidneyBissoli/senado-br-mcp-cloudflare?style=flat&logo=github)](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare)
12
12
  [![GitHub Sponsors](https://img.shields.io/github/sponsors/SidneyBissoli?logo=githubsponsors&label=Sponsor&color=db61a2)](https://github.com/sponsors/SidneyBissoli)
@@ -148,7 +148,7 @@ Claude Code auto-discovers it when you work in this repo. To use it elsewhere, c
148
148
  - **Runtime:** Cloudflare Workers (ESM)
149
149
  - **Transport:** Streamable HTTP (MCP spec 2025-03-26) via `createMcpHandler` from `agents/mcp`
150
150
  - **Protocol:** MCP over JSON-RPC — `/mcp` handles the full public server; `/mcp/openai-app-v2` exposes a curated 27-tool profile plus a shared MCP Apps widget for OpenAI app review/submission (`/mcp/openai-app` remains as a legacy alias)
151
- - **SDK:** `@modelcontextprotocol/sdk` 1.26.0+ (per-request McpServer instances)
151
+ - **SDK:** `@modelcontextprotocol/server` 2.x (per-request McpServer instances; the v1 `@modelcontextprotocol/sdk` remains only as a peer of `agents`, dev-time)
152
152
  - **Validation:** Zod schemas for all tool inputs
153
153
  - **Caching:** 2-layer (L0 memory + L1 Cache API) with SHA-256 keying
154
154
  - **e-Cidadania store:** D1 database refreshed by a Cron Trigger (every 2h) — list tools read from D1 with a live-scrape fallback and a staleness flag; detail tools stay live with write-through (see [e-Cidadania](#e-cidadania-d1-backed-cron-refreshed))
package/README.pt-BR.md CHANGED
@@ -149,7 +149,7 @@ O Claude Code a descobre automaticamente ao trabalhar neste repositório. Para u
149
149
  - **Runtime:** Cloudflare Workers (ESM)
150
150
  - **Transporte:** Streamable HTTP (spec MCP 2025-03-26) via `createMcpHandler` de `agents/mcp`
151
151
  - **Protocolo:** MCP sobre JSON-RPC — `/mcp` serve o catalogo publico completo; `/mcp/openai-app-v2` expoe um perfil curado de 27 ferramentas mais um widget MCP Apps compartilhado para revisao/submissao como app OpenAI (`/mcp/openai-app` permanece como alias legado)
152
- - **SDK:** `@modelcontextprotocol/sdk` 1.26.0+ (instâncias de McpServer por requisição)
152
+ - **SDK:** `@modelcontextprotocol/server` 2.x (instâncias de McpServer por requisição; o `@modelcontextprotocol/sdk` v1 fica só como peer do `agents`, em tempo de dev)
153
153
  - **Validação:** schemas Zod para todas as entradas das ferramentas
154
154
  - **Cache:** 2 camadas (L0 memória + L1 Cache API) com chaveamento SHA-256
155
155
  - **Store do e-Cidadania:** banco D1 atualizado por um Cron Trigger (a cada 2h) — as ferramentas de listagem leem do D1 com fallback de scraping ao vivo e flag de desatualização; as de detalhe ficam ao vivo com write-through (veja [e-Cidadania](#e-cidadania-em-d1-atualizado-por-cron))
@@ -1,6 +1,11 @@
1
1
  export const DEFAULT_MCP_ROUTE = "/mcp";
2
2
  export const OPENAI_APP_LEGACY_MCP_ROUTE = "/mcp/openai-app";
3
3
  export const OPENAI_APP_MCP_ROUTE = "/mcp/openai-app-v2";
4
+ // Rota privada do dono: mesma superficie COMPLETA da rota publica, outro
5
+ // endereco, e a telemetria a marca como uso proprio (ver src/instrument.ts).
6
+ // Precisa ser reconhecida aqui, senao handlerRouteForPath cai no fallback e
7
+ // o handler e montado numa rota diferente da que a requisicao pediu.
8
+ export const SELF_MCP_ROUTE = "/mcp/uso-proprio";
4
9
  export const OPENAI_APP_WIDGET_URI = "ui://senado-br-mcp/openai-app-dashboard-v2.html";
5
10
  export const OPENAI_APP_WIDGET_DOMAIN = "https://senado.sidneybissoli.com";
6
11
  const OPENAI_APP_MCP_ROUTES = new Set([OPENAI_APP_LEGACY_MCP_ROUTE, OPENAI_APP_MCP_ROUTE]);
@@ -89,7 +94,9 @@ export function mcpRouteForProfile(profile) {
89
94
  }
90
95
  export function handlerRouteForPath(pathname, profile) {
91
96
  const normalized = normalizeMcpRoute(pathname);
92
- if (OPENAI_APP_MCP_ROUTES.has(normalized) || normalized === DEFAULT_MCP_ROUTE) {
97
+ if (OPENAI_APP_MCP_ROUTES.has(normalized) ||
98
+ normalized === DEFAULT_MCP_ROUTE ||
99
+ normalized === SELF_MCP_ROUTE) {
93
100
  return pathname;
94
101
  }
95
102
  return mcpRouteForProfile(profile);
@@ -0,0 +1,168 @@
1
+ /**
2
+ * A FORMA da chamada, nunca o conteúdo dela.
3
+ *
4
+ * Por que existe. Em 10/09/2026 o painel do portfólio passou a medir erro por
5
+ * chamada e apontou cinco ferramentas quebradas, a pior delas
6
+ * `senado_reuniao_comissao`, com 74 erros em 74 chamadas. Diagnosticar custou
7
+ * vinte minutos de chamadas manuais porque a telemetria dizia QUE falhou e não
8
+ * DIZIA por quê — e a primeira hipótese (o agente escolheu a ferramenta errada,
9
+ * confundindo o singular com o plural `senado_reunioes_comissao`) estava
10
+ * ERRADA: erro de validação de esquema nem chega aqui, o SDK o responde antes
11
+ * do callback. As 74 falhas eram códigos bem-formados que não existem.
12
+ *
13
+ * Por que não gravar os argumentos. O dado que este servidor serve é público; a
14
+ * PERGUNTA não é. `senado_ceaps` com o id de um senador, repetido, é alguém
15
+ * investigando aquele senador, e o registro já guarda país e organização de
16
+ * rede. Parte dos parâmetros é texto livre (`palavraChave`), onde cabe qualquer
17
+ * coisa que a pessoa digitou. E ligar isso mudaria o que o serviço coleta sem
18
+ * mudar o que a página pública diz que ele coleta.
19
+ *
20
+ * O meio-termo: NOMES de parâmetro (que são o esquema publicado, não dado de
21
+ * ninguém) e a CLASSE do erro (vocabulário fechado, derivado da nossa própria
22
+ * mensagem). Isso separa "chamou sem o parâmetro obrigatório" de "chamou certo
23
+ * com um valor que não existe" — que é a bifurcação do conserto.
24
+ */
25
+ /**
26
+ * Classifica pela mensagem de erro, que é NOSSA. A ordem importa: "não
27
+ * encontrado" e "vazio" são mais específicos que "erro da fonte", e um 404
28
+ * casaria com os dois.
29
+ *
30
+ * O vocabulário foi ampliado depois de passar o classificador por TODAS as
31
+ * mensagens de erro do bcb e do senado (a varredura virou a guarda em
32
+ * `call-shape.test.ts`): 13 de 18 aqui e 12 de 18 lá caíam em `outro`, ou
33
+ * seja, a telemetria não responderia nada nestes dois servidores. As famílias
34
+ * que faltavam eram "Informe X ou Y", "só existe para", "desconhecido",
35
+ * "recusada", "não retornou dados" e "não publica" — nenhuma exótica; a versão
36
+ * inicial foi escrita a partir das mensagens do senado que eu já tinha lido.
37
+ */
38
+ export function classifyError(message) {
39
+ const m = message.toLowerCase();
40
+ // A fronteira de palavra vai só no INÍCIO. Os padrões são RADICAIS
41
+ // ("vazi", "obrigatóri", "indisponív") justamente porque a flexão muda o
42
+ // fim: `\bvazi\b` não casa "vazia", e foi assim que "Resposta upstream
43
+ // vazia" caiu em `fonte` na primeira versão — a palavra que casava era
44
+ // "upstream". Ordem também importa: "não encontrado" é mais específico que
45
+ // "erro da fonte", e a mensagem real do senado tem sinal das duas famílias.
46
+ // "desconhecido"/"unknown" só é sinal de contrato quando qualifica um VALOR
47
+ // que o chamador passou ("Índice de preços desconhecido: X. Aceitos: ...",
48
+ // "Unknown dimension(s) for dataflow X"). "Erro desconhecido ao consultar o
49
+ // calendário do IBGE" e "Unknown error" são o oposto — são justamente o caso
50
+ // sem classe — e a primeira versão desta ampliação os classificava como
51
+ // contrato.
52
+ const valorDesconhecido = /\b(desconhecid|unknown)/.test(m) && !/\b(erro desconhecid|unknown error)/.test(m);
53
+ if (valorDesconhecido ||
54
+ /\b(obrigatóri|obrigatori|exige|requer|required|inválid|invalid|validation error|não aceita|nao aceita|no máximo|no maximo|só existe|so existe|recusad)/.test(m) ||
55
+ // Inglês, das mensagens do ilo, do uis e do medical. `empty query` fica
56
+ // AQUI e não em nao_encontrado: consulta vazia é parâmetro que falta. Era o
57
+ // radical `empty` solto que a classificava errado — ele existia para "empty
58
+ // response", que é outra coisa, e agora está escrito por extenso lá.
59
+ // "narrow it/the query" e "maximum N per call" são instruções ao chamador:
60
+ // a resposta não veio porque a chamada precisa mudar, que é contrato.
61
+ /\b(empty query|not part of|too broad|too many|maximum|narrow (it|the|your)|has no codelist|has no enumerated)/.test(m)) {
62
+ return "contrato";
63
+ }
64
+ if (/\b(não encontrad|nao encontrad|não existe|nao existe|does ?n[o']t exist|not.?found|inexistent|vazi|empty response|empty result|returned empty|sem registros|não retornou dados|nao retornou dados|não publica|nao publica|404)/.test(m) ||
65
+ // "Nenhum evento encontrado", "nenhuma reunião", "nenhum registro": a forma
66
+ // varia com o substantivo de cada servidor, então case pelo padrão.
67
+ /\bnenhum[ao]?s?\b[\s\S]{0,40}\b(encontrad|resultado|registro|dado)/.test(m)) {
68
+ return "nao_encontrado";
69
+ }
70
+ // "Informe X ou Y" é a mensagem canônica de parâmetro que falta — dez delas
71
+ // no senado, com e sem qualificador na frente ("Para por=senador, informe
72
+ // 'codigoSenador'"). Fica DEPOIS de "não encontrado" de propósito: é o sinal
73
+ // mais fraco dos dois, e um "informe um código válido" fechando uma mensagem
74
+ // de não encontrado não pode sequestrar a classe. Hoje nenhuma mensagem dos
75
+ // quatro servidores casa com as duas famílias — a ordem existe para a
76
+ // mensagem que alguém escrever amanhã.
77
+ if (/\binforme\b/.test(m))
78
+ return "contrato";
79
+ // `\b5\d\d\b` e não `5\d\d`: sem a fronteira final, qualquer número com um 5
80
+ // seguido de dois dígitos casava — um código de reunião "591234" citado na
81
+ // mensagem virava "erro 5xx". A intenção sempre foi o status HTTP.
82
+ // A fonte falhou ou demorou. As quatro últimas vieram da fábrica de erros do
83
+ // ibge, onde caíam em `outro`: ela nomeia a falha de infraestrutura em vez de
84
+ // usar as palavras genéricas ("Tempo de resposta excedido", não "timeout";
85
+ // "Serviço em manutenção"; "Erro de conexão"). `erro interno do servidor` vai
86
+ // com o complemento, porque "erro interno" sozinho pode ser bug nosso.
87
+ if (/\b(timeout|tempo esgotado|tempo de resposta excedid|excedeu o tempo|indisponív|indisponiv|manutenç|manutenc|erro de conexão|erro de conexao|erro interno do servidor|internal server error|upstream|\b5\d\d\b|payload|too large|grande demais|limite de tamanho)/.test(m)) {
88
+ return "fonte";
89
+ }
90
+ return "outro";
91
+ }
92
+ /**
93
+ * Nomes dos parâmetros que a chamada trouxe, em ordem, separados por vírgula.
94
+ * Só os nomes de PRIMEIRO nível e só quando o argumento é objeto simples — o
95
+ * conteúdo nunca entra. Cortado em 200 caracteres porque blob do Analytics
96
+ * Engine tem teto de tamanho e um nome de parâmetro longo não vale a linha.
97
+ */
98
+ export function paramNames(args) {
99
+ const a = Array.isArray(args) ? args[0] : args;
100
+ if (!a || typeof a !== "object" || Array.isArray(a))
101
+ return "";
102
+ const nomes = Object.keys(a)
103
+ .filter((k) => a[k] !== undefined)
104
+ .sort();
105
+ return nomes.join(",").slice(0, 200);
106
+ }
107
+ /**
108
+ * Texto de erro de um resultado de tool, para classificar. Vazio quando não há.
109
+ *
110
+ * Lê o CAMPO `error` do envelope, não o payload serializado inteiro. O envelope
111
+ * padrão é `{ error, retryable, hint }`, e o `hint` de erro não recuperável
112
+ * termina com "a fonte oficial pode estar indisponível" — texto de formulário,
113
+ * igual em todos. Classificando o payload inteiro, esse "indisponível"
114
+ * arrastava TODO erro não recuperável para a classe `fonte`. Visto na produção
115
+ * do senado em 10/09/2026: a mensagem "Não existe reunião com o código X",
116
+ * que é `nao_encontrado` por definição, foi gravada como `fonte`.
117
+ */
118
+ export function errorText(result) {
119
+ if (!result || typeof result !== "object")
120
+ return "";
121
+ const r = result;
122
+ const estruturado = r.structuredContent?.error;
123
+ if (typeof estruturado === "string")
124
+ return estruturado;
125
+ const t = Array.isArray(r.content) ? r.content[0]?.text : undefined;
126
+ if (typeof t !== "string")
127
+ return "";
128
+ try {
129
+ const j = JSON.parse(t);
130
+ if (typeof j.error === "string")
131
+ return j.error;
132
+ }
133
+ catch {
134
+ // Não é o envelope JSON — vale o texto cru (é o caso de outros servidores).
135
+ }
136
+ return t;
137
+ }
138
+ /**
139
+ * Classifica uma exceção que ESCAPOU do handler, tendo o objeto do erro em mãos
140
+ * e não só a mensagem.
141
+ *
142
+ * Por que existe. Em 22/09/2026 o `ibge_cnae` respondia
143
+ * `Cannot read properties of undefined (reading 'divisao')` a um código de
144
+ * classe válido — um `TypeError` nosso, nascido de um `[]` da fonte que
145
+ * atravessou a camada de rede. Nenhum padrão de `classifyError` casa com essa
146
+ * frase, então ela caía em `outro`, que já era 13 dos 23 erros da ferramenta na
147
+ * semana. O comentário de `outro` avisava desde o início: *se esta classe
148
+ * crescer, é sinal de que falta uma classe*. Faltava esta. O conserto nasceu no
149
+ * `ibge-br-mcp` 5.1.2 e é o mesmo em toda a frota, porque a classe é a mesma.
150
+ *
151
+ * O sinal é o TIPO, não a frase, e é por isso que esta função existe separada
152
+ * de `classifyError`: `TypeError`, `RangeError`, `ReferenceError` e
153
+ * `SyntaxError` são erros de programa. A fonte não os produz — nós os
154
+ * produzimos ao supor a forma do que ela respondeu. Classificá-los por
155
+ * mensagem seria caçar o texto do motor de JS, que muda entre versões de Node.
156
+ *
157
+ * `classifyError` fica INTACTA: ela recebe mensagem e continua sendo o caminho
158
+ * do erro que NÓS escrevemos.
159
+ */
160
+ export function classifyThrown(error) {
161
+ if (error instanceof TypeError ||
162
+ error instanceof RangeError ||
163
+ error instanceof ReferenceError ||
164
+ error instanceof SyntaxError) {
165
+ return "defeito";
166
+ }
167
+ return classifyError(error instanceof Error ? error.message : String(error));
168
+ }
@@ -15,10 +15,12 @@
15
15
  * there is nothing to await and no need for ctx.waitUntil — a try/catch is the
16
16
  * correct and sufficient guard.
17
17
  *
18
- * Privacy: only the tool name, a coarse ok/error status, cache-outcome counts, and
19
- * aggregable request context (country, AS organization, owner's self-use marker) are
20
- * recorded. No user query content, tool parameters, IP, or PII ever reach Analytics
21
- * Engine.
18
+ * Privacy: only the tool name, a coarse ok/error status, cache-outcome counts,
19
+ * aggregable request context (country, AS organization, owner's self-use marker),
20
+ * and — since 2026-09-10 — the SHAPE of the call: the NAMES of the parameters
21
+ * supplied and a closed-vocabulary error class. No user query content, no
22
+ * parameter VALUES, no IP and no PII ever reach Analytics Engine. The reasoning
23
+ * for that line, and why the shape was worth adding, is in src/call-shape.ts.
22
24
  *
23
25
  * Request context (blobs 4–6): hosted AI-platform connectors egress from the
24
26
  * platform's own servers (e.g. Anthropic in the US), so country/AS is the only way
@@ -26,43 +28,75 @@
26
28
  * connector is only identifiable via the secret header his MCP clients send
27
29
  * (SELF_HEADER, value = the SELF_MARKER Wrangler secret).
28
30
  */
31
+ import { classifyError, classifyThrown, errorText, paramNames } from "./call-shape.js";
29
32
  import { incr, incrTool } from "./metrics.js";
30
33
  import { callCache, cacheClass } from "./observability/call-context.js";
31
34
  /** Header the owner's MCP clients send (value = the SELF_MARKER secret). */
32
35
  export const SELF_HEADER = "x-mcp-self";
36
+ /**
37
+ * Rota privada do dono: mesma superficie, mesmo resultado, outro ENDERECO.
38
+ *
39
+ * O marcador por header so funciona em cliente que aceita header custom, e o
40
+ * conector do claude.ai nao aceita — e e por ele que o dono mais usa os
41
+ * proprios servidores. Medido em 28/08/2026: o header pegava UMA chamada por
42
+ * produto por semana; todo o resto do uso proprio saia dos servidores da
43
+ * Anthropic, indistinguivel de terceiro, inflando a adocao.
44
+ *
45
+ * O conector nao manda header, mas aponta para qualquer URL. Entao a
46
+ * separacao vem da ROTA: chamada que chega aqui e uso proprio por construcao.
47
+ *
48
+ * O caminho e adivinhavel de proposito (o dono precisa cola-lo em varios
49
+ * clientes). O risco e um varredor cair aqui e ser contado como dono: sujeira
50
+ * no balde do uso proprio, nao vazamento — a rota serve o mesmo conteudo
51
+ * publico. Detectavel olhando pais/AS das chamadas marcadas.
52
+ */
53
+ export const SELF_ROUTE = "/mcp/uso-proprio";
33
54
  /** Extracts country/AS from request.cf and matches the self-use secret header. */
34
- export function tagRequest(request, selfSecret) {
55
+ export function tagRequest(request, selfSecret, sessao = "") {
35
56
  const cf = request.cf;
36
57
  return {
37
- self: !!selfSecret && request.headers.get(SELF_HEADER) === selfSecret,
58
+ self: (!!selfSecret && request.headers.get(SELF_HEADER) === selfSecret) ||
59
+ new URL(request.url).pathname === SELF_ROUTE,
38
60
  country: typeof cf?.country === "string" ? cf.country : "",
39
61
  asOrg: typeof cf?.asOrganization === "string" ? cf.asOrganization : "",
62
+ sessao,
40
63
  };
41
64
  }
42
65
  export function instrumentTool(name, cb, analytics, tag) {
43
66
  return async (...args) => {
44
67
  incr("toolCalls");
45
68
  let isError = false;
69
+ let classe = "";
46
70
  // Per-call store the cache layer increments per upstream fetch (see call-context.ts).
47
71
  const stats = { fetches: 0, hits: 0 };
48
72
  try {
49
73
  const result = await callCache.run(stats, () => cb(...args));
50
74
  isError =
51
75
  typeof result === "object" && result !== null && result.isError === true;
76
+ if (isError)
77
+ classe = classifyError(errorText(result));
52
78
  return result;
53
79
  }
54
80
  catch (e) {
55
81
  // A thrown error is also a failed tool call — record it, then rethrow so the
56
82
  // SDK still produces the normal error response.
57
83
  isError = true;
84
+ // `classifyThrown` e não `classifyError`: aqui o OBJETO do erro existe,
85
+ // e o tipo dele separa bug nosso (`TypeError` & cia. -> `defeito`) de
86
+ // condição da fonte. Pela mensagem, um `TypeError` caía em `outro`.
87
+ classe = classifyThrown(e);
58
88
  throw e;
59
89
  }
60
90
  finally {
61
- recordToolCall(name, isError, stats, analytics, tag);
91
+ // ATENÇÃO ao que NÃO chega aqui: erro de validação do esquema. O SDK o
92
+ // responde ANTES do callback, então a chamada não é contada nem como
93
+ // chamada nem como erro. Medido em 10/09/2026 pelo /metrics: três
94
+ // chamadas com argumento de forma errada não moveram o contador.
95
+ recordToolCall(name, isError, stats, analytics, tag, classe, paramNames(args));
62
96
  }
63
97
  };
64
98
  }
65
- function recordToolCall(name, isError, stats, analytics, tag) {
99
+ function recordToolCall(name, isError, stats, analytics, tag, errorClass = "", params = "", cliente = "") {
66
100
  incrTool(name, isError);
67
101
  if (!analytics)
68
102
  return;
@@ -74,6 +108,11 @@ function recordToolCall(name, isError, stats, analytics, tag) {
74
108
  // blob3 = cache class of the call (cached | live | partial | none),
75
109
  // blob4 = "self" when the owner's secret header matched, blob5 = country,
76
110
  // blob6 = AS organization (request.cf) — same positions in every portfolio MCP.
111
+ // blob7 = error class (closed vocabulary, "" when the call succeeded),
112
+ // blob8 = NAMES of the parameters supplied, comma-separated — never values.
113
+ // blob9 = session id issued at initialize (random, echoed by the client),
114
+ // blob10 = client software name from initialize (clientInfo.name, normalised;
115
+ // only on the initialize line) — same positions in every portfolio MCP.
77
116
  blobs: [
78
117
  name,
79
118
  isError ? "error" : "ok",
@@ -81,6 +120,10 @@ function recordToolCall(name, isError, stats, analytics, tag) {
81
120
  tag?.self ? "self" : "",
82
121
  tag?.country ?? "",
83
122
  tag?.asOrg ?? "",
123
+ errorClass,
124
+ params,
125
+ tag?.sessao ?? "",
126
+ cliente,
84
127
  ],
85
128
  // double1 = error flag (error rate via avg); double2 = upstream fetches in the call;
86
129
  // double3 = how many were cache hits (fetch-level cache-hit ratio via sum/sum).
@@ -91,3 +134,177 @@ function recordToolCall(name, isError, stats, analytics, tag) {
91
134
  // Swallow: a telemetry failure must never break or slow a tool response.
92
135
  }
93
136
  }
137
+ /**
138
+ * Métodos de PROTOCOLO, gravados na camada HTTP.
139
+ *
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.
169
+ */
170
+ export function protocolNamesFromBody(body, status) {
171
+ const itens = Array.isArray(body) ? body : [body];
172
+ const nomes = [];
173
+ for (const item of itens) {
174
+ if (!item || typeof item !== "object")
175
+ continue;
176
+ const msg = item;
177
+ if (typeof msg.method !== "string" || msg.method === "")
178
+ continue;
179
+ if (msg.method === "tools/call") {
180
+ if (status < 400)
181
+ continue; // instrumentTool já gravou esta
182
+ const params = msg.params;
183
+ nomes.push(typeof params?.name === "string" && params.name !== "" ? params.name : "tools/call");
184
+ }
185
+ else {
186
+ nomes.push(msg.method);
187
+ }
188
+ }
189
+ return nomes;
190
+ }
191
+ /**
192
+ * 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.
195
+ */
196
+ export function recordProtocolMethods(analytics, tag, body, status) {
197
+ if (!analytics || body === undefined)
198
+ return [];
199
+ const cliente = clientNameFromBody(body);
200
+ const nomes = protocolNamesFromBody(body, status);
201
+ const isError = status >= 400;
202
+ for (const name of nomes) {
203
+ try {
204
+ analytics.writeDataPoint({
205
+ indexes: [name],
206
+ blobs: [
207
+ name,
208
+ isError ? "error" : "ok",
209
+ "",
210
+ tag?.self ? "self" : "",
211
+ tag?.country ?? "",
212
+ tag?.asOrg ?? "",
213
+ "",
214
+ "",
215
+ tag?.sessao ?? "",
216
+ name === "initialize" ? cliente : "",
217
+ ],
218
+ doubles: [isError ? 1 : 0, 0, 0],
219
+ });
220
+ }
221
+ catch {
222
+ // Falha de telemetria nunca quebra nem atrasa a resposta.
223
+ }
224
+ }
225
+ return nomes;
226
+ }
227
+ /**
228
+ * SESSÃO E CLIENTE (blobs 9 e 10, desde 2026-09-17).
229
+ *
230
+ * O que faltava para o funil de sessão do painel ser um funil de verdade: a
231
+ * telemetria tinha `initialize` e `tools/call` como contagens soltas, sem
232
+ * nada que ligasse duas linhas à mesma sessão — a razão "chamadas por
233
+ * initialize" mistura quantas sessões usaram com quanto cada uma usou, e
234
+ * 2,5 tanto pode ser todo mundo chamando 2 ou 3 vezes quanto 10% chamando 25.
235
+ *
236
+ * O elo é o do próprio protocolo: o servidor devolve `Mcp-Session-Id` na
237
+ * resposta ao `initialize` e o cliente é obrigado a repeti-lo em toda
238
+ * requisição seguinte. O handler continua STATELESS — o transporte do SDK v2
239
+ * não emite id neste modo e, conferido em 16/09/2026 nos sete servidores e
240
+ * no código (`validateSession` retorna sem checar quando não há gerador),
241
+ * também não lê nem valida o que o cliente manda. Então o Worker sorteia o
242
+ * id no `initialize`, devolve no cabeçalho e, nas demais requisições, só lê
243
+ * o que o cliente devolveu e grava. Nada é armazenado; o id é aleatório
244
+ * (UUID v4 do `crypto`) e não identifica pessoa, máquina nem rede — só liga
245
+ * as linhas de um aperto de mão. Colisão de UUID v4 é da ordem de n²/2¹²⁹:
246
+ * não é risco prático.
247
+ *
248
+ * O cliente é o software que se apresentou no `initialize`
249
+ * (`params.clientInfo.name`): claude.ai, Claude Code, Inspector, um scanner
250
+ * com nome próprio. É autodeclarado e descreve o programa, não a pessoa. Vai
251
+ * normalizado (minúsculas, sem versão, vocabulário de caracteres fechado,
252
+ * tamanho limitado) para não virar texto livre na telemetria, e SÓ na linha
253
+ * do `initialize` — as chamadas seguintes chegam ao cliente pela sessão.
254
+ *
255
+ * Id que o cliente manda e não parece um id (fora do vocabulário, comprido
256
+ * demais) é tratado como ausente: continua sem estado, e a telemetria não
257
+ * carrega o que não sabe ler.
258
+ */
259
+ export const SESSION_HEADER = "mcp-session-id";
260
+ const SESSION_ID_OK = /^[A-Za-z0-9._~-]{1,64}$/;
261
+ /** O corpo (mensagem ou lote JSON-RPC) contém um `initialize`? */
262
+ export function isInitialize(body) {
263
+ const itens = Array.isArray(body) ? body : [body];
264
+ return itens.some((m) => !!m && typeof m === "object" && m.method === "initialize");
265
+ }
266
+ /**
267
+ * A sessão desta requisição: sorteada no `initialize` (`nova`), lida do
268
+ * cabeçalho nas demais; "" quando não há sessão legível.
269
+ */
270
+ export function sessionFromRequest(request, body) {
271
+ if (isInitialize(body))
272
+ return { id: crypto.randomUUID(), nova: true };
273
+ const enviada = request.headers.get(SESSION_HEADER) ?? "";
274
+ return { id: SESSION_ID_OK.test(enviada) ? enviada : "", nova: false };
275
+ }
276
+ /** Devolve a resposta com o `Mcp-Session-Id` quando a sessão nasceu aqui. */
277
+ export function withSessionHeader(response, sessao) {
278
+ if (!sessao.nova || !sessao.id)
279
+ return response;
280
+ const headers = new Headers(response.headers);
281
+ headers.set(SESSION_HEADER, sessao.id);
282
+ return new Response(response.body, {
283
+ status: response.status,
284
+ statusText: response.statusText,
285
+ headers,
286
+ });
287
+ }
288
+ /** Nome do cliente normalizado: minúsculas, caracteres fechados, até 40. */
289
+ export function normalizeClientName(x) {
290
+ if (typeof x !== "string")
291
+ return "";
292
+ return x
293
+ .toLowerCase()
294
+ .replace(/[^a-z0-9._+/ -]+/g, " ")
295
+ .replace(/\s+/g, " ")
296
+ .trim()
297
+ .slice(0, 40);
298
+ }
299
+ /** `clientInfo.name` do `initialize` (o primeiro, num lote); "" fora dele. */
300
+ export function clientNameFromBody(body) {
301
+ const itens = Array.isArray(body) ? body : [body];
302
+ for (const m of itens) {
303
+ if (!m || typeof m !== "object")
304
+ continue;
305
+ const msg = m;
306
+ if (msg.method === "initialize")
307
+ return normalizeClientName(msg.params?.clientInfo?.name);
308
+ }
309
+ return "";
310
+ }
package/dist/server.js CHANGED
@@ -33,6 +33,32 @@ import { instructionsForProfile, isToolEnabledForProfile, minimizeToolResultForP
33
33
  import { registerOpenAiAppWidget } from "./openai-app-widget.js";
34
34
  import { titleForTool } from "./tool-titles.js";
35
35
  import { announceServedVersions } from "./discover.js";
36
+ /**
37
+ * O esquema de entrada RECUSA parâmetro que não existe.
38
+ *
39
+ * Todas as tools do senado passam por um funil só (`host.tool` abaixo), que
40
+ * recebia a shape crua e a entregava ao SDK como objeto ABERTO. Aberto, o zod
41
+ * descarta a chave desconhecida em silêncio, o parâmetro que o chamador queria
42
+ * usar fica com o default e a ferramenta responde OUTRA pergunta com cara de
43
+ * resposta. Medido no irmão ibge-br-mcp em 11/09/2026: `periodo` no singular,
44
+ * que o esquema não tem, devolveu a população de 2026 para uma pergunta sobre
45
+ * 2023, com nenhum aviso. Resposta errada é pior que erro — erro o modelo
46
+ * corrige na chamada seguinte, resposta errada vira número em relatório. Aqui
47
+ * o campo minado é grande: 67 ferramentas, muitas com pares quase homônimos
48
+ * (`codigoSenador`/`codigoParlamentar`, `sigla`/`siglaComissao`).
49
+ *
50
+ * `.strict()` publica `additionalProperties: false` e faz o SDK responder
51
+ * `Unrecognized key: "<nome>"`, que NOMEIA a chave.
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.
56
+ */
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();
61
+ }
36
62
  export function createServer(env, ctx, options = {}) {
37
63
  const toolProfile = options.toolProfile ?? "full";
38
64
  const server = new McpServer({
@@ -76,7 +102,7 @@ export function createServer(env, ctx, options = {}) {
76
102
  return registerTool(name, {
77
103
  title: titleForTool(name),
78
104
  description,
79
- inputSchema: shape,
105
+ inputSchema: esquemaDeEntrada(name, shape),
80
106
  outputSchema: outputSchema,
81
107
  annotations: {
82
108
  title: titleForTool(name),
@@ -18,10 +18,27 @@ let inFlight = 0;
18
18
  export class UpstreamError extends Error {
19
19
  status;
20
20
  retryable;
21
- constructor(message, status, retryable) {
21
+ transport;
22
+ constructor(message, status, retryable,
23
+ /**
24
+ * True only when the upstream never produced a response: DNS, TCP, TLS,
25
+ * an aborted request, or the time budget running out. A response that
26
+ * arrived and was rejected — an HTTP status, a body over the size guard,
27
+ * an empty body, a body that is not JSON — is NOT transport, even when
28
+ * its status is 502.
29
+ *
30
+ * The distinction exists because "the Senado did not answer" and "the
31
+ * Senado answered with a different shape" are opposite facts for the
32
+ * nightly contract tier: the first means drift could not be measured
33
+ * tonight, the second is the drift it exists to catch. Status alone
34
+ * cannot carry it — 502 is used for both a real Bad Gateway and a body
35
+ * that failed to parse.
36
+ */
37
+ transport = false) {
22
38
  super(message);
23
39
  this.status = status;
24
40
  this.retryable = retryable;
41
+ this.transport = transport;
25
42
  this.name = "UpstreamError";
26
43
  }
27
44
  }
@@ -87,7 +104,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
87
104
  const elapsed = Date.now() - startTime;
88
105
  const remaining = UPSTREAM_TIMEOUT_MS - elapsed;
89
106
  if (remaining <= 0) {
90
- lastError = lastError || new UpstreamError(`[${path}] Timeout: orçamento de ${UPSTREAM_TIMEOUT_MS}ms esgotado antes da tentativa ${attempt + 1}`, 504, true);
107
+ lastError = lastError || new UpstreamError(`[${path}] Timeout: orçamento de ${UPSTREAM_TIMEOUT_MS}ms esgotado antes da tentativa ${attempt + 1}`, 504, true, true);
91
108
  break;
92
109
  }
93
110
  const controller = new AbortController();
@@ -159,7 +176,8 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
159
176
  const isAbort = err.name === "AbortError";
160
177
  lastError = new UpstreamError(isAbort
161
178
  ? `[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`
162
- : `[${path}] Erro de rede: ${err.message || "desconhecido"}`, isAbort ? 504 : 502, true);
179
+ : `[${path}] Erro de rede: ${err.message || "desconhecido"}`, isAbort ? 504 : 502, true, // Network errors are always retryable
180
+ true);
163
181
  if (isAbort)
164
182
  break; // AbortError means time budget is spent
165
183
  if (attempt < MAX_RETRIES) {
@@ -16,6 +16,7 @@ import { cachedFetch, cachedFetchWithMeta } from "../cache/manager.js";
16
16
  import { upstreamFetch } from "../throttle/upstream.js";
17
17
  import { toolError, errorFrom, ensureArray, safeInt, toBool, normalizeText } from "../utils/validation.js";
18
18
  import { digArrayRoot } from "../utils/upstream-parse.js";
19
+ import { escolherReuniao, ehReuniaoInexistente, mensagemCodigoInexistente } from "./resolver-reuniao.js";
19
20
  import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
20
21
  import { CACHE_SEMI_STATIC, CACHE_DYNAMIC, CACHE_ON_DEMAND, UPSTREAM_TIMEOUT_MS } from "../types.js";
21
22
  import { USER_AGENT } from "../version.js";
@@ -306,16 +307,57 @@ export function registerComissoesTools(server, baseUrl) {
306
307
  }
307
308
  });
308
309
  // E6. senado_reuniao_comissao
309
- server.tool("senado_reuniao_comissao", "Detalha uma reunião de comissão pelo `codigoReuniao`. Retorna um objeto com `codigo`, `titulo`, `comissao`, `data`, `hora`, `local`, `situacao`, `realizada`, `secreta`, `tipoPresenca` (presencial/semipresencial), `presidente`, links `urlPauta`/`urlResultado`/`urlAta` e `partes` (cada parte com `evento` e `itens` apreciados: `identificacao`, `ementa`, `autoria`, `relatoria`, `resultado`, `codigoMateria`). A API NÃO publica lista de presença da reunião (só presidente e eventuais convidados): reconstrua a presença pelos votos nominais (`senado_votacao_comissao`), por quem falou na transcrição (`senado_notas_taquigraficas` com `tipo=reuniao`) ou pela ata oficial (`urlAta`, quando publicada). Obtenha o `codigoReuniao` em `senado_agenda_comissoes` ou `senado_reunioes_comissao`.", {
310
- codigoReuniao: z.number().int().positive().describe("Código da reunião (campo 'codigo' na agenda de comissões)"),
310
+ server.tool("senado_reuniao_comissao", "Detalha uma reunião de comissão. Aceita DOIS caminhos: o `codigoReuniao` direto, ou `sigla` da comissão mais `data` (YYYYMMDD) — neste segundo caso a ferramenta resolve o código sozinha, e devolve as candidatas quando o dia tem mais de uma reunião. Retorna um objeto com `codigo`, `titulo`, `comissao`, `data`, `hora`, `local`, `situacao`, `realizada`, `secreta`, `tipoPresenca` (presencial/semipresencial), `presidente`, links `urlPauta`/`urlResultado`/`urlAta` e `partes` (cada parte com `evento` e `itens` apreciados: `identificacao`, `ementa`, `autoria`, `relatoria`, `resultado`, `codigoMateria`). A API NÃO publica lista de presença da reunião (só presidente e eventuais convidados): reconstrua a presença pelos votos nominais (`senado_votacao_comissao`), por quem falou na transcrição (`senado_notas_taquigraficas` com `tipo=reuniao`) ou pela ata oficial (`urlAta`, quando publicada). Sem código à mão, prefira `sigla` + `data`; o `codigoReuniao` também sai de `senado_agenda_comissoes` ou `senado_reunioes_comissao`.", {
311
+ codigoReuniao: z
312
+ .number()
313
+ .int()
314
+ .positive()
315
+ .optional()
316
+ .describe("Código da reunião (campo 'codigo' na agenda). Sem ele, informe sigla e data."),
317
+ sigla: z.string().min(2).optional().describe("Sigla da comissão (ex.: CAE) — alternativa ao código"),
318
+ data: z
319
+ .string()
320
+ .regex(/^\d{8}$/)
321
+ .optional()
322
+ .describe("Data da reunião (YYYYMMDD) — usada com `sigla`; sem ela, os últimos 14 dias"),
311
323
  }, async (params) => {
312
324
  try {
313
- const reuniaoPath = `/comissao/reuniao/${params.codigoReuniao}`;
314
- const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_reuniao_comissao", { codigo: params.codigoReuniao }, CACHE_ON_DEMAND, () => upstreamFetch(reuniaoPath, {}, baseUrl));
325
+ // Caminho por sigla: resolve o código internamente. Existe porque a
326
+ // ferramenta falhou em 74 de 74 chamadas com o código como única
327
+ // entrada — quem pergunta tem uma sigla e uma data, não um inteiro
328
+ // interno. Ver src/tools/resolver-reuniao.ts.
329
+ let codigo = params.codigoReuniao;
330
+ if (codigo === undefined) {
331
+ const sigla = (params.sigla || "").toUpperCase();
332
+ if (!sigla) {
333
+ return toolError("Informe `codigoReuniao`, ou `sigla` da comissão (com `data`, opcional). " +
334
+ "Sem nenhum dos dois não há o que detalhar.", false);
335
+ }
336
+ const hoje = new Date();
337
+ const inicio = new Date(hoje);
338
+ inicio.setDate(inicio.getDate() - 14);
339
+ const di = params.data || formatDateYMD(inicio);
340
+ const df = params.data || formatDateYMD(hoje);
341
+ const agenda = await cachedFetchWithMeta("senado_reuniao_comissao_resolve", { sigla, di, df }, CACHE_DYNAMIC, () => upstreamFetch(`/comissao/agenda/${di}/${df}`, {}, baseUrl));
342
+ const candidatas = ensureArray(agenda.value?.AgendaReuniao?.reunioes?.reuniao)
343
+ .filter((re) => (re.colegiadoCriador?.sigla || "").toUpperCase() === sigla)
344
+ .map((re) => ({
345
+ codigo: parseInt(re.codigo || "0"),
346
+ descricao: re.descricao || re.titulo || "",
347
+ data: re.dataInicio ? String(re.dataInicio).split("T")[0] : "",
348
+ hora: re.dataInicio ? String(re.dataInicio).split("T")[1]?.slice(0, 5) || null : null,
349
+ }));
350
+ const escolha = escolherReuniao(candidatas, sigla, params.data ? di : `${di}–${df}`);
351
+ if (escolha.tipo !== "codigo")
352
+ return toolError(escolha.mensagem, false);
353
+ codigo = escolha.codigo;
354
+ }
355
+ const reuniaoPath = `/comissao/reuniao/${codigo}`;
356
+ const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_reuniao_comissao", { codigo }, CACHE_ON_DEMAND, () => upstreamFetch(reuniaoPath, {}, baseUrl));
315
357
  const re = response?.DetalheReuniao?.reuniao ?? response?.reuniao ?? response;
316
358
  const com = re.colegiadoCriador || {};
317
359
  const prov = provenanceFor("SENADO_LEGIS", baseUrl, reuniaoPath, {
318
- dataset_id: `codigoReuniao=${params.codigoReuniao}`,
360
+ dataset_id: `codigoReuniao=${codigo}`,
319
361
  reference_period: re.dataInicio ? String(re.dataInicio).split("T")[0] : undefined,
320
362
  retrieved_at: fetchedAt,
321
363
  });
@@ -359,6 +401,13 @@ export function registerComissoesTools(server, baseUrl) {
359
401
  }, prov);
360
402
  }
361
403
  catch (e) {
404
+ // Corpo vazio neste endpoint é "não existe", não erro transitório. O
405
+ // upstreamFetch marca vazio como 502 retryable, o que vale para a
406
+ // maioria dos endpoints e mandava o agente repetir uma chamada que
407
+ // nunca ia funcionar.
408
+ if (ehReuniaoInexistente(e)) {
409
+ return toolError(mensagemCodigoInexistente(params.codigoReuniao ?? 0), false);
410
+ }
362
411
  return errorFrom(e, "Reunião de comissão não encontrada");
363
412
  }
364
413
  });
@@ -38,6 +38,7 @@ import { ensureArray } from "../utils/validation.js";
38
38
  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
+ import { RootNotFoundError } from "../utils/upstream-parse.js";
41
42
  import { fetchComissaoColegiado, parseComissaoItem, parseComissaoResumo } from "./comissoes.js";
42
43
  import { UFS } from "./referencia.js";
43
44
  export { DEEP_RESEARCH_TOOLS };
@@ -188,7 +189,22 @@ function handlers(baseUrl) {
188
189
  const codigo = Number.parseInt(id.slice(PREFIXO_SENADOR.length), 10);
189
190
  if (!Number.isInteger(codigo) || codigo <= 0)
190
191
  return null;
191
- const { path, fetchedAt, detalhe } = await fetchSenadorDetalhe(codigo, baseUrl);
192
+ // Desde 24/09/2026 `fetchSenadorDetalhe` LANÇA em código inexistente, em
193
+ // vez de devolver um registro montado de defaults. Aqui a ausência tem
194
+ // forma própria — `null`, que a fábrica do mcp-search traduz no
195
+ // "documento não encontrado" do contrato —, então a exceção vira null.
196
+ // O `!detalhe.codigo` continua como cinto: outro caminho de ausência que
197
+ // apareça não pode virar documento vazio.
198
+ let detalheSenador;
199
+ try {
200
+ detalheSenador = await fetchSenadorDetalhe(codigo, baseUrl);
201
+ }
202
+ catch (e) {
203
+ if (e instanceof RootNotFoundError)
204
+ return null;
205
+ throw e;
206
+ }
207
+ const { path, fetchedAt, detalhe } = detalheSenador;
192
208
  if (!detalhe.codigo)
193
209
  return null;
194
210
  const prov = provenanceFor("SENADO_LEGIS", baseUrl, path, {
@@ -133,6 +133,21 @@ async function resolveProcesso(codigoMateria, baseUrl) {
133
133
  }
134
134
  return { item, fetchedAt };
135
135
  }
136
+ /**
137
+ * Zero resultado por palavra-chave não é prova de que o tema não existe: a
138
+ * busca é REMOTA e casa contra as palavras-chave que o próprio Senado indexa,
139
+ * com tesauro dele. Medido na produção em 16/09/2026: "maconha" e "cannabis"
140
+ * acham as mesmas 6 matérias (o tesauro cobre), mas "remédio" acha 0 contra 16
141
+ * de "medicamento" e "carro" acha 5 contra 151 de "veículo". Como o casamento
142
+ * é da fonte, o conserto local é dizer o que fazer em seguida — não uma tabela
143
+ * de tradução, que aqui só repetiria o tesauro do Senado.
144
+ */
145
+ export function dicaBuscaVazia(palavraChave) {
146
+ return (`Nenhuma matéria indexada com a palavra-chave "${palavraChave}". A busca casa contra as palavras-chave ` +
147
+ "que o Senado atribui a cada processo, no vocabulário dele: tente o termo técnico ou mais geral " +
148
+ "(medicamento, não remédio; veículo, não carro), o singular, um sinônimo, " +
149
+ "ou combine com `ano`/`sigla` em vez da palavra-chave.");
150
+ }
136
151
  export function registerMateriasTools(server, baseUrl) {
137
152
  // B1. senado_buscar_materias
138
153
  server.tool("senado_buscar_materias", "Busca matérias legislativas por tipo (PEC, PL, PLP, MPV), número, ano, palavras-chave, autor, período de apresentação ou situação de tramitação; informe ao menos um critério. Para pedidos como 'matérias recentes sobre X', use `palavraChave`, `ano` ou `dataInicioApresentacao`/`dataFimApresentacao`, `ordenarPor: 'dataApresentacao'`, `ordem: 'desc'` e `limite` baixo (ex: 10); não é necessário chamar detalhes para listar resultados. Retorna `{ count, total, materias[] }`, cada item com `codigo` (codigoMateria), `sigla`, `numero`, `ano`, `ementa`, `autor`, `situacao`, `dataApresentacao`, `url` e `tramitando`. Use `codigo` em `senado_obter_materia` apenas quando o usuário pedir detalhe/tramitação/textos. `limite` padrão 100 (máx. 500); ao truncar inclui `aviso`. Ex.: `{ palavraChave: 'inteligência artificial', ano: 2025, limite: 10 }` ou `{ sigla: 'PEC', numero: 45, ano: 2019 }` (datas em YYYYMMDD ou ISO).", {
@@ -186,6 +201,8 @@ export function registerMateriasTools(server, baseUrl) {
186
201
  count: materias.length,
187
202
  total: todos.length,
188
203
  ...(todos.length > limite ? { aviso: `Exibindo ${limite} de ${todos.length} resultados. Refine a busca ou aumente o limite.` } : {}),
204
+ // Zero calado é beco sem saída (ver dicaBuscaVazia).
205
+ ...(todos.length === 0 && params.palavraChave ? { dica: dicaBuscaVazia(params.palavraChave) } : {}),
189
206
  materias,
190
207
  }, prov);
191
208
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Resolve o `codigoReuniao` a partir do que uma PESSOA tem na mão.
3
+ *
4
+ * Por quê. Em 10/09/2026 o painel apontou `senado_reuniao_comissao` com **74
5
+ * erros em 74 chamadas** — nunca funcionou para ninguém. A telemetria de forma
6
+ * (blob7/blob8) mostrou o motivo: `classe=nao_encontrado`,
7
+ * `parametros=codigoReuniao`. Ou seja, o agente chama com o parâmetro CERTO e
8
+ * um valor que não existe. Não é erro de seleção de ferramenta, que era a
9
+ * primeira hipótese — erro de validação de esquema nem chega à instrumentação,
10
+ * o SDK o responde antes do callback.
11
+ *
12
+ * A causa é de contrato: o único jeito de entrar na ferramenta era um inteiro
13
+ * interno que ninguém tem sem antes chamar OUTRA ferramenta. Quem pergunta
14
+ * "o que a CAE decidiu na reunião do dia 1º?" tem uma sigla e uma data.
15
+ *
16
+ * Este módulo não substitui `senado_reunioes_comissao`: essa continua sendo a
17
+ * ferramenta de LISTAR, e a pergunta "quais reuniões houve" é legítima por si.
18
+ * Aqui a listagem é só o caminho interno para achar UM código.
19
+ */
20
+ /**
21
+ * Escolhe UMA reunião da lista. Devolve erro com as candidatas quando há mais
22
+ * de uma, em vez de devolver a lista no lugar do detalhe: a ferramenta de
23
+ * detalhe tem uma forma de resposta só, e trocá-la pela forma de lista quando
24
+ * a busca é ambígua é o tipo de resposta polimórfica que o modelo consome mal.
25
+ */
26
+ export function escolherReuniao(reunioes, sigla, periodo) {
27
+ if (reunioes.length === 0) {
28
+ return {
29
+ tipo: "nenhuma",
30
+ mensagem: `Nenhuma reunião da comissão ${sigla} em ${periodo}. Confira a sigla com ` +
31
+ `senado_listar_comissoes ou amplie o período com senado_reunioes_comissao.`,
32
+ };
33
+ }
34
+ if (reunioes.length === 1)
35
+ return { tipo: "codigo", codigo: reunioes[0].codigo };
36
+ const lista = reunioes
37
+ .slice(0, 10)
38
+ .map((r) => `${r.codigo} (${r.data}${r.hora ? " " + r.hora : ""}, ${r.descricao})`)
39
+ .join("; ");
40
+ return {
41
+ tipo: "ambigua",
42
+ mensagem: `${reunioes.length} reuniões da comissão ${sigla} em ${periodo}. Repita informando ` +
43
+ `codigoReuniao com uma destas: ${lista}${reunioes.length > 10 ? "; …" : ""}`,
44
+ };
45
+ }
46
+ /**
47
+ * A resposta vazia do upstream em `/comissao/reuniao/{codigo}` NÃO é erro
48
+ * transitório: é "não existe reunião com esse código". O `upstreamFetch` marca
49
+ * corpo vazio como 502 retryable, o que é certo para a maioria dos endpoints e
50
+ * errado para este — e mandava o agente repetir uma chamada que nunca ia
51
+ * funcionar, gastando a vez dele. Esta função reconhece o caso.
52
+ */
53
+ export function ehReuniaoInexistente(e) {
54
+ const m = e instanceof Error ? e.message : String(e);
55
+ return /\/comissao\/reuniao\/\d+/.test(m) && /vazia|não é JSON|nao e JSON/i.test(m);
56
+ }
57
+ /** A mensagem que o agente recebe quando o código não existe. */
58
+ export function mensagemCodigoInexistente(codigo) {
59
+ return (`Não existe reunião com o código ${codigo}. Esse código é interno e não é ` +
60
+ `dedutível: pegue-o em senado_reunioes_comissao (por sigla e período) ou em ` +
61
+ `senado_agenda_comissoes (por data) — ou chame esta mesma ferramenta com ` +
62
+ `sigla e data, que ela resolve o código sozinha. Repetir a chamada com o ` +
63
+ `mesmo código não vai funcionar.`);
64
+ }
@@ -10,8 +10,8 @@ import { z } from "zod";
10
10
  import { cachedFetchWithMeta } from "../cache/manager.js";
11
11
  import { upstreamFetch } from "../throttle/upstream.js";
12
12
  import { errorFrom, ensureArray, normalizeText, safeInt } from "../utils/validation.js";
13
- import { digArrayRoot } from "../utils/upstream-parse.js";
14
- import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
13
+ import { digArrayRoot, digObjectRoot } from "../utils/upstream-parse.js";
14
+ import { provenanceFor, resultWithProvenance, toBrasiliaIso } from "../utils/provenance.js";
15
15
  import { CACHE_SEMI_STATIC, CACHE_DYNAMIC, CACHE_ON_DEMAND } from "../types.js";
16
16
  export function parseSenadorResumo(parlamentar) {
17
17
  const id = parlamentar.IdentificacaoParlamentar || parlamentar;
@@ -42,6 +42,37 @@ export function matchesPartido(sigla, filtro) {
42
42
  const shorter = Math.min(sp.length, p.length);
43
43
  return shorter >= 4 && (sp.startsWith(p) || p.startsWith(sp));
44
44
  }
45
+ /**
46
+ * `emExercicio` derivado do DADO, nunca afirmado. Regra: está em exercício quem
47
+ * tem um `Exercicio` já iniciado e ainda não encerrado — suplente que assumiu a
48
+ * vaga tem exercício aberto (`DataFim` ausente); titular licenciado tem todos
49
+ * fechados.
50
+ *
51
+ * MEDIDA em 24/09/2026 contra as duas listas oficiais (`/senador/lista/atual`,
52
+ * 81 senadores, e `/senador/afastados`, 42): 123 de 123 acertos, sem exceção.
53
+ *
54
+ * Devolve `null` — não `false` — quando não há exercício nenhum para ler
55
+ * (sub-endpoint `/mandatos` indisponível, ou mandato sem `Exercicios`): campo
56
+ * que não se sabe não pode sair afirmado, e `false` afirmaria "não está em
57
+ * exercício". As datas do upstream são ISO `AAAA-MM-DD`, então a comparação
58
+ * lexicográfica é a cronológica; `hoje` vem do chamador (fuso de Brasília)
59
+ * para a função continuar pura e testável.
60
+ */
61
+ export function derivarEmExercicio(mandatos, hoje) {
62
+ let algumExercicio = false;
63
+ for (const m of mandatos) {
64
+ for (const e of ensureArray(m?.Exercicios?.Exercicio)) {
65
+ const inicio = e?.DataInicio;
66
+ if (!inicio)
67
+ continue;
68
+ algumExercicio = true;
69
+ const fim = e?.DataFim;
70
+ if (inicio <= hoje && (!fim || fim >= hoje))
71
+ return true;
72
+ }
73
+ }
74
+ return algumExercicio ? false : null;
75
+ }
45
76
  export function parseSenadorDetalhe(dados) {
46
77
  const p = dados.Parlamentar || dados;
47
78
  const id = p.IdentificacaoParlamentar || {};
@@ -59,7 +90,15 @@ export function parseSenadorDetalhe(dados) {
59
90
  uf: id.UfParlamentar || "",
60
91
  foto: id.UrlFotoParlamentar || null,
61
92
  email: id.EmailParlamentar || null,
62
- emExercicio: true,
93
+ // O detalhe `/senador/{codigo}` NÃO carrega exercício nenhum — medido em
94
+ // 24/09/2026: traz só IdentificacaoParlamentar, DadosBasicosParlamentar e
95
+ // OutrasInformacoes. Até 24/09 este campo saía `true` fixo, e o servidor se
96
+ // contradizia sobre gente real (o código 6358 saía `emExercicio: true` aqui
97
+ // e `false` em `senado_senadores_afastados`). Quem sabe a verdade é o
98
+ // sub-endpoint `/mandatos`: `fetchSenadorDetalhe` sobrescreve este valor com
99
+ // `derivarEmExercicio`. `null` é o default honesto para quem chamar o parser
100
+ // sozinho, sem os mandatos em mãos.
101
+ emExercicio: null,
63
102
  mandatos: ensureArray(p.Mandatos?.Mandato).map((m) => ({
64
103
  legislatura: parseInt(m.PrimeiraLegislaturaDoMandato?.NumeroLegislatura || "0"),
65
104
  uf: m.UfParlamentar || "",
@@ -164,20 +203,41 @@ export function matchesNome(s, nome) {
164
203
  export async function fetchSenadorDetalhe(codigoSenador, baseUrl) {
165
204
  const path = `/senador/${codigoSenador}`;
166
205
  const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_obter_senador", { codigo: codigoSenador }, CACHE_ON_DEMAND, () => upstreamFetch(path, {}, baseUrl));
167
- const dados = response.DetalheParlamentar || response;
206
+ // Ausência com HTTP 200: para um código que não existe o upstream devolve
207
+ // `{"DetalheParlamentar":{...Metadados}}` — envelope e metadados presentes,
208
+ // SEM o nó `Parlamentar` (medido em 24/09/2026: 200, 304 bytes). O
209
+ // `?? response` que havia aqui deixava o parser montar um registro inteiro a
210
+ // partir de nada (`codigo: 0`, `nome: ""`), e quem perguntava recebia uma
211
+ // afirmação falsa. `digObjectRoot` é a defesa na BORDA: num detalhe por
212
+ // identificador único, nó ausente é ausência, nunca "vazio legítimo".
213
+ const dados = digObjectRoot(response, [
214
+ ["DetalheParlamentar", "Parlamentar"],
215
+ ["Parlamentar"],
216
+ ], "senado_obter_senador", { notFoundMessage: `Senador com código ${codigoSenador} não encontrado.` });
168
217
  const detalhe = parseSenadorDetalhe(dados);
169
218
  // The /senador/{codigo} detail carries no Mandatos; fetch the sub-endpoint.
170
219
  // Degrade to an empty list if it fails, keeping the biographical data.
171
- let mandatos = [];
220
+ // É também o único endpoint que carrega os `Exercicios`, de onde sai o
221
+ // `emExercicio` — lista vazia (falha do sub-endpoint) vira `null`, não `false`.
222
+ let mandatosBrutos = [];
172
223
  try {
173
224
  const mandatosPath = `/senador/${codigoSenador}/mandatos`;
174
225
  const { value: mResp } = await cachedFetchWithMeta("senado_obter_senador_mandatos", { codigo: codigoSenador }, CACHE_ON_DEMAND, () => upstreamFetch(mandatosPath, {}, baseUrl));
175
- mandatos = digArrayRoot(mResp, [["MandatoParlamentar", "Parlamentar", "Mandatos", "Mandato"]], "senado_obter_senador:mandatos").map(parseMandato);
226
+ mandatosBrutos = digArrayRoot(mResp, [["MandatoParlamentar", "Parlamentar", "Mandatos", "Mandato"]], "senado_obter_senador:mandatos");
176
227
  }
177
228
  catch {
178
229
  // sub-endpoint unavailable — keep the (empty) mandatos from the detail
179
230
  }
180
- return { path, fetchedAt, detalhe: { ...detalhe, mandatos } };
231
+ const hoje = toBrasiliaIso(new Date()).slice(0, 10);
232
+ return {
233
+ path,
234
+ fetchedAt,
235
+ detalhe: {
236
+ ...detalhe,
237
+ emExercicio: derivarEmExercicio(mandatosBrutos, hoje),
238
+ mandatos: mandatosBrutos.map(parseMandato),
239
+ },
240
+ };
181
241
  }
182
242
  export function registerSenadoresTools(server, baseUrl) {
183
243
  // A1. senado_listar_senadores (filtro `nome` substitui a antiga busca por nome)
@@ -215,7 +275,7 @@ export function registerSenadoresTools(server, baseUrl) {
215
275
  }
216
276
  });
217
277
  // A2. senado_obter_senador
218
- server.tool("senado_obter_senador", "Obtém o detalhe biográfico de um senador específico. Retorna um objeto com `codigo`, `nome`, `nomeCompleto`, `nomeCivil`, `sexo`, `dataNascimento`, `naturalidade`/`ufNaturalidade`, `partido`, `uf`, `foto`, `email` e a lista `mandatos` (`legislatura`, `uf`, `participacao`, `dataInicio`, `dataFim`). Requer `codigoSenador` — obtenha-o via `senado_listar_senadores` (filtro `nome`). Para filiações, profissões, licenças, comissões ou cargos use `senado_senador_historico` (parâmetro `tipo`).", {
278
+ server.tool("senado_obter_senador", "Obtém o detalhe biográfico de um senador específico. Retorna um objeto com `codigo`, `nome`, `nomeCompleto`, `nomeCivil`, `sexo`, `dataNascimento`, `naturalidade`/`ufNaturalidade`, `partido`, `uf`, `foto`, `email`, `emExercicio` e a lista `mandatos` (`legislatura`, `uf`, `participacao`, `dataInicio`, `dataFim`). `emExercicio` é derivado dos exercícios do mandato (aberto = em exercício) e vem `null` — nunca afirmado — quando os mandatos não puderam ser lidos. Código inexistente retorna erro (\"Senador com código N não encontrado\"), nunca um registro vazio. Requer `codigoSenador` — obtenha-o via `senado_listar_senadores` (filtro `nome`). Para filiações, profissões, licenças, comissões ou cargos use `senado_senador_historico` (parâmetro `tipo`).", {
219
279
  codigoSenador: z.number().int().positive().describe("Código único do senador no sistema do Senado"),
220
280
  }, async (params) => {
221
281
  try {
@@ -54,12 +54,9 @@ export function computarEstatisticas(registros, valorDe, opcoes = {}) {
54
54
  grupos: g.groups.map(({ group, ...s }) => ({ grupo: group, ...paraPtBR(s) })),
55
55
  };
56
56
  }
57
- // ─── Display layer ───────────────────────────────────────────────────────────
58
- // Shared by all five statistics tools so the wording stays identical across the
59
- // whole surface: 2-decimal money rounding + the package's labeled percentile list
60
- // (pt-BR locale), so the model never surfaces internal shorthand ("p99").
61
- /** 2-decimal money rounding (all statistics fields are monetary — BRL). */
62
- const r2 = (n) => Math.round(n * 100) / 100;
57
+ function r2(n) {
58
+ return n === null ? null : Math.round(n * 100) / 100;
59
+ }
63
60
  /**
64
61
  * Format a number as pt-BR currency deterministically ("R$ 1.234,56") — no
65
62
  * `Intl` dependency, so tests are stable and it never depends on the runtime's
@@ -79,6 +76,26 @@ export function rotularPercentis(p, formatarValor = formatarBRL) {
79
76
  * self-documenting labeled list (not bare p25..p99 keys).
80
77
  */
81
78
  export function arredondarEstatisticas(e, formatarValor = formatarBRL) {
79
+ // SEM REGISTRO NENHUM não existe distribuição, e o bloco não é emitido.
80
+ //
81
+ // Defeito medido em produção em 14/09/2026:
82
+ // `senado_ceaps({ano: 2024, estatisticas: true, nomeSenador: "ZZQX
83
+ // INEXISTENTE"})` — ano válido, filtro que não casa nada — respondia a
84
+ // distribuição inteira em zero e, pior, NARRADA por extenso pela lista de
85
+ // percentis: "mediana — metade dos valores é igual ou inferior a R$ 0,00",
86
+ // "99% dos valores são iguais ou inferiores a R$ 0,00", com bloco de
87
+ // proveniência completo. Um modelo que lê isso afirma ao leitor que a
88
+ // despesa mediana foi R$ 0,00 — afirmação sobre o mundo, saída de uma
89
+ // consulta vazia, com aparência de dado oficial.
90
+ //
91
+ // Trocar a FORMA, e não só zerar os campos, é deliberado: uma fileira de
92
+ // nulos ainda convida quem lê a tratá-los como dado, e o aviso NEGA a
93
+ // leitura errada ("não significa que os valores sejam zero"). Mesma decisão
94
+ // do `formatStats` do motor comum, repetida aqui porque este módulo mantém a
95
+ // própria camada de exibição.
96
+ if (e.n === 0) {
97
+ return { n: 0, aviso: ptBR.noRecordsNotice() };
98
+ }
82
99
  return {
83
100
  n: e.n,
84
101
  soma: r2(e.soma),
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.7.0";
2
+ export const VERSION = "3.9.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.7.0",
3
+ "version": "3.9.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",
@@ -48,23 +48,24 @@
48
48
  "release:zenodo": "tsx scripts/build-dataset/zenodo-publish.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@modelcontextprotocol/sdk": "^1.30.0",
52
51
  "@modelcontextprotocol/server": "^2.0.0",
53
52
  "@sbissoli/mcp-provenance": "^0.1.0",
54
- "@sbissoli/mcp-search": "^0.3.0",
55
- "@sbissoli/mcp-stats": "^0.2.0",
53
+ "@sbissoli/mcp-search": "^0.5.0",
54
+ "@sbissoli/mcp-stats": "^0.3.0",
56
55
  "zod": "^4.5.4"
57
56
  },
58
57
  "devDependencies": {
59
58
  "@cloudflare/workers-types": "^5.20260830.1",
60
59
  "@modelcontextprotocol/client": "^2.0.0",
60
+ "@modelcontextprotocol/sdk": "^1.30.0",
61
61
  "@sbissoli/mcp-evals": "^0.1.0",
62
- "@types/node": "^26.4.0",
63
- "agents": "^0.22.0",
62
+ "@types/node": "^26.5.1",
63
+ "agents": "^0.23.0",
64
64
  "exceljs": "^4.4.0",
65
65
  "tsx": "^4.23.13",
66
66
  "typescript": "^7.0.2",
67
- "vitest": "^4.1.11",
67
+ "vite": "^8.2.2",
68
+ "vitest": "^5.0.0",
68
69
  "wrangler": "^4.127.1"
69
70
  },
70
71
  "overrides": {
@@ -73,7 +74,7 @@
73
74
  "@hono/node-server": "^1.19.13",
74
75
  "express-rate-limit": "^8.5.2",
75
76
  "ip-address": "^10.2.0",
76
- "qs": "^6.15.3",
77
+ "qs": "^6.16.0",
77
78
  "path-to-regexp": "^8.4.2",
78
79
  "lodash": "^4.18.1",
79
80
  "js-yaml": "^4.2.0",
@@ -81,6 +82,7 @@
81
82
  "ws": "^8.21.0",
82
83
  "picomatch": "^4.0.4",
83
84
  "postcss": "^8.5.10",
84
- "uuid": "^11.1.1"
85
+ "uuid": "^11.1.1",
86
+ "sharp": "^0.35.4"
85
87
  }
86
88
  }