senado-br-mcp 3.7.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/README.md +2 -2
- package/README.pt-BR.md +1 -1
- package/dist/app-surface.js +8 -1
- package/dist/call-shape.js +215 -0
- package/dist/envelope.js +257 -0
- package/dist/instrument.js +214 -9
- package/dist/server.js +28 -2
- package/dist/throttle/adm.js +10 -2
- package/dist/throttle/upstream.js +72 -5
- package/dist/tools/comissoes.js +54 -5
- package/dist/tools/contratacoes.js +66 -2
- package/dist/tools/deep-research.js +17 -1
- package/dist/tools/materias.js +17 -0
- package/dist/tools/plenario.js +9 -2
- package/dist/tools/resolver-reuniao.js +64 -0
- package/dist/tools/senadores.js +77 -9
- package/dist/tools/supridos.js +7 -2
- package/dist/tools/taquigrafia.js +12 -3
- package/dist/tools/votacoes.js +79 -13
- package/dist/utils/estatisticas.js +23 -6
- package/dist/version.js +1 -1
- package/package.json +13 -10
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare/actions/workflows/ci.yml)
|
|
7
7
|
[](https://registry.modelcontextprotocol.io)
|
|
8
8
|
[](https://lobehub.com/mcp/sidneybissoli-senado-br-mcp-cloudflare)
|
|
9
|
-
[](https://lightnow.ai/servers/io.github.SidneyBissoli/senado-br-mcp-cloudflare)
|
|
10
10
|
[](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare)
|
|
11
11
|
[](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare)
|
|
12
12
|
[](https://github.com/sponsors/SidneyBissoli)
|
|
@@ -148,7 +148,7 @@ Claude Code auto-discovers it when you work in this repo. To use it elsewhere, c
|
|
|
148
148
|
- **Runtime:** Cloudflare Workers (ESM)
|
|
149
149
|
- **Transport:** Streamable HTTP (MCP spec 2025-03-26) via `createMcpHandler` from `agents/mcp`
|
|
150
150
|
- **Protocol:** MCP over JSON-RPC — `/mcp` handles the full public server; `/mcp/openai-app-v2` exposes a curated 27-tool profile plus a shared MCP Apps widget for OpenAI app review/submission (`/mcp/openai-app` remains as a legacy alias)
|
|
151
|
-
- **SDK:** `@modelcontextprotocol/
|
|
151
|
+
- **SDK:** `@modelcontextprotocol/server` 2.x (per-request McpServer instances; the v1 `@modelcontextprotocol/sdk` remains only as a peer of `agents`, dev-time)
|
|
152
152
|
- **Validation:** Zod schemas for all tool inputs
|
|
153
153
|
- **Caching:** 2-layer (L0 memory + L1 Cache API) with SHA-256 keying
|
|
154
154
|
- **e-Cidadania store:** D1 database refreshed by a Cron Trigger (every 2h) — list tools read from D1 with a live-scrape fallback and a staleness flag; detail tools stay live with write-through (see [e-Cidadania](#e-cidadania-d1-backed-cron-refreshed))
|
package/README.pt-BR.md
CHANGED
|
@@ -149,7 +149,7 @@ O Claude Code a descobre automaticamente ao trabalhar neste repositório. Para u
|
|
|
149
149
|
- **Runtime:** Cloudflare Workers (ESM)
|
|
150
150
|
- **Transporte:** Streamable HTTP (spec MCP 2025-03-26) via `createMcpHandler` de `agents/mcp`
|
|
151
151
|
- **Protocolo:** MCP sobre JSON-RPC — `/mcp` serve o catalogo publico completo; `/mcp/openai-app-v2` expoe um perfil curado de 27 ferramentas mais um widget MCP Apps compartilhado para revisao/submissao como app OpenAI (`/mcp/openai-app` permanece como alias legado)
|
|
152
|
-
- **SDK:** `@modelcontextprotocol/
|
|
152
|
+
- **SDK:** `@modelcontextprotocol/server` 2.x (instâncias de McpServer por requisição; o `@modelcontextprotocol/sdk` v1 fica só como peer do `agents`, em tempo de dev)
|
|
153
153
|
- **Validação:** schemas Zod para todas as entradas das ferramentas
|
|
154
154
|
- **Cache:** 2 camadas (L0 memória + L1 Cache API) com chaveamento SHA-256
|
|
155
155
|
- **Store do e-Cidadania:** banco D1 atualizado por um Cron Trigger (a cada 2h) — as ferramentas de listagem leem do D1 com fallback de scraping ao vivo e flag de desatualização; as de detalhe ficam ao vivo com write-through (veja [e-Cidadania](#e-cidadania-em-d1-atualizado-por-cron))
|
package/dist/app-surface.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export const DEFAULT_MCP_ROUTE = "/mcp";
|
|
2
2
|
export const OPENAI_APP_LEGACY_MCP_ROUTE = "/mcp/openai-app";
|
|
3
3
|
export const OPENAI_APP_MCP_ROUTE = "/mcp/openai-app-v2";
|
|
4
|
+
// Rota privada do dono: mesma superficie COMPLETA da rota publica, outro
|
|
5
|
+
// endereco, e a telemetria a marca como uso proprio (ver src/instrument.ts).
|
|
6
|
+
// Precisa ser reconhecida aqui, senao handlerRouteForPath cai no fallback e
|
|
7
|
+
// o handler e montado numa rota diferente da que a requisicao pediu.
|
|
8
|
+
export const SELF_MCP_ROUTE = "/mcp/uso-proprio";
|
|
4
9
|
export const OPENAI_APP_WIDGET_URI = "ui://senado-br-mcp/openai-app-dashboard-v2.html";
|
|
5
10
|
export const OPENAI_APP_WIDGET_DOMAIN = "https://senado.sidneybissoli.com";
|
|
6
11
|
const OPENAI_APP_MCP_ROUTES = new Set([OPENAI_APP_LEGACY_MCP_ROUTE, OPENAI_APP_MCP_ROUTE]);
|
|
@@ -89,7 +94,9 @@ export function mcpRouteForProfile(profile) {
|
|
|
89
94
|
}
|
|
90
95
|
export function handlerRouteForPath(pathname, profile) {
|
|
91
96
|
const normalized = normalizeMcpRoute(pathname);
|
|
92
|
-
if (OPENAI_APP_MCP_ROUTES.has(normalized) ||
|
|
97
|
+
if (OPENAI_APP_MCP_ROUTES.has(normalized) ||
|
|
98
|
+
normalized === DEFAULT_MCP_ROUTE ||
|
|
99
|
+
normalized === SELF_MCP_ROUTE) {
|
|
93
100
|
return pathname;
|
|
94
101
|
}
|
|
95
102
|
return mcpRouteForProfile(profile);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A FORMA da chamada, nunca o conteúdo dela.
|
|
3
|
+
*
|
|
4
|
+
* Por que existe. Em 10/09/2026 o painel do portfólio passou a medir erro por
|
|
5
|
+
* chamada e apontou cinco ferramentas quebradas, a pior delas
|
|
6
|
+
* `senado_reuniao_comissao`, com 74 erros em 74 chamadas. Diagnosticar custou
|
|
7
|
+
* vinte minutos de chamadas manuais porque a telemetria dizia QUE falhou e não
|
|
8
|
+
* DIZIA por quê — e a primeira hipótese (o agente escolheu a ferramenta errada,
|
|
9
|
+
* confundindo o singular com o plural `senado_reunioes_comissao`) estava
|
|
10
|
+
* ERRADA: erro de validação de esquema nem chega aqui, o SDK o responde antes
|
|
11
|
+
* do callback. As 74 falhas eram códigos bem-formados que não existem.
|
|
12
|
+
*
|
|
13
|
+
* Por que não gravar os argumentos. O dado que este servidor serve é público; a
|
|
14
|
+
* PERGUNTA não é. `senado_ceaps` com o id de um senador, repetido, é alguém
|
|
15
|
+
* investigando aquele senador, e o registro já guarda país e organização de
|
|
16
|
+
* rede. Parte dos parâmetros é texto livre (`palavraChave`), onde cabe qualquer
|
|
17
|
+
* coisa que a pessoa digitou. E ligar isso mudaria o que o serviço coleta sem
|
|
18
|
+
* mudar o que a página pública diz que ele coleta.
|
|
19
|
+
*
|
|
20
|
+
* O meio-termo: NOMES de parâmetro (que são o esquema publicado, não dado de
|
|
21
|
+
* ninguém) e a CLASSE do erro (vocabulário fechado, derivado da nossa própria
|
|
22
|
+
* mensagem). Isso separa "chamou sem o parâmetro obrigatório" de "chamou certo
|
|
23
|
+
* com um valor que não existe" — que é a bifurcação do conserto.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Classifica pela mensagem de erro, que é NOSSA. A ordem importa: "não
|
|
27
|
+
* encontrado" e "vazio" são mais específicos que "erro da fonte", e um 404
|
|
28
|
+
* casaria com os dois.
|
|
29
|
+
*
|
|
30
|
+
* O vocabulário foi ampliado depois de passar o classificador por TODAS as
|
|
31
|
+
* mensagens de erro do bcb e do senado (a varredura virou a guarda em
|
|
32
|
+
* `call-shape.test.ts`): 13 de 18 aqui e 12 de 18 lá caíam em `outro`, ou
|
|
33
|
+
* seja, a telemetria não responderia nada nestes dois servidores. As famílias
|
|
34
|
+
* que faltavam eram "Informe X ou Y", "só existe para", "desconhecido",
|
|
35
|
+
* "recusada", "não retornou dados" e "não publica" — nenhuma exótica; a versão
|
|
36
|
+
* inicial foi escrita a partir das mensagens do senado que eu já tinha lido.
|
|
37
|
+
*/
|
|
38
|
+
export function classifyError(message) {
|
|
39
|
+
const m = message.toLowerCase();
|
|
40
|
+
// 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";
|
|
47
|
+
// A fronteira de palavra vai só no INÍCIO. Os padrões são RADICAIS
|
|
48
|
+
// ("vazi", "obrigatóri", "indisponív") justamente porque a flexão muda o
|
|
49
|
+
// fim: `\bvazi\b` não casa "vazia", e foi assim que "Resposta upstream
|
|
50
|
+
// vazia" caiu em `fonte` na primeira versão — a palavra que casava era
|
|
51
|
+
// "upstream". Ordem também importa: "não encontrado" é mais específico que
|
|
52
|
+
// "erro da fonte", e a mensagem real do senado tem sinal das duas famílias.
|
|
53
|
+
// "desconhecido"/"unknown" só é sinal de contrato quando qualifica um VALOR
|
|
54
|
+
// que o chamador passou ("Índice de preços desconhecido: X. Aceitos: ...",
|
|
55
|
+
// "Unknown dimension(s) for dataflow X"). "Erro desconhecido ao consultar o
|
|
56
|
+
// calendário do IBGE" e "Unknown error" são o oposto — são justamente o caso
|
|
57
|
+
// sem classe — e a primeira versão desta ampliação os classificava como
|
|
58
|
+
// contrato.
|
|
59
|
+
const valorDesconhecido = /\b(desconhecid|unknown)/.test(m) && !/\b(erro desconhecid|unknown error)/.test(m);
|
|
60
|
+
if (valorDesconhecido ||
|
|
61
|
+
/\b(obrigatóri|obrigatori|exige|requer|required|inválid|invalid|validation error|não aceita|nao aceita|no máximo|no maximo|só existe|so existe|recusad)/.test(m) ||
|
|
62
|
+
// Inglês, das mensagens do ilo, do uis e do medical. `empty query` fica
|
|
63
|
+
// AQUI e não em nao_encontrado: consulta vazia é parâmetro que falta. Era o
|
|
64
|
+
// radical `empty` solto que a classificava errado — ele existia para "empty
|
|
65
|
+
// response", que é outra coisa, e agora está escrito por extenso lá.
|
|
66
|
+
// "narrow it/the query" e "maximum N per call" são instruções ao chamador:
|
|
67
|
+
// a resposta não veio porque a chamada precisa mudar, que é contrato.
|
|
68
|
+
/\b(empty query|not part of|too broad|too many|maximum|narrow (it|the|your)|has no codelist|has no enumerated)/.test(m)) {
|
|
69
|
+
return "contrato";
|
|
70
|
+
}
|
|
71
|
+
if (/\b(não encontrad|nao encontrad|não existe|nao existe|does ?n[o']t exist|not.?found|inexistent|vazi|empty response|empty result|returned empty|sem registros|não retornou dados|nao retornou dados|não publica|nao publica|404)/.test(m) ||
|
|
72
|
+
// "Nenhum evento encontrado", "nenhuma reunião", "nenhum registro": a forma
|
|
73
|
+
// varia com o substantivo de cada servidor, então case pelo padrão.
|
|
74
|
+
/\bnenhum[ao]?s?\b[\s\S]{0,40}\b(encontrad|resultado|registro|dado)/.test(m)) {
|
|
75
|
+
return "nao_encontrado";
|
|
76
|
+
}
|
|
77
|
+
// "Informe X ou Y" é a mensagem canônica de parâmetro que falta — dez delas
|
|
78
|
+
// no senado, com e sem qualificador na frente ("Para por=senador, informe
|
|
79
|
+
// 'codigoSenador'"). Fica DEPOIS de "não encontrado" de propósito: é o sinal
|
|
80
|
+
// mais fraco dos dois, e um "informe um código válido" fechando uma mensagem
|
|
81
|
+
// de não encontrado não pode sequestrar a classe. Hoje nenhuma mensagem dos
|
|
82
|
+
// quatro servidores casa com as duas famílias — a ordem existe para a
|
|
83
|
+
// mensagem que alguém escrever amanhã.
|
|
84
|
+
if (/\binforme\b/.test(m))
|
|
85
|
+
return "contrato";
|
|
86
|
+
// `\b5\d\d\b` e não `5\d\d`: sem a fronteira final, qualquer número com um 5
|
|
87
|
+
// seguido de dois dígitos casava — um código de reunião "591234" citado na
|
|
88
|
+
// mensagem virava "erro 5xx". A intenção sempre foi o status HTTP.
|
|
89
|
+
// A fonte falhou ou demorou. As quatro últimas vieram da fábrica de erros do
|
|
90
|
+
// ibge, onde caíam em `outro`: ela nomeia a falha de infraestrutura em vez de
|
|
91
|
+
// usar as palavras genéricas ("Tempo de resposta excedido", não "timeout";
|
|
92
|
+
// "Serviço em manutenção"; "Erro de conexão"). `erro interno do servidor` vai
|
|
93
|
+
// com o complemento, porque "erro interno" sozinho pode ser bug nosso.
|
|
94
|
+
if (/\b(timeout|tempo esgotado|tempo de resposta excedid|excedeu o tempo|indisponív|indisponiv|manutenç|manutenc|erro de conexão|erro de conexao|erro interno do servidor|internal server error|upstream|\b5\d\d\b|payload|too large|grande demais|limite de tamanho)/.test(m)) {
|
|
95
|
+
return "fonte";
|
|
96
|
+
}
|
|
97
|
+
return "outro";
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Nomes dos parâmetros que a chamada trouxe, em ordem, separados por vírgula.
|
|
101
|
+
* Só os nomes de PRIMEIRO nível e só quando o argumento é objeto simples — o
|
|
102
|
+
* conteúdo nunca entra. Cortado em 200 caracteres porque blob do Analytics
|
|
103
|
+
* Engine tem teto de tamanho e um nome de parâmetro longo não vale a linha.
|
|
104
|
+
*/
|
|
105
|
+
export function paramNames(args) {
|
|
106
|
+
const a = Array.isArray(args) ? args[0] : args;
|
|
107
|
+
if (!a || typeof a !== "object" || Array.isArray(a))
|
|
108
|
+
return "";
|
|
109
|
+
const nomes = Object.keys(a)
|
|
110
|
+
.filter((k) => a[k] !== undefined)
|
|
111
|
+
.sort();
|
|
112
|
+
return nomes.join(",").slice(0, 200);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Texto de erro de um resultado de tool, para classificar. Vazio quando não há.
|
|
116
|
+
*
|
|
117
|
+
* Lê o CAMPO `error` do envelope, não o payload serializado inteiro. O envelope
|
|
118
|
+
* padrão é `{ error, retryable, hint }`, e o `hint` de erro não recuperável
|
|
119
|
+
* termina com "a fonte oficial pode estar indisponível" — texto de formulário,
|
|
120
|
+
* igual em todos. Classificando o payload inteiro, esse "indisponível"
|
|
121
|
+
* arrastava TODO erro não recuperável para a classe `fonte`. Visto na produção
|
|
122
|
+
* do senado em 10/09/2026: a mensagem "Não existe reunião com o código X",
|
|
123
|
+
* que é `nao_encontrado` por definição, foi gravada como `fonte`.
|
|
124
|
+
*/
|
|
125
|
+
export function errorText(result) {
|
|
126
|
+
if (!result || typeof result !== "object")
|
|
127
|
+
return "";
|
|
128
|
+
const r = result;
|
|
129
|
+
const estruturado = r.structuredContent?.error;
|
|
130
|
+
if (typeof estruturado === "string")
|
|
131
|
+
return estruturado;
|
|
132
|
+
const t = Array.isArray(r.content) ? r.content[0]?.text : undefined;
|
|
133
|
+
if (typeof t !== "string")
|
|
134
|
+
return "";
|
|
135
|
+
try {
|
|
136
|
+
const j = JSON.parse(t);
|
|
137
|
+
if (typeof j.error === "string")
|
|
138
|
+
return j.error;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Não é o envelope JSON — vale o texto cru (é o caso de outros servidores).
|
|
142
|
+
}
|
|
143
|
+
return t;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Classifica uma exceção que ESCAPOU do handler, tendo o objeto do erro em mãos
|
|
147
|
+
* e não só a mensagem.
|
|
148
|
+
*
|
|
149
|
+
* Por que existe. Em 22/09/2026 o `ibge_cnae` respondia
|
|
150
|
+
* `Cannot read properties of undefined (reading 'divisao')` a um código de
|
|
151
|
+
* classe válido — um `TypeError` nosso, nascido de um `[]` da fonte que
|
|
152
|
+
* atravessou a camada de rede. Nenhum padrão de `classifyError` casa com essa
|
|
153
|
+
* frase, então ela caía em `outro`, que já era 13 dos 23 erros da ferramenta na
|
|
154
|
+
* semana. O comentário de `outro` avisava desde o início: *se esta classe
|
|
155
|
+
* crescer, é sinal de que falta uma classe*. Faltava esta. O conserto nasceu no
|
|
156
|
+
* `ibge-br-mcp` 5.1.2 e é o mesmo em toda a frota, porque a classe é a mesma.
|
|
157
|
+
*
|
|
158
|
+
* O sinal é o TIPO, não a frase, e é por isso que esta função existe separada
|
|
159
|
+
* de `classifyError`: `TypeError`, `RangeError`, `ReferenceError` e
|
|
160
|
+
* `SyntaxError` são erros de programa. A fonte não os produz — nós os
|
|
161
|
+
* produzimos ao supor a forma do que ela respondeu. Classificá-los por
|
|
162
|
+
* mensagem seria caçar o texto do motor de JS, que muda entre versões de Node.
|
|
163
|
+
*
|
|
164
|
+
* `classifyError` fica INTACTA: ela recebe mensagem e continua sendo o caminho
|
|
165
|
+
* do erro que NÓS escrevemos.
|
|
166
|
+
*/
|
|
167
|
+
export function classifyThrown(error) {
|
|
168
|
+
if (error instanceof TypeError ||
|
|
169
|
+
error instanceof RangeError ||
|
|
170
|
+
error instanceof ReferenceError ||
|
|
171
|
+
error instanceof SyntaxError) {
|
|
172
|
+
return "defeito";
|
|
173
|
+
}
|
|
174
|
+
return classifyError(error instanceof Error ? error.message : String(error));
|
|
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
|
+
}
|