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 +2 -2
- package/README.pt-BR.md +1 -1
- package/dist/app-surface.js +8 -1
- package/dist/call-shape.js +168 -0
- package/dist/instrument.js +225 -8
- package/dist/server.js +27 -1
- package/dist/throttle/upstream.js +21 -3
- package/dist/tools/comissoes.js +54 -5
- package/dist/tools/deep-research.js +17 -1
- package/dist/tools/materias.js +17 -0
- package/dist/tools/resolver-reuniao.js +64 -0
- package/dist/tools/senadores.js +68 -8
- package/dist/utils/estatisticas.js +23 -6
- package/dist/version.js +1 -1
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare/actions/workflows/ci.yml)
|
|
7
7
|
[](https://registry.modelcontextprotocol.io)
|
|
8
8
|
[](https://lobehub.com/mcp/sidneybissoli-senado-br-mcp-cloudflare)
|
|
9
|
-
[](https://lightnow.ai/servers/io.github.SidneyBissoli/senado-br-mcp-cloudflare)
|
|
10
10
|
[](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare)
|
|
11
11
|
[](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare)
|
|
12
12
|
[](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/
|
|
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/
|
|
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))
|
package/dist/app-surface.js
CHANGED
|
@@ -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) ||
|
|
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
|
+
}
|
package/dist/instrument.js
CHANGED
|
@@ -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,
|
|
19
|
-
* aggregable request context (country, AS organization, owner's self-use marker)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/tools/comissoes.js
CHANGED
|
@@ -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
|
|
310
|
-
codigoReuniao: z
|
|
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
|
-
|
|
314
|
-
|
|
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=${
|
|
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
|
-
|
|
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, {
|
package/dist/tools/materias.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/senadores.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "senado-br-mcp",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
55
|
-
"@sbissoli/mcp-stats": "^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.
|
|
63
|
-
"agents": "^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
|
-
"
|
|
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.
|
|
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
|
}
|