senado-br-mcp 3.9.0 → 3.11.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 -1
- package/dist/call-shape.js +47 -0
- package/dist/envelope.js +257 -0
- package/dist/instrument.js +41 -47
- package/dist/scraper/ecidadania.js +54 -61
- package/dist/server.js +1 -1
- package/dist/throttle/adm.js +10 -2
- package/dist/throttle/upstream.js +277 -133
- package/dist/tools/comissoes.js +23 -42
- package/dist/tools/contratacoes.js +66 -2
- package/dist/tools/deep-research.js +14 -5
- package/dist/tools/discursos.js +13 -40
- 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/utils/provenance.js +2 -0
- package/dist/version.js +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](https://lobehub.com/mcp/sidneybissoli-senado-br-mcp-cloudflare)
|
|
9
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
|
+
[](https://smithery.ai/servers/sidneybissoli/senado-br-mcp-cloudflare)
|
|
11
12
|
[](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare)
|
|
12
13
|
[](https://github.com/sponsors/SidneyBissoli)
|
|
13
14
|
[](LICENSE)
|
|
@@ -153,7 +154,7 @@ Claude Code auto-discovers it when you work in this repo. To use it elsewhere, c
|
|
|
153
154
|
- **Caching:** 2-layer (L0 memory + L1 Cache API) with SHA-256 keying
|
|
154
155
|
- **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))
|
|
155
156
|
- **Rate limiting:** Token bucket — global (8 req/s) + per-client (2 req/s)
|
|
156
|
-
- **Upstream throttle:** Max 6 concurrent requests, 10s
|
|
157
|
+
- **Upstream throttle:** Max 6 concurrent requests, 10s budget per trip, retry on 429/5xx/network with exponential backoff and `Retry-After` (portfolio shared fetch `@sbissoli/mcp-upstream`); every response's provenance carries the measured `retrieval` diagnostics (trips, attempts, anomalies)
|
|
157
158
|
- **Auth:** Optional Bearer token (set the `API_KEY` secret; open access when unset). Constant-time comparison.
|
|
158
159
|
- **Observability:** Structured JSON logging + in-memory counters at `/metrics`; per-tool call telemetry (selection, error rate, cache-vs-live) in Cloudflare Analytics Engine, PII-free
|
|
159
160
|
- **Liveness:** Runs on Cloudflare's own global network behind a custom domain — no third-party host that can go dark. Public `/health` and `/status` (version + last-deploy id/timestamp) make uptime and the current build verifiable; the **status** badge above pings the live endpoint
|
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,9 +28,11 @@
|
|
|
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";
|
|
35
|
+
import { withUpstreamCall } from "./throttle/upstream.js";
|
|
34
36
|
/** Header the owner's MCP clients send (value = the SELF_MARKER secret). */
|
|
35
37
|
export const SELF_HEADER = "x-mcp-self";
|
|
36
38
|
/**
|
|
@@ -62,7 +64,7 @@ export function tagRequest(request, selfSecret, sessao = "") {
|
|
|
62
64
|
sessao,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
|
-
export function instrumentTool(name, cb, analytics, tag) {
|
|
67
|
+
export function instrumentTool(name, cb, analytics, tag, gravados) {
|
|
66
68
|
return async (...args) => {
|
|
67
69
|
incr("toolCalls");
|
|
68
70
|
let isError = false;
|
|
@@ -70,7 +72,12 @@ export function instrumentTool(name, cb, analytics, tag) {
|
|
|
70
72
|
// Per-call store the cache layer increments per upstream fetch (see call-context.ts).
|
|
71
73
|
const stats = { fetches: 0, hits: 0 };
|
|
72
74
|
try {
|
|
73
|
-
|
|
75
|
+
// Dois contextos por chamada, aninhados: o coletor de REDE do fetch comum
|
|
76
|
+
// (idas, tentativas, anomalias → `retrieval` da proveniência; ver
|
|
77
|
+
// src/throttle/upstream.ts) e o acumulador de CACHE (acertos → blob3).
|
|
78
|
+
// Este é o único lugar em que uma chamada de tool começa — stdio e Worker
|
|
79
|
+
// passam pelo mesmo funil (`host.tool` em src/server.ts).
|
|
80
|
+
const result = await withUpstreamCall(() => callCache.run(stats, () => Promise.resolve(cb(...args))));
|
|
74
81
|
isError =
|
|
75
82
|
typeof result === "object" && result !== null && result.isError === true;
|
|
76
83
|
if (isError)
|
|
@@ -93,6 +100,12 @@ export function instrumentTool(name, cb, analytics, tag) {
|
|
|
93
100
|
// chamada nem como erro. Medido em 10/09/2026 pelo /metrics: três
|
|
94
101
|
// chamadas com argumento de forma errada não moveram o contador.
|
|
95
102
|
recordToolCall(name, isError, stats, analytics, tag, classe, paramNames(args));
|
|
103
|
+
// Recibo desta requisicao: e contra ele que recordProtocolMethods
|
|
104
|
+
// reconcilia. Fica no `finally` junto da gravacao, para os dois nunca
|
|
105
|
+
// discordarem. A recusa de esquema NAO passa por aqui -- e exatamente
|
|
106
|
+
// por isso que ela nao deixa recibo, e a linha dela e escrita la.
|
|
107
|
+
if (gravados)
|
|
108
|
+
gravados.set(name, (gravados.get(name) ?? 0) + 1);
|
|
96
109
|
}
|
|
97
110
|
};
|
|
98
111
|
}
|
|
@@ -135,71 +148,52 @@ function recordToolCall(name, isError, stats, analytics, tag, errorClass = "", p
|
|
|
135
148
|
}
|
|
136
149
|
}
|
|
137
150
|
/**
|
|
138
|
-
*
|
|
151
|
+
* As mensagens do corpo que ainda precisam de linha, já descontado o recibo.
|
|
139
152
|
*
|
|
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.
|
|
153
|
+
* `gravados` é CONSUMIDO (decrementado) durante a varredura. Chamar sem ele
|
|
154
|
+
* significa "a instrumentTool não gravou nada".
|
|
169
155
|
*/
|
|
170
|
-
export function
|
|
156
|
+
export function protocolMessagesFromBody(body, gravados) {
|
|
171
157
|
const itens = Array.isArray(body) ? body : [body];
|
|
172
|
-
const
|
|
158
|
+
const mensagens = [];
|
|
173
159
|
for (const item of itens) {
|
|
174
160
|
if (!item || typeof item !== "object")
|
|
175
161
|
continue;
|
|
176
162
|
const msg = item;
|
|
177
163
|
if (typeof msg.method !== "string" || msg.method === "")
|
|
178
164
|
continue;
|
|
165
|
+
const id = typeof msg.id === "string" || typeof msg.id === "number" ? String(msg.id) : "";
|
|
179
166
|
if (msg.method === "tools/call") {
|
|
180
|
-
if (status < 400)
|
|
181
|
-
continue; // instrumentTool já gravou esta
|
|
182
167
|
const params = msg.params;
|
|
183
|
-
|
|
168
|
+
const nome = typeof params?.name === "string" && params.name !== "" ? params.name : "tools/call";
|
|
169
|
+
const recibo = gravados?.get(nome) ?? 0;
|
|
170
|
+
if (recibo > 0) {
|
|
171
|
+
gravados?.set(nome, recibo - 1); // a instrumentTool já gravou esta
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
mensagens.push({ nome, id });
|
|
184
175
|
}
|
|
185
176
|
else {
|
|
186
|
-
|
|
177
|
+
mensagens.push({ nome: msg.method, id });
|
|
187
178
|
}
|
|
188
179
|
}
|
|
189
|
-
return
|
|
180
|
+
return mensagens;
|
|
190
181
|
}
|
|
191
182
|
/**
|
|
192
183
|
* Grava no Analytics Engine os métodos de protocolo de um POST no endpoint
|
|
193
|
-
* MCP (ver
|
|
194
|
-
*
|
|
184
|
+
* MCP (ver acima). Devolve os nomes gravados. Sem binding, não grava nada.
|
|
185
|
+
*
|
|
186
|
+
* `desfechos` é o que a leitura do envelope colheu por `id`; sem ele — corpo
|
|
187
|
+
* não teado, stream cortado — vale o HTTP, que é o critério de reserva.
|
|
195
188
|
*/
|
|
196
|
-
export function recordProtocolMethods(analytics, tag, body, status) {
|
|
189
|
+
export function recordProtocolMethods(analytics, tag, body, status, desfechos = new Map(), gravados) {
|
|
197
190
|
if (!analytics || body === undefined)
|
|
198
191
|
return [];
|
|
199
192
|
const cliente = clientNameFromBody(body);
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
for (const name of
|
|
193
|
+
const mensagens = protocolMessagesFromBody(body, gravados);
|
|
194
|
+
const falhouHttp = status >= 400;
|
|
195
|
+
for (const { nome: name, id } of mensagens) {
|
|
196
|
+
const { erro: isError, classe } = resolveDesfecho(id, desfechos, falhouHttp);
|
|
203
197
|
try {
|
|
204
198
|
analytics.writeDataPoint({
|
|
205
199
|
indexes: [name],
|
|
@@ -210,7 +204,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
|
|
|
210
204
|
tag?.self ? "self" : "",
|
|
211
205
|
tag?.country ?? "",
|
|
212
206
|
tag?.asOrg ?? "",
|
|
213
|
-
|
|
207
|
+
classe,
|
|
214
208
|
"",
|
|
215
209
|
tag?.sessao ?? "",
|
|
216
210
|
name === "initialize" ? cliente : "",
|
|
@@ -222,7 +216,7 @@ export function recordProtocolMethods(analytics, tag, body, status) {
|
|
|
222
216
|
// Falha de telemetria nunca quebra nem atrasa a resposta.
|
|
223
217
|
}
|
|
224
218
|
}
|
|
225
|
-
return
|
|
219
|
+
return mensagens.map((m) => m.nome);
|
|
226
220
|
}
|
|
227
221
|
/**
|
|
228
222
|
* SESSÃO E CLIENTE (blobs 9 e 10, desde 2026-09-17).
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* Everything here is pure parsing + fetch, with no MCP/tool/cache dependency, so it can be
|
|
10
10
|
* driven by the Cron pipeline (step 3) and covered by fixture-based contract tests.
|
|
11
11
|
*/
|
|
12
|
+
import { UpstreamError as PkgUpstreamError } from "@sbissoli/mcp-upstream";
|
|
12
13
|
import { UPSTREAM_TIMEOUT_MS } from "../types.js";
|
|
13
14
|
import { USER_AGENT } from "../version.js";
|
|
15
|
+
import { upstreamCall } from "../throttle/upstream.js";
|
|
14
16
|
export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
|
|
15
17
|
/**
|
|
16
18
|
* Error carrying a `retryable` flag, mirroring UpstreamError so the tool layer
|
|
@@ -28,78 +30,69 @@ const isTransientStatus = (status) => status >= 500 || status === 429;
|
|
|
28
30
|
* (`X-Requested-With: XMLHttpRequest`, wider `Accept`).
|
|
29
31
|
*/
|
|
30
32
|
export async function fetchPage(path, opts = {}) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
signal: controller.signal,
|
|
40
|
-
});
|
|
41
|
-
clearTimeout(timeout);
|
|
42
|
-
if (!resp.ok) {
|
|
43
|
-
throw ecidadaniaFetchError(`e-Cidadania retornou HTTP ${resp.status} para ${path}`, isTransientStatus(resp.status));
|
|
44
|
-
}
|
|
45
|
-
const text = await resp.text();
|
|
46
|
-
// AJAX comment fragments are legitimately empty for events with zero comments — allowEmpty
|
|
47
|
-
// lets the caller treat "" as a valid (empty) result instead of a transient failure.
|
|
48
|
-
if (!opts.allowEmpty && !text.trim()) {
|
|
49
|
-
throw ecidadaniaFetchError(`e-Cidadania retornou página vazia para ${path}`, true);
|
|
50
|
-
}
|
|
51
|
-
return text;
|
|
52
|
-
}
|
|
53
|
-
catch (e) {
|
|
54
|
-
clearTimeout(timeout);
|
|
55
|
-
if (e.name === "AbortError") {
|
|
56
|
-
throw ecidadaniaFetchError(`e-Cidadania: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true);
|
|
57
|
-
}
|
|
58
|
-
if (e instanceof Error && "retryable" in e)
|
|
59
|
-
throw e; // already classified above
|
|
60
|
-
throw ecidadaniaFetchError(`e-Cidadania: falha de rede ao acessar ${path} (${e.message})`, true);
|
|
33
|
+
const resp = await idaEcidadania(path, opts.ajax
|
|
34
|
+
? { Accept: "text/html,*/*", "User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest" }
|
|
35
|
+
: { Accept: "text/html", "User-Agent": USER_AGENT }, "e-Cidadania");
|
|
36
|
+
const text = await resp.text();
|
|
37
|
+
// AJAX comment fragments are legitimately empty for events with zero comments — allowEmpty
|
|
38
|
+
// lets the caller treat "" as a valid (empty) result instead of a transient failure.
|
|
39
|
+
if (!opts.allowEmpty && !text.trim()) {
|
|
40
|
+
throw ecidadaniaFetchError(`e-Cidadania retornou página vazia para ${path}`, true);
|
|
61
41
|
}
|
|
42
|
+
return text;
|
|
62
43
|
}
|
|
63
44
|
/**
|
|
64
45
|
* Fetch JSON from an e-Cidadania REST endpoint with validation.
|
|
65
46
|
* Ensures the response is valid JSON and an array.
|
|
66
47
|
*/
|
|
67
48
|
export async function fetchEcidadaniaJson(endpoint) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
|
|
49
|
+
const resp = await idaEcidadania(endpoint, { Accept: "application/json", "User-Agent": USER_AGENT }, "e-Cidadania REST API");
|
|
50
|
+
let data;
|
|
71
51
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
52
|
+
data = await resp.json();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw ecidadaniaFetchError(`e-Cidadania REST API retornou JSON inválido para ${endpoint}`, true);
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(data)) {
|
|
58
|
+
// API returned an object or other non-array — wrap or return empty
|
|
59
|
+
if (data && typeof data === "object")
|
|
60
|
+
return [data];
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A ida ao portal, pelo fetch comum do portfólio (desde a 3.11.0; antes um
|
|
67
|
+
* `fetch` cru com timeout de 10 s e sem retry). Mesma política da API do Senado
|
|
68
|
+
* (`upstreamSenado`: teto 10 s, 5xx/429/rede repetem, timeout não) e mesmo
|
|
69
|
+
* coletor da chamada de tool — o detalhe raspado ao vivo entra no `retrieval`
|
|
70
|
+
* do bloco e-Cidadania. No cron (`scheduled`, fora de uma chamada) o coletor é
|
|
71
|
+
* descartável e a política vale igual. O token bucket NÃO se aplica: é outro
|
|
72
|
+
* host, e o portal nunca passou por ele. As mensagens e o `retryable` são os
|
|
73
|
+
* de antes — `ecidadaniaError` (tools) e o pipeline os leem.
|
|
74
|
+
*/
|
|
75
|
+
async function idaEcidadania(path, headers, rotulo) {
|
|
76
|
+
const url = `${ECIDADANIA_BASE}${path}`;
|
|
77
|
+
try {
|
|
78
|
+
return await upstreamCall().response(url, { headers });
|
|
94
79
|
}
|
|
95
80
|
catch (e) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
81
|
+
if (!(e instanceof PkgUpstreamError))
|
|
82
|
+
throw e;
|
|
83
|
+
switch (e.kind) {
|
|
84
|
+
case "timeout":
|
|
85
|
+
case "aborted":
|
|
86
|
+
throw ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true);
|
|
87
|
+
case "network": {
|
|
88
|
+
const causa = e.cause instanceof Error ? e.cause.message : String(e.cause ?? "desconhecido");
|
|
89
|
+
throw ecidadaniaFetchError(`${rotulo}: falha de rede ao acessar ${path} (${causa})`, true);
|
|
90
|
+
}
|
|
91
|
+
default: {
|
|
92
|
+
const status = e.status ?? 502;
|
|
93
|
+
throw ecidadaniaFetchError(`${rotulo} retornou HTTP ${status} para ${path}`, isTransientStatus(status));
|
|
94
|
+
}
|
|
99
95
|
}
|
|
100
|
-
if (e instanceof Error && "retryable" in e)
|
|
101
|
-
throw e; // already classified above
|
|
102
|
-
throw ecidadaniaFetchError(`e-Cidadania REST API: falha de rede ao acessar ${endpoint} (${e.message})`, true);
|
|
103
96
|
}
|
|
104
97
|
}
|
|
105
98
|
/** Parse Brazilian number: 1.234.567 -> 1234567 */
|
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";
|