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 CHANGED
@@ -6,7 +6,7 @@
6
6
  [![CI](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare/actions/workflows/ci.yml/badge.svg)](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare/actions/workflows/ci.yml)
7
7
  [![MCP Registry](https://img.shields.io/badge/MCP-Registry-blue)](https://registry.modelcontextprotocol.io)
8
8
  [![LobeHub](https://lobehub.com/badge/mcp/sidneybissoli-senado-br-mcp-cloudflare)](https://lobehub.com/mcp/sidneybissoli-senado-br-mcp-cloudflare)
9
- [![smithery badge](https://smithery.ai/badge/sidneybissoli/senado-br-mcp-cloudflare)](https://smithery.ai/servers/sidneybissoli/senado-br-mcp-cloudflare)
9
+ [![LightNow capabilities](https://lightnow.ai/badge/io.github.SidneyBissoli/senado-br-mcp-cloudflare)](https://lightnow.ai/servers/io.github.SidneyBissoli/senado-br-mcp-cloudflare)
10
10
  [![senado-br-mcp-cloudflare MCP server](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare/badges/score.svg)](https://glama.ai/mcp/servers/SidneyBissoli/senado-br-mcp-cloudflare)
11
11
  [![GitHub stars](https://img.shields.io/github/stars/SidneyBissoli/senado-br-mcp-cloudflare?style=flat&logo=github)](https://github.com/SidneyBissoli/senado-br-mcp-cloudflare)
12
12
  [![GitHub Sponsors](https://img.shields.io/github/sponsors/SidneyBissoli?logo=githubsponsors&label=Sponsor&color=db61a2)](https://github.com/sponsors/SidneyBissoli)
@@ -148,7 +148,7 @@ Claude Code auto-discovers it when you work in this repo. To use it elsewhere, c
148
148
  - **Runtime:** Cloudflare Workers (ESM)
149
149
  - **Transport:** Streamable HTTP (MCP spec 2025-03-26) via `createMcpHandler` from `agents/mcp`
150
150
  - **Protocol:** MCP over JSON-RPC — `/mcp` handles the full public server; `/mcp/openai-app-v2` exposes a curated 27-tool profile plus a shared MCP Apps widget for OpenAI app review/submission (`/mcp/openai-app` remains as a legacy alias)
151
- - **SDK:** `@modelcontextprotocol/sdk` 1.26.0+ (per-request McpServer instances)
151
+ - **SDK:** `@modelcontextprotocol/server` 2.x (per-request McpServer instances; the v1 `@modelcontextprotocol/sdk` remains only as a peer of `agents`, dev-time)
152
152
  - **Validation:** Zod schemas for all tool inputs
153
153
  - **Caching:** 2-layer (L0 memory + L1 Cache API) with SHA-256 keying
154
154
  - **e-Cidadania store:** D1 database refreshed by a Cron Trigger (every 2h) — list tools read from D1 with a live-scrape fallback and a staleness flag; detail tools stay live with write-through (see [e-Cidadania](#e-cidadania-d1-backed-cron-refreshed))
package/README.pt-BR.md CHANGED
@@ -149,7 +149,7 @@ O Claude Code a descobre automaticamente ao trabalhar neste repositório. Para u
149
149
  - **Runtime:** Cloudflare Workers (ESM)
150
150
  - **Transporte:** Streamable HTTP (spec MCP 2025-03-26) via `createMcpHandler` de `agents/mcp`
151
151
  - **Protocolo:** MCP sobre JSON-RPC — `/mcp` serve o catalogo publico completo; `/mcp/openai-app-v2` expoe um perfil curado de 27 ferramentas mais um widget MCP Apps compartilhado para revisao/submissao como app OpenAI (`/mcp/openai-app` permanece como alias legado)
152
- - **SDK:** `@modelcontextprotocol/sdk` 1.26.0+ (instâncias de McpServer por requisição)
152
+ - **SDK:** `@modelcontextprotocol/server` 2.x (instâncias de McpServer por requisição; o `@modelcontextprotocol/sdk` v1 fica só como peer do `agents`, em tempo de dev)
153
153
  - **Validação:** schemas Zod para todas as entradas das ferramentas
154
154
  - **Cache:** 2 camadas (L0 memória + L1 Cache API) com chaveamento SHA-256
155
155
  - **Store do e-Cidadania:** banco D1 atualizado por um Cron Trigger (a cada 2h) — as ferramentas de listagem leem do D1 com fallback de scraping ao vivo e flag de desatualização; as de detalhe ficam ao vivo com write-through (veja [e-Cidadania](#e-cidadania-em-d1-atualizado-por-cron))
@@ -1,6 +1,11 @@
1
1
  export const DEFAULT_MCP_ROUTE = "/mcp";
2
2
  export const OPENAI_APP_LEGACY_MCP_ROUTE = "/mcp/openai-app";
3
3
  export const OPENAI_APP_MCP_ROUTE = "/mcp/openai-app-v2";
4
+ // Rota privada do dono: mesma superficie COMPLETA da rota publica, outro
5
+ // endereco, e a telemetria a marca como uso proprio (ver src/instrument.ts).
6
+ // Precisa ser reconhecida aqui, senao handlerRouteForPath cai no fallback e
7
+ // o handler e montado numa rota diferente da que a requisicao pediu.
8
+ export const SELF_MCP_ROUTE = "/mcp/uso-proprio";
4
9
  export const OPENAI_APP_WIDGET_URI = "ui://senado-br-mcp/openai-app-dashboard-v2.html";
5
10
  export const OPENAI_APP_WIDGET_DOMAIN = "https://senado.sidneybissoli.com";
6
11
  const OPENAI_APP_MCP_ROUTES = new Set([OPENAI_APP_LEGACY_MCP_ROUTE, OPENAI_APP_MCP_ROUTE]);
@@ -89,7 +94,9 @@ export function mcpRouteForProfile(profile) {
89
94
  }
90
95
  export function handlerRouteForPath(pathname, profile) {
91
96
  const normalized = normalizeMcpRoute(pathname);
92
- if (OPENAI_APP_MCP_ROUTES.has(normalized) || normalized === DEFAULT_MCP_ROUTE) {
97
+ if (OPENAI_APP_MCP_ROUTES.has(normalized) ||
98
+ normalized === DEFAULT_MCP_ROUTE ||
99
+ normalized === SELF_MCP_ROUTE) {
93
100
  return pathname;
94
101
  }
95
102
  return mcpRouteForProfile(profile);
@@ -0,0 +1,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
+ }
@@ -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
+ }