senado-br-mcp 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/call-shape.js +47 -0
- package/dist/envelope.js +257 -0
- package/dist/instrument.js +34 -46
- package/dist/server.js +1 -1
- package/dist/throttle/adm.js +10 -2
- package/dist/throttle/upstream.js +51 -2
- package/dist/tools/contratacoes.js +66 -2
- package/dist/tools/plenario.js +9 -2
- package/dist/tools/senadores.js +9 -1
- package/dist/tools/supridos.js +7 -2
- package/dist/tools/taquigrafia.js +12 -3
- package/dist/tools/votacoes.js +79 -13
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/dist/call-shape.js
CHANGED
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
*/
|
|
38
38
|
export function classifyError(message) {
|
|
39
39
|
const m = message.toLowerCase();
|
|
40
|
+
// PRIMEIRO de todos, e de propósito: a rota que não existe na fonte é
|
|
41
|
+
// defeito NOSSO (montamos um caminho errado), e a palavra que a nomeia —
|
|
42
|
+
// "inexistente" — cairia em `nao_encontrado` logo abaixo, que é exatamente a
|
|
43
|
+
// conflação que o conserto de 24/09/2026 desfez na borda da rede. A frase
|
|
44
|
+
// vem de `MSG_ROTA_INEXISTENTE`; ela não é vocabulário da fonte, é nosso.
|
|
45
|
+
if (m.includes("defeito do servidor mcp"))
|
|
46
|
+
return "defeito";
|
|
40
47
|
// A fronteira de palavra vai só no INÍCIO. Os padrões são RADICAIS
|
|
41
48
|
// ("vazi", "obrigatóri", "indisponív") justamente porque a flexão muda o
|
|
42
49
|
// fim: `\bvazi\b` não casa "vazia", e foi assim que "Resposta upstream
|
|
@@ -166,3 +173,43 @@ export function classifyThrown(error) {
|
|
|
166
173
|
}
|
|
167
174
|
return classifyError(error instanceof Error ? error.message : String(error));
|
|
168
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Classe de um erro JSON-RPC do PROTOCOLO, pelo código antes da mensagem.
|
|
178
|
+
*
|
|
179
|
+
* POR QUE ELE CHEGA AQUI AGORA. Até 24/09/2026 este arquivo só era chamado de
|
|
180
|
+
* dentro do handler, com a exceção da tool na mão — e o cabeçalho acima ainda
|
|
181
|
+
* registra o limite daquele desenho: *erro de validação de esquema NÃO chega a
|
|
182
|
+
* esta camada; o SDK o responde antes do handler, e a chamada não é contada nem
|
|
183
|
+
* como chamada nem como erro*. Era verdade, e era o defeito: a recusa de esquema
|
|
184
|
+
* não gerava linha NENHUMA de telemetria. Medido na produção em 24/09/2026 pela
|
|
185
|
+
* rota do dono, ela sai como HTTP **200** com `result.isError: true` e sem
|
|
186
|
+
* código JSON-RPC; e ferramenta inexistente sai como **200** com o código
|
|
187
|
+
* **−32602** (não −32601, como se supunha). Desde então a telemetria lê o
|
|
188
|
+
* ENVELOPE da resposta (src/envelope.ts) e estes casos passam a existir.
|
|
189
|
+
*
|
|
190
|
+
* Os códigos reservados pela especificação JSON-RPC 2.0 dizem, sem ambiguidade
|
|
191
|
+
* de idioma, de quem é o conserto:
|
|
192
|
+
*
|
|
193
|
+
* - −32700 parse error, −32600 invalid request, −32601 method not found e
|
|
194
|
+
* −32602 invalid params são o CHAMADOR mandando algo que o protocolo
|
|
195
|
+
* recusa. É `contrato` — e o painel exclui `contrato` da taxa de erro
|
|
196
|
+
* (`eh_recusa_de_contrato()`), porque ferramenta que recusa chamada
|
|
197
|
+
* malformada está funcionando;
|
|
198
|
+
* - −32603 internal error é erro NOSSO, que escapou. É `defeito`, e por
|
|
199
|
+
* decisão do dono em 24/09/2026 ele entra na fila de saúde do painel: sem
|
|
200
|
+
* isso passaríamos a medir e continuaríamos a não agir.
|
|
201
|
+
*
|
|
202
|
+
* Código fora dessa faixa (erro definido pelo servidor, −32000 e abaixo) cai
|
|
203
|
+
* na mensagem, que é o caminho de `classifyError`.
|
|
204
|
+
*
|
|
205
|
+
* Cópia FIEL de sih-br-mcp/worker/src/call-shape.ts, onde a função estreou:
|
|
206
|
+
* classe medida por servidor com classificador diferente não é comparável, e
|
|
207
|
+
* comparar os sete é a razão de existir da seção de saúde do painel.
|
|
208
|
+
*/
|
|
209
|
+
export function classeDoErroRpc(code, message) {
|
|
210
|
+
if (code === -32700 || code === -32600 || code === -32601 || code === -32602)
|
|
211
|
+
return "contrato";
|
|
212
|
+
if (code === -32603)
|
|
213
|
+
return "defeito";
|
|
214
|
+
return classifyError(message);
|
|
215
|
+
}
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leitura do ENVELOPE da resposta — o que transforma "o container devolveu
|
|
3
|
+
* HTTP 200" em "esta mensagem deu certo / esta falhou, e por quê".
|
|
4
|
+
*
|
|
5
|
+
* O PROBLEMA QUE ESTE ARQUIVO RESOLVE, E QUE AQUI É PIOR QUE NO `sih`. A
|
|
6
|
+
* telemetria deste Worker decide o desfecho pelo HTTP da resposta, e em cima
|
|
7
|
+
* disso `protocolNamesFromBody` (em src/instrument.ts) tinha a linha `if (status < 400) continue`, com
|
|
8
|
+
* o comentário "o hook de tools já gravou esta". A reconciliação era por STATUS,
|
|
9
|
+
* e supunha que 200 implica que o hook gravou. Quando a tool NÃO CHEGA A RODAR
|
|
10
|
+
* — recusa de esquema do zod, antes do handler — ninguém grava, e o `continue`
|
|
11
|
+
* fecha a única outra porta. O `sih` carimbava `ok` numa falha: número errado.
|
|
12
|
+
* Aqui não havia número NENHUM: a chamada não era contada nem como chamada.
|
|
13
|
+
*
|
|
14
|
+
* Medido na produção em 24/09/2026, pela rota do dono, no ilo, uis, senado e
|
|
15
|
+
* bcb — os quatro idênticos, e são DUAS formas, não uma: recusa de esquema sai
|
|
16
|
+
* HTTP 200 com `result.isError: true` e SEM código JSON-RPC; ferramenta
|
|
17
|
+
* inexistente sai HTTP 200 com o código −32602 (não −32601). Os dois são 200,
|
|
18
|
+
* então os dois caíam no `continue`.
|
|
19
|
+
*
|
|
20
|
+
* POR QUE NÃO BUFFERIZAR. Este Worker não é proxy — quem produz a resposta é o
|
|
21
|
+
* `createMcpHandler` aqui dentro —, mas a técnica é a mesma e pelo mesmo motivo:
|
|
22
|
+
* segurar bytes para ler o fim trocaria um defeito de medição por um defeito de
|
|
23
|
+
* produto (a lição do `sih`, cuja série de 34 anos leva ~30 s). O corpo é TEADO
|
|
24
|
+
* (`ReadableStream.tee()`): um ramo segue intocado para o cliente, no mesmo
|
|
25
|
+
* ritmo de antes, e o outro é lido aqui em `ctx.waitUntil`, depois que a
|
|
26
|
+
* resposta já saiu.
|
|
27
|
+
*
|
|
28
|
+
* O TEE RESOLVE, DE QUEBRA, UM PROBLEMA DE TEMPO. O hook de tools escreve
|
|
29
|
+
* durante a execução da tool, que num transporte SSE pode terminar DEPOIS de o
|
|
30
|
+
* handler já ter devolvido a Response. Ler o envelope até o fim do stream é,
|
|
31
|
+
* por construção, esperar a tool acabar — é só então que se sabe o que o hook
|
|
32
|
+
* gravou, e a reconciliação por nome (src/instrument.ts) só vale aí.
|
|
33
|
+
*
|
|
34
|
+
* REGRA DE OURO DESTE MÓDULO: ele nunca lança e nunca segura bytes. Qualquer
|
|
35
|
+
* coisa que ele não entenda vira "sem desfecho", e quem chama cai no critério
|
|
36
|
+
* antigo (o HTTP). Medir pior é aceitável; atrapalhar a resposta do usuário não é.
|
|
37
|
+
*
|
|
38
|
+
* DOIS TETOS, pelo mesmo motivo. Um evento SSE acumulado passa de
|
|
39
|
+
* `MAX_EVENTO_BYTES` → é descartado sem parsear (o `isError` pode estar no fim
|
|
40
|
+
* do JSON, mas mensagem de erro é curta: evento gigante é quase certamente
|
|
41
|
+
* resultado feliz, e o teto troca uma classificação improvável por um limite de
|
|
42
|
+
* memória e de CPU garantido). Mais de `MAX_DESFECHOS` respostas no mesmo corpo
|
|
43
|
+
* → o resto é ignorado; lote JSON-RPC de verdade tem punhado de mensagens.
|
|
44
|
+
*
|
|
45
|
+
* O CAMINHO DE ERRO-MOLE FICA DE FORA, de propósito. Ferramenta que responde
|
|
46
|
+
* ausência como DADO — uma resposta de SUCESSO cujo payload diz "não há" — está
|
|
47
|
+
* funcionando, e continua sendo `ok`. O que conta como falha é `isError: true`
|
|
48
|
+
* ou um erro JSON-RPC, nada mais. (No `sih`, de onde este arquivo vem, isso é a
|
|
49
|
+
* decisão 38 e seis ferramentas dependem dela; aqui o erro de tool já nasce de
|
|
50
|
+
* `IlostatUserError` / `IlostatUpstreamError` e vira `isError`.)
|
|
51
|
+
*
|
|
52
|
+
* Cópia de sih-br-mcp/worker/src/envelope.ts, porque o classificador não pode
|
|
53
|
+
* divergir entre os sete. O que muda é só este cabeçalho e o `teeResposta` do
|
|
54
|
+
* fim, que lá não existe: o `sih` monta a Response do zero a partir do upstream
|
|
55
|
+
* e teia no caminho; aqui a Response já vem pronta do handler.
|
|
56
|
+
*/
|
|
57
|
+
import { classeDoErroRpc, classifyError, errorText } from "./call-shape.js";
|
|
58
|
+
/** Teto de bytes acumulados por evento SSE (ou por linha) antes de desistir dele. */
|
|
59
|
+
export const MAX_EVENTO_BYTES = 64 * 1024;
|
|
60
|
+
/** Teto de respostas rastreadas por corpo. */
|
|
61
|
+
export const MAX_DESFECHOS = 64;
|
|
62
|
+
/**
|
|
63
|
+
* Desfecho de uma resposta JSON-RPC, com o `id` que a liga ao pedido.
|
|
64
|
+
* `null` quando o objeto não é uma resposta identificável:
|
|
65
|
+
* - sem `id` (ou `id: null`, que é a resposta a um pedido que nem parseou) —
|
|
66
|
+
* não há a que ligar, e o chamador fica com o critério do HTTP;
|
|
67
|
+
* - sem `result` e sem `error` — não é resposta (é notificação, ou lixo).
|
|
68
|
+
*/
|
|
69
|
+
export function desfechoDaResposta(msg) {
|
|
70
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg))
|
|
71
|
+
return null;
|
|
72
|
+
const m = msg;
|
|
73
|
+
if (typeof m.id !== "string" && typeof m.id !== "number")
|
|
74
|
+
return null;
|
|
75
|
+
const id = String(m.id);
|
|
76
|
+
if (m.error && typeof m.error === "object") {
|
|
77
|
+
const e = m.error;
|
|
78
|
+
const code = typeof e.code === "number" ? e.code : undefined;
|
|
79
|
+
const texto = typeof e.message === "string" ? e.message : "";
|
|
80
|
+
return { id, desfecho: { erro: true, classe: classeDoErroRpc(code, texto) } };
|
|
81
|
+
}
|
|
82
|
+
if (m.result && typeof m.result === "object") {
|
|
83
|
+
const r = m.result;
|
|
84
|
+
if (r.isError === true) {
|
|
85
|
+
return { id, desfecho: { erro: true, classe: classifyError(errorText(m.result)) } };
|
|
86
|
+
}
|
|
87
|
+
return { id, desfecho: { erro: false, classe: "" } };
|
|
88
|
+
}
|
|
89
|
+
// `result` primitivo (o SDK responde `{}` em alguns métodos, mas nunca um
|
|
90
|
+
// escalar) ainda é sucesso; o que não é resposta é o objeto sem os dois.
|
|
91
|
+
if (m.result !== undefined)
|
|
92
|
+
return { id, desfecho: { erro: false, classe: "" } };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* O desfecho de UMA mensagem do pedido, já com o CRITÉRIO DE RESERVA aplicado.
|
|
97
|
+
*
|
|
98
|
+
* Regra: vale o que o envelope disse. Quando o envelope não disse nada — `id`
|
|
99
|
+
* ausente (notificação), stream cortado, evento grande demais, resposta que
|
|
100
|
+
* não parseou —, vale o HTTP do container, que era o critério único até
|
|
101
|
+
* 24/09/2026. Nunca se inventa classe: sem leitura, a classe é "".
|
|
102
|
+
*/
|
|
103
|
+
export function resolveDesfecho(id, desfechos, falhouHttp) {
|
|
104
|
+
const lido = id === "" ? undefined : desfechos.get(id);
|
|
105
|
+
return lido ?? { erro: falhouHttp, classe: "" };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Lê o corpo da resposta aos pedaços e vai colhendo desfechos por `id`.
|
|
109
|
+
*
|
|
110
|
+
* Aceita os dois formatos que o transporte Streamable HTTP usa: SSE (`data:`
|
|
111
|
+
* por evento, evento fechado por linha em branco, várias linhas `data:` do
|
|
112
|
+
* mesmo evento juntadas por `\n`) e JSON cru (resposta direta, sem stream).
|
|
113
|
+
* Linhas `event:`, `id:`, `retry:` e comentários (`:`) são ignoradas.
|
|
114
|
+
*/
|
|
115
|
+
export class LeitorDeEnvelope {
|
|
116
|
+
resto = "";
|
|
117
|
+
descartandoLinha = false;
|
|
118
|
+
evento = [];
|
|
119
|
+
bytesDoEvento = 0;
|
|
120
|
+
eventoEstourado = false;
|
|
121
|
+
viuSse = false;
|
|
122
|
+
cru = "";
|
|
123
|
+
/** Desfechos colhidos, por `id` de mensagem JSON-RPC. */
|
|
124
|
+
desfechos = new Map();
|
|
125
|
+
/** Entrega mais um pedaço de TEXTO já decodificado. Nunca lança. */
|
|
126
|
+
push(texto) {
|
|
127
|
+
if (!this.viuSse && this.cru.length < MAX_EVENTO_BYTES)
|
|
128
|
+
this.cru += texto;
|
|
129
|
+
this.resto += texto;
|
|
130
|
+
for (;;) {
|
|
131
|
+
const quebra = this.resto.indexOf("\n");
|
|
132
|
+
if (quebra < 0)
|
|
133
|
+
break;
|
|
134
|
+
const linha = this.resto.slice(0, quebra);
|
|
135
|
+
this.resto = this.resto.slice(quebra + 1);
|
|
136
|
+
if (this.descartandoLinha) {
|
|
137
|
+
// Fim da linha gigante que foi descartada: o evento inteiro cai junto,
|
|
138
|
+
// porque o JSON dele está incompleto.
|
|
139
|
+
this.descartandoLinha = false;
|
|
140
|
+
this.eventoEstourado = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
this.linha(linha.endsWith("\r") ? linha.slice(0, -1) : linha);
|
|
144
|
+
}
|
|
145
|
+
// Linha sem fim à vista e já maior que o teto: para de acumular em vez de
|
|
146
|
+
// crescer com o corpo (uma resposta de 34 anos é uma única linha `data:`).
|
|
147
|
+
if (this.resto.length > MAX_EVENTO_BYTES) {
|
|
148
|
+
this.resto = "";
|
|
149
|
+
this.descartandoLinha = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Fecha o que ficou pendente. Chamar uma vez, ao fim do stream. */
|
|
153
|
+
end() {
|
|
154
|
+
if (this.resto !== "" && !this.descartandoLinha) {
|
|
155
|
+
const linha = this.resto.endsWith("\r") ? this.resto.slice(0, -1) : this.resto;
|
|
156
|
+
this.resto = "";
|
|
157
|
+
this.linha(linha);
|
|
158
|
+
}
|
|
159
|
+
this.fechaEvento();
|
|
160
|
+
// Corpo que nunca teve uma linha `data:` é JSON cru.
|
|
161
|
+
if (!this.viuSse)
|
|
162
|
+
this.registra(this.cru);
|
|
163
|
+
}
|
|
164
|
+
linha(linha) {
|
|
165
|
+
if (linha === "") {
|
|
166
|
+
this.fechaEvento();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!linha.startsWith("data:"))
|
|
170
|
+
return;
|
|
171
|
+
this.viuSse = true;
|
|
172
|
+
const pedaco = linha.slice(5).replace(/^ /, "");
|
|
173
|
+
this.bytesDoEvento += pedaco.length;
|
|
174
|
+
if (this.bytesDoEvento > MAX_EVENTO_BYTES) {
|
|
175
|
+
this.eventoEstourado = true;
|
|
176
|
+
this.evento = [];
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.evento.push(pedaco);
|
|
180
|
+
}
|
|
181
|
+
fechaEvento() {
|
|
182
|
+
const partes = this.evento;
|
|
183
|
+
const estourou = this.eventoEstourado;
|
|
184
|
+
this.evento = [];
|
|
185
|
+
this.bytesDoEvento = 0;
|
|
186
|
+
this.eventoEstourado = false;
|
|
187
|
+
if (estourou || partes.length === 0)
|
|
188
|
+
return;
|
|
189
|
+
this.registra(partes.join("\n"));
|
|
190
|
+
}
|
|
191
|
+
registra(json) {
|
|
192
|
+
if (json === "" || this.desfechos.size >= MAX_DESFECHOS)
|
|
193
|
+
return;
|
|
194
|
+
let obj;
|
|
195
|
+
try {
|
|
196
|
+
obj = JSON.parse(json);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return; // pedaço cortado ao meio, ou não era JSON: fica sem desfecho
|
|
200
|
+
}
|
|
201
|
+
for (const item of Array.isArray(obj) ? obj : [obj]) {
|
|
202
|
+
if (this.desfechos.size >= MAX_DESFECHOS)
|
|
203
|
+
return;
|
|
204
|
+
const d = desfechoDaResposta(item);
|
|
205
|
+
if (d)
|
|
206
|
+
this.desfechos.set(d.id, d.desfecho);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Consome um stream inteiro e devolve os desfechos por `id`. Nunca lança: se o
|
|
212
|
+
* stream for cortado no meio (cliente desconectou, container caiu), vale o que
|
|
213
|
+
* deu para ler até ali e o resto fica sem desfecho.
|
|
214
|
+
*/
|
|
215
|
+
export async function desfechosDoCorpo(corpo) {
|
|
216
|
+
const leitor = new LeitorDeEnvelope();
|
|
217
|
+
const decoder = new TextDecoder();
|
|
218
|
+
const reader = corpo.getReader();
|
|
219
|
+
try {
|
|
220
|
+
for (;;) {
|
|
221
|
+
const { done, value } = await reader.read();
|
|
222
|
+
if (done)
|
|
223
|
+
break;
|
|
224
|
+
if (value)
|
|
225
|
+
leitor.push(decoder.decode(value, { stream: true }));
|
|
226
|
+
}
|
|
227
|
+
leitor.push(decoder.decode());
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Stream interrompido: o que já foi lido continua valendo.
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
try {
|
|
234
|
+
reader.releaseLock();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// ignorado
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
leitor.end();
|
|
241
|
+
return leitor.desfechos;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Parte o corpo da resposta em dois: o que vai para o cliente e o que a
|
|
245
|
+
* telemetria lê. Só isto, e nunca mais que isto — a Response devolvida tem o
|
|
246
|
+
* mesmo status, o mesmo statusText e os mesmos cabeçalhos da original.
|
|
247
|
+
*
|
|
248
|
+
* Resposta sem corpo (204, HEAD, erro montado na borda) devolve `corpo: null` e
|
|
249
|
+
* a própria Response intacta: não há o que teiar, e `new Response(null, resp)`
|
|
250
|
+
* seria uma cópia à toa.
|
|
251
|
+
*/
|
|
252
|
+
export function teeResposta(resposta) {
|
|
253
|
+
if (!resposta.body)
|
|
254
|
+
return { paraCliente: resposta, paraLeitura: null };
|
|
255
|
+
const [cliente, leitura] = resposta.body.tee();
|
|
256
|
+
return { paraCliente: new Response(cliente, resposta), paraLeitura: leitura };
|
|
257
|
+
}
|
package/dist/instrument.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* connector is only identifiable via the secret header his MCP clients send
|
|
29
29
|
* (SELF_HEADER, value = the SELF_MARKER Wrangler secret).
|
|
30
30
|
*/
|
|
31
|
+
import { resolveDesfecho } from "./envelope.js";
|
|
31
32
|
import { classifyError, classifyThrown, errorText, paramNames } from "./call-shape.js";
|
|
32
33
|
import { incr, incrTool } from "./metrics.js";
|
|
33
34
|
import { callCache, cacheClass } from "./observability/call-context.js";
|
|
@@ -62,7 +63,7 @@ export function tagRequest(request, selfSecret, sessao = "") {
|
|
|
62
63
|
sessao,
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
|
-
export function instrumentTool(name, cb, analytics, tag) {
|
|
66
|
+
export function instrumentTool(name, cb, analytics, tag, gravados) {
|
|
66
67
|
return async (...args) => {
|
|
67
68
|
incr("toolCalls");
|
|
68
69
|
let isError = false;
|
|
@@ -93,6 +94,12 @@ export function instrumentTool(name, cb, analytics, tag) {
|
|
|
93
94
|
// chamada nem como erro. Medido em 10/09/2026 pelo /metrics: três
|
|
94
95
|
// chamadas com argumento de forma errada não moveram o contador.
|
|
95
96
|
recordToolCall(name, isError, stats, analytics, tag, classe, paramNames(args));
|
|
97
|
+
// Recibo desta requisicao: e contra ele que recordProtocolMethods
|
|
98
|
+
// reconcilia. Fica no `finally` junto da gravacao, para os dois nunca
|
|
99
|
+
// discordarem. A recusa de esquema NAO passa por aqui -- e exatamente
|
|
100
|
+
// por isso que ela nao deixa recibo, e a linha dela e escrita la.
|
|
101
|
+
if (gravados)
|
|
102
|
+
gravados.set(name, (gravados.get(name) ?? 0) + 1);
|
|
96
103
|
}
|
|
97
104
|
};
|
|
98
105
|
}
|
|
@@ -135,71 +142,52 @@ function recordToolCall(name, isError, stats, analytics, tag, errorClass = "", p
|
|
|
135
142
|
}
|
|
136
143
|
}
|
|
137
144
|
/**
|
|
138
|
-
*
|
|
145
|
+
* As mensagens do corpo que ainda precisam de linha, já descontado o recibo.
|
|
139
146
|
*
|
|
140
|
-
* `
|
|
141
|
-
*
|
|
142
|
-
* cliente mande (`server/discover`, por exemplo) atravessam o transporte sem
|
|
143
|
-
* tocar tool nenhuma — e são eles que contam o FUNIL DE SESSÃO: quantos
|
|
144
|
-
* `initialize` viram chamada de ferramenta de verdade, que é o que separa
|
|
145
|
-
* "acharam o servidor" de "usaram o servidor". Medido em 2026-09-10: só o
|
|
146
|
-
* sih-br-mcp os gravava, porque lá o Worker é um proxy que lê o corpo
|
|
147
|
-
* JSON-RPC antes de encaminhar. Aqui o servidor roda dentro do Worker, e o
|
|
148
|
-
* corpo vem de uma CÓPIA tirada antes de o handler consumir o stream
|
|
149
|
-
* (src/index.ts). Desde 2026-09-16 a frota inteira grava igual.
|
|
150
|
-
*
|
|
151
|
-
* Mesmo esquema de blobs, com o método no lugar do nome da tool — igual ao
|
|
152
|
-
* sih. O painel separa os dois pelo nome (`metodo_de_protocolo`). Classe de
|
|
153
|
-
* cache, classe de erro e parâmetros ficam vazios; fetches e hits, zero.
|
|
154
|
-
*
|
|
155
|
-
* O que entra, e de onde vem o desfecho:
|
|
156
|
-
* - todo método que não é `tools/call` → uma linha, "ok" se o HTTP da
|
|
157
|
-
* resposta for < 400, "error" senão. LIMITAÇÃO, a mesma do sih: erro
|
|
158
|
-
* JSON-RPC que viaja dentro de um 200 (método desconhecido, -32601) sai
|
|
159
|
-
* como "ok" — ler exigiria consumir o corpo que está sendo devolvido;
|
|
160
|
-
* - `tools/call` só quando o HTTP é ≥ 400: o transporte recusou antes de
|
|
161
|
-
* despachar (Accept errado, sessão inválida, Origin estrangeiro) e a tool
|
|
162
|
-
* nunca rodou; sem isto a recusa seria invisível. Com HTTP < 400 a
|
|
163
|
-
* `instrumentTool` já gravou a linha, com o desfecho de verdade — não se
|
|
164
|
-
* grava de novo;
|
|
165
|
-
* - lote JSON-RPC (array) → uma linha por item; item sem `method` (resposta
|
|
166
|
-
* do cliente, corpo que não é JSON) → nada.
|
|
167
|
-
*
|
|
168
|
-
* Só no Analytics Engine: os contadores do /metrics continuam contando tools.
|
|
147
|
+
* `gravados` é CONSUMIDO (decrementado) durante a varredura. Chamar sem ele
|
|
148
|
+
* significa "a instrumentTool não gravou nada".
|
|
169
149
|
*/
|
|
170
|
-
export function
|
|
150
|
+
export function protocolMessagesFromBody(body, gravados) {
|
|
171
151
|
const itens = Array.isArray(body) ? body : [body];
|
|
172
|
-
const
|
|
152
|
+
const mensagens = [];
|
|
173
153
|
for (const item of itens) {
|
|
174
154
|
if (!item || typeof item !== "object")
|
|
175
155
|
continue;
|
|
176
156
|
const msg = item;
|
|
177
157
|
if (typeof msg.method !== "string" || msg.method === "")
|
|
178
158
|
continue;
|
|
159
|
+
const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : "";
|
|
179
160
|
if (msg.method === "tools/call") {
|
|
180
|
-
if (status < 400)
|
|
181
|
-
continue; // instrumentTool já gravou esta
|
|
182
161
|
const params = msg.params;
|
|
183
|
-
|
|
162
|
+
const nome = typeof params?.name === "string" && params.name !== "" ? params.name : "tools/call";
|
|
163
|
+
const recibo = gravados?.get(nome) ?? 0;
|
|
164
|
+
if (recibo > 0) {
|
|
165
|
+
gravados?.set(nome, recibo - 1); // a instrumentTool já gravou esta
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
mensagens.push({ nome, id });
|
|
184
169
|
}
|
|
185
170
|
else {
|
|
186
|
-
|
|
171
|
+
mensagens.push({ nome: msg.method, id });
|
|
187
172
|
}
|
|
188
173
|
}
|
|
189
|
-
return
|
|
174
|
+
return mensagens;
|
|
190
175
|
}
|
|
191
176
|
/**
|
|
192
177
|
* Grava no Analytics Engine os métodos de protocolo de um POST no endpoint
|
|
193
|
-
* MCP (ver
|
|
194
|
-
*
|
|
178
|
+
* MCP (ver acima). Devolve os nomes gravados. Sem binding, não grava nada.
|
|
179
|
+
*
|
|
180
|
+
* `desfechos` é o que a leitura do envelope colheu por `id`; sem ele — corpo
|
|
181
|
+
* não teado, stream cortado — vale o HTTP, que é o critério de reserva.
|
|
195
182
|
*/
|
|
196
|
-
export function recordProtocolMethods(analytics, tag, body, status) {
|
|
183
|
+
export function recordProtocolMethods(analytics, tag, body, status, desfechos = new Map(), gravados) {
|
|
197
184
|
if (!analytics || body === undefined)
|
|
198
185
|
return [];
|
|
199
186
|
const cliente = clientNameFromBody(body);
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
for (const name of
|
|
187
|
+
const mensagens = protocolMessagesFromBody(body, gravados);
|
|
188
|
+
const falhouHttp = status >= 400;
|
|
189
|
+
for (const { nome: name, id } of mensagens) {
|
|
190
|
+
const { erro: isError, classe } = resolveDesfecho(id, desfechos, falhouHttp);
|
|
203
191
|
try {
|
|
204
192
|
analytics.writeDataPoint({
|
|
205
193
|
indexes: [name],
|
|
@@ -210,7 +198,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
|
|
|
210
198
|
tag?.self ? "self" : "",
|
|
211
199
|
tag?.country ?? "",
|
|
212
200
|
tag?.asOrg ?? "",
|
|
213
|
-
|
|
201
|
+
classe,
|
|
214
202
|
"",
|
|
215
203
|
tag?.sessao ?? "",
|
|
216
204
|
name === "initialize" ? cliente : "",
|
|
@@ -222,7 +210,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
|
|
|
222
210
|
// Falha de telemetria nunca quebra nem atrasa a resposta.
|
|
223
211
|
}
|
|
224
212
|
}
|
|
225
|
-
return
|
|
213
|
+
return mensagens.map((m) => m.nome);
|
|
226
214
|
}
|
|
227
215
|
/**
|
|
228
216
|
* SESSÃO E CLIENTE (blobs 9 e 10, desde 2026-09-17).
|
package/dist/server.js
CHANGED
|
@@ -112,7 +112,7 @@ export function createServer(env, ctx, options = {}) {
|
|
|
112
112
|
openWorldHint: true,
|
|
113
113
|
},
|
|
114
114
|
_meta: toolMetaForProfile(toolProfile),
|
|
115
|
-
}, instrumentTool(name, profiledCallback, analytics, options.requestTag));
|
|
115
|
+
}, instrumentTool(name, profiledCallback, analytics, options.requestTag, options.gravados));
|
|
116
116
|
};
|
|
117
117
|
const baseUrl = env.SENADO_BASE_URL || "https://legis.senado.leg.br/dadosabertos";
|
|
118
118
|
const admBaseUrl = env.SENADO_ADM_BASE_URL || "https://adm.senado.gov.br/adm-dadosabertos";
|
package/dist/throttle/adm.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Differences from the legislative API:
|
|
6
6
|
* - no `.json` suffix on paths
|
|
7
|
-
* -
|
|
7
|
+
* - a chave fora da cobertura responde 404, não array vazio
|
|
8
8
|
* - some datasets are very large (CEAPS year ≈ 10 MB), hence the raised guard
|
|
9
9
|
*
|
|
10
10
|
* Reuses upstreamFetch, so the global token bucket, concurrency limit,
|
|
@@ -16,7 +16,15 @@ export async function admFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
16
16
|
const base = baseUrl || SENADO_ADM_BASE_URL_DEFAULT;
|
|
17
17
|
return upstreamFetch(`/api/v1${path}`, params, base, {
|
|
18
18
|
noJsonSuffix: true,
|
|
19
|
-
treat404AsEmpty: true
|
|
19
|
+
// `absent`, e não o `treat404AsEmpty: true` que estava aqui até 24/09/2026.
|
|
20
|
+
// A flag antiga transformava TODO 404 em `[]`, e com isso 16 pontos de
|
|
21
|
+
// chamada — CEAPS, supridos, remunerações, horas extras, contratos — diziam
|
|
22
|
+
// "count: 0" para ano que a fonte NÃO PUBLICA. Medido: `/supridos/2005` e
|
|
23
|
+
// `/senadores/despesas_ceaps/2007` respondem 404, enquanto a chave válida
|
|
24
|
+
// sem dado responde `200 []`. Logo o 404 aqui nunca significou "vazio".
|
|
25
|
+
// Quem precisa do `[]` pede `on404: "empty"` explicitamente, pelo
|
|
26
|
+
// `options` abaixo, e assume o ônus de desfazer a ambiguidade.
|
|
27
|
+
on404: "absent",
|
|
20
28
|
...options,
|
|
21
29
|
});
|
|
22
30
|
}
|
|
@@ -42,6 +42,49 @@ export class UpstreamError extends Error {
|
|
|
42
42
|
this.name = "UpstreamError";
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Frase-chave da mensagem de rota inexistente. Vive numa constante porque
|
|
47
|
+
* `classifyError` casa contra ela para devolver `defeito`: se o texto mudar
|
|
48
|
+
* num lugar só, a telemetria volta a mentir em silêncio.
|
|
49
|
+
*/
|
|
50
|
+
export const MSG_ROTA_INEXISTENTE = "Rota inexistente na fonte (defeito do servidor MCP)";
|
|
51
|
+
/**
|
|
52
|
+
* A fonte respondeu, e respondeu que a CHAVE pedida não existe no acervo.
|
|
53
|
+
*
|
|
54
|
+
* Separada de `UpstreamError` porque não é falha da fonte: é resposta dela.
|
|
55
|
+
* A mensagem é escrita para cair em `nao_encontrado` no `classifyError`.
|
|
56
|
+
*/
|
|
57
|
+
export class RecursoAusenteError extends UpstreamError {
|
|
58
|
+
constructor(path) {
|
|
59
|
+
super(`[${path}] A fonte não tem registro para esta chave: o recurso não existe no acervo publicado.`, 404, false);
|
|
60
|
+
this.name = "RecursoAusenteError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Distingue os DOIS 404 que as APIs do Senado emitem — medido em 24/09/2026
|
|
65
|
+
* nas duas bases, legislativa e administrativa:
|
|
66
|
+
*
|
|
67
|
+
* | caso | corpo |
|
|
68
|
+
* |-------------------------------|------------------------------------------------------|
|
|
69
|
+
* | rota não existe (erro NOSSO) | problem+json com `detail: "No static resource ..."` |
|
|
70
|
+
* | chave sem registro | corpo vazio, ou problem+json SEM `detail` |
|
|
71
|
+
*
|
|
72
|
+
* `/servidores/xxxx` traz o `detail`; `/supridos/2005` vem com
|
|
73
|
+
* `Content-Length: 0`; `/taquigrafia/notas/sessao/99999999` traz
|
|
74
|
+
* `{instance,status,title}` sem `detail`. Só o primeiro é defeito nosso.
|
|
75
|
+
*/
|
|
76
|
+
export function rotaInexistente(corpo) {
|
|
77
|
+
if (!corpo.trim())
|
|
78
|
+
return false;
|
|
79
|
+
let detail;
|
|
80
|
+
try {
|
|
81
|
+
detail = JSON.parse(corpo).detail;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return typeof detail === "string" && /^No static resource\b/i.test(detail.trim());
|
|
87
|
+
}
|
|
45
88
|
/**
|
|
46
89
|
* Parse an HTTP Retry-After header (RFC 9110 §10.2.3) into milliseconds to wait.
|
|
47
90
|
* Accepts the delta-seconds form ("5") and the HTTP-date form; returns null when
|
|
@@ -138,10 +181,16 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
138
181
|
}
|
|
139
182
|
break;
|
|
140
183
|
}
|
|
141
|
-
if (response.status === 404 && options.
|
|
184
|
+
if (response.status === 404 && options.on404) {
|
|
142
185
|
incr("upstreamCalls");
|
|
143
186
|
log("upstream", path, 404, Date.now() - startTime, attempt);
|
|
144
|
-
|
|
187
|
+
const corpo404 = await response.text();
|
|
188
|
+
if (rotaInexistente(corpo404)) {
|
|
189
|
+
throw new UpstreamError(`[${path}] ${MSG_ROTA_INEXISTENTE} — o caminho montado não existe na API do Senado.`, 404, false);
|
|
190
|
+
}
|
|
191
|
+
if (options.on404 === "empty")
|
|
192
|
+
return [];
|
|
193
|
+
throw new RecursoAusenteError(path);
|
|
145
194
|
}
|
|
146
195
|
if (!response.ok) {
|
|
147
196
|
throw new UpstreamError(`[${path}] Upstream retornou HTTP ${response.status}`, response.status, response.status >= 500);
|
|
@@ -86,6 +86,56 @@ export function ordenarEPaginar(lista, ordem, offset, limite) {
|
|
|
86
86
|
const ordenada = ordem === "desc" ? [...lista].reverse() : lista;
|
|
87
87
|
return ordenada.slice(offset, offset + limite);
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* A lista-pai crua contém o `id`?
|
|
91
|
+
*
|
|
92
|
+
* Separada de `paiExiste` para ser testável pela convenção da casa (unitário,
|
|
93
|
+
* sem rede): a decisão que importa é a COMPARAÇÃO, não o fetch.
|
|
94
|
+
*
|
|
95
|
+
* Compara como NÚMERO dos dois lados — o id vem `number` do esquema da tool e a
|
|
96
|
+
* fonte já o serviu como string em outras coleções —, e ausência (`null`,
|
|
97
|
+
* `undefined`, `""`) nunca vira 0: `Number("")` é 0, e sem a guarda um registro
|
|
98
|
+
* sem id casaria com `id: 0` ([[notacao-do-codigo-limpa-dos-dois-lados]]).
|
|
99
|
+
*/
|
|
100
|
+
export function contemId(lista, id) {
|
|
101
|
+
return ensureArray(lista).some((c) => {
|
|
102
|
+
const bruto = c?.id;
|
|
103
|
+
if (bruto === null || bruto === undefined || bruto === "")
|
|
104
|
+
return false;
|
|
105
|
+
return Number(bruto) === id;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A contratação `id` existe na lista-pai de `tipo`?
|
|
110
|
+
*
|
|
111
|
+
* POR QUE ISTO EXISTE. A API administrativa responde **404 com corpo vazio**
|
|
112
|
+
* tanto para sub-recurso de pai INEXISTENTE quanto para seção legitimamente
|
|
113
|
+
* VAZIA — medido em 24/09/2026: `/contratos/99999999/itens` e
|
|
114
|
+
* `/contratos/541/itens` (pai real, seção sem registro) dão o mesmo 404, e
|
|
115
|
+
* `/contratos/632/itens` dá 200. A fonte não distingue os dois, então a borda
|
|
116
|
+
* da rede também não pode: é a única rota adm que fica em `on404: "empty"`, e
|
|
117
|
+
* o preço disso é desfazer a ambiguidade AQUI.
|
|
118
|
+
*
|
|
119
|
+
* Custo: nenhum, na prática. Confirmar o pai é procurar o id na lista completa
|
|
120
|
+
* que `senado_contratos` / `senado_contratacoes_lista` já baixam e cacheiam —
|
|
121
|
+
* a chamada usa as MESMAS chaves de cache, então é acerto de cache. Não há
|
|
122
|
+
* rota de item único: conferido no Swagger, `/contratos/632` dá 404 e `?id=632`
|
|
123
|
+
* é ignorado (devolve a base inteira).
|
|
124
|
+
*
|
|
125
|
+
* Em falha de rede devolve `null` (indeterminado), não `false`: a fonte fora do
|
|
126
|
+
* ar não é prova de que o contrato não existe.
|
|
127
|
+
*/
|
|
128
|
+
export async function paiExiste(tipo, id, admBaseUrl) {
|
|
129
|
+
try {
|
|
130
|
+
const { value } = tipo === "contratos"
|
|
131
|
+
? await cachedFetchWithMeta("senado_contratos", {}, CACHE_SEMI_STATIC, () => admFetchLarge("/contratacoes/contratos", {}, admBaseUrl))
|
|
132
|
+
: await cachedFetchWithMeta("senado_contratacoes_lista", { tipo }, CACHE_SEMI_STATIC, () => admFetch(`/contratacoes/${tipo}`, {}, admBaseUrl));
|
|
133
|
+
return contemId(value, id);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
89
139
|
export function registerContratacoesTools(server, admBaseUrl) {
|
|
90
140
|
// Q1. senado_contratos
|
|
91
141
|
server.tool("senado_contratos", "Busca contratos administrativos do Senado por fornecedor, CNPJ, ano, número, objeto ou mão de obra (base completa baixada e filtrada no Worker; busca parcial sem acento em objeto/fornecedor/número). Retorna `{ count, total, contratos }`, onde cada item traz `id`, `numero`, `objeto`, `empresa {nome, cnpj}`, `subEspecie`, `dataAssinatura`, `vigencia` e `unidadeGestora`. Atenção: a fonte NÃO publica o valor do contrato (nenhum campo monetário aqui nem nas seções de detalhe; apenas a seção `aditivos` de `senado_contratacao_detalhe` traz `valor`, referente ao termo aditivo) — não gaste chamadas procurando valor. A lista mistura subespécies (CT = contratos, AC = acordos de cooperação, TD = termos de doação etc.); distinga pelo campo `subEspecie` retornado. Limitado a `limite` itens (padrão 50, máx 500), com `aviso` quando há truncamento. Use o `id` retornado em `senado_contratacao_detalhe` para itens, pagamentos, garantias ou aditivos.", {
|
|
@@ -144,7 +194,7 @@ export function registerContratacoesTools(server, admBaseUrl) {
|
|
|
144
194
|
}
|
|
145
195
|
});
|
|
146
196
|
// Q2. senado_contratacao_detalhe
|
|
147
|
-
server.tool("senado_contratacao_detalhe", "Detalha uma seção específica de uma contratação já identificada pelo `id`. `tipo` indica a natureza do registro: `contratos` (contrato firmado; padrão), `atas_registro_preco` (compromisso de preços para compras futuras) ou `notas_empenho` (reserva orçamentária do gasto). `secao` escolhe o aspecto: `itens`, `pagamentos`, `garantias` (qualquer `tipo`), `aditivos` (só `contratos`) ou `acionamentos` (só `atas_registro_preco`). Retorna `{ id, tipo, secao, count, total, itens }` com os registros brutos da seção (campos conforme a API administrativa), limitados a `limite` (padrão 100, máx 500) — `count < total` indica truncagem; seção sem registros retorna `count` 0 e `itens` vazio; combinações `secao`×`tipo` inválidas (ex.: `aditivos` fora de contratos) retornam erro. Atenção: a fonte NÃO publica o valor do contrato em nenhuma seção; apenas `aditivos` traz `valor`, referente ao termo aditivo (às vezes nulo) — não procure valor monetário em itens/pagamentos/garantias. Obtenha o `id` via `senado_contratos` ou `senado_contratacoes_lista` — para localizar a contratação (não detalhá-la) use aquelas ferramentas.", {
|
|
197
|
+
server.tool("senado_contratacao_detalhe", "Detalha uma seção específica de uma contratação já identificada pelo `id`. `tipo` indica a natureza do registro: `contratos` (contrato firmado; padrão), `atas_registro_preco` (compromisso de preços para compras futuras) ou `notas_empenho` (reserva orçamentária do gasto). `secao` escolhe o aspecto: `itens`, `pagamentos`, `garantias` (qualquer `tipo`), `aditivos` (só `contratos`) ou `acionamentos` (só `atas_registro_preco`). Retorna `{ id, tipo, secao, count, total, itens }` com os registros brutos da seção (campos conforme a API administrativa), limitados a `limite` (padrão 100, máx 500) — `count < total` indica truncagem; seção sem registros retorna `count` 0 e `itens` vazio (o `id` é conferido na lista-pai antes, então `count` 0 significa mesmo seção vazia, não contratação inexistente); `id` que não existe e combinações `secao`×`tipo` inválidas (ex.: `aditivos` fora de contratos) retornam erro. Atenção: a fonte NÃO publica o valor do contrato em nenhuma seção; apenas `aditivos` traz `valor`, referente ao termo aditivo (às vezes nulo) — não procure valor monetário em itens/pagamentos/garantias. Obtenha o `id` via `senado_contratos` ou `senado_contratacoes_lista` — para localizar a contratação (não detalhá-la) use aquelas ferramentas.", {
|
|
148
198
|
id: z.number().int().positive().describe("ID da contratação (campo 'id' das listas de contratos/atas/empenhos)"),
|
|
149
199
|
tipo: z.enum(["contratos", "atas_registro_preco", "notas_empenho"]).optional().default("contratos").describe("contratos = contrato firmado (padrão); atas_registro_preco = compromisso de preços p/ compras futuras; notas_empenho = reserva orçamentária do gasto"),
|
|
150
200
|
secao: z.enum(["itens", "pagamentos", "garantias", "aditivos", "acionamentos"]).describe("Aspecto a detalhar: itens/pagamentos/garantias (qualquer tipo); aditivos (só contratos); acionamentos (só atas_registro_preco)"),
|
|
@@ -166,8 +216,22 @@ export function registerContratacoesTools(server, admBaseUrl) {
|
|
|
166
216
|
else {
|
|
167
217
|
path = `/contratacoes/${tipo}/${params.id}/${params.secao}`;
|
|
168
218
|
}
|
|
169
|
-
const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_contratacao_detalhe", { tipo, id: params.id, secao: params.secao }, CACHE_ON_DEMAND,
|
|
219
|
+
const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_contratacao_detalhe", { tipo, id: params.id, secao: params.secao }, CACHE_ON_DEMAND,
|
|
220
|
+
// `empty` sobrescreve o `absent` que `admFetch` passou a usar em
|
|
221
|
+
// 24/09/2026: aqui o 404 é mesmo ambíguo (ver `paiExiste`), e quem
|
|
222
|
+
// desfaz a ambiguidade é a conferência do pai, logo abaixo.
|
|
223
|
+
() => admFetch(path, {}, admBaseUrl, { on404: "empty" }));
|
|
170
224
|
const todos = ensureArray(response);
|
|
225
|
+
// Seção vazia: só agora vale pagar a conferência do pai — e ela é
|
|
226
|
+
// acerto de cache. Pai ausente => ausência tipada (`nao_encontrado`);
|
|
227
|
+
// pai presente => o zero é VERDADE e segue como `count: 0`.
|
|
228
|
+
if (todos.length === 0) {
|
|
229
|
+
const existe = await paiExiste(tipo, params.id, admBaseUrl);
|
|
230
|
+
if (existe === false) {
|
|
231
|
+
return toolError(`Não existe ${tipo} com id ${params.id}. Obtenha o id em ` +
|
|
232
|
+
(tipo === "contratos" ? "senado_contratos" : "senado_contratacoes_lista") + ".");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
171
235
|
const limite = params.limite ?? 100;
|
|
172
236
|
const prov = provenanceFor("SENADO_ADM", admBaseUrl, `/api/v1${path}`, {
|
|
173
237
|
dataset_id: `${tipo}=${params.id}; secao=${params.secao}`, retrieved_at: fetchedAt,
|
package/dist/tools/plenario.js
CHANGED
|
@@ -112,7 +112,14 @@ export function parseSessaoResultado(s) {
|
|
|
112
112
|
/** Parse an orientacaoBancada votacao item (flat camelCase). */
|
|
113
113
|
export function parseOrientacaoVotacao(v) {
|
|
114
114
|
return {
|
|
115
|
-
|
|
115
|
+
// `codigoVotacaoSve`, com o nome da FONTE, e não `codigoVotacao`. Medido em
|
|
116
|
+
// 24/09/2026: é um TERCEIRO espaço de numeração — as 3 votações de
|
|
117
|
+
// 12/08/2026 são 13059/13159/13161 aqui, 7101/7102/7103 em
|
|
118
|
+
// `senado_votacoes_senador` e a sessão 581816 em `senado_obter_votacao`. O
|
|
119
|
+
// campo `codigoVotacaoSve` não aparece UMA vez em `/votacao`, então nenhuma
|
|
120
|
+
// tool o resolve; chamá-lo de `codigoVotacao` convidava a alimentar o
|
|
121
|
+
// `senado_obter_votacao` com ele e receber `count: 0` calado.
|
|
122
|
+
codigoVotacaoSve: v.codigoVotacaoSve ?? null,
|
|
116
123
|
descricao: v.descricaoVotacao || null,
|
|
117
124
|
materia: v.descricaoMateria ||
|
|
118
125
|
(v.siglaTipoMateria ? `${v.siglaTipoMateria} ${v.numeroMateria}/${v.anoMateria}` : null),
|
|
@@ -214,7 +221,7 @@ export function registerPlenarioTools(server, baseUrl) {
|
|
|
214
221
|
}
|
|
215
222
|
});
|
|
216
223
|
// F3. senado_orientacao_bancada
|
|
217
|
-
server.tool("senado_orientacao_bancada", "Orientação de bancada nas votações de plenário: como cada liderança partidária orientou o voto, com placar — essencial para análise de disciplina partidária. Retorna `{ count, votacoes }`, com cada votação trazendo `
|
|
224
|
+
server.tool("senado_orientacao_bancada", "Orientação de bancada nas votações de plenário: como cada liderança partidária orientou o voto, com placar — essencial para análise de disciplina partidária. Retorna `{ count, votacoes }`, com cada votação trazendo `codigoVotacaoSve`, `descricao`, `materia`, `dataInicio`, `dataTermino`, `sessao`, totais (`totalSim`, `totalNao`, `totalAbstencao`, `obstrucoes`), `quorumInicial`/`quorumFinal` e `orientacoes` (`partido`, `voto`). Informe `data` (um dia) ou o período `dataInicio`/`dataFim`. O `codigoVotacaoSve` é o identificador interno do sistema de votação eletrônica e pertence a um espaço de numeração próprio: NÃO serve como entrada de `senado_obter_votacao` (use a `data` com `senado_search_votacoes` para chegar ao código da votação). Para o resultado das sessões use `senado_resultado_plenario`.", {
|
|
218
225
|
data: z.string().regex(/^\d{8}$/).optional().describe("Data da sessão (YYYYMMDD)"),
|
|
219
226
|
dataInicio: z.string().regex(/^\d{8}$/).optional().describe("Data início do período (YYYYMMDD)"),
|
|
220
227
|
dataFim: z.string().regex(/^\d{8}$/).optional().describe("Data fim do período (YYYYMMDD)"),
|
package/dist/tools/senadores.js
CHANGED
|
@@ -115,6 +115,14 @@ export function parseSenadorDetalhe(dados) {
|
|
|
115
115
|
export function parseVotoSenador(v, codigoSenador) {
|
|
116
116
|
const voto = ensureArray(v.votos).find((vt) => vt.codigoParlamentar === codigoSenador);
|
|
117
117
|
return {
|
|
118
|
+
// O PAR, não um dos dois. Até 24/09/2026 este campo servia só o
|
|
119
|
+
// `codigoSessaoVotacao` (4 dígitos) e a descrição mandava usar
|
|
120
|
+
// `senado_obter_votacao` com ele — que filtrava por `codigoSessao` e
|
|
121
|
+
// respondia `count: 0`. Medido: 48 de 48 votos de um senador em 2026 saíam
|
|
122
|
+
// com código de 4 dígitos, ou seja, o caminho documentado quebrava sempre.
|
|
123
|
+
// Hoje as duas pontas resolvem, e emitir os dois poupa a varredura da
|
|
124
|
+
// janela: com `codigoSessao` o `obter_votacao` acerta de primeira.
|
|
125
|
+
codigoSessao: v.codigoSessao || null,
|
|
118
126
|
codigoVotacao: v.codigoSessaoVotacao || v.codigoSessao || 0,
|
|
119
127
|
data: v.dataSessao ? String(v.dataSessao).split("T")[0] : "",
|
|
120
128
|
materia: v.identificacao || (v.sigla ? `${v.sigla} ${v.numero}/${v.ano}` : ""),
|
|
@@ -291,7 +299,7 @@ export function registerSenadoresTools(server, baseUrl) {
|
|
|
291
299
|
}
|
|
292
300
|
});
|
|
293
301
|
// A4. senado_votacoes_senador (migrated to v3 /votacao?codigoParlamentar — legacy endpoint deprecated)
|
|
294
|
-
server.tool("senado_votacoes_senador", "Lista as votações nominais de um senador, mostrando como votou em cada matéria. Retorna `{ periodo, count, votos }`, cada voto com `codigoVotacao`, `data`, `materia`, `descricao`, `voto` e `resultado`, ordenados da mais recente para a mais antiga. Sem período usa o ano corrente; informe `ano` ou o par `dataInicio`/`dataFim` (YYYYMMDD). Requer `codigoSenador` (obtenha via `senado_listar_senadores`); para detalhes de uma votação específica use `senado_obter_votacao
|
|
302
|
+
server.tool("senado_votacoes_senador", "Lista as votações nominais de um senador, mostrando como votou em cada matéria. Retorna `{ periodo, count, votos }`, cada voto com `codigoSessao`, `codigoVotacao`, `data`, `materia`, `descricao`, `voto` e `resultado`, ordenados da mais recente para a mais antiga. Sem período usa o ano corrente; informe `ano` ou o par `dataInicio`/`dataFim` (YYYYMMDD). Requer `codigoSenador` (obtenha via `senado_listar_senadores`); para detalhes de uma votação específica (votos de todos os senadores) use `senado_obter_votacao` — os dois códigos são aceitos, e `codigoSessao` resolve direto.", {
|
|
295
303
|
codigoSenador: z.number().int().positive().describe("Código único do senador"),
|
|
296
304
|
ano: z.number().int().min(1900).max(2100).optional().describe("Ano das votações"),
|
|
297
305
|
dataInicio: z.string().regex(/^\d{8}$/).optional().describe("Data início (YYYYMMDD)"),
|
package/dist/tools/supridos.js
CHANGED
|
@@ -191,8 +191,13 @@ export async function nomesSupridos(ano, admBaseUrl) {
|
|
|
191
191
|
}
|
|
192
192
|
export function registerSupridosTools(server, admBaseUrl) {
|
|
193
193
|
// R1. senado_suprimento_fundos
|
|
194
|
-
server.tool("senado_suprimento_fundos", "Suprimento de fundos do Senado (adiantamentos a supridos): relação anual de supridos, atos de concessão, empenhos, movimentações ou transações de cartão corporativo, conforme `tipo`. Retorna `{ ano, tipo, count, total, registros }` (snake_case da API administrativa), filtrável por `filtro` textual e limitado por `limite` (padrão 100, máx 500); ao truncar, inclui `aviso`. Para maior/menor/média/mediana/distribuição/ranking ('quem mais recebeu', 'fornecedor com maior gasto', 'valor mediano') use `estatisticas=true` (só nos tipos `transacoes`, `empenhos`, `atos-concessao` — os demais não têm coluna de valor): SEM `agruparPor` = distribuição das linhas (min/máx/média/mediana/percentis) + top/bottom; COM `agruparPor` = grupos ranqueados por soma decrescente (grupos[0]=maior). A coluna de valor analisada é escolhida automaticamente conforme o `tipo`; o resultado já traz o rótulo legível dela em `campoAnalisado`. Registros sem valor são excluídos das estatísticas. Em atos de concessão, cada beneficiário é identificado pelo NOME (cruzado com o cadastro de supridos) e pode-se usar `agruparPor='suprido'` para ranquear por beneficiário. Informe o `ano` (
|
|
195
|
-
|
|
194
|
+
server.tool("senado_suprimento_fundos", "Suprimento de fundos do Senado (adiantamentos a supridos): relação anual de supridos, atos de concessão, empenhos, movimentações ou transações de cartão corporativo, conforme `tipo`. Retorna `{ ano, tipo, count, total, registros }` (snake_case da API administrativa), filtrável por `filtro` textual e limitado por `limite` (padrão 100, máx 500); ao truncar, inclui `aviso`. Para maior/menor/média/mediana/distribuição/ranking ('quem mais recebeu', 'fornecedor com maior gasto', 'valor mediano') use `estatisticas=true` (só nos tipos `transacoes`, `empenhos`, `atos-concessao` — os demais não têm coluna de valor): SEM `agruparPor` = distribuição das linhas (min/máx/média/mediana/percentis) + top/bottom; COM `agruparPor` = grupos ranqueados por soma decrescente (grupos[0]=maior). A coluna de valor analisada é escolhida automaticamente conforme o `tipo`; o resultado já traz o rótulo legível dela em `campoAnalisado`. Registros sem valor são excluídos das estatísticas. Em atos de concessão, cada beneficiário é identificado pelo NOME (cruzado com o cadastro de supridos) e pode-se usar `agruparPor='suprido'` para ranquear por beneficiário. Informe o `ano` (a fonte publica de 2013 em diante); use os mesmos códigos administrativos vistos em `senado_contratacoes_lista` ou `senado_execucao_orcamentaria` para cruzar gastos.", {
|
|
195
|
+
// 2013, e não o 2010 que estava aqui: medido em 24/09/2026, `/supridos/`
|
|
196
|
+
// de 2009 a 2012 responde 404 e 2013 responde 200. Com o
|
|
197
|
+
// `treat404AsEmpty` antigo, pedir 2011 devolvia `count: 0` — lia-se
|
|
198
|
+
// "o Senado não adiantou nada em 2011". O limite fecha a porta antes da
|
|
199
|
+
// rede (classe `contrato`); fora dela, a ausência agora é tipada.
|
|
200
|
+
ano: z.number().int().min(2013).max(2100).describe("Ano de referência (a fonte publica de 2013 em diante)"),
|
|
196
201
|
tipo: z.enum(["supridos", "atos-concessao", "empenhos", "movimentacoes", "transacoes"]).optional().default("supridos").describe("Qual relação consultar (padrão: supridos)"),
|
|
197
202
|
filtro: z.string().optional().describe("Filtro textual (nome, unidade...)"),
|
|
198
203
|
estatisticas: z.boolean().optional().default(false).describe("Distribuição/ranking sobre as linhas: min/máx/média/mediana/percentis + top/bottom, ou grupos ranqueados por soma via agruparPor. Só para tipo transacoes/empenhos/atos-concessao"),
|
|
@@ -85,7 +85,15 @@ export function registerTaquigrafiaTools(server, baseUrl) {
|
|
|
85
85
|
// The acervo answers 404 for codes without transcript (CN joint sessions,
|
|
86
86
|
// cancelled/not-held sessions, some solemn ones) — serve the promised
|
|
87
87
|
// empty result instead of an upstream error.
|
|
88
|
-
|
|
88
|
+
//
|
|
89
|
+
// `empty` DE PROPÓSITO, ao contrário da classe administrativa que a
|
|
90
|
+
// varredura de 24/09/2026 converteu para `absent`: aqui o zero não é
|
|
91
|
+
// calado. Ele vem com `NOTAS_TAQUIGRAFICAS_AVISO_VAZIO`, que nomeia os
|
|
92
|
+
// buracos estruturais da cobertura e manda conferir o `tipo` — e a
|
|
93
|
+
// descrição da tool promete esse formato. O ganho da borda nova chega
|
|
94
|
+
// mesmo assim: rota montada errada agora falha alto em vez de virar
|
|
95
|
+
// "sem notas".
|
|
96
|
+
() => upstreamFetch(path, {}, baseUrl, { on404: "empty" }));
|
|
89
97
|
const nt = response?.notasTaquigraficas ?? response;
|
|
90
98
|
const quartosTodos = ensureArray(nt?.quartos);
|
|
91
99
|
let quartos = quartosTodos;
|
|
@@ -147,8 +155,9 @@ export function registerTaquigrafiaTools(server, baseUrl) {
|
|
|
147
155
|
const tipo = params.tipo ?? "sessao";
|
|
148
156
|
const path = `/taquigrafia/videos/${tipo}/${params.id}`;
|
|
149
157
|
const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_videos_taquigrafia", { tipo, id: params.id }, CACHE_ON_DEMAND,
|
|
150
|
-
// Same 404-as-empty posture as the notes acervo (missing media -> 404)
|
|
151
|
-
|
|
158
|
+
// Same 404-as-empty posture as the notes acervo (missing media -> 404),
|
|
159
|
+
// e pelo mesmo motivo: o zero aqui fala, via VIDEOS_TAQUIGRAFIA_AVISO_VAZIO.
|
|
160
|
+
() => upstreamFetch(path, {}, baseUrl, { on404: "empty" }));
|
|
152
161
|
const videosTodos = ensureArray(response).map(parseVideoUnidade);
|
|
153
162
|
let videos = videosTodos;
|
|
154
163
|
if (params.orador) {
|
package/dist/tools/votacoes.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { cachedFetchWithMeta } from "../cache/manager.js";
|
|
11
11
|
import { upstreamFetch } from "../throttle/upstream.js";
|
|
12
|
-
import { errorFrom, buildParams, ensureArray } from "../utils/validation.js";
|
|
12
|
+
import { toolError, errorFrom, buildParams, ensureArray } from "../utils/validation.js";
|
|
13
13
|
import { computarPlacar } from "../utils/placar.js";
|
|
14
14
|
import { provenanceFor, resultWithProvenance } from "../utils/provenance.js";
|
|
15
15
|
import { CACHE_ON_DEMAND } from "../types.js";
|
|
@@ -86,23 +86,89 @@ export function parseVotacaoItem(v, includeVotos = false) {
|
|
|
86
86
|
}
|
|
87
87
|
return result;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Os DOIS espaços de numeração que o item de `/votacao` carrega ao mesmo tempo.
|
|
91
|
+
*
|
|
92
|
+
* | campo da fonte | exemplo | o que é |
|
|
93
|
+
* |-----------------------|----------|----------------------------------|
|
|
94
|
+
* | `codigoSessao` | 581816 | a sessão plenária (6 dígitos) |
|
|
95
|
+
* | `codigoSessaoVotacao` | 7101 | a votação dentro dela (4 dígitos)|
|
|
96
|
+
*
|
|
97
|
+
* `/votacao` só filtra pelo primeiro: medido em 24/09/2026,
|
|
98
|
+
* `?codigoSessao=581816` devolve 3 votações e 81 votos nominais, enquanto
|
|
99
|
+
* `?codigoSessao=7101` devolve `[]`. E a fonte **ignora filtro desconhecido**
|
|
100
|
+
* em vez de recusá-lo — `?codigoSessaoVotacao=7101` devolve a base inteira
|
|
101
|
+
* (1,77 MB) —, então não existe filtro server-side pelo código da votação:
|
|
102
|
+
* resolver é do nosso lado ([[esquema-que-nao-recusa-responde-outra-pergunta]]).
|
|
103
|
+
*/
|
|
104
|
+
export const CAMPO_SESSAO = "codigoSessao";
|
|
105
|
+
export const CAMPO_VOTACAO = "codigoSessaoVotacao";
|
|
106
|
+
/**
|
|
107
|
+
* Acha, numa lista crua de `/votacao`, o item cujo `codigoSessaoVotacao` é
|
|
108
|
+
* `codigo`. Devolve `undefined` quando não está na janela.
|
|
109
|
+
*
|
|
110
|
+
* Existe separada do handler para a guarda poder exercitá-la sem montar URL —
|
|
111
|
+
* o conferidor não pode reusar o caminho do defeito
|
|
112
|
+
* ([[guarda-que-reusa-o-padrao-do-defeito]]).
|
|
113
|
+
*/
|
|
114
|
+
export function acharPorCodigoVotacao(lista, codigo) {
|
|
115
|
+
return ensureArray(lista).find((v) => {
|
|
116
|
+
const bruto = v?.[CAMPO_VOTACAO];
|
|
117
|
+
if (bruto === null || bruto === undefined || bruto === "")
|
|
118
|
+
return false;
|
|
119
|
+
return Number(bruto) === codigo;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
89
122
|
export function registerVotacoesTools(server, baseUrl) {
|
|
90
123
|
// D3. senado_obter_votacao
|
|
91
|
-
server.tool("senado_obter_votacao", "Obtém detalhes de uma votação de **plenário
|
|
92
|
-
codigoVotacao: z.number().int().positive().describe("Código
|
|
124
|
+
server.tool("senado_obter_votacao", "Obtém detalhes de uma votação de **plenário**, incluindo votos nominais. `codigoVotacao` aceita OS DOIS códigos que a fonte publica para a mesma votação: o `codigoVotacao` de 4 dígitos (ex.: 7101 — o que `senado_search_votacoes` e `senado_votacoes_senador` devolvem nesse campo) ou o `codigoSessao` de 6 dígitos da sessão plenária (ex.: 581816). Com o código da votação retorna aquela votação; com o da sessão retorna `{ codigoSessao, count, votacoes }` com todas as votações da sessão. Cada votação traz placar, `resultado` legível + `resultadoCodigo` bruto, `secreta` e `votos[]` (`codigoSenador`, `nomeSenador`, `partido`, `uf`, `voto`). Resolver o código de 4 dígitos exige varrer uma janela temporal: por padrão a janela recente da fonte (~12 meses); para votação mais antiga informe `ano`. Código que não existe em nenhum dos dois espaços retorna erro — nunca lista vazia. Atenção: códigos de `senado_votacao_comissao` e o `codigoVotacao` de `senado_orientacao_bancada` pertencem a OUTROS espaços de numeração e não são válidos aqui.", {
|
|
125
|
+
codigoVotacao: z.number().int().positive().describe("Código da votação (4 dígitos, ex. 7101) OU o codigoSessao da sessão plenária (6 dígitos, ex. 581816) — os dois são aceitos"),
|
|
126
|
+
ano: z.number().int().min(1990).max(2100).optional().describe("Só para código de votação de 4 dígitos ANTERIOR aos últimos ~12 meses: o ano em que a votação ocorreu, para abrir a janela de busca"),
|
|
93
127
|
}, async (params) => {
|
|
94
128
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
129
|
+
const codigo = params.codigoVotacao;
|
|
130
|
+
// PASSO 1 — tentar como codigoSessao, o ÚNICO filtro que a fonte honra.
|
|
131
|
+
const qp = { [CAMPO_SESSAO]: String(codigo) };
|
|
132
|
+
const { value: response, fetchedAt } = await cachedFetchWithMeta("senado_obter_votacao", { codigo }, CACHE_ON_DEMAND, () => upstreamFetch("/votacao", qp, baseUrl));
|
|
97
133
|
const votacoes = ensureArray(response).map((v) => parseVotacaoItem(v, true));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
134
|
+
if (votacoes.length > 0) {
|
|
135
|
+
const prov = provenanceFor("SENADO_LEGIS", baseUrl, "/votacao", {
|
|
136
|
+
dataset_id: `${CAMPO_SESSAO}=${codigo}`,
|
|
137
|
+
reference_period: votacoes[0]?.data || undefined,
|
|
138
|
+
retrieved_at: fetchedAt,
|
|
139
|
+
});
|
|
140
|
+
if (votacoes.length === 1)
|
|
141
|
+
return resultWithProvenance(votacoes[0], prov);
|
|
142
|
+
return resultWithProvenance({ codigoSessao: codigo, count: votacoes.length, votacoes }, prov);
|
|
143
|
+
}
|
|
144
|
+
// PASSO 2 — vazio não é resposta: o número pode ser do OUTRO espaço.
|
|
145
|
+
// Até 24/09/2026 o handler paravaaqui e devolvia `{count: 0}` — para
|
|
146
|
+
// 7101, que é a votação do PLP 124/2022 com 69 Sim. Pior que zero
|
|
147
|
+
// calado: indistinguível do zero de um id inventado.
|
|
148
|
+
const janela = params.ano
|
|
149
|
+
? { dataInicio: `${params.ano}-01-01`, dataFim: `${params.ano}-12-31` }
|
|
150
|
+
: {};
|
|
151
|
+
const { value: cru, fetchedAt: fetchedAtJanela } = await cachedFetchWithMeta("senado_obter_votacao_janela", janela, CACHE_ON_DEMAND, () => upstreamFetch("/votacao", janela, baseUrl));
|
|
152
|
+
const achado = acharPorCodigoVotacao(cru, codigo);
|
|
153
|
+
if (achado) {
|
|
154
|
+
const votacao = parseVotacaoItem(achado, true);
|
|
155
|
+
return resultWithProvenance(votacao, provenanceFor("SENADO_LEGIS", baseUrl, "/votacao", {
|
|
156
|
+
dataset_id: `${CAMPO_VOTACAO}=${codigo}`,
|
|
157
|
+
reference_period: votacao.data || undefined,
|
|
158
|
+
retrieved_at: fetchedAtJanela,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
// PASSO 3 — não está em nenhum dos dois espaços: ausência TIPADA.
|
|
162
|
+
const ondeProcurou = params.ano
|
|
163
|
+
? `no ano ${params.ano}`
|
|
164
|
+
: "na janela recente da fonte (~12 meses)";
|
|
165
|
+
return toolError(`Não existe votação de plenário com o código ${codigo}: ele não é ` +
|
|
166
|
+
`codigoSessao de nenhuma sessão nem codigoVotacao ${ondeProcurou}.` +
|
|
167
|
+
(params.ano
|
|
168
|
+
? " Confira o ano e o código em senado_search_votacoes."
|
|
169
|
+
: " Se a votação é anterior, informe o `ano`; ou obtenha o código em" +
|
|
170
|
+
" senado_search_votacoes. O `codigoVotacao` de senado_orientacao_bancada" +
|
|
171
|
+
" pertence a outro espaço de numeração e não é aceito aqui."));
|
|
106
172
|
}
|
|
107
173
|
catch (e) {
|
|
108
174
|
return errorFrom(e, "Votação não encontrada");
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "senado-br-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"build:dataset": "tsx scripts/build-dataset/index.ts",
|
|
46
46
|
"dataset:dictionary": "tsx scripts/build-dataset/index.ts --dictionary-only",
|
|
47
47
|
"release:dataset": "tsx scripts/build-dataset/release.ts",
|
|
48
|
-
"release:zenodo": "tsx scripts/build-dataset/zenodo-publish.ts"
|
|
48
|
+
"release:zenodo": "tsx scripts/build-dataset/zenodo-publish.ts",
|
|
49
|
+
"manifest:lhm": "node scripts/gen-lhm-manifest.mjs"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"@modelcontextprotocol/server": "^2.0.0",
|